feat: add Proxmox provisioning service with CI and deployment tooling
CI / javascript-check (push) Successful in 51s
CI / container-policy (push) Successful in 2s
CI / container-verify (push) Canceled after 0s
CI / container-publish (push) Canceled after 0s
CI / python-tests (push) Canceled after 6m59s

This commit is contained in:
BartelLuis
2026-09-14 20:09:12 +02:00
commit 06c3474636
52 changed files with 9779 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
.git
.venv
.venv-ci-*
venv
__pycache__
*.py[cod]
.pytest_cache
.cache
reports
build.env
deploy.env
*.egg-info
build
dist
data
secrets
backups
.env
*.key
*.pem
*.iso
*.sqlite3*
*.log
+12
View File
@@ -0,0 +1,12 @@
* text=auto
*.py text eol=lf
*.sh text eol=lf
*.html text eol=lf
*.css text eol=lf
*.js text eol=lf
*.toml text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.json text eol=lf
*.md text eol=lf
Dockerfile text eol=lf
+144
View File
@@ -0,0 +1,144 @@
name: CI
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
# Protected runs get their own group; publication is queued at job level.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}-${{ github.event_name != 'pull_request' && github.ref_protected && github.run_id || 'checks' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' || !github.ref_protected }}
defaults:
run:
shell: bash
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
jobs:
python-tests:
runs-on: ubuntu-24.04
timeout-minutes: 15
env:
PIP_DISABLE_PIP_VERSION_CHECK: "1"
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.13"
cache: pip
cache-dependency-path: pyproject.toml
- name: Run Python tests
run: sh ci/python-tests.sh
- name: Upload JUnit report
if: ${{ always() }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: python-test-results
path: reports/pytest.xml
retention-days: 7
if-no-files-found: warn
javascript-check:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: "24"
package-manager-cache: false
- name: Check JavaScript syntax
run: node --check provisioner/static/app.js
container-policy:
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions: {}
outputs:
publish: ${{ steps.policy.outputs.publish }}
steps:
# Ref names are read from the runner environment, never inserted into shell code.
# The container script independently enforces these publication rules.
- name: Select verification or publication
id: policy
run: |
publish=false
if [[ "$GITHUB_REF_PROTECTED" == true ]] &&
[[ "$GITHUB_EVENT_NAME" == push || "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then
if [[ "$GITHUB_REF_TYPE" == branch && -n "$DEFAULT_BRANCH" && "$GITHUB_REF_NAME" == "$DEFAULT_BRANCH" ]] ||
[[ "$GITHUB_REF_TYPE" == tag && "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
publish=true
fi
fi
printf 'publish=%s\n' "$publish" >> "$GITHUB_OUTPUT"
container-verify:
needs: [python-tests, javascript-check, container-policy]
if: ${{ needs.container-policy.outputs.publish == 'false' }}
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
DOCKER_HOST: unix:///var/run/docker.sock
DOCKER_BUILDKIT: "1"
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- name: Check Docker tools
run: |
docker info
docker buildx version
- name: Build and smoke-test image
run: sh ci/container.sh verify
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: container-build
path: build.env
retention-days: 7
if-no-files-found: error
container-publish:
needs: [python-tests, javascript-check, container-policy]
if: ${{ needs.container-policy.outputs.publish == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
packages: write
concurrency:
group: ghcr-container-publish
cancel-in-progress: false
queue: max
env:
DOCKER_HOST: unix:///var/run/docker.sock
DOCKER_BUILDKIT: "1"
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- name: Check Docker tools
run: |
docker info
docker buildx version
- name: Build, smoke-test and publish image
env:
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: sh ci/container.sh publish
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: container-deploy
path: |
build.env
deploy.env
retention-days: 30
if-no-files-found: error
+26
View File
@@ -0,0 +1,26 @@
__pycache__/
*.py[cod]
.pytest_cache/
.venv/
.venv-ci-*/
venv/
*.egg-info/
build/
dist/
.coverage
htmlcov/
.cache/
reports/
build.env
deploy.env
.env
data/
secrets/
backups/
*.sqlite3
*.sqlite3-wal
*.sqlite3-shm
*.key
*.iso
*.pem
*.log
+23
View File
@@ -0,0 +1,23 @@
FROM python:3.13-slim@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
DATA_DIR=/var/lib/proxmox-ais \
MASTER_KEY_FILE=/run/secrets/master.key
WORKDIR /app
RUN groupadd --gid 10001 provisioner \
&& useradd --uid 10001 --gid provisioner --no-create-home provisioner \
&& mkdir -p /var/lib/proxmox-ais /run/secrets \
&& chown -R provisioner:provisioner /var/lib/proxmox-ais /run/secrets
COPY pyproject.toml README.md ./
COPY provisioner ./provisioner
RUN pip install .
USER 10001:10001
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=3)"
ENTRYPOINT ["proxmox-ais"]
CMD ["serve", "--host", "0.0.0.0", "--port", "8080"]
+487
View File
@@ -0,0 +1,487 @@
# Feinkonzept für ein Webtool zur Proxmox Provisionierung
**Automatische Installation und gesteuerte Postinstallation**
Version 1.0 \| 13. September 2026 \| Technische Umsetzungsvorlage
### Ziel und Architekturentscheidung
Das Tool verwaltet Proxmox VE Server, Installationsprofile und versionierte Postinstallationsskripte über eine Weboberfläche. Eine vorbereitete Proxmox ISO kontaktiert das Tool, erhält die für den Server freigegebene Installationskonfiguration und installiert Proxmox. Beim ersten Start lädt ein kleiner Starthelfer die zugewiesene Nachkonfiguration und führt sie kontrolliert aus.
Empfohlen wird ein zentraler Dienst in einem Docker Container mit integrierter Weboberfläche, API und persistenter SQLite Datenbank. Die Server bauen die Verbindungen selbst per HTTPS auf. Ein eigener, zeitlich begrenzt aktiver Runner auf jedem Server übernimmt Skriptausführung, Statusmeldungen und Wiederaufnahme nach Unterbrechungen.
### Leitentscheidungen
| Bereich | Festlegung |
| --- | --- |
| Installationsmedium | Eine gemeinsame ISO je Standort oder Bereitstellungsgruppe; hostbezogene Antworten kommen vom Tool. |
| Freigabe | Nur eindeutig zugeordnete und ausdrücklich für Installation freigegebene Hosts erhalten eine Antwortdatei. |
| Reproduzierbarkeit | Jeder Lauf bindet unveränderliche Versionen von Profil, Skripten und aufgelösten Parametern. |
| Postinstallation | Wiederaufnehmbarer Ablauf mit einzelnen Schritten, Zustandsprüfung und abschließender Funktionsprüfung. |
| Betrieb | Ein Anwendungscontainer; TLS direkt oder über vorhandenen Reverse Proxy. ISO Vorbereitung zunächst als separater Arbeitsschritt. |
### Planungsannahmen und Umfang
Ausgelegt wird zunächst eine interne Umgebung mit bis zu 100 verwalteten Hosts und 10 parallelen Installationen. Diese Werte sind Abnahmeziele, keine gemessenen Leistungsangaben. DHCP, DNS, Zeitsynchronisation und Netzwerkrouting werden bereitgestellt. Unterstützte ISO Builds werden einzeln freigegeben.
Die erste Version umfasst Neuinstallation, Basiskonfiguration, Profile, Skripte, Freigaben, Live Status und Auditprotokoll. Automatisches Einschalten, BMC Steuerung, PXE, Clusterbeitritt, Ceph Aufbau und dauerhafte Konfigurationsverwaltung sind spätere Erweiterungen. Die eigentliche Löschung der Installationsdatenträger bleibt Aufgabe des Proxmox Installers.
## 1 Anbindung an den Proxmox Installer
### Installationskonfiguration und erster Start
Der Proxmox Automated Installer kann seine Antwortdatei per HTTP beziehungsweise HTTPS abrufen. Dabei sendet er einen POST Request mit Systeminformationen. Aktuelle Dokumentation beschreibt zusätzlich --answer-auth-token; daraus entsteht ein Authorization Header im Format Bearer <name>:<secret>. Das Tool liefert im Erfolgsfall TOML als Antwortinhalt. [1]
Der First Boot Mechanismus unterstützt ein ausführbares Skript von der ISO oder einer URL. Bei from-url wird es bereits im Installer heruntergeladen und auf dem Zielsystem hinterlegt; die Ausführung erfolgt beim ersten Boot. Der hier vorgesehene Starthelfer verwendet ordering = "network-online" und prüft danach selbst die Erreichbarkeit des Tools. Der dokumentierte Implementierungsansatz zeigt diese Trennung von Download und Ausführung. [2]
Das optionale Proxmox Postinstallation Webhook meldet Installationsinformationen. Es ersetzt keine lokale Skriptausführung. Im Konzept dient es ausschließlich als zusätzliches Ereignis zwischen Antwortauslieferung und erstem Start. [3]
### Vorbereitung der gemeinsamen ISO
Der folgende Befehl ist ein Konfigurationsmuster. SOURCE.iso, Token und Fingerprint sind vor Verwendung zu ersetzen. Es werden keine produktiven Zugangsdaten vorgegeben. Die unterstützte Kombination aus ISO und Assistant muss die verwendeten Optionen tatsächlich enthalten.
```bash
proxmox-auto-install-assistant prepare-iso SOURCE.iso \
--fetch-from http \
--url "https://provision.example.net/installer/v1/answer" \
--cert-fingerprint "<SHA256-Zertifikatsfingerprint>" \
--answer-auth-token "<gruppenname>:<zufaelliges-secret>"
```
Das Grundmuster mit URL, Zertifikatsfingerprint und Token ist offiziell dokumentiert. [3] Der eigene API Pfad ist eine Festlegung dieses Konzepts. Das erzeugte Medium wird zusammen mit ISO Prüfsumme, Assistant Paketversion, Gültigkeitsbereich und Prüfergebnis registriert. Skriptänderungen erfordern danach keinen neuen ISO Build.
### Abschnitt der dynamischen Antwortdatei
```toml
[first-boot]
source = "from-url"
ordering = "network-online"
url = "https://provision.example.net/bootstrap/v1/<download-token>"
cert-fingerprint = "<SHA256-Zertifikatsfingerprint>"
```
Dies ist nur der First Boot Abschnitt. Die vollständige Antwort enthält zusätzlich global, network und disk-setup mit hostbezogenen Werten. Vor Auslieferung wird sie für die freigegebene Zielversion validiert. Der Download Token gewährt ausschließlich Zugriff auf den Starthelfer dieses Laufs.
### Kompatibilität als Freigabebedingung
Keine pauschale Unterstützung aller Proxmox Versionen zusagen. Pro ISO werden Antwortschema, Token Header, First Boot Verhalten, Webhook und Bootverfahren getestet. Fehlt native Token Unterstützung, lehnt Version 1 dieses Medium ab. Ein neuer Assistant allein aktualisiert nicht die Komponenten innerhalb einer älteren ISO.
## 2 Komponenten und Bereitstellung
Die Anwendung ist ein modularer Monolith. Fachliche Komponenten bleiben im Code getrennt, werden jedoch gemeinsam ausgeliefert. Dadurch erfüllt der Regelbetrieb die Vorgabe eines Docker Containers ohne zusätzliche Datenbank oder Nachrichtenwarteschlange.
| Komponente | Aufgabe | Vorschlag |
| --- | --- | --- |
| Weboberfläche | Inventar, Profile, Skripte, Freigaben, Laufdetails | Serverseitiges HTML mit kleinen JavaScript Komponenten |
| API | Installer Adapter, Runner API, Verwaltungsfunktionen | Python mit FastAPI und typisierten Datenmodellen |
| Konfigurationsdienst | Werte zusammenführen, validieren, Versionen fixieren | TOML und JSON Serializer; restriktive Vorlagen |
| Laufsteuerung | Freigaben, Status, Timeout, Sperren, Ereignisse | Persistente Zustände; keine Aufgaben nur im Arbeitsspeicher |
| Datenhaltung | Inventar, Versionen, Läufe, Ereignisse, Audit | SQLite im lokalen Volume; Schema Migrationen |
| Artefaktablage | Unveränderliche Skriptpakete und Run Manifeste | Dateien nach Inhaltsdigest; Referenzen in Datenbank |
| Starthelfer und Runner | Start, Abruf, Ausführung und Rückmeldungen | Kleiner Bash Starthelfer; Python Runner und Bash Module |
FastAPI unterstützt OpenAPI und JSON Schema; daraus wird die technische API Dokumentation erzeugt. [4] Die Oberfläche verwendet dieselben fachlichen Dienste, aber eigene Benutzerrechte. Geheimnisse und freigegebene Skripte dürfen nicht über allgemeine Dateipfade ausgeliefert werden.
### Verbindungen
Administratoren greifen aus dem Verwaltungsnetz per HTTPS auf die Oberfläche zu. Installer und installierte Hosts erreichen nur die vorgesehenen Maschinenendpunkte. Es gibt im Grundumfang keine eingehende SSH Verbindung vom Tool zu den Hosts. Paketquellen werden direkt oder über einen internen Paketproxy erreicht.
TLS endet entweder im vorhandenen Reverse Proxy oder direkt am Anwendungsserver auf einem unprivilegierten Port. Im Proxybetrieb ist der Backend Port ausschließlich für den Proxy erreichbar. Vertrauenswürdige Proxy Header werden auf konkrete Proxy Adressen beschränkt.
### ISO Erstellung als eigener Vorgang
Version 1 stellt den vorbereiteten Befehl, die erforderlichen Parameter und eine Registrierung der erzeugten ISO bereit. Der Administrator erstellt das Medium auf einer kontrollierten Build Maschine mit dem offiziellen Assistant. Das Webtool selbst benötigt dafür weder privilegierte Rechte noch einen Docker Socket.
Ein späterer ISO Builder kann denselben Auftrag in einem kurzlebigen, separat gestarteten Build Container ausführen. Dabei gelten feste Eingabepfade, Ressourcenlimits und Prüfsummen. Das Ausführen eines vom Benutzer eingegebenen Shell Befehls im Anwendungscontainer ist nicht Teil der Architektur.
## 3 Ablauf einer Installation
| Schritt | Aktion und Ergebnis |
| --- | --- |
| 1 Vorbereiten | Host anhand Seriennummer, System UUID und MAC Adressen erfassen; FQDN, Netzwerk, Datenträgerprofil und Skriptprofil zuweisen. |
| 2 Prüfen | Aufgelöste Konfiguration anzeigen, Pflichtfelder validieren, Kollisionen bei IP und FQDN prüfen; Skriptversionen und Modulreihenfolge festschreiben. |
| 3 Freigeben | Operator erteilt eine zeitlich begrenzte Installationsfreigabe. Neuinstallationen enthalten eine gesonderte Bestätigung der zu überschreibenden Zielgeräte. |
| 4 Booten | Administrator startet den Server mit der vorbereiteten ISO über USB oder vorhandene virtuelle Medien. |
| 5 Zuordnen | Installer sendet Systemdaten. API prüft Gruppentoken, Hostzuordnung, Freigabefenster, Versionsprofil und laufende Vorgänge. |
| 6 Antworten | API reserviert atomar den Installationslauf und liefert dieselbe validierte TOML Antwort bei erlaubten Wiederholungen. |
| 7 Installieren | Installer lädt den runbezogenen Starthelfer und installiert Proxmox. Ein konfiguriertes Webhook kann das Ende der Basisinstallation melden. |
| 8 Registrieren | Nach dem Reboot legt der Starthelfer lokale Zustandsdateien und einen systemd Dienst an. Der Runner registriert sich für genau diesen Lauf. |
| 9 Konfigurieren | Runner lädt Manifest und Skriptpaket, prüft Integrität, führt Schritte aus und meldet Ereignisse, Protokolle und Neustartbedarf. |
| 10 Abschließen | Pflichtprüfungen sind erfolgreich. Der Lauf wird abgeschlossen, Ausführungsrechte erlöschen und der Runner wird deaktiviert. |
### Unbekannte und widersprüchliche Hosts
Ein unbekannter Host kann mit gültigem Gruppentoken als entdeckt gespeichert werden, erhält jedoch keine ausführbare Installationskonfiguration. Mehrdeutige Identitäten oder widersprüchliche UUID und Seriennummern führen ebenfalls zur Sperre. Die Oberfläche zeigt den Grund und die nötige Zuordnung.
Das Konzept setzt kein beliebig langes Warten des Proxmox Installers voraus. Nach einer Ablehnung wird die Zuordnung im Tool korrigiert und der Installationsversuch neu gestartet. Für unbeaufsichtigten Betrieb sind Hosts und Freigaben deshalb vorher anzulegen.
### Grenze der Installationssperre
Ein HTTP Abruf und eine tatsächliche Installation sind nicht gleichzusetzen. Wiederholungsabrufe werden nur in einem kurzen, getesteten Auslieferungsfenster zugelassen. Ab gemeldetem Installationsende, Runner Registrierung oder Ablauf dieses Fensters ist eine neue Antwort gesperrt. Das verhindert weitere Freigaben, kann eine bereits ausgelieferte und lokal gespeicherte Antwort aber nicht zurückziehen. Die Bootreihenfolge muss nach Installation auf das lokale System wechseln.
## 4 Bedienoberfläche und Berechtigungen
### Zentrale Ansichten
| Ansicht | Inhalte und Aktionen |
| --- | --- |
| Übersicht | Anzahl bereiter, laufender, gestörter und abgeschlossener Hosts; ausstehende Freigaben; letzte Ereignisse; Filter nach Standort und Profil. |
| Serverinventar | Identitäten, FQDN, IP, Standort, Tags, Profil und letzter Kontakt. Anlegen, Datenimport, Zuordnen und Sperren. |
| Serverdetail | Sollkonfiguration, erkannte Daten, Historie und aktueller Lauf. Ansichten für Schritte, redigierte Logs, Prüfungen und Fehlerursache. |
| Installationsprofile | Basisparameter und Hardwareprofil getrennt pflegen. Vorschau der vollständigen Antwort mit markierten Geheimnisreferenzen. |
| Postinstallationsprofile | Module auswählen und ordnen, Parameter setzen, Abhängigkeiten prüfen, Rebootstrategie festlegen. |
| Skriptverwaltung | Quelltext, Versionsvergleich, Syntaxprüfung, Freigabestatus, Testnachweis, unterstützte Proxmox Builds und Änderungsgrund. |
| Installationsmedien | URL, Gültigkeitsbereich, Tokenstatus, Zertifikat, Assistant Version, ISO Prüfsumme und vorbereiteter Build Befehl. |
| Audit und Einstellungen | Änderungen, Freigaben, Tokenzugriffe, Aufbewahrung, Benutzer, Zertifikate und Sicherungsstatus. |
### Benutzerrollen
| Rolle | Berechtigung |
| --- | --- |
| Leser | Inventar und redigierte Laufdaten ansehen; keine Änderungen oder Geheimnisse. |
| Operator | Hosts zuweisen, freigegebene Profile nutzen, Installationen freigeben und sichere Wiederaufnahme anfordern. |
| Skriptautor | Skripte und Profile als Entwurf bearbeiten; keine eigene Veröffentlichung im Vieraugenmodus. |
| Administrator | Benutzer, Geheimnisse, Vertrauensanker und Skriptveröffentlichungen verwalten; privilegierte Vorgänge auditieren. |
| Entwickler | Hat alle Berechtigungen. |
### Bedienregeln
Die Aktion Installation freigeben zeigt Host, Zielversion, ausgewählte Datenträger, aufgelöste Netzwerkeinstellungen und Skriptversionen zusammen an. Profiländerungen wirken nur auf neue Läufe. Ein laufender Vorgang wird niemals durch Bearbeiten eines Profils stillschweigend verändert.
Wiederaufnehmen und Neu installieren sind getrennte Aktionen. Abbrechen stoppt einen Runner am nächsten sicheren Übergang. Ein bereits laufender Paketmanager oder eine bereits gestartete Datenträgeroperation wird nicht als sicher rückgängig darstellbar behandelt. Unbekannt bezeichnet fehlende Rückmeldung, nicht nachgewiesenen Erfolg oder Fehlschlag.
## 5 Profile und Postinstallationsmodule
### Auflösung der Konfiguration
Werte werden in fester Reihenfolge zusammengeführt: globale Vorgaben, Standort, Profilversion, Hostwerte und freigegebene Laufparameter. Spezifischere Werte überschreiben allgemeinere. Sicherheitsregeln, freigegebene Proxmox Builds und gesperrte Variablen dürfen durch Hostwerte nicht gelockert werden. Ergebnis und Herkunft jedes Werts sind in der Vorschau sichtbar.
Installationsprofil und Postinstallationsprofil bleiben getrennte Objekte. Das erste enthält Sprache, Zeitzone, Root Zugang, FQDN, Managementnetz und Systemdatenträger. Das zweite enthält Module und deren Parameter. Ein Lauf speichert die vollständig aufgelöste Kombination mit Versionsnummern und Digest.
### Empfohlene Module für die erste Version
| Modul | Verhalten | Erfolgskriterium |
| --- | --- | --- |
| Voraussetzungen | Zielversion, Zeit, DNS, Konnektivität und freien Speicher prüfen | Alle Pflichtbedingungen erfüllt |
| Paketquellen | Freigegebenes Repositoryprofil anwenden; Subscription berücksichtigen | Erwartete Quellen aktiv und erreichbar |
| Basispakete | Definierte Paketliste installieren; Paketmanager Sperre beachten | Erwartete Pakete vorhanden |
| Zugang und SSH | Freigegebene Schlüssel und Benutzer konfigurieren | Konfiguration validiert; definierter Zugang vorhanden |
| Zeit und Monitoring | Zeitsynchronisation und gewählten Monitoring Agent konfigurieren | Dienste aktiv; lokale Funktionsprüfung erfolgreich |
| Zusätzlicher Storage | Freigegebene, nicht destruktive Storage Anbindung setzen | Sollressource vorhanden und lokal nutzbar |
| Abschlussprüfung | Netzwerk, PVE Dienste, Storage und Versionsstand prüfen | Alle als Pflicht markierten Prüfungen erfolgreich |
### Schnittstelle eines Moduls
Jedes Modul hat eine unveränderliche ID und Version, eine unterstützte Zielmatrix, ein Parameterschema, Abhängigkeiten, Timeout, Wiederholungsregeln und eine Prüffunktion. check ermittelt den Istzustand; apply ändert nur erforderliche Werte; verify prüft das Ergebnis. Ein Custom Bash Skript muss dieselbe Schnittstelle einhalten.
Parameter werden in einer JSON Datei übergeben. Es gibt kein eval und keine ungeprüfte Textersetzung in Shell Befehlen. Eine festgelegte Funktion liest erlaubte Parameter typisiert aus. Geheimnisse werden nur für den aktuellen Schritt als Datei mit Modus 0600 bereitgestellt, anschließend entfernt und niemals in die Profilvorschau oder Logs geschrieben.
Eine syntaktische Prüfung, beispielsweise bash -n und ein Linter, bewertet keine fachliche Sicherheit. Die Veröffentlichung verlangt zusätzlich einen Test auf einem passenden Testhost. Kernelupdates, Netzwerkumbau und destruktive Storage Schritte sind eigenständige Erweiterungen mit zusätzlicher Wiederherstellungsplanung.
## 6 Starthelfer und wiederaufnehmbare Ausführung
### Verantwortung des Starthelfers
Der Starthelfer enthält den kleinen, fest versionierten Runner, run_id, eine begrenzte Enrollment Berechtigung, die API Adresse und den erwarteten Hostbezug. Er enthält keine allgemeinen Administratorzugänge und kein vollständiges Postinstallationsskript. Das Paket bleibt unter dem Limit des freigegebenen Installers.
Vor dem ersten Netzwerkabruf schreibt er seine Konfiguration atomar nach /etc/pve-provisioner, installiert den mitgelieferten Runner und aktiviert pve-provisioner.service nach network-online.target. Bash, Python und TLS Unterstützung werden je Zielimage geprüft. Der Runner führt Enrollment und Skriptabruf aus; fehlende Voraussetzungen erzeugen einen sichtbaren Fehler.
### Lokaler Zustand und Ausführungsregeln
Der Runner verwendet /var/lib/pve-provisioner für run_id, Manifestdigest, Gerätebindung, Ereigniszähler und Schrittzustände. Er hält eine lokale flock Sperre. Der Server erlaubt gleichzeitig höchstens einen aktiven Ausführungslauf pro Host. Pakete werden erst vollständig heruntergeladen, validiert und atomar in das Laufverzeichnis übernommen.
Vor jedem Schritt prüft der Runner die Laufberechtigung und den lokalen Checkpoint. Er schreibt applying dauerhaft vor der Änderung, führt apply aus und schreibt succeeded erst nach erfolgreichem verify. Unvollständige Schritte werden nach einem Absturz erneut geprüft. Ein bloßer Rückgabecode 0 ersetzt keine Zustandsprüfung.
| Situation | Festgelegtes Verhalten |
| --- | --- |
| Netzausfall | Abrufe mit exponentiellem Abstand und Zufallsanteil wiederholen; nach konfigurierter Gesamtdauer auf manuelle Prüfung wechseln. |
| API nicht erreichbar | Bereits laufenden sicheren Schritt beenden, Ereignisse lokal puffern; keine weiteren Änderungen nach Ablauf der Laufberechtigung. |
| Geplanter Reboot | Checkpoint und Rebootabsicht speichern; Nachricht zustellen oder puffern; Neustartbudget prüfen; nach Boot über denselben Lauf fortsetzen. |
| Unklarer Schrittzustand | check und verify ausführen. Nur ausdrücklich wiederholbare Schritte erneut anwenden; sonst manuelle Prüfung. |
| Doppelte Rückmeldung | Ereignis anhand run_id und Sequenznummer deduplizieren; Quittierung wiederholt zurückgeben. |
| Erfolgreicher Abschluss | Terminalen Checkpoint speichern, finalen Status quittieren lassen, Ausführungsrechte widerrufen und Dienst deaktivieren. |
### Systemd und Fehlergrenzen
Der eigene Dienst verwendet Type=simple und Restart=on-failure mit begrenzter Startfrequenz. Er benötigt keine Proxmox Hook Wiederholung. Permanente Modulfehler führen zu einem persistenten Haltzustand; ein Neustart des Dienstes darf sie nicht automatisch erneut ausführen. Zulässige Wiederholungen sind Teil der Moduldefinition, nicht einer pauschalen Shell Schleife.
Beliebige Shell Aktionen haben keine Exactly once Garantie. Zustandsprüfungen und idempotente Schritte ermöglichen sichere Wiederaufnahme; unklare destruktive Aktionen werden nicht automatisch wiederholt.
## 7 Zustände und Ereignisse
Hostzustand, Installationsfreigabe und Ausführungslauf sind getrennte Zustandsautomaten. Ein Host kann bereits installiert sein, während ein neuer Postinstallationslauf wartet. Eine solche Nachkonfiguration erteilt niemals automatisch eine neue Erlaubnis zum Löschen und Installieren.
| Laufzustand | Bedeutung und erlaubter Übergang |
| --- | --- |
| prepared | Konfiguration fixiert; für Installation zeitlich begrenzt freigegeben. Übergang zu answer_served oder expired. |
| answer_served | Antwort ausgeliefert. Installation läuft möglicherweise; ihr Beginn ist ohne weitere Meldung nicht bewiesen. |
| installed_reported | Optionales Installer Webhook empfangen. Runner Registrierung wird erwartet; dieser Zustand darf übersprungen werden. |
| runner_ready | Enrollment abgeschlossen, Hostbindung geprüft. Manifest und Ausführungsberechtigung können ausgegeben werden. |
| running | Ein bestimmter Schritt läuft; Heartbeats und geordnete Ereignisse werden angenommen. |
| reboot_pending | Checkpoint vorhanden. Gleicher Lauf wartet auf Rückkehr mit geänderter boot_id. |
| waiting_retry | Temporärer Fehler bei einem ausdrücklich wiederholbaren Vorgang; nächster Versuch ist terminiert. |
| needs_review | Unklarer oder nicht automatisch behebbarer Zustand. Weiterarbeit erfordert eine dokumentierte Operatorentscheidung. |
| succeeded | Alle Pflichtschritte und Abschlussprüfungen erfolgreich, Abschlussmeldung gespeichert. |
| failed oder cancelled | Permanenter Fehler oder Abbruch bestätigt. Weitere Änderungen sind gesperrt. |
| expired | Freigabe oder Startfrist abgelaufen, ohne rechtzeitige Aufnahme des Laufs. |
### Ereignisformat
```json
{
"run_id": "run-2026-001",
"sequence": 42,
"boot_id": "<lokale-boot-id>",
"step_id": "base-packages",
"type": "step.succeeded",
"occurred_at": "2026-09-13T10:15:00Z",
"exit_code": 0,
"verification": {"packages_present": true}
}
```
Der Server ergänzt Empfangszeit und authentifizierte Geräteidentität. Ereignisse dürfen nur zulässige Übergänge auslösen. Veraltete Meldungen können einen terminalen Erfolg nicht auf running zurücksetzen. Der Server bestätigt die höchste lückenlos gespeicherte Sequenznummer; der Runner behält nicht quittierte Ereignisse.
Ein fehlender Heartbeat setzt den Kontaktstatus auf unbekannt und erzeugt nach einer konfigurierten Frist eine Betriebswarnung. Er beweist weder einen Modulfehler noch einen erfolgreichen Abschluss. Die Übersicht verwendet deshalb keine künstliche Prozentanzeige für nicht beobachtbare Installerphasen.
## 8 Schnittstellen des Tools
### Maschinenendpunkte
| Methode und Pfad | Vertrag |
| --- | --- |
| POST /installer/v1/answer | Native Proxmox Systemdaten und Gruppentoken; 200 mit roher TOML Antwort bei passender Freigabe. |
| GET /bootstrap/v1/{token} | Runbezogener Starthelfer; ausschließlich über zeitlich begrenzte Download Capability. |
| POST /installer/v1/report/{token} | Optionales Proxmox Webhook; eigener Token, nur Ereignisannahme für diesen Lauf. |
| POST /agent/v1/enroll | Enrollment Secret und lokal erzeugter öffentlicher Geräteschlüssel; registriert den Runner für einen Lauf. |
| POST /agent/v1/lease | Authentifiziertes Gerät erhält begrenzte Laufberechtigung oder Status wait, stop, revoked. |
| GET /agent/v1/runs/{id}/manifest | Fixierte Schrittfolge, Parameterreferenzen, Digests, Fristen und freigegebene Artefakte. |
| GET /agent/v1/artifacts/{digest} | Nur für den eigenen Lauf autorisierte, unveränderliche Skriptpakete. |
| POST /agent/v1/runs/{id}/events | Batch mit Sequenznummern; transaktionale Speicherung und Quittierung. |
| POST /agent/v1/runs/{id}/logs | Begrenzte, redigierte Logblöcke mit Sequenz und Größenlimit. |
| POST /agent/v1/runs/{id}/complete | Abschluss mit Prüfergebnissen; serverseitige Prüfung aller Pflichtschritte. |
### Verwaltungsschnittstellen
Unter /api/v1 liegen CRUD Ressourcen für hosts, profiles, modules, releases und iso-records sowie Aktionen approve-install, resume, cancel und publish. Änderungen benötigen Benutzeranmeldung, rollenbasierte Prüfung und bei Browser Sessions CSRF Schutz. Versionierte Objekte werden mit ETag beziehungsweise Versionsnummer gegen gleichzeitiges Überschreiben geschützt.
### Fehlersemantik und Validierung
401 bedeutet ungültige Authentifizierung, 403 fehlende Freigabe, 409 mehrdeutige Zuordnung oder Zustandskonflikt, 410 abgelaufene Berechtigung, 422 ungültige Konfiguration und 503 temporäre Nichtverfügbarkeit. Für unbekannte Hosts wird niemals eine Standardantwort mit Datenträgerauswahl zurückgegeben. Fehlertexte enthalten keine Geheimnisse.
Nur der Installer Adapter akzeptiert das native Proxmox Payload. Er speichert dessen Schemahinweis und normalisiert verfügbare Identitätsfelder; nicht vorhandene Felder werden nicht erfunden. Die eigene Runner API verwendet ein separat dokumentiertes Schema. Die Anwendung garantiert keine zusätzlichen HTTP Header oder eigenen Felder seitens des unveränderten Proxmox Installers.
Antworten und Starthelfer verwenden Cache-Control: no-store. Keine Weiterleitung auf fremde Domains. GET /agent/v1/runs/{id}/secrets/{step} liefert nur Geheimnisse des autorisierten aktuellen Schritts. Größen und Ratenlimits gelten je Endpunkt; nach dem Starthelfer stehen Zugangsdaten nur in authentifizierten Requests.
## 9 Vertrauensmodell und Geheimnisse
### Hostzuordnung ist keine Geräteauthentifizierung
UUID, Seriennummer und MAC Adressen helfen bei der Zuordnung, sind aber vom anfragenden System behauptete und grundsätzlich kopierbare Werte. Auch ein in einer gemeinsamen ISO gespeicherter Token ist auslesbar. Das Standardmodell setzt daher ein kontrolliertes Provisionierungsnetz und Schutz der Installationsmedien voraus.
Bei höherem Schutzbedarf erhält jeder Host ein individuelles Installationsmedium mit eigenem Token oder ein vorab unabhängig bereitgestelltes Gerätegeheimnis. Optional ist später hardwaregestützte Attestierung möglich. Ein gemeinsamer ISO Token plus MAC Abgleich darf in der Oberfläche nicht als starker Identitätsnachweis bezeichnet werden.
| Berechtigung | Begrenzung und Lebenszyklus |
| --- | --- |
| ISO Gruppentoken | Nur Antwortabruf für einen Standort und freigegebene Hosts; Ablaufdatum, Sperrung und Ratenlimit. Keine Verwaltungsrechte. |
| Bootstrap Download Token | Nur ein Starthelfer eines Laufs. Kurzes Abruffenster mit begrenzten Wiederholungen; danach gesperrt. |
| Enrollment Secret | Nur erstmalige Bindung eines Geräteschlüssels an den bereits reservierten Lauf; Ablauf nach geplantem Installationsfenster. |
| Gerätebindung | Privater Schlüssel lokal mit Modus 0600. Requests signiert mit Nonce und Request Digest; kein Gerätezugriff auf andere Hosts. |
| Laufberechtigung | Kurzlebig und erneuerbar, etwa 15 Minuten; nur zugewiesene Module, Artefakte und Ereignisse. Terminale Läufe entziehen Änderungsrechte. |
| Betriebsgeheimnisse | Verschlüsselt gespeichert; Schlüssel außerhalb des Datenvolumes. Ausgabe nur an berechtigten Schritt und niemals in allgemeine Logs. |
### Enrollment mit sicherer Wiederholung
Der erste gültige Enrollment Request bindet das Secret atomar an einen lokal generierten Geräteschlüssel. Geht die Antwort verloren, darf derselbe Schlüssel den Vorgang erneut anfragen; ein anderer wird abgewiesen. Spätere Authentifizierung verlangt Schlüsselbesitz. Eine Umbindung ist ein Auditvorgang. Signaturen und Replay Schutz werden mit einem etablierten Verfahren umgesetzt. Nach Abschluss bleibt kurzzeitig nur die idempotente Abschlussquittierung möglich.
### TLS und Integrität
Der ISO Fingerprint schützt die Verbindung zum Antwortdienst. Starthelfer und Runner verwenden passende Vertrauensanker; Zertifikatsrotation wird mit neuen Medien und Übergangsfristen geplant. Zertifikatsprüfungen werden nicht deaktiviert. Skriptpakete werden anhand eines authentifiziert bezogenen Manifests und Inhaltsdigests geprüft.
Der Bootstrap URL Token kann in Installerprotokollen erscheinen. Deshalb ist er eine begrenzte Capability und wird in Proxy und Anwendungslogs maskiert. Reproduzierbare Starthelfer mit enthaltenem Enrollment Secret werden verschlüsselt aufbewahrt. Token Verifizierer werden gehasht gespeichert. Root Passwörter sind individuell; bevorzugt wird ein für die Zielversion zulässiger Passwort Hash übertragen.
## 10 Datenmodell und Konsistenz
| Entität | Wesentliche Felder und Beziehungen |
| --- | --- |
| Host | id, Standort, FQDN, Soll IP, Status, Profilzuordnung, gesperrt, letzter Kontakt. |
| HostIdentity | host_id, Typ, Wert, Herkunft, Prüfdatum; mehrere Identitäten je Host. |
| ProfileVersion | id, profile_id, Version, Werte, Module, Schema, Zielmatrix, Status, Digest. |
| ModuleVersion | id, module_id, Version, Artefaktdigest, check/apply/verify Vertrag, Timeout, Wiederholungsregel, Freigabenachweis. |
| InstallApproval | host_id, freigegebene ProfileVersion, Gültigkeitsfenster, Genehmiger, Neuinstallationsgrund, Verbrauchsstatus. |
| ProvisioningRun | id, host_id, approval_id, Zustand, Antwortdigest, Manifestdigest, fixierte Parameter, Beginn, Ende, Versionszähler. |
| RunStep | run_id, step_id, Position, Modulversion, Versuch, Zustand, Checkpoint, Exitcode, Prüfergebnis. |
| Credential | id, Typ, Scope, Verifizierer oder Public Key, Fristen, Sperre, Lauf oder Gruppe. |
| Artifact und Secret | Digest, Größe, Ablagepfad, Typ; Geheimnisse separat verschlüsselt mit Schlüsselreferenz. Keine Secrets im Artefaktnamen. |
| Event und LogChunk | run_id, sequence, Zeit, Gerät, Nutzdaten beziehungsweise begrenzter Logblock; zusammengesetzter eindeutiger Schlüssel. |
| AuditEvent | Akteur, Aktion, Objekt, Zeitpunkt, Änderungsgrund und redigierte Vorher Nachher Werte. |
| IsoRecord | ISO Digests, Assistant Version, Zielbuild, URL, Fingerprint, Token, Teststatus. |
### Transaktionsregeln
Freigabeprüfung, Reservierung eines Laufs und Verbrauch der Freigabe erfolgen in einer Transaktion. Ein eindeutiger Index verhindert mehrere aktive Installationsläufe je Host. Wiederholte POST Requests liefern innerhalb des zugelassenen Fensters dieselbe Antwort und vergeben weder neue IP Adressen noch neue Hostnamen.
Zustandsübergänge prüfen die gespeicherte Versionsnummer. Ereignisse werden dedupliziert gespeichert, bevor die API sie quittiert. Ein Lauf referenziert nur bereits vollständig geschriebene Artefakte. Die Veröffentlichung erfolgt durch atomare Dateiumbenennung und anschließende Datenbankreferenz; verwaiste Dateien dürfen später bereinigt werden.
### SQLite im Eincontainerbetrieb
SQLite wird auf einem lokalen Docker Volume im WAL Modus betrieben. WAL erlaubt parallele Leser und einen schreibenden Vorgang; die Datenbank gehört nicht auf ein Netzwerkdateisystem. [5] Transaktionen bleiben kurz, Logdaten werden gebündelt und Schreibkonflikte kontrolliert wiederholt. Zunächst läuft ein API Prozess mit begrenzter Hintergrundarbeit.
Für mehrere aktive Anwendungsinstanzen oder höhere Schreiblast ist eine Migration auf PostgreSQL vorgesehen. Das ist eine Änderung des Betriebsmodells und erfordert zusätzliche Infrastruktur. Eine zweite Instanz darf nicht einfach dasselbe SQLite Volume parallel verwenden.
## 11 Docker Betrieb und Wiederherstellung
### Containervertrag
Das Image enthält Weboberfläche, API, Migrationswerkzeug und feste Runner Versionen. Es läuft als nicht privilegierter Benutzer ohne Hostnetz, ohne Docker Socket und ohne Zugriff auf Hostgeräte. Das Root Dateisystem ist schreibgeschützt; nur Datenvolume und temporäre Verzeichnisse sind beschreibbar. Docker Volumes entkoppeln persistente Daten vom Containerlebenszyklus. [6]
```yaml
# Zielkonfiguration; Image und Dateien entstehen in der Umsetzung
services:
provisioner:
image: ${PROVISIONER_IMAGE}
user: "10001:10001"
restart: unless-stopped
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
ports: ["127.0.0.1:8080:8080"]
volumes:
- provisioner-data:/data
- ./config:/config:ro
- ./secrets:/run/secrets:ro
tmpfs: ["/tmp:rw,noexec,nosuid,size=128m"]
environment:
APP_CONFIG: /config/app.toml
DATA_DIR: /data
MASTER_KEY_FILE: /run/secrets/master.key
volumes:
provisioner-data:
```
Dieses Beispiel setzt einen TLS Proxy auf demselben Docker Host voraus. Bei direkter TLS Terminierung werden Zertifikat und Schlüssel eingebunden und der TLS Port passend veröffentlicht. Image Digest, Dateirechte und Eigentümer des Volumes werden im Installationspaket festgelegt. Das Beispiel ist keine bereits verfügbare Software.
### Betriebsziele und Beobachtung
Startdimensionierung: 2 vCPU, 2 GB RAM und 20 GB Datenkapazität ohne ISO Archiv; unter Last zu überprüfen. /health/live prüft den Prozess, /health/ready Datenbank und notwendige Konfiguration. Gemessen werden API Fehler, Antwortzeiten, aktive Läufe, Alter des letzten Heartbeats, Speicherplatz und Backupalter. Secrets und Logtexte werden nicht zu Metriklabels.
Als Startwerte gelten 30 Sekunden Heartbeat, 3 Minuten bis Kontakt unbekannt, 30 Tage technische Logs und 180 Tage Auditdaten. Fristen sind konfigurierbar. Ein fertiger Host darf nicht allein wegen Ablaufs einer Logaufbewahrung erneut freigegeben werden.
### Sicherung und Updates
Tägliche konsistente Sicherung über die SQLite Backup API oder einen kontrollierten Anwendungsstopp; keine isolierte Kopie nur der laufenden DB Datei. Artefakte, Konfiguration und verschlüsselte Geheimnisse gehören zur Sicherung. Der Entschlüsselungsschlüssel wird separat geschützt gesichert. Planungsziel: RPO 24 Stunden, RTO 2 Stunden.
Updates stoppen neue Freigaben, sichern den Datenstand und führen versionierte Migrationen aus. Wiederherstellung und Kompatibilität mit bereits gestarteten Runnern werden vor Produktionsfreigabe geprüft. Nach Restore werden unsichere aktive Berechtigungen gesperrt und Läufe abgeglichen, damit zurückgesetzte Zustände keine Aktion erneut auslösen.
## 12 Netzwerk und Datenträgersicherheit
### Erreichbarkeit in zwei Umgebungen
Der Installer benötigt sein eigenes erreichbares Startnetz, bevor er die Antwortdatei abrufen kann. Die darin enthaltene spätere Management IP löst dieses Problem nicht. Standard ist ein dediziertes Provisionierungs VLAN mit DHCP, DNS und korrekter Route zum Tool. Nach dem Neustart muss das Zielnetz ebenfalls HTTPS zum Tool und zu den benötigten Paketquellen erlauben.
| Verbindung | Zweck und Freigabe |
| --- | --- |
| Browser zum Tool | HTTPS aus dem Verwaltungsnetz; rollenbasierte Anmeldung. |
| Installer zum Tool | HTTPS für Antwort, Starthelfer und optionales Report Webhook; maschinelle Authentifizierung. |
| Installierter Host zum Tool | HTTPS für Enrollment, Laufberechtigung, Artefakte und Status. |
| Installer und Host zur Infrastruktur | DNS, DHCP im Startnetz, Zeitdienst und freigegebene Paketquellen gemäß Standortkonzept. |
| Tool zu externen Diensten | Nur bewusst eingerichtete Ziele, etwa Sicherungsziel oder spätere Identitätsanbieter. |
Ein erfolgreicher network-online.target garantiert nicht die Erreichbarkeit eines bestimmten Dienstes. Der Runner testet Namensauflösung, TLS Verbindung und API Antwort ausdrücklich. Wechsel von IP, Bridge, Bond oder VLAN wird in der ersten Version möglichst vollständig in der Installationskonfiguration abgebildet.
### Auswahl der Systemdatenträger
Datenträger werden anhand eines geprüften Hardwareprofils und stabiler, vom freigegebenen Installer unterstützter Eigenschaften ausgewählt. Pauschale Auswahl des ersten Geräts oder eine feste Annahme über /dev/sda ist unzulässig. Anzahl und erwartete Eigenschaften der Systemdatenträger müssen zum freigegebenen Profil passen.
Die Systeminformationen des Antwortabrufs werden nicht pauschal als vollständiges Storageinventar behandelt. Fehlen Datenträgermerkmale im nativen Payload, ist vor der Freigabe eine separate Inventarisierung beziehungsweise ein geprüfter Hardwaretyp erforderlich. Ein Postinstallationsskript kann einen bereits überschriebenen falschen Datenträger nicht nachträglich schützen.
### Risikoreiche Erweiterungen
Netzwerkänderungen nach dem ersten Start benötigen einen separaten Rückfallmechanismus mit lokaler Konfigurationssicherung, Zeitfenster und Out of Band Zugang. Vollständige Root Storage Änderungen sind Neuinstallationsvorgänge. Zusätzliche destruktive Storage Module dürfen nur ausdrücklich ausgewählte Geräte verwenden und verlangen eine eigene Freigabe.
Clusterbeitritt und Ceph werden später als koordinierte Gruppenabläufe entwickelt. Dazu gehören versionsbezogene Kompatibilitätsprüfungen, Quorum, serielle Operationen und eigene Geheimnisverwaltung. Sie werden nicht als beliebige zusätzliche Bash Zeile in ein allgemeines Basisskript aufgenommen.
## 13 Abnahmekriterien und Teststrategie
Diese Prüfaufträge definieren die Fertigstellung; Installationen wurden noch nicht ausgeführt. Unit Tests prüfen Regeln und Zustände, Vertragstests echte Installer Payloads und Integrationstests Persistenz und Fehlerfälle. Vollständige Installationen werden zunächst virtuell, dann auf einem physischen Gerät je Hardwareprofil geprüft. Ein Bash Syntaxcheck allein genügt nicht.
| Nr | Testfall | Erwartetes Ergebnis |
| --- | --- | --- |
| A01 | Bekannter und freigegebener Host | Basisinstallation und alle Pflichtmodule laufen mit fixierten Versionen bis succeeded durch. |
| A02 | Unbekannter oder gesperrter Host | Keine verwendbare Antwortdatei; keine Freigabe destruktiver Installation durch das Tool. |
| A03 | Doppelte oder widersprüchliche Identität | Zuordnung abgelehnt, Konflikt sichtbar, keine automatische Auswahl eines Hosts. |
| A04 | Parallele und wiederholte Antwortabrufe | Ein Lauf und dieselbe Konfiguration; keine doppelte Vergabe von IP oder Hostname. |
| A05 | Falsches oder abgelaufenes Zertifikat | Abruf scheitert geschlossen; kein Ausweichen auf unverschlüsselten oder ungeprüften Zugriff. |
| A06 | Falscher, gesperrter oder fremder Token | 401 beziehungsweise 403; kein Zugriff auf fremde Antwort, Artefakte oder Logs. |
| A07 | Manipuliertes Skriptpaket | Digestprüfung schlägt fehl; keine Ausführung und nachvollziehbarer Fehler. |
| A08 | Verlust der Enrollment Antwort | Wiederholung mit demselben Schlüssel funktioniert; anderer Schlüssel bleibt gesperrt. |
| A09 | Netzausfall und Containerneustart | Runner puffert Ereignisse; Zustand bleibt erhalten; keine unkontrollierte Modulwiederholung. |
| A10 | Stromausfall während eines Schritts | Istzustand wird geprüft; sichere Wiederaufnahme oder needs_review, keine falsche Erfolgsmeldung. |
| A11 | Geplanter Neustart | Rückkehr in denselben Lauf; abgeschlossene Schritte werden nicht erneut angewendet. |
| A12 | Erneutes Booten der Installations ISO | Nach Sperre beziehungsweise Abschluss keine neue Antwort ohne neue ausdrückliche Freigabe. |
| A13 | Gleichzeitige Profiländerung | Aktiver Lauf behält Manifest und Versionen; neuer Lauf nutzt erst die neue Veröffentlichung. |
| A14 | Zehn parallele Läufe | Keine Zuordnungsfehler oder verlorenen Ereignisse; p95 Antwortzeit der Steuer API unter 2 s im definierten Testnetz. |
| A15 | Backup und Restore | Vollständige Wiederherstellung einschließlich Schlüssel; RPO und RTO nachgewiesen; aktive Läufe sicher abgeglichen. |
| A16 | Jeder freigegebene ISO Build | Antwortschema, Token, First Boot, Startnetz, UEFI und gegebenenfalls Secure Boot praktisch geprüft. |
## 14 Umsetzung und offene Festlegungen
### Umsetzung in fünf Arbeitspaketen
| Paket | Ergebnis | Abschlussbedingung |
| --- | --- | --- |
| 1 Technischer Nachweis | Ein Ziel ISO Build, Antwortendpoint, sicherer Starthelfer und ein Testmodul | Installation bis zu einer verifizierten Rückmeldung demonstriert |
| 2 Fachlicher Kern | Inventar, Profile, Modulversionen, Freigaben und Datenmodell | Zuordnung und Konfigurationsauflösung mit Fehlerfällen geprüft |
| 3 Robuste Ausführung | Runner, Enrollment, Checkpoints, Reboots, Ereignisse und Statusanzeige | Unterbrechungs und Wiederaufnahmetests bestehen |
| 4 Betriebsreife | Rollen, Audit, Geheimnisse, Containerpaket, Backup und Upgradepfad | Sicherheits und Wiederherstellungskriterien erfüllt |
| 5 Pilotbetrieb | Test auf realer Zielhardware, Betriebsanleitung und Supportmatrix | Alle zutreffenden Abnahmekriterien erfüllt und Betreiberfreigabe erteilt |
Liefergegenstände der Umsetzung sind Quellcode, versioniertes Containerimage, Compose Beispiel, Datenbankschema mit Migrationen, OpenAPI Spezifikation, Starthelfer und Runner, Basismodule, ISO Vorbereitungsanleitung, getestete Kompatibilitätsmatrix sowie Betriebs und Wiederherstellungsanleitung. Eine belastbare Aufwandsschätzung folgt nach dem technischen Nachweis und der Festlegung der Module.
### Vor Entwicklungsbeginn festzulegen
| Entscheidung | Vorgesehener Ausgangspunkt |
| --- | --- |
| Proxmox Zielversionen | Ein konkret getesteter aktueller ISO Build zum Start; weitere Builds nur mit Nachweis. |
| Start und Zielnetz | Dediziertes DHCP Provisionierungsnetz, erreichbare feste HTTPS Adresse des Tools. |
| Hardware und Storage | Freigegebene Servertypen; je Typ explizite Systemdatenträger und Dateisystemkonfiguration. |
| Identitätsniveau | Gruppen ISO im kontrollierten Netz; für höhere Anforderungen individuelles Medium oder unabhängiges Gerätegeheimnis. |
| Authentifizierung der Benutzer | Lokale Konten und Rollen zum Start; OIDC als optionale Erweiterung. |
| Konkrete Skriptmodule | Repositories, Pakete, SSH, Zeit, Monitoring und Abschlussprüfung; genaue Produkte und Werte festlegen. |
| TLS und Aufbewahrung | Vorhandenen Proxy nutzen, sofern verfügbar; Fristen und Backupziele an Betriebsvorgaben anpassen. |
### Abgleich mit vorhandenen Proxmox Funktionen
Proxmox Datacenter Manager dokumentiert inzwischen vorbereitete Antworten, Zielfilter, Installationsübersicht und eigene Installationstokens. [3] Falls diese Lösung bereits vorhanden ist, sollte vor Eigenentwicklung geprüft werden, welche Funktionen sie abdeckt. Der in diesem Konzept ausgearbeitete Eigenbau erfüllt die Docker Vorgabe und legt zusätzlich die hostbezogene Postinstallation mit Checkpoints und Laufsteuerung fest.
## 15 Quellen und technische Nachweise
Die Quellen belegen die verwendeten Produktmechanismen. Datenmodell, eigene API Pfade, Rollen, Fristen, Betriebsziele und Wiederaufnahmelogik sind Festlegungen dieses Feinkonzepts. Vor einer Implementierungsfreigabe werden die Mechanismen mit den tatsächlich eingesetzten ISO und Paketversionen geprüft.
[\[1\] Proxmox VE Wiki Automated Installation](https://pve.proxmox.com/wiki/Automated_Installation)
Dokumentierter Antwortabruf, Authentifizierung und First Boot Optionen. Stand des recherchierten Wiki Eintrags: 14. Juli 2026. Referenz für den versionsbezogenen Adapter und die vollständige Antwortdatei.
[\[2\] Proxmox Entwicklerarchiv First Boot Hook Implementierung](https://lists.proxmox.com/pipermail/pve-devel/2024-November/066649.html)
Primärer Implementierungsnachweis vom 18. November 2024: Abruf des First Boot Executables im Installer, Quellenwahl und Ausführungsreihenfolge. Der Beitrag ist ein historischer Patch; die Produktfreigabe stützt sich zusätzlich auf den praktischen Test des gewählten Builds.
[\[3\] Proxmox Datacenter Manager Automated Installations](https://pdm.proxmox.com/docs/automated-installations.html)
Offizielle Dokumentation zu vorbereiteten Antworten, Installationsinformationen, Tokenverwaltung und ISO Vorbereitung. Recherchierte Dokumentationsversion: 1.1.7.
[\[4\] FastAPI Features](https://fastapi.tiangolo.com/features/)
Offizielle Funktionsbeschreibung zu OpenAPI, JSON Schema und API Dokumentation.
[\[5\] SQLite Write Ahead Logging](https://www.sqlite.org/wal.html)
Offizielle Beschreibung des WAL Modus und seiner Einschränkungen, insbesondere bei gemeinsam genutzten Netzwerkdateisystemen und Schreibzugriffen.
[\[6\] Docker Volumes](https://docs.docker.com/engine/storage/volumes/)
Offizielle Beschreibung persistenter Volumes und ihrer Unabhängigkeit vom Containerlebenszyklus.
### Wichtige Nachweise aus dem Pilotbetrieb
Für jede unterstützte Kombination werden ISO Digest, Assistant Paketversion, Installer Payload Muster, validierte Antwortdatei ohne Geheimnisse, First Boot Protokoll, Runner Version und Ergebnis der Abnahmetests archiviert. Diese Nachweise bilden die Supportmatrix und verhindern, dass eine ungetestete Versionsänderung automatisch für produktive Installationen verwendet wird.
+160
View File
@@ -0,0 +1,160 @@
# Proxmox AIS
Webtool für kontrollierte Proxmox-Neuinstallationen und wiederaufnehmbare
Postinstallation auf Basis des [Feinkonzepts](Feinkonzept_Proxmox_Provisionierung.md).
Ein FastAPI-Dienst bündelt Inventar, versionierte Profile und Bash-Module,
Installationsfreigaben, ISO-Registrierung, Laufprotokolle und Auditdaten.
SQLite und unveränderliche Artefakte liegen in einem persistenten Datenverzeichnis;
der Schlüssel für verschlüsselte Geheimnisse liegt separat.
**Unabhängigkeitshinweis:** Proxmox AIS ist ein unabhängiges Projekt und steht in keiner Verbindung zur Proxmox Server Solutions GmbH oder den Entwicklern von Proxmox Virtual Environment.
**Status: erste Implementierung für die Laborabnahme.**
Es ist noch kein echter Proxmox-ISO-Build auf Hardware oder in einer VM abgenommen.
Die [Kompatibilitätsmatrix](docs/compatibility.md) trennt Softwaretests von noch
offenen Installationstests. Freigaben können Datenträger überschreiben lassen;
für die erste Abnahme ausschließlich dedizierte Testsysteme verwenden.
## Lokal starten
Python 3.12 oder neuer wird benötigt. Die Anwendung selbst läuft unter Windows
und Linux; der Host-Runner benötigt Proxmox/Linux mit systemd, Python und Bash.
PowerShell:
```powershell
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -e ".[dev]"
$env:PUBLIC_URL = "http://127.0.0.1:8080"
$env:SECURE_COOKIES = "false"
.\.venv\Scripts\proxmox-ais.exe init --username admin
.\.venv\Scripts\proxmox-ais.exe serve
```
Linux/macOS:
```bash
python3 -m venv .venv
.venv/bin/python -m pip install -e '.[dev]'
export PUBLIC_URL=http://127.0.0.1:8080
export SECURE_COOKIES=false
.venv/bin/proxmox-ais init --username admin
.venv/bin/proxmox-ais serve
```
`init` fragt das Passwort zweimal verdeckt ab, speichert einen scrypt-Hash und
legt `secrets/master.key` mit restriktiven Rechten an. Anschließend
<http://127.0.0.1:8080> öffnen. Es gibt keine mitgelieferten Zugangsdaten.
Die HTTP-Einstellungen sind ausschließlich für die lokale Entwicklung gedacht.
Optional `config/app.example.toml` kopieren und dessen Pfad über `APP_CONFIG`
setzen. Umgebungsvariablen überschreiben TOML-Werte. Weitere Einstellungen
und TLS-Konfiguration stehen in der [Betriebsanleitung](docs/operations.md).
## Registry-Container deployen
Auf dem Zielserver werden Docker Engine, das Compose-Plugin und ein
HTTPS-Reverse-Proxy benötigt. Die Anwendung wird als fertiges Image aus der
GitHub Container Registry (GHCR) heruntergeladen. Quellcode und Build-Werkzeuge werden
für das Deployment nicht benötigt.
[compose.yaml](compose.yaml) und die [Umgebungsvorlage](config/deployment.env.example)
in dasselbe Verzeichnis auf dem Zielserver kopieren; die Vorlage dort als `.env`
speichern. `PUBLIC_URL` auf die erreichbare HTTPS-URL der Anwendung setzen.
Die vollständige `PROVISIONER_IMAGE=…@sha256:…`-Zeile aus `deploy.env` im Artefakt
`container-deploy` eines erfolgreichen `container-publish`-Jobs in `.env` übernehmen. Alternativ
einen bereits veröffentlichten Versionstag aus der Projektregistry verwenden.
Compose benötigt ausdrücklich `PROVISIONER_IMAGE`; ein lokaler Build ist nicht
Teil dieser Deployment-Konfiguration.
Im Deployment-Verzeichnis ausführen. Für ein privates GHCR-Paket beim Login den
eigenen GitHub-Benutzernamen und einen Personal Access Token (classic) mit
`read:packages` als Passwort verwenden. Bei einem öffentlichen Paket entfällt
der Login. Details stehen unter [Registry-Anmeldung](docs/deployment.md#an-der-registry-anmelden-und-image-laden).
```bash
# Nur für ein privates Paket; GITHUB_USERNAME ersetzen:
docker login ghcr.io --username GITHUB_USERNAME
docker compose config --quiet
docker compose pull provisioner
# Nur bei der ersten Inbetriebnahme:
docker compose run --rm --no-deps --volume proxmox-ais-keys:/run/secrets:rw provisioner init --username admin
docker compose up -d --no-build --wait
docker compose ps
```
Der HTTPS-Reverse-Proxy leitet auf `127.0.0.1:8080` weiter. Nur `init` bindet das
Schlüsselvolume schreibbar ein; im Regelbetrieb läuft das Image als UID/GID 10001
mit schreibgeschütztem Dateisystem. Die Daten liegen in `proxmox-ais-data`, der
Master-Key getrennt in `proxmox-ais-keys`.
Die [Deployment-Anleitung](docs/deployment.md) beschreibt Image-Auswahl,
Erstinitialisierung, Updates, Rollback und die Prüfung des laufenden Containers.
## GitHub Actions CI/CD
Der [GitHub-Actions-Workflow](.github/workflows/ci.yml) prüft Python und JavaScript, baut das Image
und testet HTTPS, Anmeldung und Datenerhalt beim Neustart im gehärteten Container.
Geschützte Standardbranch-Pushes veröffentlichen `sha-<Commit>` und `edge` in
`ghcr.io/netivra/proxmox-ais`; geschützte Release-Tags wie `v0.1.0` zusätzlich
die passende Versionsnummer. Feature-Branches und Pull Requests werden ohne Push geprüft.
Die Jobs verwenden GitHub-gehostete Runner mit `ubuntu-24.04`, Python 3.13,
Node.js 24 und Docker/Buildx. Ein eigener CI-Runner ist nicht erforderlich.
Für die Veröffentlichung müssen der Standardbranch und die Release-Tags durch
aktive GitHub-Regeln geschützt sein. GHCR erhält das automatisch bereitgestellte
`GITHUB_TOKEN`; ein eigenes CI-Secret wird nicht benötigt. Einrichtung,
Schutzregeln und Artefakte stehen in der [GitHub-Anleitung](docs/github-actions.md).
Die Pipeline liefert das getestete Image und dessen Digest in `deploy.env`.
Die Installation auf dem Zielserver folgt der [Deployment-Anleitung](docs/deployment.md).
## Erster Installationslauf
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
`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
und Medium erfassen. Vorschau und geprüfte Zielgeräte kontrollieren.
6. Eine zeitlich begrenzte Freigabe mit FQDN-Bestätigung und ausdrücklicher
Datenträgerbestätigung erteilen. Erst anschließend vom vorbereiteten Medium booten.
7. Laufstatus und redigierte Logs verfolgen. Der Lauf gilt erst nach erfolgreichen
Pflichtprüfungen als abgeschlossen.
Die [ISO-Anleitung](docs/iso-preparation.md) beschreibt den offiziellen Assistant;
der [API-Ablauf](docs/api-workflow.md) zeigt konkrete Requests. Die vollständigen
Schemas stehen nach Anmeldung am laufenden Dienst unter `/openapi.json`; ein
generierter Stand liegt in [docs/openapi.json](docs/openapi.json).
## Prüfen und sichern
```bash
.venv/bin/python -m pytest
.venv/bin/proxmox-ais backup ./backups/first-snapshot
```
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 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
offline in ein leeres Datenverzeichnis und sperrt alte Maschinenberechtigungen.
Details stehen in [Betrieb und Wiederherstellung](docs/operations.md).
## Umfang und Grenzen
ISO-Erstellung erfolgt auf einer getrennten Build-Maschine. BMC-Steuerung, PXE,
Clusterbeitritt, Ceph, dauerhafte Konfigurationsverwaltung und zusätzliche
destruktive Storage-Module gehören nicht zu dieser Version. Die Kapazitäten
von 100 Hosts und zehn parallelen Installationen sind Planungsziele,
keine bereits gemessenen Leistungswerte. Gemeinsame ISO-Tokens plus gemeldete
Hardwaremerkmale setzen ein kontrolliertes Provisionierungsnetz voraus.
+147
View File
@@ -0,0 +1,147 @@
#!/bin/sh
# Build and smoke-test the same image that is optionally published to GHCR.
set -eu
fail() {
printf '%s\n' "ERROR: $*" >&2
exit 1
}
mode=${1:-}
case "$mode" in
verify|publish) ;;
*) fail 'Usage: sh ci/container.sh verify|publish' ;;
esac
: "${GITHUB_SHA:?GITHUB_SHA is required}"
: "${GITHUB_RUN_ID:?GITHUB_RUN_ID is required}"
: "${GITHUB_RUN_ATTEMPT:?GITHUB_RUN_ATTEMPT is required}"
: "${GITHUB_JOB:?GITHUB_JOB is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
: "${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}"
printf '%s\n' "$GITHUB_SHA" | grep -Eq '^[0-9a-f]{40}$' || fail 'Expected a full Git commit SHA'
for run_identifier in "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT"; do
case "$run_identifier" in
''|*[!0-9]*) fail 'GitHub run ID and attempt must be numeric' ;;
esac
done
case "$GITHUB_JOB" in
''|[!A-Za-z_]*|*[!A-Za-z0-9_-]*) fail 'GITHUB_JOB must be a safe job identifier' ;;
esac
printf '%s\n' "$GITHUB_REPOSITORY" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9_][A-Za-z0-9_.-]*$' || fail 'Expected GITHUB_REPOSITORY in owner/repository form'
registry_image="ghcr.io/$(printf '%s' "$GITHUB_REPOSITORY" | LC_ALL=C tr '[:upper:]' '[:lower:]')"
project_url="${GITHUB_SERVER_URL%/}/${GITHUB_REPOSITORY}"
job_identifier="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${GITHUB_JOB}"
# Read the literal PEP 621 version without needing the test job's virtual
# environment, installing dependencies, or evaluating project code.
project_version=$(awk '
/^\[project\][[:space:]]*$/ { in_project = 1; next }
/^\[/ { in_project = 0 }
in_project && /^[[:space:]]*version[[:space:]]*=/ {
value = $0
sub(/^[^=]*=[[:space:]]*"/, "", value)
sub(/"[[:space:]]*(#.*)?$/, "", value)
print value
exit
}
' pyproject.toml)
[ -n "$project_version" ] || fail 'Cannot read [project].version from pyproject.toml'
publish_tag=
if [ "$mode" = publish ]; then
[ "${GITHUB_REF_PROTECTED:-}" = true ] || fail 'Publication requires a protected branch or tag'
case "${GITHUB_EVENT_NAME:-}" in
push|workflow_dispatch) ;;
*) fail 'Publication is allowed only from push or workflow_dispatch events' ;;
esac
case "${GITHUB_REF_TYPE:-}" in
tag)
printf '%s\n' "${GITHUB_REF_NAME:-}" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' || fail 'Release tags must have the form vX.Y.Z'
publish_tag=${GITHUB_REF_NAME#v}
[ "$publish_tag" = "$project_version" ] || fail "Release tag $GITHUB_REF_NAME does not match project version $project_version"
;;
branch)
[ -n "${DEFAULT_BRANCH:-}" ] && [ "${GITHUB_REF_NAME:-}" = "$DEFAULT_BRANCH" ] || fail 'Only the default branch or a version tag may publish'
publish_tag=edge
;;
*) fail 'Publication requires a branch or tag ref' ;;
esac
: "${GITHUB_ACTOR:?GITHUB_ACTOR is required for publication}"
: "${GHCR_TOKEN:?GHCR_TOKEN is required for publication}"
fi
BUILD_IMAGE="${registry_image}:ci-${job_identifier}"
smoke_container="ais-smoke-${job_identifier}"
smoke_started=false
build_started=false
docker_config_dir=
cleanup() {
if [ "$smoke_started" = true ]; then
docker rm --force "$smoke_container" >/dev/null 2>&1 || true
fi
if [ "$build_started" = true ]; then
# A self-hosted runner may use a persistent daemon. Remove only this job's
# unique tag; published SHA/channel tags may be used by other jobs.
docker image rm "$BUILD_IMAGE" >/dev/null 2>&1 || true
fi
if [ -n "$docker_config_dir" ]; then
rm -f "$docker_config_dir/config.json"
rmdir "$docker_config_dir" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
rm -f build.env deploy.env
build_started=true
# Load one single-platform image locally so the smoke test and both published
# tags all use the exact image built here, without separate manifest artifacts.
docker build --pull --platform linux/amd64 \
--provenance=false --sbom=false \
--label "org.opencontainers.image.source=$project_url" \
--label "org.opencontainers.image.revision=$GITHUB_SHA" \
--label "org.opencontainers.image.version=$project_version" \
--tag "$BUILD_IMAGE" .
# Stream the smoke script through stdin, so no checkout bind mount is needed.
smoke_started=true
docker run --rm --interactive --name "$smoke_container" \
--read-only --cap-drop ALL --security-opt no-new-privileges:true \
--network none --tmpfs /tmp:rw,noexec,nosuid,size=128m \
--entrypoint python "$BUILD_IMAGE" - < tests/container_smoke.py
printf 'BUILD_IMAGE=%s\nIMAGE_REVISION=%s\nIMAGE_VERSION=%s\n' \
"$BUILD_IMAGE" "$GITHUB_SHA" "$project_version" > build.env
if [ "$mode" = publish ]; then
docker_config_dir=$(mktemp -d "${TMPDIR:-/tmp}/ais-ci-docker.XXXXXXXX")
DOCKER_CONFIG=$docker_config_dir
export DOCKER_CONFIG
printf '%s' "$GHCR_TOKEN" | docker login ghcr.io \
--username "$GITHUB_ACTOR" --password-stdin
commit_image="${registry_image}:sha-${GITHUB_SHA}"
channel_image="${registry_image}:${publish_tag}"
docker tag "$BUILD_IMAGE" "$commit_image"
docker push "$commit_image"
docker tag "$BUILD_IMAGE" "$channel_image"
docker push "$channel_image"
# Tags can be reassigned by a later rebuild; use the registry digest for
# a deployment reference that keeps identifying precisely this image.
repo_digests=$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$BUILD_IMAGE")
image_digest=
while IFS= read -r candidate; do
case "$candidate" in
"$registry_image"@sha256:*) image_digest=$candidate; break ;;
esac
done <<EOF
$repo_digests
EOF
[ -n "$image_digest" ] || fail 'Published image has no registry digest'
printf '%s\n' "${image_digest#*@sha256:}" | grep -Eq '^[0-9a-f]{64}$' || fail 'Registry returned an invalid image digest'
printf 'PROVISIONER_IMAGE=%s\n' "$image_digest" > deploy.env
printf 'Published %s and %s\nDeployment image: %s\n' "$commit_image" "$channel_image" "$image_digest"
fi
+51
View File
@@ -0,0 +1,51 @@
#!/bin/sh
# Run tests in an isolated, disposable environment on a Linux Actions runner.
set -eu
fail() {
printf '%s\n' "ERROR: $*" >&2
exit 1
}
: "${GITHUB_WORKSPACE:?GITHUB_WORKSPACE is required}"
: "${GITHUB_RUN_ID:?GITHUB_RUN_ID is required}"
: "${GITHUB_RUN_ATTEMPT:?GITHUB_RUN_ATTEMPT is required}"
: "${GITHUB_JOB:?GITHUB_JOB is required}"
for run_identifier in "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT"; do
case "$run_identifier" in
''|*[!0-9]*) fail 'GitHub run ID and attempt must be numeric' ;;
esac
done
case "$GITHUB_JOB" in
''|[!A-Za-z_]*|*[!A-Za-z0-9_-]*) fail 'GITHUB_JOB must be a safe job identifier' ;;
esac
job_identifier="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${GITHUB_JOB}"
for required_tool in python3 bash openssl ssh-keygen; do
command -v "$required_tool" >/dev/null 2>&1 || fail "Required runner tool is missing: $required_tool"
done
python3 -c 'import sys; sys.version_info >= (3, 12) or sys.exit("Python 3.12 or newer is required")'
cd "$GITHUB_WORKSPACE"
project_root=$(pwd -P)
[ -f pyproject.toml ] || fail 'GITHUB_WORKSPACE does not contain pyproject.toml'
# Resolve the checkout first and allocate a fresh directory inside it. The
# cleanup target is never taken from an arbitrary environment-provided path.
venv_dir=$(mktemp -d "$project_root/.venv-ci-${job_identifier}.XXXXXXXX")
cleanup() {
cleanup_status=$?
trap - EXIT
case "$venv_dir" in
"$project_root"/.venv-ci-"$job_identifier".*) rm -rf -- "$venv_dir" ;;
*) printf '%s\n' 'ERROR: Refusing to clean an unexpected virtual environment path' >&2 ;;
esac
exit "$cleanup_status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
python3 -m venv "$venv_dir"
"$venv_dir/bin/python" -m pip install '.[dev]'
mkdir -p reports
"$venv_dir/bin/python" -m pytest --junitxml=reports/pytest.xml
+29
View File
@@ -0,0 +1,29 @@
services:
provisioner:
image: ${PROVISIONER_IMAGE:?PROVISIONER_IMAGE in .env auf das Image aus der GitHub Container Registry setzen}
restart: unless-stopped
init: true
ports:
- "127.0.0.1:8080:8080"
environment:
DATA_DIR: /var/lib/proxmox-ais
MASTER_KEY_FILE: /run/secrets/master.key
PUBLIC_URL: ${PUBLIC_URL:-https://provision.example.net}
SECURE_COOKIES: "true"
MAINTENANCE: ${MAINTENANCE:-false}
volumes:
- ais-data:/var/lib/proxmox-ais
- ais-keys:/run/secrets:ro
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
volumes:
ais-data:
name: proxmox-ais-data
ais-keys:
name: proxmox-ais-keys
+8
View File
@@ -0,0 +1,8 @@
# Optional configuration: set APP_CONFIG to this file's absolute path.
# Environment variables override these values.
data_dir = "./data"
master_key_file = "./secrets/master.key"
public_url = "https://provision.example.net"
secure_cookies = true
# Supply the initial administrator interactively with `proxmox-ais init`.
# Keep the encryption key outside data_dir and back it up separately.
+8
View File
@@ -0,0 +1,8 @@
# Extern erreichbare HTTPS-URL der Anwendung (ohne Unterpfad).
PUBLIC_URL=https://provision.example.net
MAINTENANCE=false
# Durch die vollstaendige PROVISIONER_IMAGE-Zeile aus deploy.env im GitHub-
# Actions-Artefakt container-deploy ersetzen. Alternativ einen vorhandenen
# Versionstag aus GHCR verwenden. Der Beispieltag muss veroeffentlicht sein.
PROVISIONER_IMAGE=ghcr.io/netivra/proxmox-ais:0.1.0
+166
View File
@@ -0,0 +1,166 @@
# API-Ablauf im Labor
Die API verwendet Sessioncookies und für alle Verwaltungsänderungen
`X-CSRF-Token`. Die Beispiele benötigen Bash, curl und jq. Für PowerShell steht
die gleiche API über `Invoke-RestMethod` zur Verfügung. Das vollständige Schema
ist nach Anmeldung unter `/openapi.json` und als generierter Repositorystand in
[openapi.json](openapi.json) verfügbar.
## Anmelden und Daten lesen
```bash
export AIS_URL=https://provision.example.net
umask 077
read -rsp 'Administrator-Passwort: ' AIS_PASSWORD
printf '\n'
printf '%s' "$AIS_PASSWORD" | curl --fail --silent --show-error \
--cookie-jar session.cookies --data-urlencode username=admin \
--data-urlencode password@- "$AIS_URL/auth/login" --output /dev/null
unset AIS_PASSWORD
AIS_CSRF=$(curl --fail --silent --show-error --cookie session.cookies \
"$AIS_URL/api/v1/me" | jq -r .csrf_token)
curl --fail --silent --show-error --cookie session.cookies "$AIS_URL/api/v1/hosts"
```
Cookies wie Zugangsdaten behandeln und nach Ende der Sitzung löschen.
Der Server liefert bei nicht angemeldeten API-Aufrufen HTTP 401 und bei
unzulässigen Rollen oder fehlendem CSRF-Token HTTP 403.
## Profilversion anlegen
Zuerst einen Root-Passwort-Hash als Geheimnis in der Weboberfläche speichern.
Unter Linux kann ein SHA-512-crypt-Hash etwa mit `openssl passwd -6` interaktiv
erzeugt werden. Den Hash nicht als Klartextfeld in ein Profil aufnehmen.
[sample-profile.json](sample-profile.json) kopieren und alle Platzhalter anpassen:
Secret-ID, konkret geprüfter Build, Management-Interface, Datenträgerseriennummer
und Inventarisierungsnachweis. `9.1-1` dient lediglich als Formatbeispiel und ist
keine Kompatibilitätsfreigabe. FQDN und Management-IP kommen aus dem Hostinventar.
```bash
curl --fail --silent --show-error --cookie session.cookies \
--header "X-CSRF-Token: $AIS_CSRF" \
--json @my-installation-profile.json "$AIS_URL/api/v1/profiles"
```
Die Antwort enthält `id`, `version`, `digest` und `status=draft`. Ein weiterer
POST mit demselben Namen und Typ erstellt eine neue unveränderliche Version.
Bereits vorbereitete Läufe behalten die zuvor aufgelösten Werte und Versionen.
Veröffentlichung erfolgt in einer Sitzung einer anderen berechtigten Person:
```bash
curl --fail --silent --show-error --cookie reviewer.cookies \
--header "X-CSRF-Token: $REVIEWER_CSRF" \
--json '{"test_evidence":"lab-report-2026-09-13","reason":"Labortest erfolgreich geprüft"}' \
"$AIS_URL/api/v1/profiles/$PROFILE_ID/publish"
```
Module werden analog über `POST /api/v1/modules` und
`POST /api/v1/modules/{id}/publish` versioniert und veröffentlicht. Ausgangsvorlagen
stehen unter `GET /api/v1/modules/builtin`. Sie enthalten keinen ausgefüllten
Testnachweis und benötigen Anpassung sowie Prüfung auf dem Zielhost.
Ein Postinstallationsprofil verweist auf unveränderliche Modul-IDs:
```json
{
"name": "lab-postinstall",
"kind": "postinstall",
"target_builds": ["9.1-1"],
"steps": [
{
"id": "final-check",
"module_id": "REPLACE_WITH_PUBLISHED_MODULE_ID",
"parameters": {},
"required": true
}
],
"reboot_budget": 1
}
```
Modulabhängigkeiten müssen bereits in vorherigen Schritten enthalten sein.
Mindestens eine Pflichtprüfung wird verlangt. Modulparameter werden gegen das
JSON-Schema der gewählten Modulversion validiert.
## Host prüfen und Installation freigeben
Gruppentoken, geprüftes Medium und beide veröffentlichten Profile müssen bereits
existieren. Der Host verweist über `installation_profile_id`,
`postinstall_profile_id` und `iso_id` auf diese Objekte.
```bash
curl --fail --silent --show-error --cookie session.cookies \
"$AIS_URL/api/v1/hosts/$HOST_ID/preview"
curl --fail --silent --show-error --cookie session.cookies \
--header "X-CSRF-Token: $AIS_CSRF" \
--json '{"expected_version":1,"valid_minutes":30,"confirmation":"pve01.lab.example.net","disks_confirmed":true,"reason":"Freigegebener dedizierter Labortest"}' \
"$AIS_URL/api/v1/hosts/$HOST_ID/approve-install"
```
Vor dem zweiten Aufruf tatsächlichen FQDN, aktuelle Hostversion und dargestellte
Zielgeräte prüfen. Die Aktion erzeugt einen vorbereiteten Lauf und bindet die
Konfiguration. Ein zweiter gleichzeitiger aktiver Lauf für denselben Host ist gesperrt.
Der Proxmox-Installer sendet seinen nativen Request selbst. Für einen isolierten
API-Test zeigt dieses Beispiel die verwendete Payload-Struktur:
```json
{
"$schema": {"version": "1.0"},
"product": {"product": "pve"},
"iso": {"release": "9.1", "build": "1"},
"dmi": {
"system": {
"uuid": "d2e59b03-13cf-4ac9-a390-78c55f6a36d3",
"serial": "LAB-HOST-001"
}
},
"network-interfaces": [{"mac": "02:00:00:00:00:01"}]
}
```
Der echte Installer darf weitere Hardwarefelder senden. Registrierung und
Antwortschema stammen aus den
[offiziellen Installer-Typen](https://github.com/proxmox/proxmox-rs/blob/master/proxmox-installer-types/src/answer.rs).
Ein unbekannter Host erhält keine TOML-Antwort. Der Gruppentoken muss zum Standort
und zum freigegebenen Medium des Hosts passen.
```bash
curl --fail --silent --show-error \
--header "Authorization: Bearer $INSTALLER_GROUP_TOKEN" \
--json @installer-info.json "$AIS_URL/installer/v1/answer" \
--output answer.toml
```
Dieser Abruf verbraucht eine Freigabe. `answer.toml` enthält den Root-Hash und
runbezogene Downloadberechtigungen; wie Zugangsdaten schützen. Innerhalb des
kurzen Auslieferungsfensters liefern Wiederholungen dieselbe Antwort.
## Runner und Status
Der Starthelfer richtet den Runner automatisch ein. Das Gerät erzeugt einen
eigenen Ed25519-Schlüssel, registriert dessen öffentlichen Anteil und signiert
weitere Requests einschließlich Methode, Pfad, Zeitstempel, Nonce und Body-Digest.
Ein gewöhnlicher curl-Aufruf mit Installer-Gruppentoken berechtigt nicht zum
Manifest-, Artefakt- oder Secret-Abruf.
```bash
curl --fail --silent --show-error --cookie session.cookies \
"$AIS_URL/api/v1/runs/$RUN_ID"
```
Die Laufansicht enthält Schrittzustände, redigierte Logs und die fixierte
Konfiguration. Fortsetzung und Abbruch benötigen `expected_version` und einen
Begründungstext über `/api/v1/runs/{id}/resume` bzw. `/cancel`. Eine Fortsetzung
erst nach Prüfung des lokalen Zustands anfordern. Ein bereits laufender
Paketmanager wird beim Abbruch am nächsten sicheren Übergang angehalten.
Für einen dauerhaft verlassenen Lauf steht `/api/v1/runs/{id}/reconcile` zur
Verfügung. Zuerst den Abbruch anfordern, ausgestellte Leases und das
Antwortauslieferungsfenster ablaufen lassen sowie Installer und Runner lokal
stoppen und prüfen. Danach `expected_version`, `reason`, `confirmation` mit dem
Host-FQDN und `execution_stopped: true` senden. Diese gesonderte Bestätigung beendet
den Lauf als `cancelled`; eine neue Installation benötigt anschließend eine
eigene Freigabe. Der Ablauf ist auch für `needs_review` nach Restore vorgesehen.
+44
View File
@@ -0,0 +1,44 @@
# Kompatibilität und Abnahme
Es gibt zum Auslieferungszeitpunkt **keinen auf Hardware oder in einer VM
freigegebenen Proxmox-ISO-Build**. Die Registrierung `test_status=passed` ist eine
vom Administrator dokumentierte Freigabe; sie ersetzt keinen tatsächlichen Test.
| Kombination | Status | Nachweis |
| --- | --- | --- |
| API/SQLite/Sicherheitslogik, Python 3.14 unter Windows | Automatisierte lokale Softwaretests | `python -m pytest` |
| Python 3.13 im bereitgestellten Dockerfile | Image gebaut; Tests, TLS-Start und Dienstneustart bestanden | Ergebnisse im [Testbericht](test-report.md) |
| Python 3.12+ | Deklarierte Python-Untergrenze; keine vollständige CI-Matrix | Auf gewählter Version testen |
| Proxmox VE ISO mit nativem Answer-Token und First-Boot-URL | Pro konkretem ISO-Build zu testen | Noch offen |
| UEFI, BIOS und Secure Boot | Je verwendetem Bootmodus zu testen | Noch offen |
| Netzunterbrechung/Neustart auf echtem Proxmox-Host | Runner-Softwaretests plus erforderlicher Integrationstest | Hardware-/VM-Test offen |
| 100 Hosts, zehn gleichzeitige Installationen | Planungsziel | Lasttest offen |
| RPO 24 Stunden / RTO zwei Stunden | Planungsziel | Betrieblicher Restore-Test offen |
Ein ISO ohne native Unterstützung für `--answer-auth-token` wird nicht freigegeben.
Die Assistant-Version allein belegt nicht die Funktionen der Komponenten in einer
älteren ISO. Alle Beispieldaten sind Platzhalter und stellen keine Freigabe dar.
## Freigabe eines Builds
Für jeden Build ein eigenes Prüfprotokoll außerhalb der Anwendung archivieren:
- Original-ISO und SHA-256, Assistant-Paketversion, Build-Befehl, Ergebnisdatei
und Prüfsumme; Tokens im Protokoll redigieren.
- Installer-Request als redigiertes JSON; UUID-, Seriennummer- und MAC-Zuordnung
sowie Ablehnung unbekannter und widersprüchlicher Identitäten.
- Antwortdatei ohne Geheimnisse, Prüfung mit dem zugehörigen Assistant, korrekte
Netzwerkwerte und Auswahl ausschließlich der vorgesehenen Testdatenträger.
- Bearer-Header, TLS-Verifikation, First-Boot-Download, Zeitpunkt des ersten
Starts und Vertrauen in die Server-CA.
- Modulabläufe `check`, `apply`, `verify`, Verlust einer Serverantwort,
Netzausfall, erneuter Start und kontrollierter Reboot. Unterbrochene nicht sichere
Änderungen müssen einen prüfbedürftigen Zustand ergeben.
- Abschluss nur nach erfolgreichen Pflichtprüfungen; danach keine erneute
Installationsantwort oder weitere Skriptausführung mit alten Berechtigungen.
- Hardware-/VM-Konfiguration und Bootverfahren. Secure Boot nur nennen,
wenn genau dieser Modus tatsächlich geprüft wurde.
Die Akzeptanzszenarien A01A16 des Feinkonzepts sind die vollständige fachliche
Abnahmeliste. Repositorytests decken die simulierbaren Server- und Runner-Teile ab,
nicht die Betriebssysteminstallation oder reale Ausfälle.
+241
View File
@@ -0,0 +1,241 @@
# Deployment mit einem Image aus der Container Registry
Proxmox AIS wird auf dem Zielserver aus einem bereits veröffentlichten
Containerimage gestartet. Der [GitHub-Actions-Workflow](github-actions.md) erstellt dieses Image; auf dem
Zielserver werden nur Docker Compose und die Betriebskonfiguration benötigt.
Diese Anleitung setzt einen erfolgreichen `container-publish`-Job und ein
weiterhin abrufbares Image voraus.
## Voraussetzungen und Dateien
Der Zielserver benötigt Linux auf amd64, Docker Engine, das Docker-Compose-Plugin
und einen HTTPS-Reverse-Proxy. Die Docker-Befehle unter einem Deploymentkonto
mit Docker-Zugriff ausführen. Dasselbe Konto für `docker login` und
`docker compose` verwenden, damit die Registry-Anmeldung verfügbar ist.
Die [Docker-Installationsanleitung](https://docs.docker.com/engine/install/debian/)
beschreibt beispielsweise die Installation auf Debian.
Python, Node.js, ein CI-Runner und Build-Werkzeuge gehören zur Build-Umgebung;
die Anwendung samt Laufzeit ist im veröffentlichten Image enthalten. Ein
Checkout des Anwendungsquellcodes ist auf dem Zielserver nicht erforderlich.
Ein beschreibbares Deploymentverzeichnis bereitstellen, beispielsweise
`/opt/proxmox-ais`, und diese beiden Dateien aus dem gewünschten Projektstand
herunterladen beziehungsweise dorthin kopieren:
| Datei aus dem Repository | Dateiname auf dem Zielserver |
| --- | --- |
| [compose.yaml](../compose.yaml) | `/opt/proxmox-ais/compose.yaml` |
| [config/deployment.env.example](../config/deployment.env.example) | `/opt/proxmox-ais/.env` |
Im weiteren Verlauf alle Compose-Befehle in diesem Verzeichnis ausführen:
```bash
cd /opt/proxmox-ais
chmod 600 .env
docker version
docker compose version
```
Die Compose-Datei verwendet das Image aus `PROVISIONER_IMAGE`. Ein fehlender
Wert führt bereits bei der Konfigurationsprüfung zu einem Fehler.
## Image und öffentliche Adresse festlegen
In GitHub unter **Actions** den erfolgreichen Workflow-Lauf mit
`container-publish` öffnen, unter **Artifacts** das Archiv `container-deploy`
herunterladen und entpacken. Es enthält `build.env` und `deploy.env`.
Die vollständige Zeile `PROVISIONER_IMAGE=…@sha256:…` aus `deploy.env` in die
`.env` auf dem Zielserver übernehmen. Der Digest legt genau das veröffentlichte
Image fest. Compose liest `deploy.env` nicht automatisch ein.
[GitHub-Artefakte herunterladen](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/download-workflow-artifacts).
Alternativ auf GitHub das zugehörige **Packages**-Paket öffnen und den vollständigen
Imagepfad mit einem tatsächlich vorhandenen Versions-Tag kopieren. Der aktuelle
Imagepfad der Beispieldatei sieht so aus:
```dotenv
PROVISIONER_IMAGE=ghcr.io/netivra/proxmox-ais:0.1.0
PUBLIC_URL=https://provision.example.net
MAINTENANCE=false
```
`PROVISIONER_IMAGE` und `PUBLIC_URL` für die Installation festlegen. Der
Imagepfad entspricht dem aktuellen Wert der Vorlage; die Verfügbarkeit des
Tags `0.1.0` wird damit nicht vorausgesetzt. Maßgeblich sind das
Pipeline-Artefakt oder die Registry-Anzeige deines Projekts.
Der Registry-Endpunkt ist `ghcr.io`. Für reproduzierbare Deployments den Digest
bevorzugen und die bisher verwendeten Digests für spätere Updates aufbewahren.
`PUBLIC_URL` ist die vollständige HTTPS-Basisadresse ohne zusätzlichen Pfad,
unter der Browser, Installer und installierte Hosts den Dienst erreichen.
DNS und Zertifikatskette müssen für diese Systeme gültig sein. Der
Reverse-Proxy auf dem Zielserver leitet diese Adresse an
`http://127.0.0.1:8080` weiter. Der Compose-Port ist nur auf Loopback gebunden.
Bei einem Proxy auf einem anderen Host oder in einem anderen Container dessen
Verbindung gezielt nach der [Betriebsanleitung](operations.md#tls-und-reverse-proxy)
einrichten; `127.0.0.1` bezeichnet dort jeweils das eigene System.
## An der Registry anmelden und Image laden
Öffentliche GHCR-Pakete lassen sich ohne Anmeldung herunterladen. Für ein
privates Paket einen GitHub Personal Access Token **(classic)** mit dem Scope
`read:packages` verwenden. Das zugehörige GitHub-Konto benötigt Lesezugriff auf
das Paket; bei Organisations-SSO den Token dafür autorisieren. Der Token wird
bei der Anmeldung als Passwort verdeckt eingegeben und gehört nicht in `.env`.
Das kurzlebige `GITHUB_TOKEN` des CI-Jobs ist kein Deployment-Zugang.
[GHCR-Authentifizierung](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-with-a-personal-access-token-classic).
Nur für private Pakete anmelden und `GITHUB_USERNAME` durch den GitHub-Namen
des Token-Inhabers ersetzen:
```bash
docker login ghcr.io --username GITHUB_USERNAME
```
Konfiguration prüfen und das gewählte Image herunterladen:
```bash
docker compose config --quiet
docker compose pull provisioner
```
Erst nach erfolgreichem Pull fortfahren. `unauthorized` oder `denied` weist
auf Anmeldung beziehungsweise Zugriffsrechte hin; bei `manifest unknown` den
Imagepfad und den verfügbaren Tag/Digest prüfen. Bei internen Zertifikaten
den CA-Vertrauensanker im Docker-Dienst des Zielservers einrichten.
## Einmalige Initialisierung und Start
Nur bei der ersten Inbetriebnahme mit neuen Daten- und Schlüsselvolumes den
Administrator und den Master-Key anlegen:
```bash
docker compose run --rm --no-deps \
--volume proxmox-ais-keys:/run/secrets:rw provisioner init --username admin
```
Das Passwort wird zweimal interaktiv abgefragt. Es gibt kein Standardpasswort.
Das Schlüsselvolume ist für diesen einen Aufruf beschreibbar. Im Regelbetrieb
wird es schreibgeschützt eingebunden. Bei einer bestehenden Installation oder
einem Update diesen Initialisierungsbefehl nicht erneut ausführen.
Anschließend den Dienst starten:
```bash
docker compose up -d --no-build --wait
docker compose ps
docker compose logs --tail=100 provisioner
```
`--wait` wartet auf einen laufenden, gesunden Dienst gemäß dem Healthcheck im
Image. Der Proxyzugriff wird separat geprüft.
[Compose-Startoptionen](https://docs.docker.com/reference/cli/docker/compose/up/).
Mit `curl`, sofern auf dem Zielserver verfügbar, den lokalen Dienst und die
öffentliche HTTPS-Adresse prüfen; die Beispieladresse ersetzen:
```bash
curl --fail http://127.0.0.1:8080/health/ready
curl --fail https://provision.example.net/health/ready
```
Beide Aufrufe sollen `"status":"ready"` liefern. Danach die öffentliche
Adresse im Browser öffnen und mit dem angelegten Administrator anmelden.
## Daten und Schlüssel
Die Anwendung läuft im Container als UID/GID `10001:10001`. Die Compose-Datei
verwendet diese dauerhaften, von Docker verwalteten Volumes:
| Volume | Inhalt | Containerpfad |
| --- | --- | --- |
| `proxmox-ais-data` | SQLite-Datenbank und Artefakte | `/var/lib/proxmox-ais` |
| `proxmox-ais-keys` | Master-Key für verschlüsselte Geheimnisse | `/run/secrets` |
Für neue Volumes übernimmt Docker die vorbereiteten Verzeichnisse aus dem
Image; sie sind für den Dienstbenutzer angelegt. Beim Wiederverwenden oder
Wiederherstellen bestehender Volumes müssen Eigentümer und Rechte weiterhin
zu UID/GID 10001 passen. Lokale Bind-Mount-Verzeichnisse benötigen dieselben
passenden Rechte, falls die Compose-Datei später entsprechend angepasst wird.
[Docker-Volumes](https://docs.docker.com/engine/storage/volumes/).
Containerneustarts und Updates erhalten diese Volumes.
`docker compose down --volumes` würde sie löschen. Für mehrere getrennte Instanzen auf demselben
Host die Volume-Namen in Compose und beim Initialisierungsaufruf je Instanz
anpassen. Daten und Master-Key nach der
[Backup-Anleitung](operations.md#backup) getrennt sichern.
## Updates
Ein Wartungsfenster vorsehen. Vor dem ersten Neustart den Digest des
tatsächlich laufenden Images ermitteln, insbesondere bei Verwendung eines
beweglichen Tags:
```bash
AIS_RUNNING_IMAGE_ID=$(docker inspect --format '{{.Image}}' "$(docker compose ps -q provisioner)")
docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$AIS_RUNNING_IMAGE_ID"
```
Den zum Projekt passenden Registry-Digest zusammen mit dem Backup aufbewahren
und in `.env` als `PROVISIONER_IMAGE` eintragen. Damit verwendet auch der
Wartungsneustart genau die bisherige Version. Falls kein Registry-Digest
angezeigt wird, die bisherige Version vor dem Update eindeutig zuordnen.
Jetzt in `.env` `MAINTENANCE=true` setzen und die Einstellung mit der
bisherigen Version übernehmen:
```bash
docker compose up -d --no-build --wait
```
Der Wartungsmodus sperrt neue Installationsfreigaben. Bereits gestartete
Installer und Host-Runner werden dadurch nicht beendet. Ihre laufenden
Phasen vor dem eigentlichen Versionswechsel prüfen und einen geeigneten
Zeitpunkt für den Dienststopp abwarten.
Jetzt die Daten mit der bisher eingesetzten Anwendungsversion sichern und
den Master-Key separat verwahren. Die [Backup-Anleitung](operations.md#backup)
enthält dafür die Containerbefehle. Zusätzlich die aktuelle `.env`, den
bisherigen Image-Digest und die Proxykonfiguration sichern.
Danach die neue `PROVISIONER_IMAGE`-Zeile aus dem erfolgreichen
`container-publish`-Job in `.env` übernehmen. `PUBLIC_URL` und die
Volume-Namen beibehalten, `MAINTENANCE=true` gesetzt lassen. Folgender Ablauf
prüft und lädt das neue Image, bevor er den bisherigen Dienst stoppt:
```bash
(
set -eu
docker compose config --quiet
docker compose pull provisioner
docker compose stop provisioner
docker compose up -d --no-build --wait
docker compose ps
)
```
Bei einem fehlgeschlagenen Pull läuft der bisherige Dienst weiter. Nach dem
Update Readiness über HTTPS, Anmeldung und Inventar prüfen. Erst nach
erfolgreicher Prüfung in `.env` `MAINTENANCE=false` setzen und übernehmen:
```bash
docker compose up -d --no-build --wait
```
Der Initialisierungsbefehl gehört ausschließlich zur ersten Inbetriebnahme.
## Rückkehr zum vorherigen Stand
Falls der neue Dienst nicht gesund startet, zunächst
`docker compose logs --tail=100 provisioner` prüfen. Das vorherige Image darf
nur auf eine damit kompatible Datenbank zugreifen. Bei bestätigter
Kompatibilität die gesicherte `.env` beziehungsweise den bisherigen exakten
Image-Digest wiederherstellen und den Updateablauf erneut ausführen; den
Wartungsmodus erst nach erfolgreicher Prüfung beenden.
Hat die neue Version das Schema bereits verändert, den Dienst stoppen und
mit der zum Backup passenden Anwendungsversion nach der
[Restore-Anleitung](operations.md#restore) in ein neues leeres Datenvolume
wiederherstellen. Die vorhandenen Daten erhalten, bis die Wiederherstellung
geprüft ist. Der Restore setzt Maschinenberechtigungen zurück und erfordert
den dort beschriebenen Abgleich laufender Installationen.
+203
View File
@@ -0,0 +1,203 @@
# GitHub Actions und Container Registry
Der [Workflow](../.github/workflows/ci.yml) führt die Python-Tests aus, prüft die
JavaScript-Syntax und baut und testet das Anwendungsimage. Freigegebene Builds
werden in die GitHub Container Registry (GHCR) veröffentlicht. Der Workflow
endet beim Image-Push und aktualisiert keinen Zielserver. Die anschließenden
Betriebsschritte stehen in der [Deployment-Anleitung](deployment.md).
## Auslöser und Image-Tags
Der Workflow läuft bei Pushes auf alle Branches und Tags, bei Pull Requests
und beim manuellen Start mit `workflow_dispatch`.
| Ref und Ereignis | Container-Job | Veröffentlichung |
| --- | --- | --- |
| Pull Request, auch aus einem Fork | `container-verify` | Keine |
| Feature-Branch, ungeschützter Ref oder sonstiger Tag | `container-verify` | Keine |
| Geschützter Standardbranch bei Push oder manuellem Start | `container-publish` | `sha-<vollständiger Commit-SHA>` und `edge` |
| Geschützter Tag `vX.Y.Z` bei Push oder manuellem Start | `container-publish` | `sha-<vollständiger Commit-SHA>` und `X.Y.Z` |
Release-Tags müssen dem Muster `v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)`
vollständig entsprechen. Führende Nullen, Vorabversionen wie `v1.0.0-rc.1` und
Build-Zusätze veröffentlichen daher kein Image. Bei einem gültigen geschützten
Release-Tag muss dessen Version außerdem exakt `[project].version` in
[pyproject.toml](../pyproject.toml) entsprechen; eine Abweichung bricht den Lauf ab.
Das Image liegt für dieses Repository unter `ghcr.io/netivra/proxmox-ais`.
Der Workflow leitet den Pfad aus dem kleingeschriebenen GitHub-Repositorynamen
ab; bei einem Fork oder einer Umbenennung ändert sich deshalb der Imagepfad.
`edge` bezeichnet den zuletzt veröffentlichten Standardbranch-Build. Ein
erneuter Lauf eines älteren Commits kann `edge` zurücksetzen. SHA-Tags enthalten
alle 40 Zeichen des Commits; erneute Builds desselben Quellstands können durch
externe Build-Abhängigkeiten einen anderen Digest ergeben. Es gibt keinen
`latest`-Tag. Im Betrieb den Digest aus `deploy.env` verwenden und die
zugehörigen Registry-Versionen für Updates und Rollback aufbewahren.
## Jobs und Artefakte
`python-tests` und `javascript-check` prüfen den Quellstand. Das Skript
[ci/python-tests.sh](../ci/python-tests.sh) installiert `.[dev]` in einer
eigenen temporären virtuellen Umgebung, führt Pytest aus und entfernt die
Umgebung anschließend. Die JavaScript-Prüfung verwendet
`node --check provisioner/static/app.js`; sie ist kein Browsertest.
`container-policy` bestimmt anhand des GitHub-Ereignisses, des Ref-Schutzes
und des Branch- beziehungsweise Tag-Namens, welcher Container-Job laufen darf.
Nach erfolgreichen Python- und JavaScript-Prüfungen läuft genau einer der
Jobs `container-verify` und `container-publish`. Beide bauen über
[ci/container.sh](../ci/container.sh) ein `linux/amd64`-Image und prüfen es
mit dem [Container-Smoke-Test](../tests/container_smoke.py). Der Publish-Job
lädt genau das bereits getestete Image hoch, ohne einen zweiten Build.
Die Veröffentlichung wird im Skript erneut auf Ereignis und Ref geprüft;
dort erfolgt auch der Abgleich mit der Projektversion.
Publish-Jobs teilen eine Warteschlange mit `queue: max` und laufen einzeln.
Bis zu 100 Jobs können warten; ein laufender Publish-Job wird durch neue
Commits nicht abgebrochen. Die Reihenfolge richtet sich nach dem Eintritt in
die Warteschlange, nicht zwingend nach dem Alter der Commits. Bei Pull Requests
und ungeschützten Refs ersetzt ein neuer Lauf den vorherigen Lauf desselben Refs.
[GitHub-Parallelität und Warteschlangen](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency).
Der Smoke-Test prüft TLS, Anmeldung, CSRF, ausgelieferte Webdateien und
Datenerhalt über einen Dienstneustart. Er läuft als UID 10001 mit
schreibgeschütztem Dateisystem, entfernten Linux-Capabilities und temporären
Testdaten. Eine Proxmox-Installation ist kein Bestandteil dieses Tests.
Der Build verwendet `--provenance=false --sbom=false` und veröffentlicht
keine Provenance- oder SBOM-Attestierungen. Die OCI-Labels für Quellrepository,
Commit und Anwendungsversion bleiben erhalten.
| Artefakt in GitHub Actions | Inhalt | Aufbewahrung |
| --- | --- | --- |
| `python-test-results` | `reports/pytest.xml` als JUnit-Datei, auch nach fehlgeschlagenen Tests, sofern erzeugt | 7 Tage |
| `container-build` | `build.env` nach erfolgreichem Verify-Job | 7 Tage |
| `container-deploy` | `build.env` und `deploy.env` nach erfolgreichem Publish-Job | 30 Tage |
Die Aufbewahrung wird im Workflow mit `retention-days` gesetzt und ist durch
die übergeordneten GitHub-Einstellungen begrenzt. Die JUnit-Datei wird als
Download bereitgestellt; der Workflow installiert keinen zusätzlichen
Testberichtsdienst. Auf der Übersicht des jeweiligen Laufs unter **Artifacts**
das gewünschte Archiv herunterladen und entpacken.
[Workflow-Artefakte](https://docs.github.com/en/actions/tutorials/store-and-share-data),
[Artefakte herunterladen](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/download-workflow-artifacts).
`build.env` enthält den temporären lokalen Image-Tag, Commit und
Anwendungsversion. Dieser `ci-…`-Tag wird nicht veröffentlicht und beim
Jobende lokal entfernt. `deploy.env` enthält ausschließlich die
Deploymentreferenz `PROVISIONER_IMAGE=ghcr.io/netivra/proxmox-ais@sha256:…`.
Die Dateien enthalten keine Zugangsdaten. `deploy.env` wird auf dem Zielserver
nicht automatisch eingelesen; seine Image-Zeile in die dortige `.env` übernehmen.
## GitHub einrichten
Ein eigener CI-Runner ist nicht erforderlich. Alle Jobs verwenden
GitHub-gehostete Linux-amd64-Runner mit `ubuntu-24.04`. Der Workflow richtet
Python 3.13 über `actions/setup-python` und Node.js 24 über `actions/setup-node`
ein. Docker und Buildx werden vom Runner bereitgestellt und vor dem Build
geprüft. Bash, OpenSSL und der OpenSSH-Client werden dort ebenfalls verwendet.
Diese Build-Umgebung ist
unabhängig vom AIS-Host-Runner für die Postinstallation auf Proxmox-Hosts.
[GitHub-gehostete Runner](https://docs.github.com/en/actions/reference/runners/github-hosted-runners).
Im Repository unter **Settings → Actions → General** GitHub Actions aktivieren
und die im Workflow verwendeten Actions von `actions/*` zulassen. Diese sind
im Workflow auf vollständige Commit-SHAs festgelegt. Es werden
keine selbst angelegten Repository-Secrets oder CI-Variablen benötigt.
Der Workflow verwendet standardmäßig `contents: read`; nur
`container-publish` erhält zusätzlich `packages: write` und verwendet das
automatisch bereitgestellte `GITHUB_TOKEN` für die Anmeldung an `ghcr.io`.
[Actions-Einstellungen](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository).
GHCR-Pakete sind bei der ersten Veröffentlichung standardmäßig privat. Ein
bereits existierendes Paket muss mit diesem Repository verbunden sein und
dessen Actions Schreibzugriff haben. In den Paketeinstellungen unter
**Manage Actions access** gegebenenfalls `Netivra/Proxmox-AIS` mit Schreibrecht
hinzufügen. Dort auch die gewünschte Paketsichtbarkeit festlegen.
[GHCR und GITHUB_TOKEN](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-in-a-github-actions-workflow),
[Paketzugriff und Sichtbarkeit](https://docs.github.com/en/packages/learn-github-packages/configuring-a-packages-access-control-and-visibility).
## Standardbranch und Release-Tags schützen
**Vor der ersten Veröffentlichung müssen aktive Schutzregeln für die
veröffentlichten Refs eingerichtet sein.** Der Workflow und das Skript
verlangen `github.ref_protected == true`. GitHub setzt dieses Merkmal, wenn
Branchschutz oder ein passendes Ruleset für den auslösenden Ref konfiguriert
ist. Ohne Schutz wird nur geprüft.
[GitHub-Ref-Kontext](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#github-context).
Unter den Repository-Einstellungen für Rulesets zwei Regeln anlegen und
jeweils **Enforcement status: Active** wählen:
| Ruleset | Ziel | Vorgesehene Regeln |
| --- | --- | --- |
| Standardbranch | **Include default branch**, etwa `main` | Pull Request und Review verlangen; Löschen und Force-Push verhindern; erfolgreiche Statuschecks verlangen |
| Releases | Tags mit dem Muster `v*` | Erstellung, Aktualisierung und Löschung beschränken; nur Release-Verantwortliche in die Bypass-Liste aufnehmen |
Für Pull Requests die Statuschecks `python-tests`, `javascript-check`,
`container-policy` und `container-verify` nach ihrem ersten Lauf als
verpflichtend auswählen. `container-publish` ist bei Pull Requests absichtlich
übersprungen und wird deshalb nicht als Pflichtprüfung eingerichtet.
Das Tag-Ruleset schützt alle `v*`-Tags; die engere Versionsprüfung übernimmt
zusätzlich das CI-Skript. Release-Verantwortliche setzen Tags auf einen
geprüften Commit mit der passenden Projektversion. Ein Tag auf einem Commit
des geschützten Standardbranches ist selbst noch kein geschützter Tag.
[Rulesets anlegen](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/creating-rulesets-for-a-repository),
[Verfügbare Schutzregeln](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets).
Die Regeln und Paketberechtigungen im GitHub-Projekt sind Teil der Einrichtung;
sie werden durch den Commit der Workflow-Datei nicht automatisch angelegt.
Welche Rulesets verfügbar sind, hängt von Repository-Sichtbarkeit und
GitHub-Tarif ab. Die CI-Abfragen ersetzen keine restriktiven Schreibrechte
auf Repository und Paket; Änderungen an Workflow und CI-Skripten im Review prüfen.
[Ruleset-Verfügbarkeit](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/creating-rulesets-for-a-repository).
## Erster Lauf und Release
Nach dem Push der Workflow-Datei unter **Actions** den Lauf öffnen.
Python- und JavaScript-Prüfung sowie der passende Container-Job müssen
erfolgreich sein. Für einen geschützten Standardbranch zusätzlich prüfen,
dass das GHCR-Paket `sha-<Commit>` und `edge` enthält und das Artefakt
`container-deploy` mit `deploy.env` bereitsteht. Ein lokaler Test ersetzt
diesen ersten GitHub-Lauf samt GHCR-Anmeldung und Push nicht.
Für ein Release zuerst `[project].version` auf die gewünschte Version setzen
und den geprüften Commit übernehmen. Anschließend durch ein für das
Release-Ruleset berechtigtes Konto den passenden Tag erstellen und pushen.
Im vorhandenen Checkout heißt der GitHub-Remote `GH-AIS`; in anderen Checkouts
den Namen mit `git remote -v` prüfen und im Befehl entsprechend ersetzen.
Für Projektversion `0.1.0`, sofern der Tag noch nicht existiert:
```bash
git tag -a v0.1.0 -m "Release 0.1.0"
git push GH-AIS v0.1.0
```
Der Release-Lauf muss zusätzlich den Image-Tag `0.1.0` erzeugen. Für einen
manuellen Branch-Lauf unter **Actions** den Workflow und **Run workflow**
wählen. Dafür muss die Workflow-Datei bereits auf dem Standardbranch liegen.
Ein manueller Lauf veröffentlicht nur, wenn dieselben Ref-Schutz- und
Versionsbedingungen erfüllt sind.
[Workflow manuell starten](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow).
Branch-Push und Pull Request können für denselben Quellstand getrennte Läufe
erzeugen. Pull Requests werden im Merge-Kontext geprüft; ihre Tests führen
keinen Registry-Push aus. Die Jobs laden keine produktiven Deployment-Zugänge.
## Fehlersuche und Deployment
Bei übersprungenem Publish-Job zuerst Ereignis, Standardbranch, Tagformat und
aktive Schutzregeln prüfen. Bei einer Versionsabweichung müssen Git-Tag und
`pyproject.toml` aufeinander abgestimmt werden. Bei `denied` oder `unauthorized`
im Publish-Job die Organisationsrichtlinien, `packages: write` und den
Actions-Zugriff auf das GHCR-Paket kontrollieren. Fehlt `container-deploy`,
die Logs des Build-, Smoke- und Push-Schritts prüfen; vor erfolgreicher
Veröffentlichung steht kein Deployment-Digest bereit.
Zum Deployment [compose.yaml](../compose.yaml) und
[config/deployment.env.example](../config/deployment.env.example) auf den
Zielserver kopieren und die Image-Zeile aus `deploy.env` verwenden.
Die [Deployment-Anleitung](deployment.md) beschreibt GHCR-Anmeldung,
Erstinitialisierung, HTTPS-Prüfung, Updates und Rollback. Für private Pakete
benötigt das Deploymentkonto einen Personal Access Token (classic) mit
`read:packages`; öffentliche Pakete lassen sich anonym laden.
+50
View File
@@ -0,0 +1,50 @@
# ISO auf einer Build-Maschine vorbereiten
Die ISO wird außerhalb des Webcontainers mit dem offiziellen
`proxmox-auto-install-assistant` erstellt. Der Container benötigt weder
privilegierte Rechte noch Zugriff auf einen Docker-Socket.
1. Original-ISO aus vertrauenswürdiger Proxmox-Quelle beziehen und ihre SHA-256
gegen den veröffentlichten Wert prüfen. Build und Assistant-Version festhalten.
2. In der Anwendung einen standortbezogenen Gruppentoken erzeugen. Den einmal
ausgegebenen vollständigen Wert `<gruppenname>:<secret>` sicher übernehmen.
3. Zertifikatsfingerprint des verwendeten TLS-Endpunkts über einen
vertrauenswürdigen Weg ermitteln. Bei einem Reverse-Proxy dessen Zertifikat verwenden.
4. Verfügbare Optionen prüfen und die ISO erstellen:
```bash
proxmox-auto-install-assistant prepare-iso --help
proxmox-auto-install-assistant prepare-iso SOURCE.iso \
--fetch-from http \
--url 'https://provision.example.net/installer/v1/answer' \
--cert-fingerprint '<SHA256-Zertifikatsfingerprint>' \
--answer-auth-token '<gruppenname>:<secret>'
```
URL-, Fingerprint- und Token-Optionen entsprechen der
[offiziellen Proxmox-Dokumentation](https://pdm.proxmox.com/docs/automated-installations.html#preparing-an-iso).
Die URL zeigt hier auf den eigenen Antwortdienst. Den Befehl mit echtem Token
nicht in öffentliche Logs oder gemeinsam genutzte Shell-History schreiben.
5. Ergebnis-ISO, Ausgangs-ISO, Assistant-Version, Build, Fingerprint und Gruppe
in der Medienverwaltung registrieren. Zunächst `draft` verwenden.
6. Den Build anhand der [Abnahmematrix](compatibility.md) im isolierten Labor
prüfen. Ein neuer Assistant aktualisiert die Komponenten einer alten ISO
nicht automatisch. Erst nach Prüfung `passed` setzen und Testnachweis referenzieren.
Der Installer sendet Hardwaremerkmale per POST und erhält bei eindeutiger,
freigegebener Zuordnung eine TOML-Antwort. Ein kurzes Wiederholungsfenster liefert
dieselbe Antwort für denselben Lauf. Nach dessen Ende oder Runner-Anmeldung
ist der Abruf gesperrt. Bei abgelehnter Zuordnung die Ursache korrigieren und
den Installationsversuch erneut starten.
Der dynamische First-Boot-Abschnitt verwendet `source = "from-url"` und
`ordering = "network-online"`. Der Installer lädt den Starthelfer, das installierte
System führt ihn beim ersten Boot aus. Diese Trennung beschreibt der
[offizielle First-Boot-Implementierungsentwurf](https://lists.proxmox.com/pipermail/pve-devel/2024-November/066649.html).
Die Gesamtfunktion muss mit der konkreten ISO praktisch geprüft werden.
Ein gemeinsamer ISO-Token ist vom Medium auslesbar, Hardwaremerkmale sind kopierbar.
Zugriff auf Medium und Provisionierungsnetz beschränken. Nach erfolgreicher
Installation die Bootreihenfolge auf das lokale System umstellen; eine bereits
ausgelieferte Antwort kann der Server nicht nachträglich ungültig machen.
+2502
View File
File diff suppressed because it is too large Load Diff
+216
View File
@@ -0,0 +1,216 @@
# Betrieb und Wiederherstellung
Für die Installation aus der GitHub Container Registry (GHCR) siehe
[Deployment-Anleitung](deployment.md). Alle `docker compose`-Befehle unten
werden im Deployment-Verzeichnis mit `compose.yaml` und `.env` ausgeführt.
`PROVISIONER_IMAGE` verweist auf das veröffentlichte Image, vorzugsweise per Digest.
Die CLI ist im Container enthalten; auf dem Zielserver ist keine eigene
Python-Installation erforderlich.
## Konfiguration
`proxmox-ais` liest die optionale TOML-Datei aus `APP_CONFIG`, anschließend
gleichnamige großgeschriebene Umgebungsvariablen. Relative Pfade beziehen sich
auf das Arbeitsverzeichnis.
Die mitgelieferte Compose-Datei übernimmt `PROVISIONER_IMAGE`, `PUBLIC_URL`
und `MAINTENANCE` aus `.env`. Weitere Anwendungseinstellungen bei Bedarf unter
`services.provisioner.environment` in `compose.yaml` ergänzen.
| Einstellung | Standard | Bedeutung |
| --- | --- | --- |
| `DATA_DIR` | `./data` | SQLite, Artefakte und Dienstsperre auf lokalem Speicher |
| `MASTER_KEY_FILE` | `./secrets/master.key` | Separat abgelegter Fernet-Schlüssel |
| `PUBLIC_URL` | `https://localhost:8080` | Extern erreichbare Basis-URL ohne Pfad |
| `SECURE_COOKIES` | `true` | Sessioncookies nur über HTTPS |
| `FOUR_EYES` | `true` | Autor darf eigene Versionen nicht veröffentlichen |
| `MAINTENANCE` | `false` | Neue Installationsfreigaben aussetzen |
| `ANSWER_WINDOW_SECONDS` | `300` | Wiederholungsfenster des Antwortabrufs |
| `ENROLLMENT_HOURS` | `4` | Maximale Registrierungsfrist |
| `LEASE_SECONDS` | `900` | Gültigkeit der Runner-Ausführungsberechtigung |
| `RUNNER_CA_FILE` | nicht gesetzt | Vertrauensanker für Runner bei interner CA |
`TESTING` und Bootstrap-Passwörter nicht im Regelbetrieb setzen. Benutzer werden
interaktiv mit `init` und danach über die Administrationsoberfläche angelegt.
Das Datenverzeichnis darf den Master-Key nicht enthalten. Unter Windows schützt
zusätzlich eine auf das Dienstkonto beschränkte NTFS-ACL das Schlüsselverzeichnis.
## TLS und Reverse-Proxy
Der Compose-Port ist nur an Loopback gebunden. Ein bestehender Proxy terminiert
TLS. Bei externem Proxy das private Container-/Verwaltungsnetz gezielt freigeben.
Der Dienst wertet Proxy-Header standardmäßig nicht aus; alle absoluten URLs
entstehen aus `PUBLIC_URL`. Bei einer nativen Installation kann der Dienst
TLS auch direkt terminieren:
```bash
proxmox-ais serve --host 0.0.0.0 --port 8080 \
--tls-cert /etc/proxmox-ais/fullchain.pem \
--tls-key /etc/proxmox-ais/server.key
```
Zertifikatskette und DNS müssen im Installer und auf installierten Hosts gültig
sein. Ein ISO-Fingerprint ersetzt nicht automatisch den CA-Vertrauensanker des
Runners. Bei interner CA `RUNNER_CA_FILE` konfigurieren und die gesamte Kette vom
Installer bis zum Runner prüfen. Keine TLS-Prüfung abschalten. Zertifikatsrotation
erfordert die Prüfung vorhandener Medien.
Capability-Tokens stehen zwangsläufig in Bootstrap-URLs. Der integrierte Server
schreibt daher keine Access-Logs. Im vorgeschalteten Proxy Bootstrap- und
Installer-Report-Pfade redigieren oder deren Access-Logging deaktivieren. Keine
Authorization-Header protokollieren. Skriptausgaben werden zusätzlich redigiert;
Skripte sollten Geheimnisse grundsätzlich nie ausgeben.
## Betrieb
Der Dienst verwendet genau einen Anwendungsprozess. Mehrere Uvicorn-Worker oder
gleichzeitige Dienste für dasselbe Datenverzeichnis verhindert die Dienstsperre.
SQLite benötigt lokalen Speicher mit zuverlässigen Dateisperren; kein NFS/SMB-Volume
einsetzen. Als Startwert sind 2 vCPU, 2 GB RAM und 20 GB Speicher ohne ISO-Archiv
vorgesehen; Kapazität muss unter realer Last geprüft werden.
`/health/live` prüft den Prozess, `/health/ready` Datenbank und Konfiguration.
Readiness, freien Speicher, Backupalter und ausbleibende Runner-Meldungen überwachen.
DHCP, DNS, NTP und Paketquellen sind externe Voraussetzungen. Der Server baut keine
eingehenden SSH-Verbindungen zu Hosts auf.
## Backup
Beim Registry-Deployment die Sicherung mit der aktuell eingesetzten Image-Version
erstellen, bevor `PROVISIONER_IMAGE` für ein Update geändert wird. Das
Hostverzeichnis `/srv/ais-backups` muss vorhanden und für UID/GID 10001
beschreibbar sein. Beispielsweise einmalig vom Administrator anlegen:
```bash
sudo install -d -o 10001 -g 10001 -m 0700 /srv/ais-backups
docker compose run --rm --no-deps --volume /srv/ais-backups:/backups \
provisioner backup /backups/2026-09-13
```
Für jede Sicherung einen neuen Zielnamen wählen. Bei einer nativen
Python-Installation lautet der entsprechende Befehl
`proxmox-ais backup /srv/ais-backups/2026-09-13`.
Das Ziel muss neu sein und außerhalb von `DATA_DIR` liegen. Die Anwendung kann
weiterlaufen: Die SQLite-Backup-API erzeugt einen konsistenten Snapshot, anschließend
werden unveränderliche Artefakte kopiert. Artefakte währenddessen nicht manuell
löschen. Die Sicherung enthält `database.sqlite3`, `artifacts/`, `settings.json`
und `manifest.json`. Der Master-Key wird ausgeschlossen; sein Fingerprint verhindert
die Wiederherstellung mit einem versehentlich falschen Schlüssel.
`settings.json` enthält die wirksamen Anwendungseinstellungen einschließlich
Standortvorgaben, Fristen und Freigaberegeln. Lokale Daten-/Schlüsselpfade und
Bootstrap-Zugangsdaten sind ausgeschlossen. Individuelle TOML-Datei,
Umgebungsvariablen, Proxykonfiguration, CA-/TLS-Dateien, ursprüngliche
ISOs und Prüfprotokolle separat sichern. Registrierte ISO-Metadaten liegen in der
Datenbank. Backup-Prüfsummen erkennen beschädigte Dateien; sie sind keine Signatur
gegen einen Angreifer mit Schreibzugriff. Backupverzeichnis deshalb schützen.
Den Master-Key aus `proxmox-ais-keys` über einen getrennten verschlüsselten
Sicherungsweg verwahren.
Eine lokale Kopie in ein bereits vorhandenes Zielverzeichnis ist möglich:
```bash
docker compose cp provisioner:/run/secrets/master.key /secure/offline/master.key
```
Die Schlüsselkopie ist genauso vertraulich wie alle gespeicherten Geheimnisse.
## Restore
1. Wartungsmodus aktivieren, neue Freigaben stoppen und Anwendungsdienst
herunterfahren. Die Host-Runner separat berücksichtigen: ein bereits
gestarteter Befehl kann noch laufen.
2. Bisheriges Datenverzeichnis erhalten. Ein neues leeres Datenverzeichnis
wählen und den zum Backup gehörenden Master-Key separat bereitstellen.
3. Die gleiche Anwendungsversion wie beim Backup verwenden. Beim
Registry-Deployment deren Digest in `.env` als `PROVISIONER_IMAGE` setzen
und herunterladen. Das Schlüsselvolume muss bereits den passenden
Master-Key als `master.key`, lesbar für UID/GID 10001, enthalten. In ein
neues, leeres Datenvolume wiederherstellen:
```bash
docker compose stop provisioner
docker compose pull provisioner
docker volume create proxmox-ais-data-restored
docker compose run --rm --no-deps \
--volume /srv/ais-backups:/backups:ro \
--volume proxmox-ais-data-restored:/var/lib/proxmox-ais \
provisioner restore /backups/2026-09-13
```
`proxmox-ais-data-restored` muss neu sein; existiert es schon, einen anderen
unbenutzten Namen wählen. Nach erfolgreichem Restore in `compose.yaml` unter
`volumes.ais-data.name` diesen neuen Namen eintragen. Das bisherige Datenvolume
bleibt erhalten. Bei einer nativen Python-Installation alternativ:
```bash
export DATA_DIR=/srv/proxmox-ais-restored
export MASTER_KEY_FILE=/secure/offline/master.key
proxmox-ais restore /srv/ais-backups/2026-09-13
```
4. Individuelle Konfiguration und Zertifikate wiederherstellen. Im Wartungsmodus
starten, anmelden und Inventar sowie aktuelle Hostzustände abgleichen.
Beim Registry-Deployment dafür `MAINTENANCE=true` in `.env` setzen und
`docker compose up -d --no-build --wait` ausführen.
5. Aktive Läufe stehen auf `needs_review`. Alte Sessions, Gerätezugänge,
Bootstrap-/Enrollment-/Report-Capabilities und Gruppentokens gelten nicht mehr;
offene Installationsfreigaben sind gesperrt. Gruppentokens und davon abhängige
Medien neu ausgeben. Zuvor ausgelieferte Installer nicht ungeprüft neu booten.
6. Installer und Runner auf jedem betroffenen Host lokal stoppen und den
tatsächlichen Zustand prüfen. Danach den verlassenen Lauf über
`POST /api/v1/runs/{id}/reconcile` abschließen: aktuelle `expected_version`,
aussagekräftige `reason`, `confirmation` mit dem Host-FQDN und
`execution_stopped: true` übermitteln. Die Aktion beendet den Lauf als
`cancelled`, sperrt seine Berechtigungen und schreibt einen Auditeintrag.
Erst danach ist eine neue ausdrückliche Installationsfreigabe möglich.
Der automatische Wiederanschluss alter Geräteidentitäten nach Restore
ist nicht Teil dieser Version.
Außerhalb eines Restore zuerst `/cancel` anfordern. Ein offline befindlicher
Runner kann seine bereits erhaltene Lease bis zum Ablauf behalten; eine
Abbruchanforderung zieht diese nicht zurück. `/reconcile` weist den Abgleich
deshalb zurück, solange eine zuvor ausgestellte Lease oder das
Antwortauslieferungsfenster noch gültig ist. Auch nach deren Ablauf ist die
ausdrückliche Bestätigung erforderlich, dass Installer und Runner lokal gestoppt
und kontrolliert wurden. Eine nicht mehr erreichbare Maschine ist kein Nachweis
dafür. Das Verfahren erteilt selbst keine Neuinstallationsfreigabe.
Restore überschreibt keine bestehende Datenbank. Die Dienstsperre verhindert
Restore während eines laufenden Dienstes im Zielverzeichnis. Ein fehlgeschlagener
Restore hinterlässt `RESTORE_FAILED`; der CLI-Start verweigert dann den Betrieb.
Nach Ursachenklärung erneut in ein neues leeres Ziel restaurieren.
Bereits heruntergeladene Antwortdateien kann der Server nicht zurückziehen.
Ebenso beendet eine Credential-Sperrung einen lokal laufenden Skriptprozess nicht
unmittelbar. Restore erfordert deshalb den physischen Zustandsabgleich der Hosts.
RPO 24 Stunden und RTO zwei Stunden sind Ziele aus dem Konzept. Erst wiederholte
Sicherung und zeitlich gemessene Wiederherstellung unter realen Bedingungen
weisen diese Ziele nach.
## Updates
Der vollständige Ablauf für Registry-Images steht unter
[Deployment aktualisieren](deployment.md#updates).
Vor dem ersten Neustart `PROVISIONER_IMAGE` auf den Digest des tatsächlich
laufenden Images festlegen, wie dort beschrieben. Anschließend
`MAINTENANCE=true` in `.env` setzen und die laufende Version mit
`docker compose up -d --no-build --wait` neu erstellen. Der Wartungsmodus
sperrt neue Freigaben; bereits gestartete Installations- und Modulabläufe werden
dadurch nicht beendet. Diese vor dem Update kontrolliert abschließen lassen.
Mit der bisherigen Version Daten und Schlüssel sichern und den alten
Image-Digest festhalten. Erst danach `PROVISIONER_IMAGE` ändern, das neue Image
mit `docker compose pull provisioner` laden, den Dienst mit
`docker compose stop provisioner` stoppen und mit
`docker compose up -d --no-build --wait` neu starten. Nach Prüfung von Anmeldung,
Inventar und Readiness `MAINTENANCE=false` setzen und erneut `up` ausführen.
Bei Updates wird `init` nicht wiederholt.
Die Datenbank
führt eine Migrationstabelle und `PRAGMA user_version`. Neuere unbekannte Schemas
werden abgewiesen. Downgrades erfolgen über die zur Sicherung passende Version
und den beschriebenen Restore; keine alte Anwendung auf eine bereits migrierte
Produktivdatenbank starten.
+29
View File
@@ -0,0 +1,29 @@
{
"name": "lab-system-single-disk",
"kind": "installation",
"target_builds": ["9.1-1"],
"reason": "Beispiel zur Anpassung im isolierten Labor",
"values": {
"root_secret_id": "REPLACE_WITH_SECRET_ID",
"global": {
"keyboard": "de",
"country": "de",
"timezone": "Europe/Berlin",
"mailto": "admin@example.net"
},
"network": {
"source": "from-answer",
"gateway": "192.0.2.1",
"dns": "192.0.2.53",
"filter": {"ID_NET_NAME_MAC": "enx020000000001"}
},
"disk_setup": {
"filesystem": "ext4",
"filter": {"ID_SERIAL_SHORT": "LAB_SYSTEM_DISK_001"},
"filter_match": "all",
"expected_count": 1,
"expected_serials": ["LAB_SYSTEM_DISK_001"],
"inventory_evidence": "REPLACE_WITH_VERIFIED_DISK_INVENTORY_REFERENCE"
}
}
}
+152
View File
@@ -0,0 +1,152 @@
# Softwaretestbericht
Stand: 13. September 2026. Die hier dokumentierten Softwaretests führen keine
Proxmox-Installation aus und verändern keine Hostdatenträger, Paketquellen oder
Produktivdienste. Die noch offenen praktischen Prüfungen stehen in der
[Kompatibilitätsmatrix](compatibility.md).
## Automatisierte Prüfungen
Die 49 Anwendungstests wurden unter Windows mit Python 3.14 ausgeführt:
47 Tests bestanden; zwei Linux-spezifische Prozesstests wurden dort übersprungen.
Die 68 zusätzlichen CI-Skripttests bestanden unter Windows mit Git Bash.
Insgesamt enthält die Testsuite jetzt 117 Testfälle; der aktuelle Gesamtlauf
unter Windows/Python 3.14.7 ergab 115 bestandene und zwei übersprungene Tests.
Unter Linux/Python 3.13.15 bestanden alle 117 Tests über das migrierte
CI-Einstiegsskript `ci/python-tests.sh`, einschließlich der OpenSSH-Prüfung.
| Testgruppe | Umfang | Geprüftes Verhalten |
| --- | --- | --- |
| `tests/test_acceptance.py` | 14 bestanden | Authentifizierung, CSRF, Rollen und Vieraugenregel; Ablehnung unbekannter, gesperrter und widersprüchlicher Hosts; Gruppenzuordnung und Freigabeablauf; zehn parallele Antwortabrufe mit genau einem Lauf; unveränderliche Konfiguration; Enrollment, Signaturen, Nonce-Replay, fremde Läufe, Ereignisreihenfolge und Pflichtverifikation; Artefaktmanipulation; Verschlüsselung; Backup/Restore und Schlüsselprüfung; Abgleich verlassener Läufe erst nach Lease-Ablauf und ausdrücklicher lokaler Bestätigung |
| `tests/test_protocol.py` | 8 bestanden | Tatsächlicher Runner gegen die HTTP-API über einen Testtransport mit gültigen Ed25519-Signaturen; Check-/Apply-Abläufe, verlorene Abschlussantwort, simulierter Neustart, fehlgeschlagene Verifikation und Operatorfortsetzung, wiederholte Abbruchbestätigung, optionale Fehler mit verpflichtender Abschlussprüfung sowie redigierte Logs |
| `tests/test_runner.py` | Windows: 25 bestanden, 2 übersprungen; Linux-CI: 27 bestanden | Dauerhafte Checkpoints, Wiederaufnahme nach Unterbrechung, Ablauf von Leases, Rebootbudget, Queue- und Logbegrenzung, Secrets-Redigierung, Hashprüfung, TLS-Zwang, eigenständiger Starthelfer, lokale Hardwaremerkmale, echte OpenSSL-Signaturen, SSH-Schlüsselprüfung, Repositoryprüfung, Bash-Syntaxprüfung sowie Prozess-Timeout und Ausgaberedigierung |
| `tests/test_ci.py` | 68 bestanden | Veröffentlichung nur von geschützten Standardbranch-/Versions-Refs und erlaubten GitHub-Ereignissen; Release-Versionsabgleich; GHCR-Pfade; kein Push nach Build-/Smoke-Fehlern; Digest-Artefakte; temporäre Docker-Zugangsdaten und Image-Tags; Isolation nach Run-ID, Wiederholung und Job-ID; eigene Python-Testumgebung mit Aufräumen bei Erfolg und Fehlern; fehlende Host-Werkzeuge verhindern übersprungene Pflichtprüfungen |
Die API- und Protokolltests verwenden eine temporäre SQLite-Datenbank sowie
temporäre Schlüssel. Die Protokolltests führen die Runner-Steuerung und
Signaturprüfung aus; die eigentlichen Modulphasen werden simuliert. Runner-Tests
prüfen Bash-Syntax und eingebetteten Python-Code ohne Provisionierungsänderungen.
Das ist kein Nachweis für funktionierende Betriebssysteminstallation oder
erfolgreiche Konfiguration echter Proxmox-Dienste.
Unter Windows benötigt die Veröffentlichung von Bash-Modulen Git Bash.
Der vorhandene WSL-Bash-Starter ohne Linux-Distribution genügt nicht. Die Anwendung
erkennt die native Git-Bash-Installation für die Syntaxprüfung.
Alle Tests erneut ausführen:
```powershell
.\.venv\Scripts\python.exe -m pytest
```
Beim Testlauf können Upstream-DeprecationWarnings von Starlette/httpx und AnyIO
erscheinen. Sie sind von fehlgeschlagenen Tests zu unterscheiden.
## Containerprüfung
`proxmox-ais-server:0.1.0` wurde erfolgreich mit Python 3.13 und Debian slim
gebaut. Das Dockerfile fixiert die geprüfte Basis über den Digest
`sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285`.
Ein kurzlebiger Container wurde mit schreibgeschütztem Dateisystem, deaktiviertem
Netzwerk und entfernten Linux-Capabilities gestartet. Die Prüfung ergab:
| Merkmal | Beobachtung |
| --- | --- |
| Benutzer | UID 10001 |
| Bash | `/usr/bin/bash`, GNU Bash 5.2.37 |
| OpenSSL | `/usr/bin/openssl`, OpenSSL 3.5.7 |
| CLI-Einstiegspunkt | `proxmox-ais`, Unterbefehle `init`, `serve`, `backup`, `restore` |
Auch Wheel-Erstellung und Installation wurden erfolgreich geprüft. In einem
früheren Linux-Testlauf im Anwendungsimage bestanden 48 Tests; ein Test wurde übersprungen, weil
`ssh-keygen` im Anwendungsimage nicht installiert ist. Die SSH-Schlüsselprüfung
wurde unter Windows ausgeführt. Der Linux-Lauf umfasste die POSIX-spezifischen
Prüfungen zur Prozessbeendigung nach Timeout und zur Redigierung von Ausgaben.
Der Testcontainer lief ohne Rootrechte, mit schreibgeschütztem Dateisystem,
entfernten Capabilities und `no-new-privileges`.
`tests/container_smoke.py` prüfte zusätzlich den installierten Dienst als echten
Prozess im Container: Anmeldung über TLS mit eigener vertrauenswürdiger Test-CA,
Ablehnung eines nicht vertrauten Zertifikats, CSRF, ausgelieferte Webdateien sowie
Erhalt von Hosts und Sitzungen nach einem Dienstneustart. Der Prozess lief als
UID 10001 mit schreibgeschütztem Root-Dateisystem, ohne Capabilities und mit
`no-new-privileges`; sämtliche Testdaten lagen in einem temporären Verzeichnis.
Die folgende Abfrage verändert weder Anwendungsdaten noch Konfiguration. Reproduzierbarer
Befehl für Bash unter Linux oder Git Bash:
```bash
docker run --rm --read-only --network none --cap-drop ALL \
--entrypoint python proxmox-ais-server:0.1.0 \
-c 'import os, shutil, subprocess; print(os.getuid()); print(shutil.which("bash")); print(shutil.which("openssl")); subprocess.run(["bash", "--version"], check=True); subprocess.run(["openssl", "version"], check=True)'
```
TLS-Reverse-Proxy, reale Provisionierungsnetze, Medienboot, Initialisierung und
Wiederherstellung in der späteren Betriebsumgebung müssen zusätzlich geprüft werden.
Ein erfolgreicher Image-Build allein ist keine Betriebsfreigabe.
## GitHub Actions und GHCR
Der [Workflow](../.github/workflows/ci.yml) verwendet GitHub-gehostete
Ubuntu-24.04-Runner, Python 3.13 und Node.js 24. Python- und JavaScript-Prüfung
müssen erfolgreich sein, bevor das Containerimage gebaut und getestet wird.
Die [Einrichtungsanleitung](github-actions.md) beschreibt Schutzregeln,
Paketberechtigungen und die erste Veröffentlichung.
Bei der Migration wurden die folgenden Prüfungen lokal ausgeführt:
- Die vollständige Windows-Testsuite bestand mit 115 erfolgreichen Tests;
zwei Linux-Prozesstests wurden plattformbedingt übersprungen.
- Unter Linux/Python 3.13.15 führte `ci/python-tests.sh` alle 117 Tests
erfolgreich als UID 10001 aus. Das Skript installierte die Abhängigkeiten
in einer eigenen virtuellen Umgebung, schrieb den JUnit-Bericht und
entfernte die Umgebung anschließend.
- Die 68 CI-Skripttests prüfen mit Docker- und Python-Testdoubles
GitHub-Ereignisse, geschützte Refs, Versionsabgleich, GHCR-Pfade,
Publish-Reihenfolge, Digest-Artefakte und Fehlerpfade. Run-ID, Wiederholung
und Job-ID isolieren temporäre Ressourcen. Ein Build- oder Smoke-Fehler
verhindert Registry-Anmeldung und Push.
- Die im Workflow eingebettete Veröffentlichungsentscheidung wurde mit
17 Fällen ausgeführt: geschützte Branches und Release-Tags, manueller Start,
andere Standardbranchnamen, Pull Requests, ungeschützte Refs, ungültige Tags
und Shell-Sonderzeichen im Ref-Namen.
- `actionlint` 1.7.12 prüfte den Workflow mit genau einer ausgenommenen
Schema-Meldung: Die aktuelle Version kennt `concurrency.queue` noch nicht.
`queue: max` wurde gegen die offizielle
[GitHub-Syntax](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency)
geprüft; die fehlende Linter-Unterstützung ist in
[actionlint #657](https://github.com/rhysd/actionlint/issues/657) dokumentiert.
Weitere Linter-Befunde gab es nicht.
- `node --check provisioner/static/app.js` und `docker compose config --quiet`
mit gesetzten Beispielwerten für Image und öffentliche URL waren erfolgreich.
- `ci/container.sh verify` lief mit GitHub-Umgebungsvariablen gegen den lokalen
Linux-Docker-Dienst 29.7.2. amd64-Build und Container-Smoke-Test bestanden:
vertrauenswürdiges TLS, Ablehnung eines fremden Zertifikats, Anmeldung,
CSRF, Webdateien sowie Erhalt von Hosts und Sitzungen beim Dienstneustart.
Das Skript erzeugte `build.env` und entfernte den temporären Image-Tag.
Ein tatsächlicher GitHub-Actions-Lauf einschließlich GHCR-Anmeldung,
Registry-Push und Artefakt-Upload wurde bei dieser lokalen Prüfung nicht
ausgeführt. Die Push-Fehlerpfade und Digest-Auswertung sind durch die
CI-Skripttests abgedeckt; die Paketberechtigungen und aktiven Schutzregeln
müssen im GitHub-Repository eingerichtet und beim ersten Lauf bestätigt werden.
## Browserprüfung
In Chromium wurden Anmeldung, alle neun Verwaltungsansichten, Hostanlage und
Hostbearbeitung, Geheimnisreferenzen, Gruppentoken, ISO-Registrierung und Buildbefehl,
Modulvorlagen, Skript- und Profilveröffentlichung, Installationsvorschau,
Freigabe, Abbruch, Laufabgleich, Benutzeranlage und Leserrechte durchgespielt.
Die mobile Ansicht bei 390 Pixeln wurde ebenfalls geprüft. Es traten keine
JavaScript- oder CSP-Fehler auf. Die separate Testkonfiguration verwendete
temporäre Daten und deaktivierte die Vieraugenregel für den Formulartest mit
einem Administrator; die Vieraugenregel wurde in den API-Tests geprüft.
## API-Artefakt
[openapi.json](openapi.json) wird über `create_app(Settings(...)).openapi()` aus
dem aktuellen Anwendungscode erzeugt. Der Export verwendet ausschließlich
temporäre Testverzeichnisse, keine produktiven Benutzer oder Schlüssel. Er
enthält Schemas und Endpunktbeschreibungen, keine Datenbankinhalte, Sessions oder
Geheimniswerte. Bei Änderungen an den API-Modellen muss der Export erneuert werden.
+3
View File
@@ -0,0 +1,3 @@
"""Proxmox AIS: controlled installation and post-installation provisioning."""
__version__ = "0.1.0"
+766
View File
@@ -0,0 +1,766 @@
from contextlib import asynccontextmanager
import asyncio
import base64
from collections import defaultdict, deque
import hashlib
import hmac
import json
from pathlib import Path
import re
import shlex
import sqlite3
import time
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from fastapi import Depends, FastAPI, Form, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
import jsonschema
from pydantic import ValidationError
from .config import Settings
from .db import Database
from .models import (Approval, Completion, Enroll, EventBatch, GroupCreate, HostCreate, HostUpdate, IsoCreate, LeaseRequest, LogBatch, ModuleCreate, ProfileCreate, Publish, RunAction, RunReconcile, SecretCreate, UserCreate, normalize_identity)
from .security import Security, atomic_artifact, canonical, digest, token
from .service import Service, TERMINAL, audit, get_host, new_id, now_iso, public_run, require, unpack
ASSETS = Path(__file__).parent
class RequestGuards:
"""Bound bodies before FastAPI parses them; no capability URLs in access logs."""
def __init__(self, app, max_bytes):
self.app, self.max_bytes = app, max_bytes
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
return await self.app(scope, receive, send)
consumed = 0
messages = []
while True:
message = await receive()
if message["type"] == "http.disconnect":
return
consumed += len(message.get("body", b""))
if consumed > self.max_bytes:
return await JSONResponse({"detail":"Anfrage überschreitet das Größenlimit."},413)(scope,receive,send)
messages.append(message)
if not message.get("more_body", False):
break
async def replay():
if messages:
return messages.pop(0)
return await receive()
async def guarded_send(message):
if message["type"] == "http.response.start":
referrer_policy = b"no-referrer" if scope["path"].startswith(("/bootstrap/","/installer/","/agent/")) else b"same-origin"
message.setdefault("headers", []).extend([(b"cache-control", b"no-store"),(b"x-content-type-options",b"nosniff"),(b"x-frame-options",b"DENY"),(b"referrer-policy",referrer_policy),(b"content-security-policy",b"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'")])
await send(message)
await self.app(scope,replay,guarded_send)
def create_app(settings: Settings | None = None):
settings = settings or Settings.from_env()
db = Database(settings)
security = Security(settings)
service = Service(settings, db, security)
rate_buckets = defaultdict(deque)
def rate_limit(key, limit, seconds=60):
timestamp = time.monotonic()
queue = rate_buckets[key]
while queue and queue[0] < timestamp - seconds:
queue.popleft()
require(len(queue) < limit, 429, "Zu viele Anfragen. Bitte später erneut versuchen.")
queue.append(timestamp)
if len(rate_buckets) > 10000:
for item in list(rate_buckets):
if not rate_buckets[item] or rate_buckets[item][-1] < timestamp - 3600:
rate_buckets.pop(item, None)
async def maintenance_loop():
while True:
await asyncio.sleep(30)
await asyncio.to_thread(service.maintain)
@asynccontextmanager
async def lifespan(app):
from .cli import ServiceLock
require(not (settings.data_dir / "RESTORE_FAILED").exists(), 503, "Unvollständige Wiederherstellung muss zuerst behoben werden.")
with ServiceLock(settings.data_dir):
db.initialize()
if settings.bootstrap_username and settings.bootstrap_password:
with db.connection(write=True) as connection:
if not connection.execute("SELECT 1 FROM users LIMIT 1").fetchone():
connection.execute("INSERT INTO users(id,username,password_hash,role,created_at) VALUES(?,?,?,?,?)", (new_id("user"),settings.bootstrap_username,security.hash_password(settings.bootstrap_password),"admin",now_iso()))
service.maintain()
task = asyncio.create_task(maintenance_loop())
try:
yield
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(title="Proxmox AIS", version="0.1.0", description="Kontrollierte Proxmox-Installation und wiederaufnehmbare Nachkonfiguration.", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
app.state.db, app.state.settings, app.state.security, app.state.service = db, settings, security, service
app.add_middleware(RequestGuards, max_bytes=settings.max_request_bytes)
(ASSETS / "static").mkdir(exist_ok=True)
(ASSETS / "templates").mkdir(exist_ok=True)
app.mount("/static",StaticFiles(directory=ASSETS / "static"),name="static")
templates = Jinja2Templates(directory=ASSETS / "templates")
@app.exception_handler(sqlite3.IntegrityError)
async def conflict(request, error):
return JSONResponse({"detail":"Konflikt: Identität, IP, FQDN, Name oder aktiver Lauf ist bereits vergeben."},409)
@app.exception_handler(sqlite3.OperationalError)
async def db_unavailable(request,error):
return JSONResponse({"detail":"Datenbank vorübergehend nicht verfügbar."},503)
@app.exception_handler(RequestValidationError)
@app.exception_handler(ValidationError)
async def validation_failed(request,error):
errors = [{"loc":list(e["loc"]),"msg":e["msg"],"type":e["type"]} for e in error.errors()]
return JSONResponse({"detail":"Ungültige Eingaben.","errors":errors},422)
@app.exception_handler(ValueError)
async def bad_value(request,error):
return JSONResponse({"detail":"Ungültiger Wert oder nicht lesbare Konfiguration."},422)
def current_user(request: Request):
session_token = request.cookies.get("ais_session", "")
require(bool(session_token),401,"Anmeldung erforderlich.")
with db.connection() as connection:
row = connection.execute("SELECT u.id,u.username,u.role,s.csrf_token FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>? AND u.disabled=0",(digest(session_token),time.time())).fetchone()
require(row is not None,401,"Sitzung ist ungültig oder abgelaufen.")
user = dict(row)
if request.method not in {"GET","HEAD","OPTIONS"}:
csrf = request.headers.get("x-csrf-token", "")
require(hmac.compare_digest(csrf,user["csrf_token"]),403,"CSRF-Prüfung fehlgeschlagen. Seite neu laden.")
return user
def roles(*allowed):
def check(user=Depends(current_user)):
require(user["role"] in {*allowed,"admin","developer"},403,"Für diese Aktion fehlt die Berechtigung.")
return user
return check
@app.get("/health/live")
def health_live():
return {"status":"ok"}
@app.get("/health/ready")
def health_ready():
with db.connection() as connection:
connection.execute("SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1").fetchone()
require(settings.master_key_file.is_file(),503,"Notwendige Konfiguration fehlt.")
return {"status":"ready"}
@app.get("/login",response_class=HTMLResponse,include_in_schema=False)
def login_page(request:Request):
return templates.TemplateResponse(request=request,name="login.html",context={"error":None})
@app.post("/auth/login",include_in_schema=False)
def login(request:Request,username:str=Form(...),password:str=Form(...)):
rate_limit("login:" + (request.client.host if request.client else "unknown"),10,300)
origin = request.headers.get("origin")
require(not origin or origin == settings.public_url or (settings.testing and origin == str(request.base_url).rstrip("/")),403,"Anmeldeanfrage stammt von einer fremden Website.")
with db.connection(write=True) as connection:
row = connection.execute("SELECT * FROM users WHERE username=? AND disabled=0",(username,)).fetchone()
if row is None or not security.verify_password(password,row["password_hash"]):
audit(connection,"anonymous","login.failed","",data={"username":username[:64]})
return templates.TemplateResponse(request=request,name="login.html",context={"error":"Benutzername oder Passwort ist falsch."},status_code=401)
session_token, csrf = token(),token()
connection.execute("INSERT INTO sessions VALUES(?,?,?,?)",(digest(session_token),row["id"],csrf,time.time()+settings.session_hours*3600))
audit(connection,row["id"],"login.succeeded",row["id"])
response = RedirectResponse("/",status_code=303)
response.set_cookie("ais_session",session_token,httponly=True,secure=settings.secure_cookies,samesite="strict",max_age=settings.session_hours*3600,path="/")
return response
@app.post("/auth/logout")
def logout(request:Request,user=Depends(current_user)):
with db.connection(write=True) as connection:
connection.execute("DELETE FROM sessions WHERE token_hash=?",(digest(request.cookies.get("ais_session","")),))
audit(connection,user["id"],"logout",user["id"])
response = JSONResponse({"status":"ok"})
response.delete_cookie("ais_session",path="/")
return response
@app.get("/",response_class=HTMLResponse,include_in_schema=False)
def index(request:Request):
try:
user = current_user(request)
except HTTPException:
return RedirectResponse("/login",303)
return templates.TemplateResponse(request=request,name="index.html",context={"user":user})
@app.get("/api/v1/me")
def me(user=Depends(current_user)):
return user
@app.get("/openapi.json",include_in_schema=False)
def openapi(user=Depends(current_user)):
return app.openapi()
@app.get("/api/v1/dashboard")
def dashboard(user=Depends(current_user)):
service.maintain()
with db.connection() as connection:
hosts = [get_host(connection,r[0]) for r in connection.execute("SELECT id FROM hosts ORDER BY created_at DESC")]
runs = [decorate_run(r,connection) for r in connection.execute("SELECT * FROM runs ORDER BY created_at DESC LIMIT 20")]
counts = {"hosts":len(hosts),"ready":sum(h["status"] == "prepared" and not h["blocked"] for h in hosts),"active":sum(h["status"] in {"answer_served","installed_reported","runner_ready","running","reboot_pending","waiting_retry"} for h in hosts),"succeeded":sum(h["status"]=="succeeded" for h in hosts),"needs_review":sum(h["status"] in {"needs_review","failed"} for h in hosts),"discovered":connection.execute("SELECT COUNT(*) FROM discoveries").fetchone()[0]}
events = [unpack(r) for r in connection.execute("SELECT * FROM audit ORDER BY created_at DESC LIMIT 12")]
return {"counts":counts,"recent_events":events,"hosts":hosts,"runs":runs,"maintenance":settings.maintenance}
@app.get("/api/v1/hosts")
def hosts(user=Depends(current_user)):
with db.connection() as connection:
return [get_host(connection,r[0]) for r in connection.execute("SELECT id FROM hosts ORDER BY fqdn")]
@app.post("/api/v1/hosts",status_code=201)
def create_host(payload:HostCreate,user=Depends(roles("operator"))):
with db.connection(write=True) as connection:
return service.create_host(connection,payload,user["id"])
@app.post("/api/v1/hosts/import",status_code=201)
def import_hosts(payload:list[HostCreate],user=Depends(roles("operator"))):
require(1 <= len(payload) <= 100,422,"Ein Import darf 1 bis 100 Hosts enthalten.")
with db.connection(write=True) as connection:
return [service.create_host(connection,item,user["id"]) for item in payload]
@app.get("/api/v1/discoveries")
def discoveries(user=Depends(current_user)):
with db.connection() as connection:
return [unpack(r) for r in connection.execute("SELECT * FROM discoveries ORDER BY last_seen DESC")]
@app.get("/api/v1/hosts/{host_id}")
def host_detail(host_id:str,user=Depends(current_user)):
with db.connection() as connection:
host = get_host(connection,host_id)
host["runs"] = [decorate_run(r,connection) for r in connection.execute("SELECT * FROM runs WHERE host_id=? ORDER BY created_at DESC",(host_id,))]
return host
@app.patch("/api/v1/hosts/{host_id}")
def update_host(host_id:str,payload:HostUpdate,user=Depends(roles("operator"))):
with db.connection(write=True) as connection:
return service.update_host(connection,host_id,payload,user["id"])
@app.get("/api/v1/hosts/{host_id}/preview")
def preview(host_id:str,user=Depends(roles("operator","author"))):
with db.connection() as connection:
return service.resolve(connection,host_id)[0]
@app.post("/api/v1/hosts/{host_id}/approve-install",status_code=201)
def approve(host_id:str,payload:Approval,user=Depends(roles("operator"))):
service.maintain()
with db.connection(write=True) as connection:
return service.approve(connection,host_id,payload,user["id"])
@app.get("/api/v1/profiles")
def profiles(user=Depends(current_user)):
with db.connection() as connection:
return [unpack(r) for r in connection.execute("SELECT * FROM profiles ORDER BY name,version DESC")]
@app.post("/api/v1/profiles",status_code=201)
def create_profile(payload:ProfileCreate,user=Depends(roles("author"))):
with db.connection(write=True) as connection:
return service.create_profile(connection,payload,user["id"])
@app.get("/api/v1/modules")
def modules(user=Depends(current_user)):
with db.connection() as connection:
result = [unpack(r) for r in connection.execute("SELECT * FROM modules ORDER BY name,version DESC")]
if user["role"] not in {"author","admin","developer"}:
for module in result:
module.pop("source",None)
return result
@app.post("/api/v1/modules",status_code=201)
def create_module(payload:ModuleCreate,user=Depends(roles("author"))):
try:
jsonschema.Draft202012Validator.check_schema(payload.parameters_schema)
except jsonschema.SchemaError:
raise HTTPException(422,"Parameterschema ist ungültig.")
require("$ref" not in canonical(payload.parameters_schema),422,"Externe und rekursive Schema-Referenzen werden nicht unterstützt.")
payload.source = payload.source.replace("\r\n","\n")
checksum = atomic_artifact(service.artifact_dir,payload.source)
with db.connection(write=True) as connection:
version = connection.execute("SELECT COALESCE(MAX(version),0)+1 FROM modules WHERE name=?",(payload.name,)).fetchone()[0]
module_id = new_id("module")
connection.execute("INSERT INTO modules VALUES(?,?,?,?,?,?,?,?)",(module_id,payload.name,version,"draft",checksum,canonical(payload.model_dump()),user["id"],now_iso()))
audit(connection,user["id"],"module.created",module_id,payload.reason,{"digest":checksum})
return unpack(connection.execute("SELECT * FROM modules WHERE id=?",(module_id,)).fetchone())
@app.get("/api/v1/modules/builtin")
def builtin_modules(user=Depends(roles("author"))):
from .builtin_modules import catalog
return catalog()
@app.get("/api/v1/modules/{object_id}")
def module_detail(object_id:str,user=Depends(current_user)):
with db.connection() as connection:
result = unpack(connection.execute("SELECT * FROM modules WHERE id=?",(object_id,)).fetchone())
if user["role"] not in {"admin","developer","author"}:
result.pop("source",None)
return result
@app.get("/api/v1/profiles/{object_id}")
def profile_detail(object_id:str,user=Depends(current_user)):
with db.connection() as connection:
return unpack(connection.execute("SELECT * FROM profiles WHERE id=?",(object_id,)).fetchone())
@app.post("/api/v1/modules/{object_id}/publish")
def publish_module(object_id:str,payload:Publish,user=Depends(roles())):
return publish_object("modules",object_id,payload,user)
@app.post("/api/v1/profiles/{object_id}/publish")
def publish_profile(object_id:str,payload:Publish,user=Depends(roles())):
return publish_object("profiles",object_id,payload,user)
def publish_object(table,object_id,payload,user):
with db.connection() as connection:
item = unpack(connection.execute(f"SELECT * FROM {table} WHERE id=?",(object_id,)).fetchone())
require(item["status"] == "draft",409,"Veröffentlichte Versionen sind unveränderlich. Neue Version erstellen.")
require(not settings.four_eyes or item["created_by"] != user["id"],403,"Vieraugenprinzip: Eine andere Person muss diese Version veröffentlichen.")
require(item["target_builds"],422,"Mindestens ein getesteter Zielbuild ist erforderlich.")
if table == "modules":
service.module_syntax(item["source"])
else:
require(item["kind"] != "postinstall" or item["steps"],422,"Postinstallationsprofil benötigt Schritte.")
with db.connection(write=True) as connection:
row = connection.execute(f"SELECT * FROM {table} WHERE id=?",(object_id,)).fetchone()
require(row["status"] == "draft",409,"Version wurde bereits veröffentlicht.")
data = json.loads(row["data"])
data.update({"test_evidence":payload.test_evidence,"published_by":user["id"],"published_at":now_iso()})
if table == "profiles":
data["digest"] = digest(canonical({k:v for k,v in data.items() if k != "digest"}))
connection.execute(f"UPDATE {table} SET status='published',data=? WHERE id=?",(canonical(data),object_id))
audit(connection,user["id"],table[:-1]+".published",object_id,payload.reason,{"test_evidence":payload.test_evidence})
return unpack(connection.execute(f"SELECT * FROM {table} WHERE id=?",(object_id,)).fetchone())
@app.get("/api/v1/groups")
def groups(user=Depends(roles("operator","author"))):
with db.connection() as connection:
return [dict(r) for r in connection.execute("SELECT id,name,site,expires_at,revoked,created_at FROM groups ORDER BY name")]
@app.post("/api/v1/groups",status_code=201)
def create_group(payload:GroupCreate,user=Depends(roles())):
secret = token()
with db.connection(write=True) as connection:
group_id = new_id("group")
connection.execute("INSERT INTO groups VALUES(?,?,?,?,?,?,?)",(group_id,payload.name,payload.site,digest(secret),time.time()+payload.valid_hours*3600,0,now_iso()))
audit(connection,user["id"],"group.created",group_id)
result = dict(connection.execute("SELECT id,name,site,expires_at,revoked,created_at FROM groups WHERE id=?",(group_id,)).fetchone())
return {**result,"token":payload.name + ":" + secret}
@app.post("/api/v1/groups/{group_id}/revoke")
def revoke_group(group_id:str,user=Depends(roles())):
with db.connection(write=True) as connection:
changed = connection.execute("UPDATE groups SET revoked=1 WHERE id=?",(group_id,)).rowcount
require(changed,404,"Gruppe nicht gefunden.")
audit(connection,user["id"],"group.revoked",group_id)
return {"status":"revoked"}
@app.get("/api/v1/iso-records")
def iso_records(user=Depends(current_user)):
with db.connection() as connection:
return [iso_command(unpack(r),connection) for r in connection.execute("SELECT * FROM iso_records ORDER BY created_at DESC")]
def iso_command(iso,connection):
group = connection.execute("SELECT name FROM groups WHERE id=?",(iso["group_id"],)).fetchone()
args = ["proxmox-auto-install-assistant","prepare-iso","SOURCE.iso","--fetch-from","http","--url",settings.public_url + "/installer/v1/answer","--cert-fingerprint",iso["fingerprint"],"--answer-auth-token",(group[0] if group else "gruppe") + ":<SECRET>"]
iso["command"] = shlex.join(args)
return iso
@app.post("/api/v1/iso-records",status_code=201)
def create_iso(payload:IsoCreate,user=Depends(roles())):
require(payload.test_status != "passed" or (payload.native_token_support and len(payload.test_evidence)>=5),422,"Freigegebenes Medium benötigt nativen Token-Support und dokumentierten Testnachweis.")
with db.connection(write=True) as connection:
require(connection.execute("SELECT 1 FROM groups WHERE id=?",(payload.group_id,)).fetchone(),422,"Gruppe nicht gefunden.")
iso_id = new_id("iso")
connection.execute("INSERT INTO iso_records VALUES(?,?,?,?)",(iso_id,payload.name,canonical(payload.model_dump()),now_iso()))
audit(connection,user["id"],"iso.registered",iso_id,data={"build":payload.build,"test_status":payload.test_status})
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())):
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())):
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()))
audit(connection,user["id"],"secret.created",secret_id)
return {"id":secret_id,"name":payload.name}
@app.get("/api/v1/users")
def users(user=Depends(roles())):
with db.connection() as connection:
return [dict(r) for r in connection.execute("SELECT id,username,role,disabled,created_at FROM users ORDER BY username")]
@app.post("/api/v1/users",status_code=201)
def create_user(payload:UserCreate,user=Depends(roles())):
with db.connection(write=True) as connection:
user_id = new_id("user")
connection.execute("INSERT INTO users(id,username,password_hash,role,created_at) VALUES(?,?,?,?,?)",(user_id,payload.username,security.hash_password(payload.password),payload.role,now_iso()))
audit(connection,user["id"],"user.created",user_id,data={"role":payload.role})
return {"id":user_id,"username":payload.username,"role":payload.role}
@app.post("/api/v1/users/{user_id}/disable")
def disable_user(user_id:str,user=Depends(roles())):
require(user_id != user["id"],409,"Eigenes Konto kann nicht deaktiviert werden.")
with db.connection(write=True) as connection:
require(connection.execute("UPDATE users SET disabled=1 WHERE id=?",(user_id,)).rowcount,404,"Benutzer nicht gefunden.")
connection.execute("DELETE FROM sessions WHERE user_id=?",(user_id,))
audit(connection,user["id"],"user.disabled",user_id)
return {"status":"disabled"}
@app.get("/api/v1/audit")
def audit_list(user=Depends(current_user)):
with db.connection() as connection:
return [unpack(r) for r in connection.execute("SELECT * FROM audit ORDER BY created_at DESC LIMIT 500")]
@app.get("/api/v1/runs")
def runs(user=Depends(current_user)):
with db.connection() as connection:
return [decorate_run(r,connection) for r in connection.execute("SELECT * FROM runs ORDER BY created_at DESC LIMIT 500")]
def decorate_run(row,connection):
result = public_run(row)
result["contact_status"] = "unknown" if row["last_seen"] and row["status"] not in TERMINAL and time.time()-row["last_seen"]>settings.heartbeat_unknown_seconds else "current" if row["last_seen"] else "pending"
specs = {s["id"]:s for s in result["manifest"]["steps"]}
result["steps"] = [{**specs[r["step_id"]],**dict(r),"verification":json.loads(r["verification"])} for r in connection.execute("SELECT * FROM run_steps WHERE run_id=? ORDER BY position",(row["id"],))]
return result
@app.get("/api/v1/runs/{run_id}")
def run_detail(run_id:str,user=Depends(current_user)):
with db.connection() as connection:
row = connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone()
require(row is not None,404,"Lauf nicht gefunden.")
result = decorate_run(row,connection)
result["events"] = [unpack(r) for r in connection.execute("SELECT * FROM events WHERE run_id=? ORDER BY sequence DESC LIMIT 200",(run_id,))][::-1]
result["logs"] = [unpack(r) for r in connection.execute("SELECT * FROM logs WHERE run_id=? ORDER BY sequence DESC LIMIT 100",(run_id,))][::-1]
return result
@app.post("/api/v1/runs/{run_id}/cancel")
def cancel_run(run_id:str,payload:RunAction,user=Depends(roles("operator"))):
with db.connection(write=True) as connection:
row = connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone()
run = unpack(row)
require(run["version"] == payload.expected_version and run["status"] not in TERMINAL,409,"Laufzustand hat sich geändert oder ist bereits abgeschlossen.")
data = json.loads(row["data"])
data["cancel_requested"] = True
status = "cancelled" if run["status"] == "prepared" else run["status"]
connection.execute("UPDATE runs SET data=?,status=?,version=version+1 WHERE id=?",(canonical(data),status,run_id))
if status == "cancelled":
connection.execute("UPDATE approvals SET status='revoked' WHERE id=?",(run["approval_id"],))
connection.execute("UPDATE hosts SET status='cancelled' WHERE id=?",(run["host_id"],))
audit(connection,user["id"],"run.cancel_requested",run_id,payload.reason)
return public_run(connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone())
@app.post("/api/v1/runs/{run_id}/resume")
def resume_run(run_id:str,payload:RunAction,user=Depends(roles("operator"))):
with db.connection(write=True) as connection:
run = unpack(connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone())
require(run["version"] == payload.expected_version and run["status"] in {"needs_review","waiting_retry"},409,"Nur ein wartender, unverändert angezeigter Lauf kann fortgesetzt werden.")
require(run["device_key"] and not run.get("cancel_requested"),409,"Keine aktive Gerätebindung für sichere Wiederaufnahme vorhanden.")
require(not get_host(connection,run["host_id"])["blocked"],403,"Host ist gesperrt.")
connection.execute("UPDATE runs SET status='runner_ready',version=version+1 WHERE id=?",(run_id,))
connection.execute("UPDATE hosts SET status='runner_ready' WHERE id=?",(run["host_id"],))
audit(connection,user["id"],"run.resumed",run_id,payload.reason)
return public_run(connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone())
@app.post("/api/v1/runs/{run_id}/reconcile")
def reconcile_run(run_id:str,payload:RunReconcile,user=Depends(roles("operator"))):
"""Close an externally checked abandoned run; never grants installation."""
with db.connection(write=True) as connection:
row = connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone()
run = unpack(row)
host = get_host(connection,run["host_id"])
require(run["version"] == payload.expected_version and run["status"] not in TERMINAL,409,"Lauf wurde geändert oder ist bereits beendet.")
require(payload.execution_stopped and payload.confirmation == host["fqdn"],422,"Vor dem Abschließen muss lokal geprüft sein, dass Installer und Runner gestoppt sind; Host-FQDN bestätigen.")
require(not row["lease_until"] or row["lease_until"]<=time.time(),409,"Aktuelle Laufberechtigung muss vor dem Abgleich ablaufen. Zuerst Abbruch anfordern.")
require(not row["answer_until"] or row["answer_until"]<=time.time(),409,"Auslieferungsfenster ist noch aktiv. Abgleich erst nach dessen Ablauf möglich.")
data = json.loads(row["data"])
data.update({"cancel_requested":True,"reconciled_by":user["id"],"reconciliation_reason":payload.reason})
connection.execute("UPDATE runs SET status='cancelled',version=version+1,data=?,completed_at=?,device_key=NULL,enrollment_hash=NULL,bootstrap_hash=NULL,report_hash=NULL,answer_ciphertext=NULL,bootstrap_ciphertext=NULL,lease_until=NULL WHERE id=?",(canonical(data),time.time(),run_id))
connection.execute("UPDATE approvals SET status='revoked' WHERE id=?",(run["approval_id"],))
connection.execute("UPDATE hosts SET status='cancelled',version=version+1 WHERE id=?",(host["id"],))
audit(connection,user["id"],"run.reconciled",run_id,payload.reason,{"execution_stopped_confirmed":True,"fqdn":host["fqdn"]})
return public_run(connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone())
def installer_group(request):
authorization = request.headers.get("authorization", "")
require(authorization.startswith("Bearer ") and ":" in authorization,401,"Gültiger Installer-Gruppentoken erforderlich.")
name,secret = authorization[7:].split(":",1)
with db.connection() as connection:
row = connection.execute("SELECT * FROM groups WHERE name=?",(name,)).fetchone()
require(row is not None and hmac.compare_digest(digest(secret),row["token_hash"]),401,"Ungültiger Installer-Gruppentoken.")
require(not row["revoked"],403,"Installer-Gruppentoken ist gesperrt.")
require(row["expires_at"] > time.time(),410,"Installer-Gruppentoken ist abgelaufen.")
rate_limit("installer:" + row["id"],120)
return row
@app.post("/installer/v1/answer",response_class=Response)
async def installer_answer(request:Request):
rate_limit("answer-ip:" + (request.client.host if request.client else "unknown"),240)
group = installer_group(request)
try:
payload = await request.json()
except (ValueError,UnicodeDecodeError):
raise HTTPException(422,"Ungültige Installer-Systemdaten.")
service.maintain()
try:
with db.connection(write=True) as connection:
answer = service.serve_answer(connection,group,payload)
except HTTPException as error:
with db.connection(write=True) as connection:
audit(connection,"installer:"+group["name"],"installation.denied","",str(error.detail))
raise
require(answer is not None,403,"Unbekannter Host wurde als entdeckt gespeichert. Vor erneutem Start zuordnen und freigeben.")
return Response(answer,media_type="application/toml")
@app.get("/bootstrap/v1/{download_token}",response_class=Response)
def bootstrap(download_token:str):
with db.connection(write=True) as connection:
row = connection.execute("SELECT * FROM runs WHERE bootstrap_hash=?",(digest(download_token),)).fetchone()
require(row is not None,401,"Ungültige Download-Berechtigung.")
require(row["status"] in {"answer_served","installed_reported"} and time.time()<row["answer_until"],410,"Starthelfer ist nicht mehr zum Download freigegeben.")
require(not get_host(connection,row["host_id"])["blocked"] and not json.loads(row["data"]).get("cancel_requested"),403,"Lauf ist gesperrt.")
rate_limit("bootstrap:"+row["id"],12,300)
audit(connection,"installer","bootstrap.downloaded",row["id"])
return Response(security.decrypt(row["bootstrap_ciphertext"]),media_type="text/x-shellscript")
@app.post("/installer/v1/report/{report_token}")
async def installer_report(report_token:str,request:Request):
try:
await request.json()
except ValueError:
raise HTTPException(422,"Ungültiger Installationsbericht.")
with db.connection(write=True) as connection:
row = connection.execute("SELECT * FROM runs WHERE report_hash=?",(digest(report_token),)).fetchone()
require(row is not None,401,"Ungültige Report-Berechtigung.")
require(row["enroll_until"] and row["enroll_until"]>time.time(),410,"Report-Berechtigung ist abgelaufen.")
require(row["status"] in {"answer_served","installed_reported","runner_ready","running"},409,"Installationsbericht passt nicht zum Laufzustand.")
if row["status"] == "answer_served":
connection.execute("UPDATE runs SET status='installed_reported',version=version+1,last_seen=? WHERE id=?",(time.time(),row["id"]))
connection.execute("UPDATE hosts SET status='installed_reported' WHERE id=?",(row["host_id"],))
audit(connection,"installer","installation.reported",row["id"])
return {"status":"accepted"}
@app.post("/agent/v1/enroll")
def enroll(payload:Enroll,request:Request):
rate_limit("enroll:" + (request.client.host if request.client else "unknown"),60)
try:
key = base64.b64decode(payload.public_key,validate=True)
Ed25519PublicKey.from_public_bytes(key)
except (ValueError,TypeError):
raise HTTPException(422,"Ungültiger Ed25519-Geräteschlüssel.")
with db.connection(write=True) as connection:
row = connection.execute("SELECT * FROM runs WHERE id=?",(payload.run_id,)).fetchone()
require(row is not None and row["enrollment_hash"] and hmac.compare_digest(row["enrollment_hash"],digest(payload.enrollment_secret)),401,"Ungültige Enrollment-Berechtigung.")
require(row["enroll_until"] > time.time(),410,"Enrollment-Berechtigung ist abgelaufen.")
require(row["status"] in {"answer_served","installed_reported","runner_ready","running","reboot_pending","needs_review","waiting_retry"},403,"Lauf erlaubt keine Registrierung.")
run_data = json.loads(row["data"])
require(not run_data.get("cancel_requested"),403,"Lauf ist zum Abbruch markiert.")
identities = [{"kind":i.kind,"value":normalize_identity(i.kind,i.value)} for i in payload.identities]
host = service.match_host(connection,identities)
require(host and host["id"] == row["host_id"] and not host["blocked"],403,"Geräteidentität passt nicht zum freigegebenen Host.")
require(not row["device_key"] or row["device_key"] == payload.public_key,409,"Enrollment ist bereits an einen anderen Geräteschlüssel gebunden.")
if not row["device_key"]:
run_data["boot_id"] = payload.boot_id
connection.execute("UPDATE runs SET device_key=?,data=?,status='runner_ready',version=version+1,last_seen=? WHERE id=?",(payload.public_key,canonical(run_data),time.time(),row["id"]))
connection.execute("UPDATE hosts SET status='runner_ready' WHERE id=?",(row["host_id"],))
audit(connection,"device:"+row["id"],"runner.enrolled",row["id"])
return {"run_id":row["id"],"status":"enrolled","manifest_digest":run_data["manifest_digest"]}
async def device(request:Request):
run_id = request.headers.get("x-run-id", "")
key = request.headers.get("x-device-key", "")
timestamp = request.headers.get("x-timestamp", "")
nonce = request.headers.get("x-nonce", "")
try:
require(abs(time.time()-int(timestamp)) <= 300,401,"Signaturzeit liegt außerhalb des zulässigen Fensters.")
require(bool(re.fullmatch(r"[A-Za-z0-9_-]{16,128}",nonce)),401,"Ungültige Request-Nonce.")
body = await request.body()
message = f"{request.method}\n{request.url.path}\n{timestamp}\n{nonce}\n{hashlib.sha256(body).hexdigest()}".encode()
signature = base64.b64decode(request.headers.get("x-signature", ""),validate=True)
with db.connection(write=True) as connection:
row = connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone()
require(row is not None and row["device_key"] and hmac.compare_digest(row["device_key"],key),401,"Gerätebindung ungültig.")
Ed25519PublicKey.from_public_bytes(base64.b64decode(key,validate=True)).verify(signature,message)
require(not connection.execute("SELECT 1 FROM nonces WHERE run_id=? AND nonce=?",(run_id,nonce)).fetchone(),409,"Request-Nonce wurde bereits verwendet.")
if row["status"] in TERMINAL:
complete_retry = request.url.path == f"/agent/v1/runs/{run_id}/complete" and row["status"] == "succeeded" and row["completed_at"] and time.time()-row["completed_at"] < 3600
cancel_retry = request.url.path == f"/agent/v1/runs/{run_id}/events" and row["status"] == "cancelled" and row["completed_at"] and time.time()-row["completed_at"] < 3600
require(complete_retry or cancel_retry,403,"Laufberechtigung ist beendet.")
connection.execute("INSERT INTO nonces VALUES(?,?,?)",(run_id,nonce,time.time()+600))
rate_limit("device:"+run_id,600)
return run_id
except (ValueError,TypeError,InvalidSignature):
raise HTTPException(401,"Ungültige Gerätesignatur.")
def authorized_run(connection,run_id,device_id,lease=False):
require(run_id == device_id,403,"Kein Zugriff auf einen fremden Lauf.")
row = connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone()
require(row is not None,404,"Lauf nicht gefunden.")
if lease:
require(row["status"] in {"runner_ready","running"} and row["lease_until"] and row["lease_until"]>time.time(),403,"Aktuelle Ausführungsberechtigung erforderlich.")
require(not json.loads(row["data"]).get("cancel_requested") and not get_host(connection,row["host_id"])["blocked"],403,"Lauf ist gesperrt.")
return row
@app.post("/agent/v1/lease")
def lease(payload:LeaseRequest,device_id=Depends(device)):
with db.connection(write=True) as connection:
row = authorized_run(connection,payload.run_id,device_id)
data = json.loads(row["data"])
host = get_host(connection,row["host_id"])
action = "stop" if data.get("cancel_requested") else "wait" if host["blocked"] or row["status"] in {"needs_review","waiting_retry"} else "run" if row["status"] in {"runner_ready","running","reboot_pending"} else "revoked"
expiry = time.time()+settings.lease_seconds if action == "run" else time.time()
# A lease already held by an offline device cannot be recalled.
# Keep its horizon for the operator reconciliation gate.
remembered_expiry = expiry if action == "run" else row["lease_until"]
connection.execute("UPDATE runs SET lease_until=?,last_seen=? WHERE id=?",(remembered_expiry,time.time(),row["id"]))
return {"action":action,"expires_at":expiry,"run_version":row["version"]}
@app.get("/agent/v1/runs/{run_id}/manifest")
def manifest(run_id:str,device_id=Depends(device)):
with db.connection() as connection:
row = authorized_run(connection,run_id,device_id,lease=True)
data = json.loads(row["data"])
return {**data["manifest"],"digest":data["manifest_digest"]}
@app.get("/agent/v1/artifacts/{checksum}",response_class=Response)
def artifact(checksum:str,device_id=Depends(device)):
require(bool(re.fullmatch(r"[a-f0-9]{64}",checksum)),404,"Artefakt nicht gefunden.")
with db.connection() as connection:
row = authorized_run(connection,device_id,device_id,lease=True)
require(checksum in {s["digest"] for s in json.loads(row["data"])["manifest"]["steps"]},403,"Artefakt gehört nicht zum Lauf.")
path = service.artifact_dir / checksum
require(path.is_file(),404,"Artefakt fehlt.")
content = path.read_bytes()
require(digest(content)==checksum,503,"Artefaktintegrität konnte nicht bestätigt werden.")
return Response(content,media_type="application/octet-stream")
@app.get("/agent/v1/runs/{run_id}/secrets/{step_id}")
def step_secrets(run_id:str,step_id:str,device_id=Depends(device)):
with db.connection(write=True) as connection:
row = authorized_run(connection,run_id,device_id,lease=True)
step = connection.execute("SELECT * FROM run_steps WHERE run_id=? AND step_id=?",(run_id,step_id)).fetchone()
require(step is not None and step["status"] != "succeeded",403,"Kein Geheimniszugriff für diesen Schritt.")
specs = {s["id"]:s for s in json.loads(row["data"])["manifest"]["steps"]}
remaining = [s for s in connection.execute("SELECT * FROM run_steps WHERE run_id=? ORDER BY position",(run_id,)) if s["status"] != "succeeded" and not (s["status"] == "failed" and not specs[s["step_id"]]["required"])]
require(remaining and remaining[0]["step_id"] == step_id,403,"Geheimnisse sind nur für den aktuellen Schritt verfügbar.")
audit(connection,"device:"+run_id,"step.secrets_read",run_id,data={"step_id":step_id})
return json.loads(security.decrypt(row["secrets_ciphertext"]))["steps"].get(step_id,{})
@app.post("/agent/v1/runs/{run_id}/events")
def events(run_id:str,payload:EventBatch,device_id=Depends(device)):
with db.connection(write=True) as connection:
row = authorized_run(connection,run_id,device_id)
ack = connection.execute("SELECT COALESCE(MAX(sequence),0) FROM events WHERE run_id=?",(run_id,)).fetchone()[0]
for event in payload.events:
redacted = service.redact_payload(row,event.model_dump())
if event.sequence <= ack:
stored = connection.execute("SELECT data FROM events WHERE run_id=? AND sequence=?",(run_id,event.sequence)).fetchone()
require(stored and stored[0] == redacted,409,"Sequenznummer wurde mit anderem Ereignisinhalt wiederholt.")
continue
require(event.sequence == ack+1,409,"Ereignisse müssen lückenlos und aufsteigend eintreffen.")
row = connection.execute("SELECT * FROM runs WHERE id=?",(run_id,)).fetchone()
apply_event(connection,row,event)
connection.execute("INSERT INTO events VALUES(?,?,?,?)",(run_id,event.sequence,redacted,now_iso()))
ack = event.sequence
connection.execute("UPDATE runs SET last_seen=? WHERE id=?",(time.time(),run_id))
return {"ack_sequence":ack}
def apply_event(connection,row,event):
data = json.loads(row["data"])
status = row["status"]
require(status not in TERMINAL,409,"Terminaler Lauf nimmt keine neuen Ereignisse an.")
if event.type == "heartbeat":
return
if event.type.startswith("step."):
require(status in {"runner_ready","running"},409,"Schrittereignis ist in diesem Laufzustand nicht zulässig.")
step = connection.execute("SELECT * FROM run_steps WHERE run_id=? AND step_id=?",(row["id"],event.step_id)).fetchone()
require(step is not None,422,"Schritt gehört nicht zum fixierten Manifest.")
specs = {s["id"]:s for s in data["manifest"]["steps"]}
previous = connection.execute("SELECT * FROM run_steps WHERE run_id=? AND position<? AND status!='succeeded'",(row["id"],step["position"])).fetchall()
require(all(s["status"] == "failed" and not specs[s["step_id"]]["required"] for s in previous),409,"Vorheriger Pflichtschritt ist nicht erfolgreich abgeschlossen.")
for dependency in specs[event.step_id]["dependencies"]:
dep = connection.execute("SELECT status FROM run_steps WHERE run_id=? AND step_id=?",(row["id"],dependency)).fetchone()
require(dep and dep[0] == "succeeded",409,"Schrittabhängigkeit ist nicht erfolgreich.")
if event.type == "step.started":
require(row["lease_until"] and row["lease_until"]>time.time() and not data.get("cancel_requested") and not get_host(connection,row["host_id"])["blocked"],403,"Keine aktuelle Erlaubnis für einen neuen Schritt.")
require(step["status"] in {"pending","applying","failed"},409,"Abgeschlossener Schritt darf nicht erneut gestartet werden.")
connection.execute("UPDATE run_steps SET status='applying',attempt=attempt+1 WHERE run_id=? AND step_id=?",(row["id"],event.step_id))
status = "running"
elif event.type == "step.succeeded":
require(step["status"] == "applying" and event.exit_code == 0 and bool(event.verification),409,"Erfolg benötigt einen laufenden Schritt und erfolgreiche Verifikation.")
require(event.verification.get("passed") is True or ("passed" not in event.verification and all(v is True for v in event.verification.values())),409,"Verifikation bestätigt keinen Erfolg.")
connection.execute("UPDATE run_steps SET status='succeeded',verification=? WHERE run_id=? AND step_id=?",(service.redact_payload(row,event.verification),row["id"],event.step_id))
else:
require(step["status"] == "applying",409,"Nur ein laufender Schritt kann fehlschlagen.")
connection.execute("UPDATE run_steps SET status='failed',verification=? WHERE run_id=? AND step_id=?",(service.redact_payload(row,event.verification),row["id"],event.step_id))
status = "needs_review" if specs[event.step_id]["required"] else "running"
elif event.type == "run.needs_review":
status = "needs_review"
elif event.type == "run.reboot_pending":
require(status in {"running","runner_ready"} and data.get("reboots",0)<data["manifest"]["reboot_budget"],409,"Neustart ist nicht erlaubt oder Budget erschöpft.")
data["reboots"] = data.get("reboots",0)+1
data["boot_id"] = event.boot_id
status = "reboot_pending"
elif event.type == "run.resumed":
require((status == "reboot_pending" and event.boot_id != data.get("boot_id")) or status == "runner_ready",409,"Wiederaufnahme benötigt einen neuen Boot oder eine Operatorfreigabe.")
data["boot_id"] = event.boot_id
status = "runner_ready"
elif event.type == "run.cancelled":
require(data.get("cancel_requested"),409,"Kein Abbruch angefordert.")
status = "cancelled"
connection.execute("UPDATE runs SET status=?,data=?,version=version+1 WHERE id=?",(status,canonical(data),row["id"]))
if status == "cancelled":
connection.execute("UPDATE runs SET completed_at=?,lease_until=NULL,enrollment_hash=NULL,bootstrap_hash=NULL,report_hash=NULL WHERE id=?",(time.time(),row["id"]))
connection.execute("UPDATE hosts SET status=? WHERE id=?",(status,row["host_id"]))
@app.post("/agent/v1/runs/{run_id}/logs")
def logs(run_id:str,payload:LogBatch,device_id=Depends(device)):
with db.connection(write=True) as connection:
row = authorized_run(connection,run_id,device_id)
ack = connection.execute("SELECT COALESCE(MAX(sequence),0) FROM logs WHERE run_id=?",(run_id,)).fetchone()[0]
for chunk in payload.chunks:
data = service.redact_payload(row,chunk.model_dump())
if chunk.sequence <= ack:
existing = connection.execute("SELECT data FROM logs WHERE run_id=? AND sequence=?",(run_id,chunk.sequence)).fetchone()
require(existing and existing[0]==data,409,"Logsequenz wurde mit anderem Inhalt wiederholt.")
continue
require(chunk.sequence==ack+1,409,"Logsequenz ist nicht lückenlos.")
connection.execute("INSERT INTO logs VALUES(?,?,?,?)",(run_id,chunk.sequence,data,now_iso()))
ack = chunk.sequence
return {"ack_sequence":ack}
@app.post("/agent/v1/runs/{run_id}/complete")
def complete(run_id:str,payload:Completion,device_id=Depends(device)):
with db.connection(write=True) as connection:
row = authorized_run(connection,run_id,device_id)
if row["status"] == "succeeded":
return {"status":"succeeded"}
data = json.loads(row["data"])
require(row["status"] in {"running","runner_ready"} and not data.get("cancel_requested"),409,"Lauf ist nicht abschließbar.")
steps = connection.execute("SELECT * FROM run_steps WHERE run_id=?",(run_id,)).fetchall()
specs = {s["id"]:s for s in data["manifest"]["steps"]}
require(steps and all(s["status"]=="succeeded" or (s["status"]=="failed" and not specs[s["step_id"]]["required"]) for s in steps) and bool(payload.verification),409,"Alle Pflichtschritte müssen verifiziert erfolgreich sein.")
require(all(isinstance(payload.verification.get(s["id"]),dict) and payload.verification[s["id"]].get("passed") is True for s in specs.values() if s["required"]),409,"Abschlussprüfung muss jeden Pflichtschritt ausdrücklich als erfolgreich bestätigen.")
data["verification"] = json.loads(service.redact_payload(row,payload.verification))
connection.execute("UPDATE runs SET status='succeeded',version=version+1,data=?,completed_at=?,lease_until=NULL,enrollment_hash=NULL,bootstrap_hash=NULL,report_hash=NULL,answer_ciphertext=NULL,bootstrap_ciphertext=NULL WHERE id=?",(canonical(data),time.time(),run_id))
connection.execute("UPDATE hosts SET status='succeeded',version=version+1 WHERE id=?",(row["host_id"],))
audit(connection,"device:"+run_id,"run.succeeded",run_id)
return {"status":"succeeded"}
return app
+88
View File
@@ -0,0 +1,88 @@
"""Build a self-contained first-boot executable without target-side downloads."""
import base64
import json
from pathlib import Path
import urllib.parse
def render_bootstrap(config: dict) -> str:
required = {"api_url", "run_id", "enrollment_secret", "identities", "manifest_digest"}
if not required.issubset(config):
raise ValueError(f"Missing bootstrap values: {', '.join(sorted(required - config.keys()))}")
if urllib.parse.urlsplit(config["api_url"]).scheme != "https":
raise ValueError("Bootstrap requires a certificate-validated HTTPS API URL")
runner = base64.b64encode(Path(__file__).with_name("runner.py").read_bytes()).decode()
settings = base64.b64encode(json.dumps(config, ensure_ascii=False, sort_keys=True).encode()).decode()
script = f'''#!/bin/bash
set -euo pipefail
umask 077
test "$(id -u)" = 0
command -v python3 >/dev/null
command -v openssl >/dev/null
command -v systemctl >/dev/null
# Persist every input before enabling the first network operation.
python3 - <<'PVE_BOOTSTRAP_PY'
import base64, json, os, pathlib, tempfile
etc = pathlib.Path('/etc/pve-provisioner')
state = pathlib.Path('/var/lib/pve-provisioner')
etc.mkdir(mode=0o700, parents=True, exist_ok=True)
state.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(etc, 0o700)
os.chmod(state, 0o700)
config = base64.b64decode('{settings}')
settings = json.loads(config)
existing = state / 'state.json'
if existing.exists() and json.loads(existing.read_text())['run_id'] != json.loads(config)['run_id']:
raise SystemExit('Another run owns this host; explicit local state archival is required')
def persist(path, content, mode=0o600):
fd, name = tempfile.mkstemp(prefix='.tmp-', dir=path.parent)
try:
os.fchmod(fd, mode)
with os.fdopen(fd, 'wb') as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, path)
directory = os.open(path.parent, os.O_DIRECTORY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
if os.path.exists(name):
os.unlink(name)
if settings.get('ca_pem'):
persist(etc / 'trusted-ca.pem', settings.pop('ca_pem').encode())
settings['ca_file'] = str(etc / 'trusted-ca.pem')
config = json.dumps(settings, sort_keys=True).encode()
persist(etc / 'config.json', config)
persist(etc / 'runner.py', base64.b64decode('{runner}'))
service = b"""[Unit]
Description=Proxmox one-run provisioner
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=3600
StartLimitBurst=120
[Service]
Type=simple
ExecStart=/usr/bin/python3 /etc/pve-provisioner/runner.py
Restart=on-failure
RestartSec=30
TimeoutStopSec=14500
KillMode=mixed
UMask=0077
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
"""
persist(pathlib.Path('/etc/systemd/system/pve-provisioner.service'), service, 0o644)
PVE_BOOTSTRAP_PY
systemctl daemon-reload
systemctl enable --now pve-provisioner.service
'''
if len(script.encode()) >= 1024 * 1024:
raise ValueError("Bootstrap exceeds installer size limit")
return script
+26
View File
@@ -0,0 +1,26 @@
# Mitgelieferte Modulentwürfe
Alle acht Module werden als **Entwurf ohne Hardware-Testnachweis und ohne freigegebene Zielbuilds** angeboten. Vor Veröffentlichung müssen Quelltext, konkrete Parameter und Verhalten auf einem passenden Testhost geprüft werden. Die Syntaxprüfung ersetzt diesen Nachweis nicht.
| Modul | Parameter und Umfang |
| --- | --- |
| Voraussetzungen | `allowed_versions`: exakte Versionsstrings aus `pveversion`; `dns_names`: aufzulösende Namen; `minimum_free_mb`: freier Platz auf `/`. Eine leere Versionsliste lässt die zusätzliche Versionsprüfung aus; die Buildfreigabe im Dienst bleibt erforderlich. |
| Paketquellen | `url`, `suite`, `components`, `keyring` sind zwingend. Verwaltet genau `/etc/apt/sources.list.d/pve-provisioner.sources` mit HTTPS und vorhandenem APT-Schlüsselbund. Andere Quellen, insbesondere Subscription-Konfigurationen, werden nicht automatisch entfernt. |
| Basispakete | `packages`: Debian-Paketnamen ohne Shell-Ausdrücke oder APT-Optionen. Installiert fehlende Pakete, wartet auf die Paketmanagersperre und prüft anschließend den Installationsstatus. Führt kein allgemeines Systemupgrade aus. |
| SSH-Zugang | `users`: Liste aus `name` und `authorized_keys`. Benutzer müssen bereits existieren und eine Login-Shell sowie ein sicher berechtigtes Home-Verzeichnis haben. OpenSSH validiert vollständige Schlüssel vor jeder Änderung. Schlüssel werden ergänzt und Dateirechte, effektive lokale SSH-Schlüsselrichtlinie, `sshd -t` sowie der aktive Dienst geprüft. Verbindung und gegebenenfalls abweichende `Match`-Regeln aus dem realen Managementnetz sind separat zu testen. |
| Zeitsynchronisation | `servers`: explizite NTP-Hostnamen oder IP-Adressen. `chrony` muss zuvor installiert sein und `sourcedir /etc/chrony/sources.d` verwenden. Verwaltet eine eigene Quelldatei und wartet begrenzt auf Synchronisation. |
| Monitoring | `enabled`: standardmäßig `false`. Bei Aktivierung muss `prometheus-node-exporter` bereits installiert sein. Aktiviert den Dienst und prüft dessen lokalen Metrics-Endpunkt. Netzwerkzugriff auf den Exporter muss im Standortnetz passend geregelt sein. |
| Zusätzlicher Storage | `id`, `path`, `content`: registriert ein bereits existierendes Verzeichnis unter `/mnt/` oder `/srv/` als PVE-Verzeichnisstorage. Kein Formatieren, kein Mounten, keine Änderung widersprüchlicher vorhandener Storage-Konfiguration. |
| Abschlussprüfung | `allowed_versions`, `dns_names`, `storage_ids`, `require_time_sync`: prüft PVE-Dienste, Version, DNS, Zeit und angegebenen aktiven Storage. |
Modulabhängigkeiten beziehen sich im Verwaltungsmodell auf **Modulnamen**. Beim Freigeben werden sie auf die konkreten Schritt-IDs des unveränderlichen Laufmanifests aufgelöst. Profilparameter werden gegen das jeweilige JSON-Schema geprüft. Beispielsweise muss das Paketprofil `chrony` enthalten, wenn der Zeitschritt auf einem Host ohne Chrony eingeplant wird.
## Modulvertrag
Der Runner startet `bash modul.sh check|apply|verify parameter.json`. `check` liefert `0`, wenn der Sollzustand erreicht ist, `1`, wenn eine Änderung nötig ist, und einen anderen Rückgabecode für einen Prüffehler. Ein Schritt gilt erst nach erfolgreichem `verify` als erfolgreich. `apply` darf mit `194` einen geplanten Neustart anfordern; der Runner schreibt zuerst seinen Checkpoint und kontrolliert das Neustartbudget. Ein Modul darf den Neustart nicht selbst auslösen.
Die Parameterdatei enthält die freigegebenen Parameter sowie ein Objekt `secrets` mit ausschließlich den Geheimnissen des aktuellen Schritts. Sie wird mit Modus `0600` angelegt und nach dem Schritt entfernt. Module sollen keine Geheimnisse ausgeben; zusätzlich redigiert der Runner bekannte Geheimniswerte vor der dauerhaften Logablage. Logausgabe ist pro Phase und in der lokalen Warteschlange begrenzt.
Das Schritt-Timeout gilt gemeinsam für `check`, `apply` und `verify`. Nach Unterbrechungen werden `check` und `verify` erneut ausgeführt; ein nicht bestätigter Zustand darf nur bei `retry_safe=true` erneut angewendet werden. Ein permanenter Fehler wartet auf eine explizite Wiederaufnahme im Webtool. Die standardmäßige Wartefrist beträgt 24 Stunden, die maximale automatische Wiederherstellung bei Netzausfall 30 Minuten.
Chrony-Kommandos orientieren sich an der offiziellen Dokumentation zu [chronyc](https://chrony-project.org/doc/4.7/chronyc.html) und [sourcedir](https://chrony-project.org/doc/4.7/chrony.conf.html). Die tatsächliche Paketversion und Distribution bleiben Bestandteil des Zielhost-Tests.
+53
View File
@@ -0,0 +1,53 @@
"""Conservative module drafts. Publication always requires target-host evidence."""
from pathlib import Path
def schema(properties, required=()):
return {"type": "object", "additionalProperties": False, "properties": properties, "required": list(required)}
STRING_LIST = {"type": "array", "items": {"type": "string"}, "maxItems": 100}
DEFINITIONS = [
("prerequisites", "Voraussetzungen", "PVE-Version, DNS, Uhrzeit und freien Speicher prüfen.",
schema({"allowed_versions": STRING_LIST, "dns_names": STRING_LIST,
"minimum_free_mb": {"type": "integer", "minimum": 512, "maximum": 1048576}}),
{"allowed_versions": [], "dns_names": [], "minimum_free_mb": 2048}, [], 120, True),
("repositories", "Paketquellen", "Eine signierte, explizit freigegebene HTTPS-Paketquelle verwalten.",
schema({"url": {"type": "string"}, "suite": {"type": "string"}, "components": STRING_LIST,
"keyring": {"type": "string"}}, ("url", "suite", "components", "keyring")),
{}, ["prerequisites"], 600, True),
("packages", "Basispakete", "Explizit genannte Pakete installieren; keine globale Aktualisierung.",
schema({"packages": STRING_LIST}), {"packages": []}, ["prerequisites"], 1800, True),
("ssh", "SSH-Zugang", "Freigegebene Schlüssel vorhandenen Benutzern hinzufügen; sshd validieren.",
schema({"users": {"type": "array", "maxItems": 50, "items": schema({
"name": {"type": "string", "pattern": "^[a-z_][a-z0-9_-]{0,31}$"},
"authorized_keys": STRING_LIST}, ("name", "authorized_keys"))}}),
{"users": []}, ["prerequisites"], 120, True),
("time", "Zeitsynchronisation", "Chrony mit expliziten Zeitservern konfigurieren und Synchronisation prüfen.",
schema({"servers": STRING_LIST}, ("servers",)), {"servers": []}, ["packages"], 600, True),
("monitoring", "Monitoring", "Optional den Debian prometheus-node-exporter aktivieren.",
schema({"enabled": {"type": "boolean"}}), {"enabled": False}, ["packages"], 600, False),
("storage", "Zusätzlicher Storage", "Vorhandenes Verzeichnis ohne Formatierung als PVE-Storage anbinden.",
schema({"id": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,31}$"},
"path": {"type": "string"}, "content": STRING_LIST}, ("id", "path", "content")),
{}, ["prerequisites"], 120, True),
("final-verification", "Abschlussprüfung", "PVE-Dienste, Versionsstand, DNS, Zeit und aktiven Storage prüfen.",
schema({"allowed_versions": STRING_LIST, "dns_names": STRING_LIST,
"storage_ids": STRING_LIST, "require_time_sync": {"type": "boolean"}}),
{"allowed_versions": [], "dns_names": [], "storage_ids": [], "require_time_sync": True},
["prerequisites"], 180, True),
]
def catalog():
result = []
root = Path(__file__).parent
names = {definition[0]: definition[1] for definition in DEFINITIONS}
for module_id, name, description, parameters_schema, defaults, dependencies, timeout, required in DEFINITIONS:
result.append({"id": module_id, "name": name, "description": description, "version": "1.0.0",
"source": (root / f"{module_id}.sh").read_text(encoding="utf-8"),
"parameters_schema": parameters_schema, "default_parameters": defaults,
"dependencies": [names[dependency] for dependency in dependencies], "timeout_seconds": timeout,
"retry_safe": True, "required": required, "status": "draft",
"test_evidence": "", "target_builds": []})
return result
@@ -0,0 +1,31 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import json, pathlib, re, socket, subprocess, sys
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
try:
version = subprocess.run(['pveversion'], check=True, capture_output=True, text=True, timeout=30).stdout
match = re.search(r'pve-manager/([^/\s]+)', version)
if not match or (p.get('allowed_versions') and match[1] not in p['allowed_versions']):
raise ValueError('PVE version verification failed')
for service in ('pve-cluster.service', 'pvedaemon.service', 'pveproxy.service', 'pvestatd.service'):
subprocess.run(['systemctl', 'is-active', '--quiet', service], check=True, timeout=30)
for name in p.get('dns_names', []):
socket.getaddrinfo(name, 443)
if p.get('require_time_sync', True):
sync = subprocess.run(['timedatectl', 'show', '-p', 'NTPSynchronized', '--value'], check=True, capture_output=True, text=True, timeout=30)
if sync.stdout.strip() != 'yes':
raise ValueError('System clock is not synchronized')
for storage_id in p.get('storage_ids', []):
if not isinstance(storage_id, str) or not re.fullmatch(r'[A-Za-z][A-Za-z0-9_-]{0,31}', storage_id):
raise ValueError('Invalid storage identifier')
result = subprocess.run(['pvesm', 'status', '--storage', storage_id], check=True, capture_output=True, text=True, timeout=30)
if not any(re.match(r'^' + re.escape(storage_id) + r'\s+\S+\s+active\s', line) for line in result.stdout.splitlines()):
raise ValueError('Required storage is not active')
print(json.dumps({'passed': True, 'pve_version': match[1], 'services': 'active', 'clock': 'checked', 'storage': 'checked'}))
except (ValueError, OSError, subprocess.SubprocessError) as exc:
print(str(exc), file=sys.stderr)
sys.exit(2)
PY
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import json, pathlib, subprocess, sys, urllib.request
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
if not isinstance(p.get('enabled', False), bool):
raise SystemExit('enabled must be a boolean')
if not p.get('enabled', False):
print('{"passed":true,"enabled":false}')
raise SystemExit(0)
service = 'prometheus-node-exporter.service'
active = subprocess.run(['systemctl', 'is-active', '--quiet', service], timeout=30).returncode == 0
if mode == 'check':
sys.exit(0 if active else 1)
if mode == 'apply':
package = subprocess.run(['dpkg-query', '-W', '-f=${Status}', 'prometheus-node-exporter'], capture_output=True, text=True, timeout=30)
if package.returncode != 0 or package.stdout != 'install ok installed':
raise SystemExit('Install prometheus-node-exporter with the package module first')
subprocess.run(['systemctl', 'enable', '--now', service], check=True, timeout=60)
subprocess.run(['systemctl', 'is-active', '--quiet', service], check=True, timeout=30)
with urllib.request.urlopen('http://127.0.0.1:9100/metrics', timeout=10) as response:
body = response.read(2 * 1024 * 1024)
if b'node_exporter_build_info' not in body:
raise SystemExit('Node exporter metrics validation failed')
print('{"passed":true,"metrics":"available"}')
PY
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import json, pathlib, re, subprocess, sys
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
packages = p.get('packages', [])
if not isinstance(packages, list) or len(packages) > 100 or any(not isinstance(x, str) or not re.fullmatch(r'[a-z0-9][a-z0-9+.-]{0,100}', x) for x in packages):
raise SystemExit('Invalid package list; names only, no options or shell expressions')
def installed(name):
result = subprocess.run(['dpkg-query', '-W', '-f=${Status}', name], capture_output=True, text=True, timeout=30)
return result.returncode == 0 and result.stdout == 'install ok installed'
missing = [name for name in packages if not installed(name)]
if mode == 'apply' and missing:
subprocess.run(['apt-get', '-o', 'DPkg::Lock::Timeout=180', 'update'], check=True, timeout=600)
subprocess.run(['apt-get', '-o', 'DPkg::Lock::Timeout=180', 'install', '-y', '--no-install-recommends', '--', *missing], check=True, timeout=1200)
missing = [name for name in packages if not installed(name)]
print(json.dumps({'passed': not missing, 'missing': missing}))
sys.exit(1 if missing else 0)
PY
@@ -0,0 +1,30 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import json, pathlib, re, shutil, socket, subprocess, sys, time
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
try:
result = subprocess.run(['pveversion'], check=True, capture_output=True, text=True, timeout=20)
match = re.search(r'pve-manager/([^/\s]+)', result.stdout)
if not match:
raise ValueError('Target does not report a Proxmox VE manager version')
if p.get('allowed_versions') and match[1] not in p['allowed_versions']:
raise ValueError('PVE version is outside the approved module target versions')
minimum = p.get('minimum_free_mb', 2048)
if not isinstance(minimum, int) or not 512 <= minimum <= 1048576:
raise ValueError('minimum_free_mb is invalid')
if shutil.disk_usage('/').free < minimum * 1024 * 1024:
raise ValueError('Insufficient free root filesystem space')
if time.time() < 1704067200:
raise ValueError('System clock is not plausible')
for name in p.get('dns_names', []):
if not isinstance(name, str) or not name or len(name) > 253:
raise ValueError('Invalid DNS target')
socket.getaddrinfo(name, 443)
print(json.dumps({'passed': True, 'pve_version': match[1], 'dns': 'resolved', 'free_space': 'sufficient'}))
except (ValueError, OSError, subprocess.SubprocessError) as exc:
print(str(exc), file=sys.stderr)
sys.exit(2)
PY
@@ -0,0 +1,48 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import json, os, pathlib, re, subprocess, sys, tempfile, urllib.parse
def has_repository_indexes(policy, url, suite, components):
indexes = [line.split() for line in policy.splitlines()]
return all(any(any(field.rstrip('/') == url.rstrip('/') for field in fields) and f'{suite}/{component}' in fields for fields in indexes) for component in components)
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
url, suite, components, keyring = (p.get(k) for k in ('url', 'suite', 'components', 'keyring'))
if not isinstance(url, str) or any(c.isspace() for c in url):
raise SystemExit('An explicit HTTPS repository URL is required')
parsed = urllib.parse.urlsplit(url)
if parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise SystemExit('Repository URL must use HTTPS without credentials or query parameters')
if not isinstance(suite, str) or not re.fullmatch(r'[a-z][a-z0-9-]{0,40}', suite):
raise SystemExit('Invalid repository suite')
if not isinstance(components, list) or not components or any(not isinstance(c, str) or not re.fullmatch(r'[a-z][a-z0-9/-]{0,50}', c) for c in components):
raise SystemExit('Invalid repository components')
if not isinstance(keyring, str) or not re.fullmatch(r'/(usr/share|etc/apt)/keyrings/[A-Za-z0-9_.-]+\.(gpg|asc)', keyring) or not pathlib.Path(keyring).is_file():
raise SystemExit('An existing administrator-provisioned APT keyring is required')
expected = f'Types: deb\nURIs: {url}\nSuites: {suite}\nComponents: {" ".join(components)}\nSigned-By: {keyring}\n'
target = pathlib.Path('/etc/apt/sources.list.d/pve-provisioner.sources')
matches = target.is_file() and target.read_text() == expected
if mode == 'check':
sys.exit(0 if matches else 1)
if mode == 'apply' and not matches:
fd, name = tempfile.mkstemp(prefix='.pve-provisioner-', dir=target.parent)
try:
os.fchmod(fd, 0o644)
with os.fdopen(fd, 'w') as stream:
stream.write(expected)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, target)
finally:
if os.path.exists(name):
os.unlink(name)
if mode == 'apply':
subprocess.run(['apt-get', '-o', 'DPkg::Lock::Timeout=180', 'update'], check=True, timeout=500)
policy = subprocess.run(['apt-cache', 'policy'], check=True, capture_output=True, text=True, timeout=30).stdout
indexed = has_repository_indexes(policy, url, suite, components)
passed = target.is_file() and target.read_text() == expected and indexed
print(json.dumps({'passed': passed, 'repository': url, 'suite': suite}))
sys.exit(0 if passed else 1)
PY
+96
View File
@@ -0,0 +1,96 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import base64, json, os, pathlib, pwd, re, subprocess, sys, tempfile
def validate_public_key(key):
if not isinstance(key, str) or '\n' in key or '\r' in key or len(key) > 16384:
raise SystemExit('Invalid SSH public key')
parts = key.split()
if len(parts) < 2 or parts[0] not in ('ssh-ed25519', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521'):
raise SystemExit('Unsupported SSH public key format')
try:
blob = base64.b64decode(parts[1], validate=True)
size = int.from_bytes(blob[:4], 'big')
if blob[4:4 + size].decode() != parts[0] or len(blob) <= 4 + size:
raise ValueError()
except (ValueError, UnicodeError):
raise SystemExit('Invalid SSH public key encoding')
# sshd -t does not parse authorized_keys. Validate the complete public key.
fd, key_file = tempfile.mkstemp(prefix='pve-public-key-')
try:
with os.fdopen(fd, 'w') as stream:
stream.write(key + '\n')
parsed = subprocess.run(['ssh-keygen', '-l', '-f', key_file], capture_output=True, text=True, timeout=15)
if parsed.returncode != 0:
raise SystemExit('OpenSSH rejected the configured public key')
finally:
os.unlink(key_file)
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
users = p.get('users', [])
if not isinstance(users, list) or len(users) > 50:
raise SystemExit('Invalid SSH users')
changes = []
for entry in users:
name = entry.get('name', '')
if not isinstance(name, str) or not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', name):
raise SystemExit('Invalid SSH account name')
try:
user = pwd.getpwnam(name)
except KeyError:
raise SystemExit('SSH module requires an existing user account')
keys = entry.get('authorized_keys', [])
if not isinstance(keys, list) or not keys or len(keys) > 100:
raise SystemExit('At least one authorized key per configured user is required')
for key in keys:
validate_public_key(key)
home = pathlib.Path(user.pw_dir)
if not home.is_dir() or home.stat().st_uid not in (0, user.pw_uid) or home.stat().st_mode & 0o022:
raise SystemExit('Account home must have safe ownership and must not be writable by group or others')
if user.pw_shell in ('/usr/sbin/nologin', '/sbin/nologin', '/bin/false'):
raise SystemExit('SSH account requires an interactive login shell')
effective = subprocess.run(['/usr/sbin/sshd', '-T', '-C', f'user={name},host=localhost,addr=127.0.0.1'], check=True, capture_output=True, text=True, timeout=30)
settings = dict(line.split(None, 1) for line in effective.stdout.splitlines() if ' ' in line)
if settings.get('pubkeyauthentication') != 'yes' or (name == 'root' and settings.get('permitrootlogin') not in ('yes', 'prohibit-password', 'without-password')):
raise SystemExit('Effective SSH policy does not permit public-key login for this account')
key_paths = settings.get('authorizedkeysfile', '').split()
accepted = {'.ssh/authorized_keys', '%h/.ssh/authorized_keys', str(home / '.ssh/authorized_keys')}
if not accepted.intersection(key_paths):
raise SystemExit('Effective SSH policy does not use the managed authorized_keys file')
directory = pathlib.Path(user.pw_dir) / '.ssh'
target = directory / 'authorized_keys'
if directory.is_symlink() or target.is_symlink():
raise SystemExit('Refusing symlinked SSH paths')
existing = target.read_text() if target.exists() else ''
missing = [key for key in keys if key not in existing.splitlines()]
correct_permissions = directory.exists() and target.exists() and directory.stat().st_mode & 0o777 == 0o700 and target.stat().st_mode & 0o777 == 0o600 and target.stat().st_uid == user.pw_uid and directory.stat().st_uid == user.pw_uid
if missing or not correct_permissions:
changes.append((user, directory, target, existing, missing))
if mode == 'check':
sys.exit(1 if changes else 0)
if mode == 'apply':
for user, directory, target, existing, missing in changes:
directory.mkdir(mode=0o700, exist_ok=True)
os.chmod(directory, 0o700)
os.chown(directory, user.pw_uid, user.pw_gid)
content = existing.rstrip('\n') + ('\n' if existing else '') + '\n'.join(missing) + ('\n' if missing else '')
fd, name = tempfile.mkstemp(prefix='.authorized-', dir=directory)
try:
os.fchmod(fd, 0o600)
os.fchown(fd, user.pw_uid, user.pw_gid)
with os.fdopen(fd, 'w') as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, target)
finally:
if os.path.exists(name):
os.unlink(name)
subprocess.run(['/usr/sbin/sshd', '-t'], check=True, timeout=30)
subprocess.run(['systemctl', 'is-active', '--quiet', 'ssh.service'], check=True, timeout=30)
if mode == 'verify' and changes:
raise SystemExit(1)
print(json.dumps({'passed': True, 'accounts': len(users), 'sshd': 'validated'}))
PY
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import json, pathlib, re, subprocess, sys
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
storage_id, location, content = (p.get(k) for k in ('id', 'path', 'content'))
if not isinstance(storage_id, str) or not re.fullmatch(r'[A-Za-z][A-Za-z0-9_-]{0,31}', storage_id):
raise SystemExit('Invalid storage identifier')
if not isinstance(location, str) or any(c.isspace() for c in location) or not location.startswith(('/mnt/', '/srv/')):
raise SystemExit('Storage must be an existing absolute directory under /mnt or /srv')
directory = pathlib.Path(location)
if not directory.is_dir() or directory.is_symlink() or str(directory.resolve()) != location.rstrip('/'):
raise SystemExit('Storage directory must already exist without symbolic links or traversal')
if not isinstance(content, list) or not content or not set(content).issubset({'images', 'rootdir', 'vztmpl', 'iso', 'backup', 'snippets'}):
raise SystemExit('Invalid storage content types')
def configuration():
result = subprocess.run(['pvesh', 'get', '/storage', '--output-format', 'json'], check=True, capture_output=True, text=True, timeout=30)
if not any(entry.get('storage') == storage_id for entry in json.loads(result.stdout)):
return None
detail = subprocess.run(['pvesh', 'get', '/storage/' + storage_id, '--output-format', 'json'], check=True, capture_output=True, text=True, timeout=30)
return json.loads(detail.stdout)
existing = configuration()
if existing and (existing.get('type') != 'dir' or existing.get('path', '').rstrip('/') != location.rstrip('/') or set(existing.get('content', '').split(',')) != set(content)):
raise SystemExit('Existing storage has conflicting settings; automatic changes are refused')
if mode == 'check':
sys.exit(0 if existing else 1)
if mode == 'apply' and not existing:
subprocess.run(['pvesm', 'add', 'dir', storage_id, '--path', location, '--content', ','.join(content)], check=True, timeout=60)
result = subprocess.run(['pvesm', 'status', '--storage', storage_id], check=True, capture_output=True, text=True, timeout=30)
if not any(re.match(r'^' + re.escape(storage_id) + r'\s+dir\s+active\s', line) for line in result.stdout.splitlines()):
raise SystemExit('Storage is not active')
print(json.dumps({'passed': True, 'storage': storage_id, 'destructive_operations': False}))
PY
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
set -euo pipefail
case "${1:-}" in check|apply|verify) ;; *) exit 2 ;; esac
exec python3 - "$1" "$2" <<'PY'
import ipaddress, json, os, pathlib, re, subprocess, sys, tempfile
mode, params_file = sys.argv[1:]
p = json.loads(pathlib.Path(params_file).read_text())
servers = p.get('servers')
if not isinstance(servers, list) or not 1 <= len(servers) <= 16:
raise SystemExit('Between one and sixteen explicit NTP servers are required')
for server in servers:
if not isinstance(server, str) or len(server) > 253:
raise SystemExit('Invalid NTP server')
try:
ipaddress.ip_address(server)
except ValueError:
if not re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?', server):
raise SystemExit('NTP server must be a hostname or IP address')
target = pathlib.Path('/etc/chrony/sources.d/pve-provisioner.sources')
expected = ''.join(f'server {server} iburst\n' for server in servers)
active = subprocess.run(['systemctl', 'is-active', '--quiet', 'chrony.service'], timeout=30).returncode == 0
matches = target.is_file() and target.read_text() == expected
if mode == 'check':
sys.exit(0 if matches and active else 1)
if mode == 'apply':
# The package module owns installation; this module never swaps NTP daemons.
config = pathlib.Path('/etc/chrony/chrony.conf')
if not config.is_file() or not any(line.strip() == 'sourcedir /etc/chrony/sources.d' for line in config.read_text().splitlines()):
raise SystemExit('Install chrony first and enable its standard sources.d directory')
target.parent.mkdir(mode=0o755, exist_ok=True)
fd, name = tempfile.mkstemp(prefix='.pve-provisioner-', dir=target.parent)
try:
os.fchmod(fd, 0o644)
with os.fdopen(fd, 'w') as stream:
stream.write(expected)
stream.flush()
os.fsync(stream.fileno())
os.replace(name, target)
finally:
if os.path.exists(name):
os.unlink(name)
subprocess.run(['systemctl', 'enable', '--now', 'chrony.service'], check=True, timeout=60)
subprocess.run(['chronyc', 'reload', 'sources'], check=True, timeout=30)
subprocess.run(['chronyc', 'waitsync', '30', '0.5', '0', '2'], check=True, timeout=90)
subprocess.run(['systemctl', 'is-active', '--quiet', 'chrony.service'], check=True, timeout=30)
if not target.is_file() or target.read_text() != expected:
raise SystemExit(1)
print(json.dumps({'passed': True, 'clock': 'synchronized'}))
PY
+287
View File
@@ -0,0 +1,287 @@
"""Operational commands; no administrative password is saved in clear text."""
from __future__ import annotations
import argparse
from contextlib import closing, suppress
from dataclasses import asdict
import getpass
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import sqlite3
import sys
import tempfile
from datetime import datetime, timezone
from uuid import uuid4
class ServiceLock:
"""A process-lifetime lock shared by the web service and offline operations."""
def __init__(self, data_dir: Path):
self.path = Path(data_dir) / ".service.lock"
self.handle = None
def __enter__(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
self.handle = self.path.open("a+b")
try:
if self.path.stat().st_size == 0:
self.handle.write(b"0")
self.handle.flush()
self.handle.seek(0)
if os.name == "nt":
import msvcrt
msvcrt.locking(self.handle.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(self.handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except (OSError, BlockingIOError) as exc:
self.handle.close()
self.handle = None
raise RuntimeError("Data directory is in use. Stop the service before this operation.") from exc
return self
def __exit__(self, *_):
if self.handle is not None:
if os.name == "nt":
import msvcrt
self.handle.seek(0)
msvcrt.locking(self.handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN)
self.handle.close()
self.handle = None
def load_settings():
"""Read optional TOML, then environment overrides, with safe defaults."""
from provisioner.config import Settings
return Settings.from_env()
def initialize(settings, username: str | None = None) -> None:
from cryptography.fernet import Fernet
from provisioner.db import Database
from provisioner.security import Security
data_dir = Path(settings.data_dir).resolve()
key_path = Path(settings.master_key_file).resolve()
if key_path == data_dir or data_dir in key_path.parents:
raise ValueError("MASTER_KEY_FILE must be outside DATA_DIR")
with ServiceLock(data_dir):
database = Database(settings)
database.initialize()
with database.connection() as connection:
if connection.execute("SELECT 1 FROM users LIMIT 1").fetchone():
raise ValueError("Already initialized; use the administrator interface to manage users")
username = username or input("Administrator username: ").strip()
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{1,63}", username):
raise ValueError("Administrator username requires 2 to 64 letters, digits, dots, underscores or hyphens")
password = getpass.getpass("Administrator password (at least 12 characters): ")
if len(password) < 12:
raise ValueError("Use a password with at least 12 characters")
if password != getpass.getpass("Repeat administrator password: "):
raise ValueError("Passwords do not match")
if not key_path.exists():
key_path.parent.mkdir(parents=True, exist_ok=True)
descriptor = os.open(key_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(descriptor, "wb") as handle:
handle.write(Fernet.generate_key())
security = Security(settings)
with database.connection() as connection:
connection.execute(
"INSERT INTO users(id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)",
(str(uuid4()), username, security.hash_password(password), "admin", datetime.now(timezone.utc).isoformat()),
)
print(f"Initialized {data_dir}. Administrator: {username}")
print(f"Back up the encryption key separately: {key_path}")
def _digest(path: Path) -> str:
with path.open("rb") as handle:
return hashlib.file_digest(handle, "sha256").hexdigest()
def backup(settings, destination: Path) -> None:
"""Snapshot SQLite, then immutable referenced artifacts; never include keys."""
from provisioner.db import Database
destination = destination.resolve()
data_dir = Path(settings.data_dir).resolve()
if destination == data_dir or data_dir in destination.parents:
raise ValueError("Backup destination must be outside DATA_DIR")
if destination.exists():
raise ValueError("Backup destination already exists; use a new directory")
database = Database(settings)
if not database.path.exists():
raise ValueError("No initialized database found")
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = Path(tempfile.mkdtemp(prefix=".ais-backup-", dir=destination.parent))
try:
database.backup(temporary / "database.sqlite3")
artifact_dir = data_dir / "artifacts"
if artifact_dir.exists():
if artifact_dir.is_symlink():
raise ValueError("Refusing symlink in artifact storage")
# Artifacts are immutable; copying after the database snapshot includes
# every artifact referenced by that snapshot, plus possibly newer ones.
for source in artifact_dir.rglob("*"):
if source.is_symlink():
raise ValueError("Refusing symlink in artifact storage")
if source.is_file():
target = temporary / "artifacts" / source.relative_to(artifact_dir)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
config = {
"public_url": settings.public_url,
"secure_cookies": settings.secure_cookies,
"key_included": False,
"master_key_sha256": _digest(Path(settings.master_key_file)),
"format": 1,
"created_at": datetime.now(timezone.utc).isoformat(),
"configuration": {
name: value for name, value in asdict(settings).items()
if name not in {"data_dir", "master_key_file", "bootstrap_username", "bootstrap_password", "testing"}
},
}
(temporary / "settings.json").write_text(json.dumps(config, indent=2), encoding="utf-8")
hashes = {path.relative_to(temporary).as_posix(): _digest(path) for path in temporary.rglob("*") if path.is_file()}
(temporary / "manifest.json").write_text(json.dumps(hashes, indent=2), encoding="utf-8")
temporary.rename(destination)
except BaseException:
if temporary.resolve().parent == destination.parent and temporary.name.startswith(".ais-backup-"):
with suppress(OSError):
shutil.rmtree(temporary)
raise
print(f"Backup complete: {destination}")
print("Encryption key is excluded. Save it separately and test restoration.")
def _invalidate_restored_state(connection: sqlite3.Connection) -> None:
"""Prevent rollback of the database from resurrecting machine credentials."""
connection.execute("DELETE FROM sessions")
connection.execute("DELETE FROM nonces")
connection.execute("UPDATE approvals SET status='revoked' WHERE status='approved'")
connection.execute("UPDATE groups SET revoked=1")
connection.execute("UPDATE hosts SET status='needs_review' WHERE id IN (SELECT host_id FROM runs WHERE status NOT IN ('succeeded','failed','cancelled','expired'))")
connection.execute("UPDATE runs SET status='needs_review' WHERE status NOT IN ('succeeded','failed','cancelled','expired')")
connection.execute(
"UPDATE runs SET device_key=NULL, bootstrap_hash=NULL, enrollment_hash=NULL, report_hash=NULL, "
"answer_until=0, enroll_until=0, lease_until=0, answer_ciphertext=NULL, bootstrap_ciphertext=NULL"
)
connection.execute(
"INSERT INTO audit(id,actor,action,object_id,reason,data,created_at) VALUES(?,?,?,?,?,?,?)",
(str(uuid4()), "system", "backup.restore", "database", "Offline restore; credentials revoked and active runs require review", "{}", datetime.now(timezone.utc).isoformat()),
)
def restore(settings, source: Path) -> None:
"""Restore into a new data directory; the original data is never overwritten."""
from cryptography.fernet import Fernet
from provisioner.db import Database
source = source.resolve()
data_dir = Path(settings.data_dir).resolve()
if not Path(settings.master_key_file).is_file():
raise ValueError("Restore the separately protected MASTER_KEY_FILE first")
Fernet(Path(settings.master_key_file).read_bytes().strip())
if not (source / "manifest.json").is_file():
raise ValueError("Missing backup manifest")
hashes = json.loads((source / "manifest.json").read_text(encoding="utf-8"))
if "database.sqlite3" not in hashes or "settings.json" not in hashes:
raise ValueError("Incomplete backup manifest")
for name, expected in hashes.items():
original = source / name
candidate = original.resolve()
if source not in candidate.parents or original.is_symlink() or any(parent.is_symlink() for parent in original.parents if parent != source.parent) or not candidate.is_file():
raise ValueError("Unsafe or missing backup file")
if _digest(candidate) != expected:
raise ValueError(f"Backup checksum mismatch: {name}")
config = json.loads((source / "settings.json").read_text(encoding="utf-8"))
if config.get("format") != 1:
raise ValueError("Unsupported backup format")
if _digest(Path(settings.master_key_file)) != config.get("master_key_sha256"):
raise ValueError("MASTER_KEY_FILE does not match this backup")
with ServiceLock(data_dir):
existing = [path for path in data_dir.iterdir() if path.name != ".service.lock"]
if existing:
raise ValueError("Restore requires an empty DATA_DIR. Keep the old directory until validation completes.")
database = Database(settings)
shutil.copy2(source / "database.sqlite3", database.path)
try:
with closing(sqlite3.connect(database.path)) as connection, connection:
connection.execute("PRAGMA foreign_keys=ON")
if connection.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
raise ValueError("Backup database failed integrity check")
if connection.execute("PRAGMA user_version").fetchone()[0] != 1:
raise ValueError("Unsupported backup database schema")
_invalidate_restored_state(connection)
for name in hashes:
if name.startswith("artifacts/"):
target = data_dir / name
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source / name, target)
except BaseException:
# Leave failed restoration for inspection; never start it silently.
(data_dir / "RESTORE_FAILED").write_text("Restore failed. Do not start this data directory.\n", encoding="utf-8")
raise
print(f"Restored into {data_dir}; active runs need review and previous machine credentials are invalid.")
print("Review settings.json, revoke/reissue installation media, and reconcile hosts before new approvals.")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="proxmox-ais")
commands = parser.add_subparsers(dest="command", required=True)
init_parser = commands.add_parser("init", help="Create external encryption key and initial administrator")
init_parser.add_argument("--username")
serve_parser = commands.add_parser("serve", help="Start the HTTP service behind a trusted TLS proxy")
serve_parser.add_argument("--host", default="127.0.0.1")
serve_parser.add_argument("--port", type=int, default=8080)
serve_parser.add_argument("--tls-cert", type=Path)
serve_parser.add_argument("--tls-key", type=Path)
backup_parser = commands.add_parser("backup", help="Create a consistent database and artifact snapshot")
backup_parser.add_argument("destination", type=Path)
restore_parser = commands.add_parser("restore", help="Restore offline into an empty DATA_DIR")
restore_parser.add_argument("source", type=Path)
args = parser.parse_args(argv)
try:
settings = load_settings()
if args.command == "init":
initialize(settings, args.username)
elif args.command == "backup":
backup(settings, args.destination)
elif args.command == "restore":
restore(settings, args.source)
elif args.command == "serve":
import uvicorn
from provisioner.app import create_app
if bool(args.tls_cert) != bool(args.tls_key):
raise ValueError("Provide both --tls-cert and --tls-key")
if (Path(settings.data_dir) / "RESTORE_FAILED").exists():
raise ValueError("This data directory contains a failed restore")
uvicorn.run(
create_app(settings), host=args.host, port=args.port,
proxy_headers=False, access_log=False,
ssl_certfile=str(args.tls_cert) if args.tls_cert else None,
ssl_keyfile=str(args.tls_key) if args.tls_key else None,
)
except (OSError, ValueError, RuntimeError, sqlite3.Error) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+64
View File
@@ -0,0 +1,64 @@
from dataclasses import dataclass, field, fields
import os
from pathlib import Path
import tomllib
from urllib.parse import urlsplit
@dataclass
class Settings:
data_dir: Path = Path("data")
master_key_file: Path = Path("secrets/master.key")
public_url: str = "https://localhost:8080"
secure_cookies: bool = True
bootstrap_username: str | None = None
bootstrap_password: str | None = None
testing: bool = False
session_hours: int = 8
answer_window_seconds: int = 300
enrollment_hours: int = 4
lease_seconds: int = 900
heartbeat_unknown_seconds: int = 180
log_retention_days: int = 30
audit_retention_days: int = 180
max_request_bytes: int = 1048576
four_eyes: bool = True
maintenance: bool = False
trusted_proxy_ips: str = "127.0.0.1"
runner_ca_file: str | None = None
defaults: dict = field(default_factory=dict)
sites: dict = field(default_factory=dict)
def __post_init__(self):
self.data_dir = Path(self.data_dir)
self.master_key_file = Path(self.master_key_file)
self.public_url = self.public_url.rstrip("/")
url = urlsplit(self.public_url)
if not url.hostname or url.username or url.password or url.query or url.fragment or url.path:
raise ValueError("PUBLIC_URL muss eine absolute Basis-URL ohne Pfad sein.")
if url.scheme != "https" and not (url.scheme == "http" and url.hostname in {"localhost", "127.0.0.1", "testserver"}):
raise ValueError("PUBLIC_URL benötigt HTTPS; HTTP ist nur für lokale Entwicklung erlaubt.")
if self.master_key_file.resolve().is_relative_to(self.data_dir.resolve()):
raise ValueError("Der Master-Key muss außerhalb des Datenverzeichnisses liegen.")
for name in ("session_hours", "answer_window_seconds", "enrollment_hours", "lease_seconds", "max_request_bytes"):
if getattr(self, name) <= 0:
raise ValueError(f"{name} muss positiv sein.")
@classmethod
def from_env(cls):
values = {}
if os.environ.get("APP_CONFIG"):
with open(os.environ["APP_CONFIG"], "rb") as stream:
loaded = tomllib.load(stream)
values.update(loaded.get("app", loaded))
bools = {"secure_cookies", "testing", "four_eyes", "maintenance"}
ints = {f.name for f in fields(cls) if f.type is int}
allowed = {f.name for f in fields(cls)}
unknown = set(values) - allowed
if unknown:
raise ValueError(f"Unbekannte Konfiguration: {', '.join(sorted(unknown))}")
for key in allowed:
value = os.environ.get(key.upper())
if value is not None:
values[key] = value.lower() in {"true", "1", "yes"} if key in bools else int(value) if key in ints else value
return cls(**values)
+66
View File
@@ -0,0 +1,66 @@
from contextlib import closing, contextmanager
from pathlib import Path
import sqlite3
SCHEMA = """
CREATE TABLE IF NOT EXISTS schema_migrations(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL, created_at TEXT NOT NULL, disabled INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS sessions(token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), csrf_token TEXT NOT NULL, expires_at REAL NOT NULL);
CREATE TABLE IF NOT EXISTS hosts(id TEXT PRIMARY KEY, fqdn TEXT NOT NULL UNIQUE COLLATE NOCASE, management_ip TEXT UNIQUE, site TEXT NOT NULL, status TEXT NOT NULL, blocked INTEGER NOT NULL DEFAULT 0, version INTEGER NOT NULL DEFAULT 1, data TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS host_identities(host_id TEXT NOT NULL REFERENCES hosts(id), kind TEXT NOT NULL, value TEXT NOT NULL, UNIQUE(kind,value));
CREATE TABLE IF NOT EXISTS profiles(id TEXT PRIMARY KEY, name TEXT NOT NULL, kind TEXT NOT NULL, version INTEGER NOT NULL, status TEXT NOT NULL, data TEXT NOT NULL, created_by TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(name,kind,version));
CREATE TABLE IF NOT EXISTS modules(id TEXT PRIMARY KEY, name TEXT NOT NULL, version INTEGER NOT NULL, status TEXT NOT NULL, digest TEXT NOT NULL, data TEXT NOT NULL, created_by TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(name,version));
CREATE TABLE IF NOT EXISTS groups(id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, site TEXT NOT NULL, token_hash TEXT NOT NULL, expires_at REAL NOT NULL, revoked INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS iso_records(id TEXT PRIMARY KEY, name TEXT NOT NULL, data TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS secrets(id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, ciphertext TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS approvals(id TEXT PRIMARY KEY, host_id TEXT NOT NULL REFERENCES hosts(id), status TEXT NOT NULL, expires_at REAL NOT NULL, data TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE UNIQUE INDEX IF NOT EXISTS one_open_approval ON approvals(host_id) WHERE status='approved';
CREATE TABLE IF NOT EXISTS runs(id TEXT PRIMARY KEY, host_id TEXT NOT NULL REFERENCES hosts(id), approval_id TEXT NOT NULL UNIQUE REFERENCES approvals(id), status TEXT NOT NULL, version INTEGER NOT NULL DEFAULT 1, data TEXT NOT NULL, answer_ciphertext TEXT, bootstrap_ciphertext TEXT, secrets_ciphertext TEXT, bootstrap_hash TEXT UNIQUE, enrollment_hash TEXT, report_hash TEXT UNIQUE, device_key TEXT, answer_until REAL, enroll_until REAL, lease_until REAL, last_seen REAL, completed_at REAL, created_at TEXT NOT NULL);
CREATE UNIQUE INDEX IF NOT EXISTS one_active_run ON runs(host_id) WHERE status NOT IN ('succeeded','failed','cancelled','expired');
CREATE TABLE IF NOT EXISTS run_steps(run_id TEXT NOT NULL REFERENCES runs(id), step_id TEXT NOT NULL, position INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'pending', attempt INTEGER NOT NULL DEFAULT 0, verification TEXT NOT NULL DEFAULT '{}', PRIMARY KEY(run_id,step_id));
CREATE TABLE IF NOT EXISTS events(run_id TEXT NOT NULL REFERENCES runs(id), sequence INTEGER NOT NULL, data TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(run_id,sequence));
CREATE TABLE IF NOT EXISTS logs(run_id TEXT NOT NULL REFERENCES runs(id), sequence INTEGER NOT NULL, data TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(run_id,sequence));
CREATE TABLE IF NOT EXISTS nonces(run_id TEXT NOT NULL REFERENCES runs(id), nonce TEXT NOT NULL, expires_at REAL NOT NULL, PRIMARY KEY(run_id,nonce));
CREATE TABLE IF NOT EXISTS audit(id TEXT PRIMARY KEY, actor TEXT NOT NULL, action TEXT NOT NULL, object_id TEXT NOT NULL, reason TEXT NOT NULL, data TEXT NOT NULL, created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS discoveries(id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL UNIQUE, site TEXT NOT NULL, data TEXT NOT NULL, reason TEXT NOT NULL, last_seen REAL NOT NULL);
INSERT OR IGNORE INTO schema_migrations(version) VALUES(1);
"""
class Database:
def __init__(self, settings):
self.path = settings.data_dir / "provisioner.sqlite3"
def initialize(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
with self.connection() as connection:
connection.execute("PRAGMA journal_mode=WAL")
version = connection.execute("PRAGMA user_version").fetchone()[0]
if version > 1:
raise RuntimeError("Datenbankschema ist neuer als diese Anwendung.")
connection.executescript(SCHEMA)
connection.execute("PRAGMA user_version=1")
@contextmanager
def connection(self, write=False):
connection = sqlite3.connect(self.path, timeout=15, isolation_level=None)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys=ON")
connection.execute("PRAGMA busy_timeout=15000")
try:
if write:
connection.execute("BEGIN IMMEDIATE")
yield connection
if connection.in_transaction:
connection.commit()
except BaseException:
if connection.in_transaction:
connection.rollback()
raise
finally:
connection.close()
def backup(self, destination: Path):
destination.parent.mkdir(parents=True, exist_ok=True)
with self.connection() as source, closing(sqlite3.connect(destination)) as target:
source.backup(target)
+199
View File
@@ -0,0 +1,199 @@
from typing import Any, Literal
from ipaddress import ip_interface
import re
import uuid
from pydantic import BaseModel, ConfigDict, Field, field_validator
class Model(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
class Identity(Model):
kind: Literal["uuid", "serial", "mac"]
value: str = Field(min_length=1, max_length=200)
@field_validator("value")
@classmethod
def meaningful(cls, value):
if value.lower() in {"unknown", "none", "not specified", "default string", "to be filled by o.e.m."}:
raise ValueError("Identität enthält einen Hersteller-Platzhalter.")
return value
def normalize_identity(kind, value):
value = value.strip().lower()
if kind == "mac":
value = value.replace("-", ":")
if not re.fullmatch(r"(?:[0-9a-f]{2}:){5}[0-9a-f]{2}", value) or value in {"00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff"}:
raise ValueError("Ungültige MAC-Adresse.")
if kind == "uuid":
parsed = uuid.UUID(value)
if parsed.int in {0, 2**128 - 1}:
raise ValueError("Ungültige System-UUID.")
value = str(parsed)
return value
class HostCreate(Model):
fqdn: str = Field(min_length=3, max_length=253)
site: str = Field(min_length=1, max_length=80)
management_ip: str | None = None
tags: list[str] = Field(default_factory=list, max_length=30)
identities: list[Identity] = Field(min_length=1, max_length=32)
installation_profile_id: str | None = None
postinstall_profile_id: str | None = None
iso_id: str | None = None
overrides: dict[str, Any] = Field(default_factory=dict)
blocked: bool = False
@field_validator("fqdn")
@classmethod
def hostname(cls, value):
value = value.lower().rstrip(".")
if "." not in value or any(not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", part) for part in value.split(".")):
raise ValueError("Ein gültiger FQDN ist erforderlich.")
return value
@field_validator("management_ip")
@classmethod
def address(cls, value):
return str(ip_interface(value)) if value else None
class HostUpdate(Model):
expected_version: int = Field(ge=1)
fqdn: str | None = None
site: str | None = None
management_ip: str | None = None
tags: list[str] | None = None
identities: list[Identity] | None = None
installation_profile_id: str | None = None
postinstall_profile_id: str | None = None
iso_id: str | None = None
overrides: dict | None = None
blocked: bool | None = None
class StepSpec(Model):
id: str = Field(pattern=r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$")
module_id: str
parameters: dict = Field(default_factory=dict)
secret_refs: dict[str, str] = Field(default_factory=dict)
required: bool = True
class ProfileCreate(Model):
name: str = Field(min_length=1, max_length=120)
kind: Literal["installation", "postinstall"]
values: dict = Field(default_factory=dict)
steps: list[StepSpec] = Field(default_factory=list, max_length=50)
target_builds: list[str] = Field(default_factory=list, max_length=30)
locked_fields: list[str] = Field(default_factory=lambda: ["disk_setup", "global.root-password", "global.root-password-hashed"])
reboot_budget: int = Field(default=1, ge=0, le=5)
reason: str = Field(default="", max_length=1000)
class ModuleCreate(Model):
name: str = Field(min_length=1, max_length=120)
source: str = Field(min_length=10, max_length=262144)
parameters_schema: dict = Field(default_factory=lambda: {"type": "object", "additionalProperties": False})
dependencies: list[str] = Field(default_factory=list, max_length=20)
target_builds: list[str] = Field(default_factory=list, max_length=30)
timeout_seconds: int = Field(default=600, ge=1, le=7200)
retry_safe: bool = False
test_evidence: str = Field(default="", max_length=2000)
reason: str = Field(default="", max_length=1000)
class Publish(Model):
test_evidence: str = Field(min_length=5, max_length=2000)
reason: str = Field(min_length=3, max_length=1000)
class Approval(Model):
expected_version: int = Field(ge=1)
valid_minutes: int = Field(default=30, ge=1, le=1440)
confirmation: str
disks_confirmed: bool
reason: str = Field(min_length=5, max_length=1000)
class RunAction(Model):
expected_version: int = Field(ge=1)
reason: str = Field(min_length=5, max_length=1000)
class RunReconcile(RunAction):
confirmation: str
execution_stopped: bool
class GroupCreate(Model):
name: str = Field(pattern=r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$")
site: str = Field(min_length=1, max_length=80)
valid_hours: int = Field(default=720, ge=1, le=8760)
class IsoCreate(Model):
name: str = Field(min_length=1, max_length=120)
build: str = Field(pattern=r"^[0-9]+\.[0-9]+(?:\.[0-9]+)?-[0-9]+$")
sha256: str = Field(pattern=r"^[a-fA-F0-9]{64}$")
assistant_version: str = Field(min_length=1, max_length=80)
fingerprint: str = Field(pattern=r"^(?:[a-fA-F0-9]{64}|(?:[a-fA-F0-9]{2}:){31}[a-fA-F0-9]{2})$")
group_id: str
test_status: Literal["draft", "passed"] = "draft"
test_evidence: str = Field(default="", max_length=3000)
native_token_support: bool = False
class UserCreate(Model):
username: str = Field(pattern=r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,63}$")
password: str = Field(min_length=12, max_length=1024)
role: Literal["reader", "operator", "author", "admin", "developer"]
class SecretCreate(Model):
name: str = Field(min_length=1, max_length=120)
value: str = Field(min_length=1, max_length=16384)
class Enroll(Model):
run_id: str
enrollment_secret: str = Field(min_length=20, max_length=200)
public_key: str = Field(min_length=40, max_length=100)
identities: list[Identity] = Field(min_length=1, max_length=32)
boot_id: str = Field(min_length=1, max_length=80)
class Event(Model):
sequence: int = Field(ge=1)
boot_id: str = Field(min_length=1, max_length=80)
step_id: str | None = None
type: Literal["step.started", "step.succeeded", "step.failed", "run.needs_review", "run.reboot_pending", "run.resumed", "run.cancelled", "heartbeat"]
occurred_at: str = Field(max_length=80)
exit_code: int | None = None
verification: dict = Field(default_factory=dict)
class EventBatch(Model):
events: list[Event] = Field(min_length=1, max_length=100)
class LogChunk(Model):
sequence: int = Field(ge=1)
step_id: str | None = None
text: str = Field(max_length=16384)
class LogBatch(Model):
chunks: list[LogChunk] = Field(min_length=1, max_length=32)
class Completion(Model):
verification: dict
class LeaseRequest(Model):
run_id: str
+668
View File
@@ -0,0 +1,668 @@
#!/usr/bin/env python3
"""Short-lived Linux provisioning runner. Target dependencies: Python 3, Bash, OpenSSL.
Module contract: check=0 means converged, check=1 means apply is needed; all other
check exits fail. Every successful apply is followed by verify. Exit 194 from
apply requests a checkpointed reboot. Module scripts must not reboot themselves.
"""
from __future__ import annotations
import argparse
import base64
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import random
import re
import selectors
import signal
import ssl
import subprocess
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
MAX_ARTIFACT = 2 * 1024 * 1024
MAX_RESPONSE = 4 * 1024 * 1024
MAX_LOG_BYTES = 1024 * 1024
MAX_PHASE_OUTPUT = 128 * 1024
MAX_EVENTS = 4096
DIGEST = re.compile(r"[a-f0-9]{64}\Z")
STEP_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\Z")
def canonical_json(value):
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
def atomic_write(path, content, mode=0o600):
path = Path(path)
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(prefix=".tmp-", dir=path.parent)
try:
os.fchmod(descriptor, mode) if hasattr(os, "fchmod") else None
with os.fdopen(descriptor, "wb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
if os.name == "posix":
parent_fd = os.open(path.parent, os.O_DIRECTORY)
try:
os.fsync(parent_fd)
finally:
os.close(parent_fd)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def verified_digest(content, expected):
if not isinstance(expected, str) or not DIGEST.fullmatch(expected):
raise Halt("Invalid artifact digest")
if hashlib.sha256(content).hexdigest() != expected:
raise Halt("Artifact digest mismatch; execution refused")
return content
def discover_identities(expected, sys_root=Path("/sys")):
"""Check bootstrap host binding against identities actually observed on target."""
def normalize(kind, value):
value = value.strip().lower()
if kind == "uuid":
parsed = uuid.UUID(value)
if parsed.int in (0, 2 ** 128 - 1):
raise ValueError("Empty UUID")
return str(parsed)
if kind == "mac":
value = value.replace("-", ":")
if not re.fullmatch(r"(?:[0-9a-f]{2}:){5}[0-9a-f]{2}", value) or value in ("00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff"):
raise ValueError("Invalid MAC")
if not value or value in ("unknown", "none", "not specified", "default string", "to be filled by o.e.m."):
raise ValueError("Placeholder identity")
return value
observed = set()
candidates = [("uuid", sys_root / "class/dmi/id/product_uuid"),
("serial", sys_root / "class/dmi/id/product_serial")]
candidates.extend(("mac", path) for path in (sys_root / "class/net").glob("*/address"))
for kind, path in candidates:
try:
observed.add((kind, normalize(kind, path.read_text())))
except (OSError, ValueError):
continue
try:
required = {(item["kind"], normalize(item["kind"], item["value"])) for item in expected}
except (KeyError, ValueError) as exc:
raise Halt("Invalid expected host identity") from exc
if not required or not required.issubset(observed):
raise Halt("Observed target identities do not match the bootstrap host binding")
return [{"kind": kind, "value": value} for kind, value in sorted(required)]
class TransportError(Exception):
pass
class Rejected(Exception):
pass
class Halt(Exception):
pass
class Deferred(Exception):
pass
class RebootRequested(Exception):
pass
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise Rejected("API redirects are not permitted")
class DeviceKey:
def __init__(self, directory):
self.directory = Path(directory)
self.path = self.directory / "device-key.pem"
def ensure(self):
if not self.path.exists():
result = subprocess.run(["openssl", "genpkey", "-algorithm", "ED25519"],
capture_output=True, check=True, timeout=15)
atomic_write(self.path, result.stdout)
os.chmod(self.path, 0o600)
@property
def public_key(self):
result = subprocess.run(["openssl", "pkey", "-in", str(self.path), "-pubout",
"-outform", "DER"], capture_output=True, check=True, timeout=15)
# RFC 8410 Ed25519 SubjectPublicKeyInfo: fixed 12-byte prefix + raw key.
if not result.stdout.startswith(bytes.fromhex("302a300506032b6570032100")) or len(result.stdout) != 44:
raise Halt("Device key is not Ed25519")
return base64.b64encode(result.stdout[12:]).decode()
def sign(self, content):
fd, path = tempfile.mkstemp(prefix=".signature-", dir=self.directory)
try:
with os.fdopen(fd, "wb") as stream:
stream.write(content)
result = subprocess.run(["openssl", "pkeyutl", "-sign", "-rawin", "-inkey",
str(self.path), "-in", path], capture_output=True,
check=True, timeout=15)
return base64.b64encode(result.stdout).decode()
finally:
os.unlink(path)
class API:
def __init__(self, config, key):
self.config, self.key = config, key
self.base = config["api_url"].rstrip("/")
parsed = urllib.parse.urlsplit(self.base)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise Halt("api_url must be an HTTPS origin with normal certificate validation")
context = ssl.create_default_context(cafile=config.get("ca_file"))
self.opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=context), NoRedirect())
def request(self, method, path, payload=None, *, signed=True, raw=False, attempts=5):
body = b"" if payload is None else canonical_json(payload)
if len(body) > MAX_RESPONSE:
raise Halt("Outgoing request exceeds size limit")
url = self.base + path
for attempt in range(attempts):
headers = {"Accept": "application/octet-stream" if raw else "application/json"}
if payload is not None:
headers["Content-Type"] = "application/json"
if signed:
timestamp, nonce = str(int(time.time())), os.urandom(24).hex()
request_path = urllib.parse.urlsplit(url).path
message = f"{method}\n{request_path}\n{timestamp}\n{nonce}\n{hashlib.sha256(body).hexdigest()}".encode()
headers.update({"X-Run-ID": self.config["run_id"], "X-Device-Key": self.key.public_key,
"X-Timestamp": timestamp, "X-Nonce": nonce,
"X-Signature": self.key.sign(message)})
request = urllib.request.Request(url, data=body if payload is not None else None,
method=method, headers=headers)
try:
with self.opener.open(request, timeout=10) as response:
result = response.read((MAX_ARTIFACT if raw else MAX_RESPONSE) + 1)
if len(result) > (MAX_ARTIFACT if raw else MAX_RESPONSE):
raise Halt("Response exceeds size limit")
if raw:
return result
try:
return json.loads(result)
except (ValueError, UnicodeError) as exc:
raise Halt("Invalid API response") from exc
except urllib.error.HTTPError as exc:
if exc.code not in (408, 425, 429, 500, 502, 503, 504):
raise Rejected(f"API rejected {method} {path}: HTTP {exc.code}") from exc
except (urllib.error.URLError, TimeoutError, OSError):
pass
if attempt + 1 < attempts:
time.sleep(min(20, 2 ** attempt) + random.random())
raise TransportError("API unavailable after bounded retries")
class Runner:
def __init__(self, config, directory="/var/lib/pve-provisioner", api=None):
self.config = config
self.directory = Path(directory)
self.directory.mkdir(parents=True, exist_ok=True, mode=0o700)
self.state_path = self.directory / "state.json"
self.key = DeviceKey(self.directory)
self.api = api
self.boot_id = Path("/proc/sys/kernel/random/boot_id").read_text().strip() if os.name == "posix" else "test-boot"
self.state = json.loads(self.state_path.read_text()) if self.state_path.exists() else {
"format": 1, "run_id": config["run_id"], "status": "pending", "steps": {},
"events": [], "logs": [], "event_sequence": 0, "log_sequence": 0,
"reboot_count": 0, "boot_id": self.boot_id,
}
if self.state["run_id"] != config["run_id"]:
raise Halt("Existing local state belongs to another run")
self.lease = {"action": "wait", "expires_at": 0, "run_version": 0}
self.last_heartbeat = 0
self.secret_values = []
self.stop_requested = False
self.step_deadline = None
self.save()
def save(self):
atomic_write(self.state_path, canonical_json(self.state))
def event(self, event_type, step_id=None, **fields):
if len(self.state["events"]) >= MAX_EVENTS:
# Reserve a durable halt locally; never evict unacknowledged events.
self.state.update(status="needs_review", reason="Event queue limit reached")
self.save()
raise Halt("Event queue limit reached")
self.state["event_sequence"] += 1
self.state["events"].append({"sequence": self.state["event_sequence"], "boot_id": self.boot_id,
"step_id": step_id, "type": event_type,
"occurred_at": datetime.now(timezone.utc).isoformat(), **fields})
self.save()
def log(self, step_id, content):
for value in sorted(self.secret_values, key=len, reverse=True):
if value:
content = content.replace(value, "[REDACTED]")
content = content[:MAX_PHASE_OUTPUT]
remaining = MAX_LOG_BYTES - sum(len(chunk["text"].encode()) for chunk in self.state["logs"])
encoded = content.encode()
if len(encoded) > remaining:
content = encoded[:max(0, remaining)].decode(errors="ignore")
self.state["logs_truncated"] = True
# Drop newly arriving overflow, never evict a sequenced/unacknowledged
# chunk: server acknowledgements require a contiguous sequence.
for position in range(0, len(content), 16000):
self.state["log_sequence"] += 1
self.state["logs"].append({"sequence": self.state["log_sequence"], "step_id": step_id,
"text": content[position:position + 16000]})
self.save()
def flush(self):
base = f"/agent/v1/runs/{self.config['run_id']}"
for queue, endpoint, key in (("logs", "logs", "chunks"), ("events", "events", "events")):
while self.state[queue]:
batch = self.state[queue][:100 if queue == "events" else 8]
response = self.api.request("POST", f"{base}/{endpoint}", {key: batch}, attempts=1)
ack = response.get("ack_sequence")
if not isinstance(ack, int) or ack < batch[0]["sequence"] or ack > self.state["event_sequence" if queue == "events" else "log_sequence"]:
raise Halt("Invalid event/log acknowledgement")
self.state[queue] = [event for event in self.state[queue] if event["sequence"] > ack]
self.save()
def network_failure(self):
self.state.setdefault("network_failed_since", time.time())
self.save()
if time.time() - self.state["network_failed_since"] > int(self.config.get("network_deadline_seconds", 1800)):
raise Halt("Network recovery deadline exceeded")
def renew_lease(self):
self.lease = self.api.request("POST", "/agent/v1/lease", {"run_id": self.config["run_id"]}, attempts=1)
if self.lease.get("action") not in ("run", "wait", "stop", "revoked"):
raise Halt("Invalid lease action")
self.state["run_version"] = self.lease.get("run_version", 0)
self.state.pop("network_failed_since", None)
self.save()
def authorize(self):
if self.stop_requested:
raise Deferred("Runner service is stopping")
self.renew_lease()
if self.lease["action"] == "stop":
self.cancel()
raise Deferred("Run cancelled")
if self.lease["action"] == "revoked":
raise Halt("Execution permission revoked or stopped")
if self.lease["action"] != "run" or self.lease.get("expires_at", 0) <= time.time():
raise Deferred("Waiting for execution permission")
def heartbeat(self, step_id):
if time.monotonic() - self.last_heartbeat < 30:
return
self.last_heartbeat = time.monotonic()
try:
self.renew_lease()
self.event("heartbeat", step_id)
self.flush()
except TransportError:
self.state.setdefault("network_failed_since", time.time())
self.save()
except Rejected:
# Finish the running safe phase; authorize() prevents another apply.
self.lease = {"action": "revoked", "expires_at": 0}
def manifest(self):
path = self.directory / "manifest.json"
if path.exists():
manifest = json.loads(path.read_text())
else:
manifest = self.api.request("GET", f"/agent/v1/runs/{self.config['run_id']}/manifest")
claimed = manifest.get("digest")
unsigned = {key: value for key, value in manifest.items() if key != "digest"}
digest = hashlib.sha256(canonical_json(unsigned)).hexdigest()
expected = self.config.get("manifest_digest") or self.state.get("manifest_digest") or claimed
if not expected or digest != expected or (claimed and claimed != digest):
raise Halt("Manifest digest mismatch")
if manifest.get("run_id") != self.config["run_id"]:
raise Halt("Manifest belongs to another run")
steps = manifest.get("steps")
if not isinstance(steps, list) or not 1 <= len(steps) <= 100:
raise Halt("Manifest must contain 1 to 100 steps")
seen = set()
for step in steps:
if not STEP_ID.fullmatch(step.get("id", "")) or step["id"] in seen:
raise Halt("Invalid or duplicate step identifier")
if not DIGEST.fullmatch(step.get("digest", "")) or not isinstance(step.get("parameters", {}), dict):
raise Halt("Invalid module digest or parameters")
if not isinstance(step.get("timeout_seconds", 600), int) or not 1 <= step.get("timeout_seconds", 600) <= 14400:
raise Halt("Invalid module timeout")
if not set(step.get("dependencies", [])).issubset(seen):
raise Halt("Module dependencies must precede their dependants")
seen.add(step["id"])
self.state["manifest_digest"] = digest
atomic_write(path, canonical_json(manifest))
self.save()
return manifest
def artifact(self, step):
digest = step["digest"]
if not DIGEST.fullmatch(digest):
raise Halt("Invalid artifact digest")
path = self.directory / "artifacts" / digest
content = path.read_bytes() if path.exists() else self.api.request("GET", f"/agent/v1/artifacts/{digest}", raw=True)
if len(content) > MAX_ARTIFACT:
raise Halt("Artifact exceeds size limit")
verified_digest(content, digest)
if not path.exists():
atomic_write(path, content)
return path
def execute(self, step, phase, artifact, parameters):
# Verify again immediately before *every* execution, including check/verify.
verified_digest(Path(artifact).read_bytes(), step["digest"])
deadline = self.step_deadline or (time.monotonic() + step.get("timeout_seconds", 600))
if time.monotonic() >= deadline:
self.log(step["id"], f"[{phase}] Step timeout reached before phase start")
return 124
process = subprocess.Popen(["/bin/bash", str(artifact), phase, str(parameters)],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
start_new_session=True, cwd=self.directory,
env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8",
"DEBIAN_FRONTEND": "noninteractive", "PVE_RUN_ID": self.config["run_id"]})
output = bytearray()
capture_limit = MAX_PHASE_OUTPUT + max((len(value.encode()) for value in self.secret_values), default=0)
timed_out = False
poller = selectors.DefaultSelector()
poller.register(process.stdout, selectors.EVENT_READ)
try:
while process.poll() is None or poller.get_map():
if time.monotonic() >= deadline:
timed_out = True
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
break
for key, _ in poller.select(timeout=0.25):
chunk = os.read(key.fileobj.fileno(), 8192)
if not chunk:
poller.unregister(key.fileobj)
elif len(output) < capture_limit:
output.extend(chunk[:capture_limit - len(output)])
# Continued lease renewal never interrupts a package operation.
self.heartbeat(step["id"])
process.wait(timeout=5)
except BaseException:
if process.poll() is None:
os.killpg(process.pid, signal.SIGKILL)
process.wait(timeout=5)
raise
finally:
poller.close()
process.stdout.close()
text = output.decode("utf-8", errors="replace")
if len(output) >= MAX_PHASE_OUTPUT:
text += "\n[output truncated]"
self.log(step["id"], f"[{phase}]\n{text}")
return 124 if timed_out else process.returncode
def run_step(self, step, reboot_budget):
step_id = step["id"]
checkpoint = self.state["steps"].get(step_id, {})
if checkpoint.get("status") == "succeeded":
return
if checkpoint.get("status") == "failed":
if step.get("required", True):
raise Halt(f"Step {step_id} previously failed; explicit review is required")
return
self.authorize()
self.flush()
artifact = self.artifact(step)
secrets = self.api.request("GET", f"/agent/v1/runs/{self.config['run_id']}/secrets/{step_id}")
if not isinstance(secrets, dict):
raise Halt("Invalid step secret response")
def secret_strings(value):
if isinstance(value, dict):
return [item for child in value.values() for item in secret_strings(child)]
if isinstance(value, list):
return [item for child in value for item in secret_strings(child)]
return [str(value)] if value is not None else []
self.secret_values = secret_strings(secrets)
if any(len(value.encode()) > 16384 for value in self.secret_values):
raise Halt("Step secret exceeds the supported redaction limit")
parameters = self.directory / "step-parameters.json"
atomic_write(parameters, canonical_json({**step.get("parameters", {}), "secrets": secrets}))
self.step_deadline = time.monotonic() + step.get("timeout_seconds", 600)
try:
self.event("step.started", step_id)
self.flush() # Server knows the attempt before any module phase.
check = self.execute(step, "check", artifact, parameters)
recovering = checkpoint.get("status") in ("applying", "reboot_pending")
if recovering:
verified = self.execute(step, "verify", artifact, parameters)
if check == 0 and verified == 0:
self.succeed(step_id, recovered=True)
return
if not step.get("retry_safe", False):
raise Halt(f"Interrupted step {step_id} cannot be repeated safely")
if check == 0 and verified != 0:
check = 1
if check not in (0, 1):
self.fail(step, check, "check failed")
return
if check == 0:
verified = self.execute(step, "verify", artifact, parameters)
if verified == 0:
self.succeed(step_id, unchanged=True)
return
# A converged check with a failing verify is an inconsistent module.
self.fail(step, verified, "verification failed")
return
self.authorize()
self.state["steps"][step_id] = {"status": "applying", "attempt": checkpoint.get("attempt", 0) + 1}
self.state["status"] = "running"
self.save() # Durable checkpoint before any mutation.
code = self.execute(step, "apply", artifact, parameters)
if code == 194:
if self.state["reboot_count"] >= reboot_budget:
raise Halt("Reboot budget exhausted")
self.state["reboot_count"] += 1
self.state["status"] = "reboot_pending"
self.state["reboot_boot_id"] = self.boot_id
self.state["steps"][step_id]["status"] = "reboot_pending"
self.save()
self.event("run.reboot_pending", step_id)
try:
self.flush()
except (TransportError, Rejected):
pass
self.authorize()
raise RebootRequested()
if code != 0:
self.fail(step, code, "apply failed")
return
verified = self.execute(step, "verify", artifact, parameters)
if verified != 0:
self.fail(step, verified, "verification failed")
return
self.succeed(step_id)
finally:
parameters.unlink(missing_ok=True)
self.secret_values = []
self.step_deadline = None
def succeed(self, step_id, **verification):
self.state["steps"].setdefault(step_id, {})["status"] = "succeeded"
self.state["steps"][step_id]["verification"] = {"passed": True, **verification}
self.event("step.succeeded", step_id, exit_code=0, verification={"passed": True, **verification})
def fail(self, step, code, reason):
self.state["steps"].setdefault(step["id"], {}).update(status="failed", exit_code=code)
self.event("step.failed", step["id"], exit_code=code, verification={"passed": False, "reason": reason})
if step.get("required", True):
raise Halt(f"Required step {step['id']}: {reason} (exit {code})")
def halt(self, reason):
self.state.update(status="needs_review", reason=reason, halted_version=self.state.get("run_version", 0))
self.state.setdefault("review_started_at", time.time())
self.save()
if len(self.state["events"]) < MAX_EVENTS:
self.event("run.needs_review", verification={"reason": reason})
if self.api is None:
return
try:
self.flush()
except (TransportError, Rejected, Halt):
pass
def cancel(self):
self.state["status"] = "cancellation_pending"
self.event("run.cancelled", verification={"reason": "Operator cancelled at a safe transition"})
self.flush()
self.state["status"] = "cancelled"
self.save()
def review(self):
if time.time() - self.state.get("review_started_at", time.time()) >= self.config.get("review_deadline_seconds", 86400):
return False
self.flush()
self.renew_lease()
if self.lease["action"] == "stop":
self.cancel()
return False
if self.lease["action"] == "run" and self.lease.get("run_version", 0) > self.state.get("halted_version", 0):
# Only an explicit server-side resume permits recovery. Failed steps
# become interrupted steps and still pass check/verify + retry_safe.
for checkpoint in self.state["steps"].values():
if checkpoint.get("status") == "failed":
checkpoint["status"] = "applying"
self.state.update(status="running")
self.state.pop("review_started_at", None)
self.state.pop("reason", None)
self.event("run.resumed")
return True
raise Deferred("Awaiting explicit operator resume")
def finish(self):
self.flush()
verification = {key: value.get("verification", {"passed": False}) for key, value in self.state["steps"].items()}
response = self.api.request("POST", f"/agent/v1/runs/{self.config['run_id']}/complete", {"verification": verification})
if response.get("status") != "succeeded":
raise Halt("Completion was not acknowledged")
self.state["status"] = "succeeded"
self.save()
def run(self):
try:
if self.state["status"] in ("succeeded", "cancelled"):
return 0
if self.api is None:
self.key.ensure()
self.api = API(self.config, self.key)
if self.state["status"] == "cancellation_pending":
self.flush()
self.state["status"] = "cancelled"
self.save()
return 0
if self.state["status"] == "needs_review":
if not self.review():
return 0
if self.state["status"] == "completion_pending":
self.finish()
return 0
if not self.state.get("enrolled"):
self.api.request("POST", "/agent/v1/enroll", {
"run_id": self.config["run_id"], "enrollment_secret": self.config["enrollment_secret"],
"public_key": self.key.public_key, "identities": discover_identities(self.config["identities"]),
"boot_id": self.boot_id}, signed=False)
self.state["enrolled"] = True
self.save()
if self.state["status"] == "reboot_pending":
if self.state.get("reboot_boot_id") == self.boot_id:
self.authorize()
raise RebootRequested()
self.state.update(status="running", boot_id=self.boot_id)
self.save()
self.event("run.resumed")
self.flush()
self.authorize()
manifest = self.manifest()
self.flush()
for step in manifest["steps"]:
for dependency in step.get("dependencies", []):
if self.state["steps"].get(dependency, {}).get("status") != "succeeded":
raise Halt(f"Dependency {dependency} has not succeeded")
self.run_step(step, int(manifest.get("reboot_budget", 1)))
try:
self.flush()
except TransportError:
self.network_failure()
self.state["status"] = "completion_pending"
self.save()
self.finish()
return 0
except RebootRequested:
return 194
except (Halt, Rejected) as exc:
self.halt(str(exc))
return 75
except Deferred:
return 0 if self.state["status"] == "cancelled" else 75
except TransportError:
try:
self.network_failure()
except Halt as exc:
self.halt(str(exc))
return 75
return 75
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", default="/etc/pve-provisioner/config.json")
parser.add_argument("--state-dir", default="/var/lib/pve-provisioner")
args = parser.parse_args()
if os.name != "posix" or os.geteuid() != 0:
parser.error("The runner requires a Linux target and root privileges")
import fcntl
os.umask(0o077)
directory = Path(args.state_dir)
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
with (directory / "runner.lock").open("a") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return 0
# Secrets from a power failure are removed before any recovery operation.
(directory / "step-parameters.json").unlink(missing_ok=True)
config = json.loads(Path(args.config).read_text())
runner = Runner(config, directory)
def request_stop(signum, frame):
runner.stop_requested = True
signal.signal(signal.SIGTERM, request_stop)
signal.signal(signal.SIGINT, request_stop)
result = runner.run()
if runner.state.get("enrolled") and "enrollment_secret" in config:
config.pop("enrollment_secret")
atomic_write(args.config, canonical_json(config))
if result == 194:
subprocess.run(["systemctl", "reboot"], check=True, timeout=15)
return 0
if runner.state["status"] in ("succeeded", "cancelled"):
subprocess.run(["systemctl", "disable", "pve-provisioner.service"], check=True, timeout=15)
return result
if __name__ == "__main__":
raise SystemExit(main())
+86
View File
@@ -0,0 +1,86 @@
import base64
import hashlib
import hmac
import json
import os
from pathlib import Path
import re
import secrets
from cryptography.fernet import Fernet
def canonical(value):
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
def digest(value):
if not isinstance(value, bytes):
value = value.encode()
return hashlib.sha256(value).hexdigest()
def token():
return secrets.token_urlsafe(32)
class Security:
def __init__(self, settings):
path = settings.master_key_file
if not path.exists():
if not settings.testing:
raise RuntimeError("Master-Key fehlt. Zuerst 'provisioner init' ausführen.")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(Fernet.generate_key())
path.chmod(0o600)
self.fernet = Fernet(path.read_bytes().strip())
@staticmethod
def hash_password(password):
if len(password) < 12 or len(password) > 1024:
raise ValueError("Passwörter benötigen 12 bis 1024 Zeichen.")
salt = os.urandom(16)
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 verify_password(password, stored):
try:
algorithm, salt, expected = stored.split("$")
if algorithm != "scrypt" or len(password) > 1024:
return False
result = hashlib.scrypt(password.encode(), salt=base64.b64decode(salt), n=16384, r=8, p=1)
return hmac.compare_digest(result, base64.b64decode(expected))
except (ValueError, TypeError):
return False
def encrypt(self, value):
return self.fernet.encrypt(value.encode()).decode()
def decrypt(self, value):
return self.fernet.decrypt(value.encode()).decode()
def redact(text, values=()):
text = str(text)
for value in sorted({str(v) for v in values if v}, key=len, reverse=True):
text = text.replace(value, "[REDACTED]")
text = re.sub(r"(?i)(bearer\s+)[^\s\"']+", r"\1[REDACTED]", text)
text = re.sub(r"(/(?:bootstrap/v1|installer/v1/report)/)[A-Za-z0-9_-]+", r"\1[REDACTED]", text)
text = re.sub(r"(?i)((?:password|secret|token|authorization)\s*[=:]\s*)[^\s,;]+", r"\1[REDACTED]", text)
return text
def atomic_artifact(directory: Path, source: str):
content = source.encode("utf-8")
checksum = digest(content)
directory.mkdir(parents=True, exist_ok=True)
target = directory / checksum
if not target.exists():
temporary = directory / (".tmp-" + token())
with temporary.open("xb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, target)
return checksum
+433
View File
@@ -0,0 +1,433 @@
"""Transactional domain rules shared by browser and machine APIs."""
from copy import deepcopy
from datetime import datetime, timezone
from fnmatch import fnmatchcase
from ipaddress import ip_address, ip_interface
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import time
import uuid
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import HTTPException
import jsonschema
import tomli_w
from .models import HostCreate, normalize_identity
from .security import atomic_artifact, canonical, digest, redact, token
TERMINAL = {"succeeded", "failed", "cancelled", "expired"}
def now_iso():
return datetime.now(timezone.utc).isoformat()
def new_id(prefix):
return prefix + "-" + uuid.uuid4().hex
def require(condition, status, detail):
if not condition:
raise HTTPException(status, detail)
def unpack(row):
if row is None:
raise HTTPException(404, "Objekt nicht gefunden.")
result = dict(row)
data = json.loads(result.pop("data", "{}"))
return {**data, **result}
def audit(connection, actor, action, object_id, reason="", data=None):
connection.execute("INSERT INTO audit VALUES(?,?,?,?,?,?,?)", (new_id("audit"), actor, action, object_id, reason, canonical(data or {}), now_iso()))
def get_host(connection, host_id):
host = unpack(connection.execute("SELECT * FROM hosts WHERE id=?", (host_id,)).fetchone())
host["identities"] = [dict(r) for r in connection.execute("SELECT kind,value FROM host_identities WHERE host_id=? ORDER BY kind,value", (host_id,))]
host["blocked"] = bool(host["blocked"])
stored = json.loads(connection.execute("SELECT data FROM hosts WHERE id=?", (host_id,)).fetchone()[0])
host["management_ip"] = stored.get("management_ip")
latest = connection.execute("SELECT last_seen FROM runs WHERE host_id=? ORDER BY created_at DESC LIMIT 1",(host_id,)).fetchone()
host["last_seen"] = latest[0] if latest else None
for field,table,label in (("installation_profile_id","profiles","installation_profile_name"),("postinstall_profile_id","profiles","postinstall_profile_name"),("iso_id","iso_records","iso_name")):
name = connection.execute(f"SELECT name FROM {table} WHERE id=?",(host.get(field),)).fetchone()
host[label] = name[0] if name else None
return host
def public_run(row):
result = unpack(row)
for field in ("answer_ciphertext", "bootstrap_ciphertext", "secrets_ciphertext", "bootstrap_hash", "enrollment_hash", "report_hash", "device_key"):
result.pop(field, None)
return result
def deep_merge(base, patch, provenance=None, source="", prefix=""):
for key, value in patch.items():
path = f"{prefix}.{key}" if prefix else key
if isinstance(value, dict):
if not isinstance(base.get(key), dict):
base[key] = {}
deep_merge(base[key], value, provenance, source, path)
else:
base[key] = deepcopy(value)
if provenance is not None:
provenance[path] = source
return base
def leaf_paths(value, prefix=""):
for key, item in value.items():
path = f"{prefix}.{key}" if prefix else key
if isinstance(item, dict):
yield from leaf_paths(item, path)
else:
yield path
class Service:
def __init__(self, settings, db, security):
self.settings, self.db, self.security = settings, db, security
self.artifact_dir = settings.data_dir / "artifacts"
def create_host(self, connection, payload, actor):
host_id = new_id("host")
data = payload.model_dump()
normalized = [(item.kind, normalize_identity(item.kind, item.value)) for item in payload.identities]
require(len(set(normalized)) == len(normalized), 422, "Identitäten sind doppelt angegeben.")
address = str(ip_interface(payload.management_ip).ip) if payload.management_ip else None
connection.execute("INSERT INTO hosts(id,fqdn,management_ip,site,status,data,blocked,created_at) VALUES(?,?,?,?,?,?,?,?)", (host_id, payload.fqdn, address, payload.site, "ready", canonical(data), payload.blocked, now_iso()))
connection.executemany("INSERT INTO host_identities VALUES(?,?,?)", [(host_id, *item) for item in normalized])
audit(connection, actor, "host.created", host_id)
return get_host(connection, host_id)
def update_host(self, connection, host_id, patch, actor):
previous = get_host(connection, host_id)
require(previous["version"] == patch.expected_version, 409, "Host wurde zwischenzeitlich geändert. Ansicht neu laden.")
values = json.loads(connection.execute("SELECT data FROM hosts WHERE id=?", (host_id,)).fetchone()[0])
values.update(patch.model_dump(exclude_unset=True, exclude={"expected_version"}))
payload = HostCreate.model_validate(values)
identities = [(i.kind, normalize_identity(i.kind, i.value)) for i in payload.identities]
require(len(set(identities)) == len(identities), 422, "Identitäten sind doppelt angegeben.")
active = connection.execute("SELECT id FROM runs WHERE host_id=? AND status NOT IN ('succeeded','failed','cancelled','expired')", (host_id,)).fetchone()
changed_keys = set(patch.model_fields_set) - {"expected_version", "blocked", "tags"}
require(not active or not changed_keys, 409, "Während eines aktiven Laufs sind nur Sperre und Tags änderbar.")
address = str(ip_interface(payload.management_ip).ip) if payload.management_ip else None
connection.execute("UPDATE hosts SET fqdn=?,management_ip=?,site=?,blocked=?,data=?,version=version+1 WHERE id=?", (payload.fqdn,address,payload.site,payload.blocked,canonical(payload.model_dump()),host_id))
connection.execute("DELETE FROM host_identities WHERE host_id=?", (host_id,))
connection.executemany("INSERT INTO host_identities VALUES(?,?,?)", [(host_id, *item) for item in identities])
audit(connection, actor, "host.updated", host_id, data={"fields": sorted(patch.model_fields_set)})
return get_host(connection, host_id)
def create_profile(self, connection, payload, actor):
data = payload.model_dump()
require(payload.kind == "postinstall" or not payload.steps, 422, "Installationsprofile enthalten keine Skriptschritte.")
self.reject_inline_secrets(data["values"])
for step in data["steps"]:
self.reject_inline_secrets(step["parameters"])
version = connection.execute("SELECT COALESCE(MAX(version),0)+1 FROM profiles WHERE name=? AND kind=?", (payload.name,payload.kind)).fetchone()[0]
profile_id = new_id("profile")
data["digest"] = digest(canonical(data))
connection.execute("INSERT INTO profiles VALUES(?,?,?,?,?,?,?,?)", (profile_id,payload.name,payload.kind,version,"draft",canonical(data),actor,now_iso()))
audit(connection, actor, "profile.created", profile_id, payload.reason)
return unpack(connection.execute("SELECT * FROM profiles WHERE id=?", (profile_id,)).fetchone())
@staticmethod
def reject_inline_secrets(value):
for path in leaf_paths(value):
name = path.rsplit(".", 1)[-1].lower().replace("-", "_")
require(name not in {"password", "root_password", "root_password_hashed", "secret", "token", "private_key"}, 422, "Geheimnisse müssen über Secret-Referenzen eingebunden werden.")
def resolve(self, connection, host_id):
host = get_host(connection, host_id)
require(not host["blocked"], 403, "Host ist gesperrt.")
install = unpack(connection.execute("SELECT * FROM profiles WHERE id=?", (host.get("installation_profile_id"),)).fetchone())
post = unpack(connection.execute("SELECT * FROM profiles WHERE id=?", (host.get("postinstall_profile_id"),)).fetchone())
require(install["kind"] == "installation" and post["kind"] == "postinstall", 422, "Profiltypen passen nicht zur Zuordnung.")
require(install["status"] == post["status"] == "published", 403, "Beide Profile müssen veröffentlicht sein.")
iso = unpack(connection.execute("SELECT * FROM iso_records WHERE id=?", (host.get("iso_id"),)).fetchone())
require(iso["test_status"] == "passed" and iso["native_token_support"] and len(iso["test_evidence"]) >= 5, 403, "ISO benötigt Testnachweis und native Token-Unterstützung.")
group = connection.execute("SELECT * FROM groups WHERE id=?", (iso["group_id"],)).fetchone()
require(group and not group["revoked"] and group["expires_at"] > time.time() and group["site"] == host["site"], 403, "ISO-Gruppe ist ungültig oder gehört zu einem anderen Standort.")
require(iso["build"] in install["target_builds"] and iso["build"] in post["target_builds"], 422, "Zielbuild ist nicht in beiden Profilen freigegeben.")
resolved, provenance = {}, {}
deep_merge(resolved, self.settings.defaults, provenance, "Globale Vorgaben")
deep_merge(resolved, self.settings.sites.get(host["site"], {}), provenance, f"Standort {host['site']}")
deep_merge(resolved, install["values"], provenance, f"Profil {install['name']} v{install['version']}")
overrides = host.get("overrides", {})
paths = list(leaf_paths(overrides))
for locked in install.get("locked_fields", []):
require(not any(path == locked or path.startswith(locked + ".") for path in paths), 422, f"Host darf gesperrtes Feld {locked} nicht überschreiben.")
require(set(overrides) <= {"global", "network", "root_secret_id", "disk_setup"}, 422, "Unzulässige Hostparameter.")
deep_merge(resolved, overrides, provenance, "Host")
stored_host = json.loads(connection.execute("SELECT data FROM hosts WHERE id=?", (host_id,)).fetchone()[0])
deep_merge(resolved, {"global": {"fqdn": host["fqdn"]}, "network": {"cidr": stored_host.get("management_ip")}}, provenance, "Host")
self.validate_installation(resolved)
secret_row = connection.execute("SELECT * FROM secrets WHERE id=?", (resolved["root_secret_id"],)).fetchone()
require(secret_row is not None, 422, "Root-Passwort-Hash als Secret fehlt.")
root_hash = self.security.decrypt(secret_row["ciphertext"])
require(root_hash.startswith(("$6$", "$5$", "$y$")) and len(root_hash) > 30, 422, "Root-Secret muss ein unterstützter crypt-Passwort-Hash sein.")
steps, seen, seen_names = [], set(), {}
secrets_snapshot = {"root": root_hash, "steps": {}}
for step in post["steps"]:
require(step["id"] not in seen, 422, "Schritt-IDs müssen eindeutig sein.")
module = unpack(connection.execute("SELECT * FROM modules WHERE id=?", (step["module_id"],)).fetchone())
require(module["status"] == "published" and iso["build"] in module["target_builds"], 403, f"Modul {module['name']} ist für den Build nicht freigegeben.")
require(set(module["dependencies"]) <= set(seen_names), 422, f"Abhängigkeiten von {module['name']} sind nicht vorher eingeplant.")
try:
jsonschema.Draft202012Validator(module["parameters_schema"]).validate(step["parameters"])
except jsonschema.ValidationError:
raise HTTPException(422, f"Parameter von {module['name']} passen nicht zum Schema.")
checksum = module["digest"]
path = self.artifact_dir / checksum
require(path.is_file() and digest(path.read_bytes()) == checksum, 422, "Modulartefakt fehlt oder ist beschädigt.")
secrets_snapshot["steps"][step["id"]] = {}
for name, secret_id in step.get("secret_refs", {}).items():
require(bool(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,63}", name)), 422, "Ungültiger Secret-Parametername.")
secret = connection.execute("SELECT ciphertext FROM secrets WHERE id=?", (secret_id,)).fetchone()
require(secret is not None, 422, "Ein Schritt-Secret fehlt.")
secrets_snapshot["steps"][step["id"]][name] = self.security.decrypt(secret[0])
steps.append({**step, "name": module["name"], "module_version": module["version"], "digest": checksum, "timeout_seconds": module["timeout_seconds"], "retry_safe": module["retry_safe"], "dependencies": [seen_names[name] for name in module["dependencies"]]})
seen.add(step["id"])
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."]}
snapshot["digest"] = digest(canonical(snapshot))
return snapshot, secrets_snapshot
@staticmethod
def validate_installation(values):
require(isinstance(values,dict),422,"Installationsparameter müssen ein Objekt sein.")
require(set(values) <= {"global", "network", "disk_setup", "root_secret_id"}, 422, "Unbekannte Installationsparameter.")
glob = values.get("global", {})
require(isinstance(glob,dict) and all(isinstance(glob.get(k),str) for k in ("keyboard","country","timezone","mailto","fqdn")),422,"Globale Pflichtfelder müssen Zeichenketten sein.")
require(set(glob) <= {"keyboard", "country", "timezone", "mailto", "fqdn", "root-ssh-keys", "reboot-on-error"}, 422, "Nicht freigegebene globale Antwortoption.")
require(all(glob.get(k) for k in ("keyboard", "country", "timezone", "mailto", "fqdn")), 422, "Globale Pflichtfelder fehlen.")
require(bool(re.fullmatch(r"[a-z]{2}", glob["country"])) and "@" in glob["mailto"], 422, "Land oder E-Mail ungültig.")
require(glob["keyboard"] in {"de","de-ch","dk","en-gb","en-us","es","fi","fr","fr-be","fr-ca","fr-ch","hu","is","it","jp","lt","mk","nl","no","pl","pt","pt-br","se","si","tr"},422,"Tastaturlayout wird vom Installer nicht unterstützt.")
require(isinstance(glob.get("reboot-on-error",False),bool),422,"reboot-on-error muss ein Wahrheitswert sein.")
require(isinstance(glob.get("root-ssh-keys",[]),list) and all(isinstance(k,str) and k.startswith(("ssh-ed25519 ","ssh-rsa ","ecdsa-sha2-")) for k in glob.get("root-ssh-keys",[])),422,"Root-SSH-Schlüssel müssen als Liste öffentlicher Schlüssel angegeben werden.")
try:
ZoneInfo(glob["timezone"])
except (ZoneInfoNotFoundError, TypeError):
raise HTTPException(422, "Ungültige Zeitzone.")
network = values.get("network", {})
require(isinstance(network,dict),422,"Netzwerkparameter müssen ein Objekt sein.")
require(set(network) <= {"source", "cidr", "gateway", "dns", "filter"}, 422, "Nicht freigegebene Netzwerkoption.")
require(network.get("source") == "from-answer" and network.get("filter"), 422, "Explizites Managementnetz und Interface-Filter erforderlich.")
try:
address = ip_interface(network["cidr"])
gateway, dns = ip_address(network["gateway"]), ip_address(network["dns"])
require(gateway.version == address.version and gateway in address.network, 422, "Gateway liegt außerhalb des Managementnetzes.")
require(address.ip != gateway, 422, "Hostadresse darf nicht der Gatewayadresse entsprechen.")
if address.version == 4:
require(address.ip not in {address.network.network_address,address.network.broadcast_address}, 422, "Hostadresse ist Netz- oder Broadcastadresse.")
except (ValueError, KeyError, TypeError):
raise HTTPException(422, "Management-IP mit CIDR, Gateway und DNS müssen gültig sein.")
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(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.")
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.")
lvm = disks.get("lvm",{})
require(isinstance(lvm,dict) and set(lvm) <= {"hdsize","swapsize","maxroot","maxvz","minfree"},422,"Nicht unterstützte LVM-Option.")
require(all(isinstance(v,(int,float)) and not isinstance(v,bool) and v >= (2 if k in {"hdsize","maxroot"} else 0) and v < 1000000 for k,v in lvm.items()),422,"Ungültige LVM-Größenangabe.")
else:
require("lvm" not in disks,422,"LVM-Optionen sind mit ZFS nicht kombinierbar.")
zfs = disks.get("zfs",{})
require(isinstance(zfs,dict) and set(zfs) <= {"raid","ashift","arc-max","checksum","compress","copies","hdsize"},422,"Nicht unterstützte ZFS-Option.")
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.")
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}.")
require(isinstance(zfs.get("checksum","on"),str) and isinstance(zfs.get("compress","on"),str) and zfs.get("checksum","on") in {"on","fletcher4","sha256"} and zfs.get("compress","on") in {"on","off","lzjb","lz4","zle","gzip","zstd"},422,"Nicht unterstützte ZFS-Kompression oder Prüfsumme.")
require(isinstance(values.get("root_secret_id"),str) and bool(values["root_secret_id"]), 422, "Root-Secret-Referenz fehlt.")
def approve(self, connection, host_id, payload, actor):
require(not self.settings.maintenance, 503, "Wartungsmodus: Neue Freigaben sind gesperrt.")
host = get_host(connection, host_id)
require(host["version"] == payload.expected_version, 409, "Host wurde geändert. Vorschau erneut prüfen.")
require(payload.confirmation == host["fqdn"] and payload.disks_confirmed, 422, "FQDN und Überschreiben der aufgeführten Systemdatenträger müssen bestätigt werden.")
active = connection.execute("SELECT id FROM runs WHERE host_id=? AND status NOT IN ('succeeded','failed','cancelled','expired')", (host_id,)).fetchone()
require(not active, 409, "Für diesen Host existiert bereits ein aktiver Lauf.")
snapshot, secret_values = self.resolve(connection, host_id)
run_id, approval_id = new_id("run"), new_id("approval")
manifest = {"run_id": run_id, "steps": snapshot["steps"], "reboot_budget": snapshot["reboot_budget"]}
manifest_digest = digest(canonical(manifest))
run_data = {"snapshot": snapshot, "manifest": manifest, "manifest_digest": manifest_digest, "fqdn": host["fqdn"], "site": host["site"], "cancel_requested": False, "reboots": 0}
expiry = time.time() + payload.valid_minutes * 60
connection.execute("INSERT INTO approvals VALUES(?,?,?,?,?,?)", (approval_id,host_id,"approved",expiry,canonical({"actor":actor,"reason":payload.reason,"snapshot_digest":snapshot["digest"]}),now_iso()))
connection.execute("INSERT INTO runs(id,host_id,approval_id,status,data,secrets_ciphertext,created_at) VALUES(?,?,?,?,?,?,?)", (run_id,host_id,approval_id,"prepared",canonical(run_data),self.security.encrypt(canonical(secret_values)),now_iso()))
connection.executemany("INSERT INTO run_steps(run_id,step_id,position) VALUES(?,?,?)", [(run_id,s["id"],i) for i,s in enumerate(snapshot["steps"])])
connection.execute("UPDATE hosts SET status='prepared',version=version+1 WHERE id=?", (host_id,))
audit(connection, actor, "installation.approved", host_id, payload.reason, {"run_id":run_id,"disks":snapshot["disks"],"expires_at":expiry})
return public_run(connection.execute("SELECT * FROM runs WHERE id=?", (run_id,)).fetchone())
@staticmethod
def installer_identity(payload):
require(isinstance(payload,dict), 422, "Installer-Payload muss ein Objekt sein.")
meta = payload.get("$schema", payload.get("$fetchinfo", {}))
dmi = payload.get("dmi",{})
require(isinstance(meta,dict) and isinstance(dmi,dict),422,"Native Metadaten oder DMI-Daten sind ungültig.")
schema = meta.get("version", "legacy")
require(isinstance(schema,str) and schema in {"1.0","legacy"},422,"Nicht freigegebenes Installer-Payload-Schema.")
system = dmi.get("system", {})
interfaces = payload.get("network-interfaces",payload.get("network_interfaces",[]))
require(isinstance(system,dict) and isinstance(interfaces,list) and len(interfaces)<=64,422,"Native Hardwarekennungen sind ungültig.")
raw = []
for field,kind in (("uuid","uuid"),("serial","serial")):
if system.get(field):
raw.append((kind,system[field]))
for item in interfaces:
if isinstance(item,dict) and item.get("mac"):
raw.append(("mac",item["mac"]))
identities = []
for kind,value in raw:
if not isinstance(value,str) or value.lower() in {"unknown","none","not specified","default string","to be filled by o.e.m."}:
continue
try:
identities.append({"kind":kind,"value":normalize_identity(kind,value)})
except ValueError:
continue
require(identities, 422, "Installer übermittelt keine verwendbare Hardwarekennung.")
product = payload.get("product", {})
iso = payload.get("iso", {})
require(isinstance(product,dict) and isinstance(iso,dict),422,"Native Produkt- und ISO-Angaben fehlen.")
require(product.get("product") == "pve", 422, "Nur Proxmox VE Installer werden unterstützt.")
release, build = iso.get("release"), iso.get("build")
require(release and build, 422, "Native ISO Release- und Build-Informationen fehlen.")
return identities, f"{release}-{build}", schema
@staticmethod
def match_host(connection, identities, site=None):
candidates = set()
for identity in identities:
for row in connection.execute("SELECT host_id FROM host_identities WHERE kind=? AND value=?", (identity["kind"],identity["value"])):
candidates.add(row[0])
require(len(candidates) <= 1, 409, "Widersprüchliche Hardwarekennungen gehören zu mehreren Hosts.")
if not candidates:
return None
host = get_host(connection, candidates.pop())
if site is not None:
require(host["site"] == site, 403, "Host gehört nicht zum Standort des Gruppentokens.")
for kind in ("uuid", "serial"):
expected = {x["value"] for x in host["identities"] if x["kind"] == kind}
supplied = {x["value"] for x in identities if x["kind"] == kind}
require(not expected or not supplied or supplied <= expected, 409, "UUID und Seriennummer widersprechen der gespeicherten Hostidentität.")
return host
def serve_answer(self, connection, group, payload):
identities, build, schema = self.installer_identity(payload)
host = self.match_host(connection, identities, group["site"])
if host is None:
fingerprint = digest(canonical(sorted(identities,key=lambda i:(i["kind"],i["value"]))))
connection.execute("INSERT INTO discoveries VALUES(?,?,?,?,?,?) ON CONFLICT(fingerprint) DO UPDATE SET last_seen=excluded.last_seen", (new_id("discovery"),fingerprint,group["site"],canonical({"identities":identities,"build":build,"schema":schema}),"Host ist nicht zugeordnet.",time.time()))
# The caller commits discovery before returning the denial.
return None
require(not host["blocked"], 403, "Host ist gesperrt.")
row = connection.execute("SELECT * FROM runs WHERE host_id=? ORDER BY created_at DESC LIMIT 1", (host["id"],)).fetchone()
require(row is not None, 403, "Keine ausdrückliche Installationsfreigabe vorhanden.")
run = unpack(row)
iso = run["snapshot"]["iso"]
require(iso["group_id"] == group["id"] and iso["build"] == build, 403, "Installergruppe oder Zielbuild stimmt nicht mit der Freigabe überein.")
require(not run.get("cancel_requested"), 403, "Lauf ist zum Abbruch markiert.")
require(run["status"] != "expired", 410, "Installationsfreigabe ist abgelaufen.")
if run["status"] == "answer_served":
require(time.time() < run["answer_until"], 410, "Auslieferungsfenster ist abgelaufen.")
audit(connection, "installer:" + group["name"], "answer.repeated", run["id"])
return self.security.decrypt(run["answer_ciphertext"])
require(run["status"] == "prepared", 403, "Dieser Lauf erlaubt keine weitere Installation.")
approval = connection.execute("SELECT * FROM approvals WHERE id=?", (run["approval_id"],)).fetchone()
require(approval["status"] == "approved" and approval["expires_at"] > time.time(), 410, "Installationsfreigabe ist abgelaufen.")
require(self.settings.testing or self.settings.public_url.startswith("https://"), 503, "Maschinenendpunkte benötigen eine konfigurierte HTTPS-Adresse.")
bootstrap_token, enrollment_secret, report_token = token(), token(), token()
from .bootstrap import render_bootstrap
bootstrap_config = {"api_url":self.settings.public_url,"run_id":run["id"],"enrollment_secret":enrollment_secret,"identities":run["snapshot"]["identities"],"manifest_digest":run["manifest_digest"]}
if self.settings.runner_ca_file:
from pathlib import Path
bootstrap_config["ca_pem"] = Path(self.settings.runner_ca_file).read_text()
bootstrap = render_bootstrap(bootstrap_config)
require(len(bootstrap.encode()) <= 1024 * 1024, 422, "Starthelfer überschreitet das Größenlimit.")
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")
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"])
run_data.update({"answer_digest":digest(answer),"installer_schema":schema})
connection.execute("UPDATE runs SET status='answer_served',version=version+1,data=?,answer_ciphertext=?,bootstrap_ciphertext=?,bootstrap_hash=?,enrollment_hash=?,report_hash=?,answer_until=?,enroll_until=?,last_seen=? WHERE id=?", (canonical(run_data),self.security.encrypt(answer),self.security.encrypt(bootstrap),digest(bootstrap_token),digest(enrollment_secret),digest(report_token),time.time()+self.settings.answer_window_seconds,time.time()+self.settings.enrollment_hours*3600,time.time(),run["id"]))
connection.execute("UPDATE approvals SET status='consumed' WHERE id=?", (run["approval_id"],))
connection.execute("UPDATE hosts SET status='answer_served' WHERE id=?", (host["id"],))
audit(connection, "installer:" + group["name"], "answer.served", run["id"])
return answer
def redact_run(self, row, text):
values = []
if row["secrets_ciphertext"]:
secret_values = json.loads(self.security.decrypt(row["secrets_ciphertext"]))
values.append(secret_values.get("root", ""))
for step in secret_values.get("steps", {}).values():
values.extend(step.values())
return redact(text, values)
def redact_payload(self, row, value):
def walk(item):
if isinstance(item, str):
return self.redact_run(row, item)
if isinstance(item, dict):
return {key:walk(child) for key,child in item.items()}
if isinstance(item, list):
return [walk(child) for child in item]
return item
return canonical(walk(value))
def maintain(self):
with self.db.connection(write=True) as connection:
timestamp = time.time()
connection.execute("DELETE FROM sessions WHERE expires_at<?", (timestamp,))
connection.execute("DELETE FROM nonces WHERE expires_at<?", (timestamp,))
expired = connection.execute("SELECT r.id,r.host_id,r.approval_id FROM runs r JOIN approvals a ON a.id=r.approval_id WHERE r.status='prepared' AND a.expires_at<?", (timestamp,)).fetchall()
for run in expired:
connection.execute("UPDATE runs SET status='expired',version=version+1,completed_at=? WHERE id=?", (timestamp,run["id"]))
connection.execute("UPDATE approvals SET status='expired' WHERE id=?", (run["approval_id"],))
connection.execute("UPDATE hosts SET status='expired' WHERE id=?", (run["host_id"],))
audit(connection,"system","approval.expired",run["id"])
for table,days in (("logs",self.settings.log_retention_days),("audit",self.settings.audit_retention_days)):
cutoff = datetime.fromtimestamp(timestamp-days*86400,timezone.utc).isoformat()
connection.execute(f"DELETE FROM {table} WHERE created_at<?", (cutoff,))
def module_syntax(self, source):
executable = shutil.which("bash")
if os.name == "nt":
git = shutil.which("git")
candidate = Path(git).parent.parent / "bin" / "bash.exe" if git else None
if candidate and candidate.is_file():
executable = str(candidate)
require(executable is not None, 503, "Bash-Syntaxprüfung nicht verfügbar. Modul im Linux-Container veröffentlichen.")
result = subprocess.run([executable,"-n"],input=source.replace("\r\n","\n").encode(),capture_output=True,timeout=15)
require(result.returncode == 0, 422, "Bash-Syntaxprüfung fehlgeschlagen.")
require(all(re.search(r"\b" + phase + r"\b",source) for phase in ("check","apply","verify")), 422, "Modul muss check/apply/verify implementieren.")
+424
View File
@@ -0,0 +1,424 @@
/* Proxmox AIS administration console. No client-side dependencies. */
'use strict';
const icons = {
dashboard: '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
server: '<rect x="3" y="3" width="18" height="7" rx="2"/><rect x="3" y="14" width="18" height="7" rx="2"/><path d="M7 6.5h.01M7 17.5h.01M15 6.5h3M15 17.5h3"/>',
activity: '<path d="M3 12h4l3-8 4 16 3-8h4"/>',
layers: '<path d="m12 3 9 5-9 5-9-5 9-5Zm-9 9 9 5 9-5M3 16l9 5 9-5"/>',
workflow: '<rect x="3" y="3" width="6" height="6" rx="1.5"/><rect x="15" y="15" width="6" height="6" rx="1.5"/><path d="M6 9v7a2 2 0 0 0 2 2h7M15 6h6m-3-3v6"/>',
code: '<path d="m8 6-6 6 6 6m8-12 6 6-6 6m-3-15-2 18"/>',
disc: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="2"/><path d="m7 7 2 2m6 6 2 2"/>',
shield: '<path d="M12 3 3 7v5c0 5 9 9 9 9s9-4 9-9V7l-9-4Z"/><path d="m8 12 3 3 5-6"/>',
settings: '<path d="m9 3-1 3-3 1-2 4 2 2v3l3 2 1 3h5l1-3 3-1 2-4-2-2V8l-3-2-1-3Z"/><circle cx="11.5" cy="12" r="3"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
arrow: '<path d="M4 12h15m-6-6 6 6-6 6"/>',
refresh: '<path d="M20 7v5h-5M4 17v-5h5"/><path d="M5.5 8a7 7 0 0 1 11.8-3L20 8M4 16l2.7 3A7 7 0 0 0 18.5 16"/>',
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
check: '<path d="m5 12 4 4L19 6"/>',
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
alert: '<path d="m12 3 10 18H2L12 3Z"/><path d="M12 9v5m0 3h.01"/>',
edit: '<path d="m16 3 5 5-12 12-6 1 1-6L16 3Zm-3 3 5 5"/>',
key: '<circle cx="8" cy="8" r="5"/><path d="m12 12 9 9m-4-4 3-3m-6 0 3-3"/>',
users: '<circle cx="9" cy="8" r="4"/><path d="M2 21v-3a7 7 0 0 1 14 0v3M17 4a4 4 0 0 1 0 8m3 9v-3a6 6 0 0 0-3-5"/>',
lock: '<rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3m-4 5v2"/>',
copy: '<rect x="8" y="8" width="13" height="13" rx="2"/><path d="M16 8V3H3v13h5"/>',
back: '<path d="M20 12H5m6-6-6 6 6 6"/>',
stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
play: '<path d="m7 4 14 8-14 8V4Z"/>',
file: '<path d="M14 3H5v18h14V8l-5-5Zm0 0v5h5M8 13h8M8 17h5"/>',
};
const svg = (name) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icons[name] || icons.file}</svg>`;
const esc = (value) => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const json = value => JSON.stringify(value ?? {}, null, 2);
const arr = value => Array.isArray(value) ? value : (value?.items || []);
const toDate = value => new Date(typeof value==='number' && value<1e12 ? value*1000 : value);
const fmtDate = value => value && !Number.isNaN(toDate(value).getTime()) ? new Intl.DateTimeFormat('de-DE', {dateStyle:'short',timeStyle:'short'}).format(toDate(value)) : 'Noch kein Kontakt';
const shortId = value => String(value || '').slice(0, 10);
const roleNames = {reader:'Leser',operator:'Operator',author:'Skriptautor',admin:'Administrator',developer:'Entwickler'};
const statusNames = {draft:'Entwurf',published:'Veröffentlicht',discovered:'Entdeckt',ready:'Bereit',prepared:'Freigegeben',approved:'Freigegeben',answer_served:'Antwort ausgeliefert',answer_delivered:'Antwort ausgeliefert',installing:'Installation',installed:'Installiert',installed_reported:'Basisinstallation gemeldet',bootstrapping:'Erster Start',enrolled:'Registriert',runner_ready:'Runner bereit',running:'In Ausführung',postinstall_running:'Nachkonfiguration',postinstalling:'Nachkonfiguration',waiting_retry:'Wiederaufnahme erwartet',reboot_pending:'Neustart erwartet',waiting_reboot:'Neustart erwartet',rebooting:'Neustart',succeeded:'Erfolgreich',failed:'Fehlgeschlagen',needs_review:'Prüfung nötig',unknown:'Kontakt unbekannt',cancelled:'Abgebrochen',canceled:'Abgebrochen',cancel_requested:'Abbruch angefordert',blocked:'Gesperrt',pending:'Ausstehend',checking:'Prüfen',applying:'Anwenden',verifying:'Validieren',skipped:'Übersprungen',active:'Aktiv',passed:'Geprüft',expired:'Abgelaufen',revoked:'Widerrufen'};
const greenStatuses = new Set(['succeeded','passed','published','active','ready']);
const redStatuses = new Set(['failed','needs_review','blocked','revoked']);
const orangeStatuses = new Set(['draft','pending','discovered','unknown','expired','waiting_reboot','waiting_retry','reboot_pending','cancel_requested']);
const blueStatuses = new Set(['approved','prepared','answer_served','answer_delivered','installing','installed','bootstrapping','enrolled','runner_ready','running','postinstall_running','postinstalling','checking','applying','verifying','rebooting']);
const badge = status => `<span class="badge ${greenStatuses.has(status)?'green':redStatuses.has(status)?'red':orangeStatuses.has(status)?'orange':blueStatuses.has(status)?'blue':''}">${esc(statusNames[status] || status || 'Unbekannt')}</span>`;
const state = {me:null,page:'dashboard',id:null,data:null,routeVersion:0,settingsTab:'users',runTab:'steps',refreshing:false};
const canOperate = () => ['admin','developer','operator'].includes(state.me?.role);
const canAuthor = () => ['admin','developer','author'].includes(state.me?.role);
const canAdmin = () => ['admin','developer'].includes(state.me?.role);
const main = document.getElementById('main');
const modal = document.getElementById('modal');
let modalSubmit = null;
function errorMessage(detail) {
if (typeof detail === 'string') return detail;
if (Array.isArray(detail)) return detail.map(e => `${(e.loc || []).filter(v=>v!=='body').join('.')}: ${e.msg || json(e)}`).join('\n');
return detail?.message || json(detail);
}
async function api(path, options = {}) {
const headers = {'Accept':'application/json', ...(options.headers || {})};
if (options.body !== undefined && typeof options.body !== 'string') {
headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(options.body);
}
if (options.method && options.method !== 'GET') headers['X-CSRF-Token'] = state.me?.csrf_token || '';
const response = await fetch(path.startsWith('/auth/') ? path : `/api/v1${path}`, {...options, headers, credentials:'same-origin'});
if (response.status === 401) { window.location.href = '/login'; throw new Error('Ihre Sitzung ist abgelaufen. Bitte erneut anmelden.'); }
const contentType = response.headers.get('content-type') || '';
const result = response.status === 204 ? null : contentType.includes('json') ? await response.json() : await response.text();
if (!response.ok) throw new Error(errorMessage(result?.errors || result?.detail || result?.message || result || `Anfrage fehlgeschlagen (${response.status})`));
return result;
}
function toast(message, isError=false) {
const el=document.createElement('div'); el.className=`toast${isError?' error':''}`; el.textContent=message;
const region=document.getElementById('toast-region');while(region.children.length>=3)region.firstElementChild.remove();region.append(el); setTimeout(()=>el.remove(), isError?9000:4500);
}
function actionButton(label, action, icon='plus', attrs='', primary=false) {
if(action==='preview-host' && !canOperate() && !canAuthor())return '';
return `<button type="button" class="button${primary?' primary':''}" data-action="${action}" ${attrs}>${svg(icon)}${esc(label)}</button>`;
}
function header(title, description, actions='', eyebrow='PROVISIONING CONSOLE') {
return `<div class="page-header"><div><span class="eyebrow">${esc(eyebrow)}</span><h1>${esc(title)}</h1><p>${esc(description)}</p></div><div class="header-actions">${actions}</div></div>`;
}
function empty(title, description, icon='server', action='') {
return `<div class="empty-state"><div class="empty-icon">${svg(icon)}</div><h3>${esc(title)}</h3><p>${esc(description)}</p>${action}</div>`;
}
function table(headings, rows) {
return `<div class="table-wrap"><table><thead><tr>${headings.map(h=>`<th>${esc(h)}</th>`).join('')}</tr></thead><tbody>${rows.join('')}</tbody></table></div>`;
}
function toolbar(placeholder='Suchen …', sites=[], statuses=[]) {
return `<div class="toolbar"><div class="filter-group"><label class="search-field">${svg('search')}<input id="list-search" type="search" aria-label="Liste durchsuchen" placeholder="${esc(placeholder)}"></label>${sites.length?`<select id="site-filter" class="filter-select" aria-label="Nach Standort filtern"><option value="">Alle Standorte</option>${sites.map(s=>`<option>${esc(s)}</option>`).join('')}</select>`:''}${statuses.length?`<select id="status-filter" class="filter-select" aria-label="Nach Status filtern"><option value="">Alle Status</option>${statuses.map(s=>`<option value="${esc(s)}">${esc(statusNames[s]||s)}</option>`).join('')}</select>`:''}</div><span class="toolbar-info" id="filter-count"></span></div>`;
}
const searchAttrs = (value, site='', status='') => `data-search="${esc(String(value).toLowerCase())}" data-site="${esc(site)}" data-status="${esc(status)}"`;
function applyFilters() {
const query=(document.getElementById('list-search')?.value || '').toLowerCase();
const site=document.getElementById('site-filter')?.value || '';
const status=document.getElementById('status-filter')?.value || '';
let count=0, total=0;
main.querySelectorAll('[data-search]').forEach(row=>{
total++; const visible=row.dataset.search.includes(query) && (!site || row.dataset.site===site) && (!status || row.dataset.status===status);
row.hidden=!visible; if(visible) count++;
});
const countEl=document.getElementById('filter-count'); if(countEl) countEl.textContent=`${count} ${count===1?'Eintrag':'Einträge'}${count!==total?` von ${total}`:''}`;
let noResults=document.getElementById('no-filter-results');
if(!noResults && total) {noResults=document.createElement('div');noResults.id='no-filter-results';noResults.className='empty-state';noResults.textContent='Keine Einträge für diese Auswahl.';main.querySelector('.filterable')?.append(noResults);}
if(noResults) noResults.hidden=count!==0;
}
function hostRows(hosts, compact=false) {
return hosts.map(h=>`<tr ${searchAttrs(`${h.fqdn} ${h.management_ip} ${h.site} ${(h.tags||[]).join(' ')}`,h.site,h.blocked?'blocked':h.status)}><td><div class="table-title"><span class="row-icon">${svg('server')}</span><div><a href="#/hosts/${encodeURIComponent(h.id)}"><strong>${esc(h.fqdn || h.name || `Entdeckter Host ${shortId(h.id)}`)}</strong></a><small>${esc(h.management_ip || 'Keine Management-IP')}</small></div></div></td><td>${esc(h.site || '')}</td><td>${badge(h.blocked?'blocked':h.status)}</td>${compact?'':`<td>${(h.tags||[]).map(t=>`<span class="tag">${esc(t)}</span>`).join('') || '<span class="muted"></span>'}</td><td>${esc(fmtDate(h.last_contact || h.last_seen))}</td>`}<td><a class="button-link" href="#/hosts/${encodeURIComponent(h.id)}">Details ${svg('arrow')}</a></td></tr>`);
}
function runRows(runs) {
return runs.map(r=>{
const steps=arr(r.steps),finished=steps.filter(s=>['succeeded','failed','skipped'].includes(s.status)).length;
const waiting={prepared:'Freigabe aktiv · wartet auf ISO',answer_served:'Antwort ausgeliefert',answer_delivered:'Antwort ausgeliefert',installed_reported:'Wartet auf den ersten Start',installing:'Basisinstallation',installed:'Wartet auf den Runner'}[r.status];
const progress=steps.length?Math.round(finished/steps.length*100):0;
const progressCell=waiting?esc(waiting):steps.length?`${finished} / ${steps.length} geprüft<progress class="run-progress" max="100" value="${progress}" aria-label="Geprüfte Konfigurationsschritte">${progress}%</progress>`:esc(fmtDate(r.started_at || r.created_at));
return `<tr ${searchAttrs(`${r.host_fqdn||r.fqdn||r.host_id} ${r.id}`,r.site,r.status)}><td><div class="table-title"><span class="row-icon">${svg('activity')}</span><div><a href="#/runs/${encodeURIComponent(r.id)}"><strong>${esc(r.host_fqdn || r.fqdn || shortId(r.host_id))}</strong></a><small>Lauf ${esc(shortId(r.id))}</small></div></div></td><td>${badge(r.cancel_requested && !['cancelled','succeeded','failed','expired'].includes(r.status)?'cancel_requested':r.status)}${r.contact_status==='unknown'?` ${badge('unknown')}`:''}</td><td>${progressCell}</td><td><a class="button-link" href="#/runs/${encodeURIComponent(r.id)}">Ansehen ${svg('arrow')}</a></td></tr>`;
});
}
function eventList(events, blank='Noch keine Ereignisse') {
if(!events.length) return empty(blank,'Ereignisse erscheinen hier, sobald Sie Hosts und Konfigurationen verwalten.','activity');
return `<div class="activity-list">${events.map(e=>`<div class="activity-item"><span class="event-dot">${svg(e.status==='failed'?'alert':'activity')}</span><div class="activity-copy"><strong>${esc(e.message || e.action || e.type || e.event_type || 'Statusänderung')}</strong><p>${esc(e.host_fqdn || e.actor || e.username || e.object_id || shortId(e.run_id))} · ${esc(fmtDate(e.created_at || e.timestamp))}</p></div></div>`).join('')}</div>`;
}
function dashboard(data) {
const c=data.counts||{}, hosts=arr(data.hosts), runs=arr(data.runs), events=arr(data.recent_events);
const metrics=[['Server gesamt',c.hosts||0,'Inventarisierte Hosts','server',''],['Bereit zur Installation',c.ready||0,'Freigegebene Server','shield','orange'],['Aktive Läufe',c.active||0,'Installation & Nachkonfiguration','activity','green'],['Prüfung erforderlich',c.needs_review||0,'Vorgänge mit Handlungsbedarf','alert','red']];
return header('Infrastruktur im Überblick','Installationen steuern. Konfigurationen prüfen. Den Überblick behalten.',actionButton('Aktualisieren','refresh','refresh')+(canOperate()?actionButton('Server hinzufügen','create-host','plus','',true):''),'WORKSPACE / ÜBERSICHT')+
`<div class="metrics-grid">${metrics.map(m=>`<div class="metric"><div class="metric-label">${m[0]}<span class="metric-icon ${m[4]}">${svg(m[3])}</span></div><div class="metric-value">${Number(m[1])}</div><div class="metric-note">${m[2]}</div></div>`).join('')}</div><div class="dashboard-grid"><div class="stack"><section class="card"><div class="card-header"><div><h2>Installationsläufe <span class="count-label">${runs.length}</span></h2><p>Die letzten Provisionierungen und ihr aktueller Stand</p></div><a class="button-link" href="#/runs">Alle Läufe ${svg('arrow')}</a></div>${runs.length?table(['SERVER','STATUS','FORTSCHRITT',''],runRows(runs.slice(0,6))):empty('Bereit für den ersten Lauf','Erfassen Sie einen Server, weisen Sie geprüfte Profile zu und erteilen Sie die Installationsfreigabe.','activity',canOperate()?actionButton('Server erfassen','create-host','plus'):'')}<div class="card-footer"><span><span class="status-dot"></span> Status aus persistenten Laufereignissen</span><span>${Number(c.succeeded||0)} erfolgreich abgeschlossen</span></div></section><section class="card"><div class="card-header"><div><h2>Serverinventar <span class="count-label">${Number(c.hosts||0)}</span></h2><p>Ihre zuletzt erfassten Server</p></div><a class="button-link" href="#/hosts">Zum Inventar ${svg('arrow')}</a></div>${hosts.length?table(['SERVER','STANDORT','STATUS',''],hostRows(hosts.slice(0,5),true)):empty('Ihr Inventar beginnt hier','Identitäten, Managementnetz und Profilzuordnungen an einem Ort.','server')}<div class="card-footer"><span>${Number(c.discovered||0)} neu entdeckte Hosts</span><a href="#/hosts">Inventar verwalten →</a></div></section></div><div class="stack"><section class="card intro-card"><div class="card-header"><span class="eyebrow">${c.hosts?'IHR PROVISIONIERUNGSABLAUF':'ERSTE SCHRITTE'}</span>${svg('workflow')}</div><div class="card-content"><h3>Von der ISO zum<br>fertigen Server.</h3><p>Ein klarer Ablauf für jede Installation.</p><div class="setup-steps"><a class="setup-step" href="#/media"><span class="setup-number">01</span><span><strong>Installationsmedium vorbereiten</strong><small>ISO registrieren und Build prüfen</small></span></a><a class="setup-step" href="#/installation"><span class="setup-number">02</span><span><strong>Konfiguration festlegen</strong><small>Profile und Skriptversionen veröffentlichen</small></span></a><a class="setup-step${c.hosts?' complete':''}" href="#/hosts"><span class="setup-number">${c.hosts?'✓':'03'}</span><span><strong>Server erfassen & freigeben</strong><small>Vorschau prüfen, Installation bestätigen</small></span></a></div></div></section><section class="card"><div class="card-header"><div><h2>Letzte Ereignisse</h2><p>Was sich in Ihrem Workspace verändert</p></div></div>${eventList(events.slice(0,5))}<div class="card-footer"><a class="button-link" href="#/audit">Auditprotokoll öffnen ${svg('arrow')}</a></div></section></div></div>`;
}
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')+`<section class="card filterable">${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):'')}</section>`+(unassigned.length?`<section class="card space-top"><div class="card-header"><div><h2>Entdeckte Hardware <span class="count-label">${unassigned.length}</span></h2><p>Noch nicht zugeordnete Geräte erhalten keine Installationskonfiguration.</p></div>${badge('discovered')}</div>${table(['IDENTITÄTEN','STANDORT / BUILD','ABLEHNUNGSGRUND',''],unassigned.map(d=>`<tr><td>${arr(d.identities).map(i=>`<div class="small-text mono">${esc(i.kind)}: ${esc(i.value)}</div>`).join('')}</td><td>${esc(d.site)}<br><span class="small-text muted">${esc(d.build)}</span></td><td>${esc(d.reason)}</td><td>${canOperate()?`<button class="button small" data-action="assign-discovery" data-id="${esc(d.id)}">Als Server erfassen</button>`:''}</td></tr>`))}</section>`:'');
}
function hostPage(host) {
const id=esc(host.id), runs=arr(host.runs), identities=arr(host.identities);
return header(host.fqdn||'Entdeckter Server',`${host.site||'Kein Standort'} · ${host.management_ip||'Keine Management-IP'}`,`<a class="button" href="#/hosts">${svg('back')}Inventar</a>${canOperate()?actionButton('Bearbeiten','edit-host','edit',`data-id="${id}"`)+actionButton('Installation freigeben','approve-host','shield',`data-id="${id}"`,true):''}`,'SERVERDETAIL / '+shortId(host.id))+
`<div class="detail-grid"><div class="stack"><section class="card"><div class="card-header"><h2>Serverkonfiguration</h2>${badge(host.blocked?'blocked':host.status)}</div><div class="card-content"><dl class="detail-list"><dt>FQDN</dt><dd>${esc(host.fqdn||'')}</dd><dt>Management-IP</dt><dd class="mono">${esc(host.management_ip||'')}</dd><dt>Standort</dt><dd>${esc(host.site||'')}</dd><dt>Tags</dt><dd>${(host.tags||[]).map(t=>`<span class="tag">${esc(t)}</span>`).join('')||''}</dd><dt>Letzter Kontakt</dt><dd>${esc(fmtDate(host.last_contact||host.last_seen))}</dd><dt>Versionsstand</dt><dd>${esc(host.version)}</dd></dl><h3 class="section-label">Hardware-Identitäten</h3>${identities.length?`<dl class="detail-list">${identities.map(i=>`<dt>${esc({uuid:'System-UUID',serial:'Seriennummer',mac:'MAC-Adresse'}[i.kind]||i.kind)}</dt><dd class="mono">${esc(i.value)}</dd>`).join('')}</dl>`:'<p class="muted small-text">Noch keine Identitäten hinterlegt.</p>'}</div><div class="card-footer"><span>Erfasst am ${esc(fmtDate(host.created_at))}</span>${canOperate()?`<button class="button-link" data-action="toggle-host" data-id="${id}">${host.blocked?'Host entsperren':'Host sperren'}</button>`:''}</div></section><section class="card"><div class="card-header"><h2>Installationshistorie</h2><span class="count-label">${runs.length}</span></div>${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')}</section></div><div class="stack"><section class="card"><div class="card-header"><h2>Profilzuordnung</h2>${svg('layers')}</div><div class="card-content"><dl class="detail-list"><dt>Installation</dt><dd>${esc(host.installation_profile_name||host.installation_profile_id||'Nicht zugewiesen')}</dd><dt>Postinstallation</dt><dd>${esc(host.postinstall_profile_name||host.postinstall_profile_id||'Nicht zugewiesen')}</dd><dt>ISO-Medium</dt><dd>${esc(host.iso_name||host.iso_id||'Nicht zugewiesen')}</dd></dl><p class="small-text muted space-top">Jeder Lauf bindet feste Profil- und Modulversionen. Spätere Änderungen wirken auf neue Läufe.</p>${actionButton('Aufgelöste Vorschau','preview-host','file',`data-id="${id}"`)}</div></section>${host.discovered_data?`<section class="card"><div class="card-header"><h2>Erkannte Systemdaten</h2></div><div class="card-content"><pre class="code-block light">${esc(json(host.discovered_data))}</pre></div></section>`:''}<section class="card"><div class="card-header"><h2>Hostüberschreibungen</h2></div><div class="card-content"><pre class="code-block light">${esc(json(host.overrides))}</pre></div></section></div></div>`;
}
function profilesPage(profiles, kind) {
const installation=kind==='installation'; const list=profiles.filter(p=>p.kind===kind);
return header(installation?'Installationsprofile':'Postinstallationsprofile',installation?'Sprache, Managementnetz und explizite Systemdatenträger versioniert definieren.':'Geprüfte Module in einen nachvollziehbaren Ablauf mit festen Versionen bringen.',canAuthor()?actionButton('Profil erstellen','create-profile','plus',`data-kind="${kind}"`,true):'',`KONFIGURATION / ${installation?'INSTALLATION':'POSTINSTALLATION'}`)+
(list.length?`<div class="card space-bottom">${toolbar('Profilname oder Zielbuild suchen …')}</div><div class="cards-grid filterable">${list.map(p=>`<section class="card profile-card" ${searchAttrs(`${p.name} ${(p.target_builds||[]).join(' ')}`)}><div class="profile-top"><span class="profile-symbol${installation?'':' blue'}">${svg(installation?'layers':'workflow')}</span>${badge(p.status)}</div><h2>${esc(p.name)}</h2><p>Version ${esc(p.version)} · ${installation?'Installationskonfiguration':`${arr(p.steps).length} Konfigurationsschritte`}</p><div class="profile-meta">${(p.target_builds||[]).map(b=>`<span class="tag">PVE ${esc(b)}</span>`).join('')||'<span class="muted small-text">Keine Zielbuilds</span>'}</div><code title="${esc(p.digest||'')}">${p.digest?`SHA256 ${esc(p.digest.slice(0,25))}`:'Digest nach Veröffentlichung'}</code><div class="profile-actions"><button class="button small" data-action="view-profile" data-id="${esc(p.id)}">${svg('file')}Ansehen</button>${canAuthor()?`<button class="button small" data-action="version-profile" data-id="${esc(p.id)}">${svg('plus')}Neue Version</button>`:''}${canAdmin()&&p.status==='draft'?`<button class="button small primary" data-action="publish-profile" data-id="${esc(p.id)}">Freigeben</button>`:''}</div></section>`).join('')}</div>`:`<section class="card">${empty(installation?'Noch keine Installationsprofile':'Noch keine Postinstallationsprofile',installation?'Legen Sie zuerst ein Root-Geheimnis und die Netzwerk- und Datenträgerkonfiguration Ihres Hardwaretyps an.':'Erstellen und veröffentlichen Sie zunächst Skriptmodule. Fassen Sie diese anschließend zu einem Ablauf zusammen.',installation?'layers':'workflow',canAuthor()?actionButton('Erstes Profil erstellen','create-profile','plus',`data-kind="${kind}"`,true):'')}</section>`);
}
function modulesPage(modules) {
return header('Skriptmodule','Check, Apply und Verify: versionierte Bausteine für die Nachkonfiguration.',canAuthor()?actionButton('Aus Vorlage','module-catalog','layers')+actionButton('Modul erstellen','create-module','plus','',true):'','KONFIGURATION / SKRIPTE')+`<section class="card filterable">${toolbar('Name oder Zielbuild suchen …',[],['draft','published'])}${modules.length?table(['MODUL','VERSION','STATUS','ZIELBUILDS','WIEDERHOLUNG',''],modules.map(m=>`<tr ${searchAttrs(`${m.name} ${(m.target_builds||[]).join(' ')}`,'',m.status)}><td><div class="table-title"><span class="row-icon">${svg('code')}</span><div><strong>${esc(m.name)}</strong><small>${Number(m.timeout_seconds||300)} s Timeout</small></div></div></td><td>v${esc(m.version)}</td><td>${badge(m.status)}</td><td>${(m.target_builds||[]).map(b=>`<span class="tag">${esc(b)}</span>`).join('')}</td><td>${m.retry_safe?'Explizit erlaubt':'Manuelle Prüfung'}</td><td><div class="table-actions"><button class="button small" data-action="view-module" data-id="${esc(m.id)}">Ansehen</button>${canAuthor()?`<button class="button small" data-action="version-module" data-id="${esc(m.id)}">Neue Version</button>`:''}${canAdmin()&&m.status==='draft'?`<button class="button small primary" data-action="publish-module" data-id="${esc(m.id)}">Freigeben</button>`:''}</div></td></tr>`)):empty('Ihre Konfiguration als Bausteine','Jedes Modul definiert Zustandsprüfung, Änderung und Erfolgskontrolle. Eine Veröffentlichung erfordert einen Testnachweis.','code',canAuthor()?actionButton('Erstes Modul erstellen','create-module','plus','',true):'')}</section>`;
}
function runsPage(runs) {
return header('Installationsläufe','Installationen und Nachkonfigurationen vom ersten Kontakt bis zur Abschlussprüfung.',actionButton('Aktualisieren','refresh','refresh'),'VERWALTUNG / LÄUFE')+`<section class="card filterable">${toolbar('Host oder Lauf-ID suchen …',[],[...new Set(runs.map(r=>r.status))])}${runs.length?table(['SERVER / LAUF','STATUS','FORTSCHRITT / START',''],runRows(runs)):empty('Noch keine Installationsläufe','Starten Sie einen freigegebenen Server mit dem registrierten Installationsmedium. Der Lauf wird beim Antwortabruf automatisch angelegt.','activity','<a class="button" href="#/hosts">Zum Serverinventar →</a>')}</section>`;
}
function runPage(run) {
const steps=arr(run.steps), events=arr(run.events), logs=arr(run.logs), terminal=['succeeded','failed','cancelled','canceled','expired'].includes(run.status);
const resumeAllowed=['needs_review','waiting_retry'].includes(run.status) && !run.cancel_requested;
const actions=`<a class="button" href="#/runs">${svg('back')}Alle Läufe</a>${canOperate()&&resumeAllowed?actionButton('Wiederaufnehmen','resume-run','play',`data-id="${esc(run.id)}"`,true):''}${canOperate()&&!terminal?actionButton('Abbrechen','cancel-run','stop',`data-id="${esc(run.id)}"`)+actionButton('Lauf abgleichen','reconcile-run','shield',`data-id="${esc(run.id)}"`):''}`;
let content='';
if(state.runTab==='steps') content=steps.length?`<ol class="run-steps">${steps.map((s,i)=>`<li class="run-step"><span class="step-number">${s.status==='succeeded'?'✓':i+1}</span><div class="run-step-copy"><strong>${esc(s.name || s.step_id || s.id || `Schritt ${i+1}`)}</strong><p>Modul ${esc(s.module_name || s.module_id || '')} · Versuch ${Number(s.attempt || s.attempts || 0)}${s.exit_code!=null?` · Exit ${Number(s.exit_code)}`:''}</p>${s.required===false?'<p>Optionaler Schritt</p>':''}${s.error?`<p>${esc(s.error)}</p>`:''}${s.verification&&Object.keys(s.verification).length?`<details><summary>Pr?fergebnis${s.status==='failed'?' / Fehlerursache':''}</summary><pre class="code-block light">${esc(json(s.verification))}</pre></details>`:''}${s.checkpoint?`<details><summary>Checkpoint</summary><pre class="code-block light">${esc(json(s.checkpoint))}</pre></details>`:''}</div>${badge(s.status)}</li>`).join('')}</ol>`: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?`<div class="card-content"><pre class="code-block">${esc(logs.map(l=>typeof l==='string'?l:`${l.created_at?fmtDate(l.created_at)+' ':''}${l.step_id?'['+l.step_id+'] ':''}${l.content||l.text||l.message||json(l)}`).join('\n'))}</pre></div>`: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')+`<div class="detail-grid"><div class="stack"><section class="card"><div class="card-header"><h2>Ausführungsstatus</h2>${badge(run.status)}</div><div class="card-content"><div class="tabs" role="tablist" aria-label="Laufdetails">${[['steps','Schritte'],['events','Ereignisse'],['logs','Protokolle']].map(([id,label])=>`<button role="tab" type="button" aria-selected="${state.runTab===id}" class="tab${state.runTab===id?' active':''}" data-action="run-tab" data-tab="${id}">${label}${id==='steps'?` (${steps.length})`:''}</button>`).join('')}</div>${run.error||run.error_reason?`<div class="alert alert-danger">${esc(run.error||run.error_reason)}</div>`:''}</div>${content}</section></div><div class="stack"><section class="card"><div class="card-header"><h2>Laufdaten</h2></div><div class="card-content"><dl class="detail-list"><dt>Server</dt><dd><a href="#/hosts/${encodeURIComponent(run.host_id)}">${esc(run.host_fqdn||shortId(run.host_id))} ↗</a></dd><dt>Gestartet</dt><dd>${esc(fmtDate(run.started_at||run.created_at))}</dd><dt>Abgeschlossen</dt><dd>${run.finished_at||run.completed_at?esc(fmtDate(run.finished_at||run.completed_at)):''}</dd><dt>Letzter Heartbeat</dt><dd>${esc(fmtDate(run.last_heartbeat||run.last_contact||run.last_seen))}</dd><dt>Versionsstand</dt><dd>${esc(run.version)}</dd><dt>Manifest-Digest</dt><dd class="mono">${esc(run.manifest_digest||'Noch nicht erstellt')}</dd><dt>Antwort-Digest</dt><dd class="mono">${esc(run.answer_digest||run.answer_sha256||'')}</dd></dl></div></section><section class="card"><div class="card-header"><h2>Fixierte Konfiguration</h2>${svg('lock')}</div><div class="card-content"><p class="small-text muted">Profil- und Skriptversionen dieses Laufs bleiben nach der Reservierung unverändert.</p><pre class="code-block light">${esc(json(run.manifest || run.snapshot || run.profiles || {installation_profile_id:run.installation_profile_id,postinstall_profile_id:run.postinstall_profile_id}))}</pre></div></section></div></div>`;
}
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')+
`<div class="alert alert-info">Die ISO wird auf einer Build-Maschine mit dem Proxmox Auto Install Assistant vorbereitet. Registrieren Sie anschließend den konkreten Build mit Prüfsumme und Testnachweis.</div><div class="stack"><section class="card"><div class="card-header"><div><h2>Registrierte ISO-Medien <span class="count-label">${records.length}</span></h2><p>Freigabe ausschließlich für die dokumentierte Build-Kombination</p></div></div>${records.length?table(['MEDIUM','ZIELBUILD','ASSISTANT','TESTSTATUS',''],records.map(r=>`<tr><td><div class="table-title"><span class="row-icon">${svg('disc')}</span><div><strong>${esc(r.name)}</strong><small>${esc(shortId(r.sha256))}…</small></div></div></td><td>${esc(r.build)}</td><td>${esc(r.assistant_version)}</td><td>${badge(r.test_status)}</td><td><button class="button small" data-action="view-iso" data-id="${esc(r.id)}">Details & Buildbefehl</button></td></tr>`)):empty('Noch keine Installationsmedien','Erstellen Sie zuerst eine Bereitstellungsgruppe. Registrieren Sie danach Ihr geprüftes ISO-Medium.','disc',canAdmin()?actionButton('ISO registrieren','create-iso','plus','',true):'')}</section><section class="card groups-card"><div class="card-header"><div><h2>Bereitstellungsgruppen</h2><p>Zeitlich begrenzte Gruppentoken für den Antwortabruf</p></div></div>${groups.length?table(['GRUPPE','STANDORT','GÜLTIG BIS','STATUS'],groups.map(g=>`<tr><td><strong>${esc(g.name)}</strong><br><small class="muted mono">${esc(g.id)}</small></td><td>${esc(g.site||'')}</td><td>${esc(fmtDate(g.expires_at))}</td><td>${badge(g.revoked?'revoked':toDate(g.expires_at)<new Date()?'expired':'active')}</td></tr>`)):empty('Keine Gruppen vorhanden','Ein Gruppentoken berechtigt zum Antwortabruf für zugeordnete und freigegebene Hosts.','key',canAdmin()?actionButton('Gruppe erstellen','create-group','plus'):'')}</section></div>`;
}
function auditPage(events) {
return header('Auditprotokoll','Änderungen, Freigaben und privilegierte Zugriffe nachvollziehen.',actionButton('Aktualisieren','refresh','refresh'),'SYSTEM / AUDIT')+`<section class="card filterable">${toolbar('Aktion, Benutzer oder Objekt suchen …')}${events.length?table(['ZEITPUNKT','AKTEUR','AKTION','OBJEKT',''],events.map(e=>`<tr ${searchAttrs(`${e.action} ${e.actor||e.username||e.actor_id} ${e.object_id||e.target_id} ${e.reason||''}`)}><td>${esc(fmtDate(e.created_at||e.timestamp))}</td><td><strong>${esc(e.actor||e.username||e.actor_id||'System')}</strong></td><td>${esc(e.action)}</td><td>${esc(e.object_type||e.target_type||'')} <span class="mono">${esc(shortId(e.object_id||e.target_id))}</span></td><td><button class="button small" data-action="view-audit" data-id="${esc(e.id)}">Details</button></td></tr>`)):empty('Noch keine Auditereignisse','Änderungen und Freigaben werden mit Akteur, Zeitpunkt und Änderungsgrund aufgezeichnet.','shield')}</section>`;
}
function settingsPage(users=[], secrets=[]) {
if(!canAdmin()) return header('Einstellungen','Ihre Zugriffsrechte im Workspace.','','SYSTEM / EINSTELLUNGEN')+`<section class="card"><div class="card-header"><h2>Ihr Konto</h2></div><div class="card-content"><dl class="detail-list"><dt>Benutzername</dt><dd>${esc(state.me.username)}</dd><dt>Rolle</dt><dd>${esc(roleNames[state.me.role]||state.me.role)}</dd></dl><p class="small-text muted space-top">Benutzer und Geheimnisse werden durch Administratoren verwaltet.</p></div></section>`;
const tab=state.settingsTab;
return header('Einstellungen','Benutzerkonten und verschlüsselte Betriebsgeheimnisse verwalten.',tab==='users'?actionButton('Benutzer anlegen','create-user','plus','',true):actionButton('Geheimnis hinterlegen','create-secret','key','',true),'SYSTEM / EINSTELLUNGEN')+`<div class="tabs" role="tablist" aria-label="Einstellungen"><button type="button" role="tab" aria-selected="${tab==='users'}" class="tab${tab==='users'?' active':''}" data-action="settings-tab" data-tab="users">Benutzer & Rollen</button><button type="button" role="tab" aria-selected="${tab==='secrets'}" class="tab${tab==='secrets'?' active':''}" data-action="settings-tab" data-tab="secrets">Geheimnisse</button></div>`+(tab==='users'?`<section class="card"><div class="card-header"><h2>Benutzerkonten <span class="count-label">${users.length}</span></h2></div>${users.length?table(['BENUTZER','ROLLE','ERSTELLT'],users.map(u=>`<tr><td><div class="table-title"><span class="row-icon">${svg('users')}</span><strong>${esc(u.username)}</strong>${u.id===state.me.id?'<span class="tag">Sie</span>':''}</div></td><td>${esc(roleNames[u.role]||u.role)}</td><td>${esc(fmtDate(u.created_at))}</td></tr>`)):empty('Keine Benutzer gefunden','Legen Sie ein Benutzerkonto mit der passenden Rolle an.','users')}</section><div class="alert alert-info space-top">Leser sehen redigierte Daten. Operatoren verwalten Hosts und Läufe. Skriptautoren erstellen Entwürfe. Administratoren verwalten Freigaben, Benutzer und Geheimnisse. Entwickler haben alle Berechtigungen.</div>`:`<div class="alert alert-info">Geheimnisse werden verschlüsselt gespeichert und über ihre ID referenziert. Der gespeicherte Wert wird in der Konsole nicht erneut angezeigt.</div><section class="card"><div class="card-header"><h2>Geheimnisreferenzen <span class="count-label">${secrets.length}</span></h2></div>${secrets.length?table(['NAME','REFERENZ-ID','ERSTELLT'],secrets.map(s=>`<tr><td><div class="table-title"><span class="row-icon">${svg('key')}</span><strong>${esc(s.name)}</strong></div></td><td class="mono">${esc(s.id)}</td><td>${esc(fmtDate(s.created_at))}</td></tr>`)):empty('Noch keine Geheimnisse hinterlegt','Hinterlegen Sie beispielsweise den Root-Passwort-Hash für ein Installationsprofil.','key',actionButton('Geheimnis hinterlegen','create-secret','key','',true))}</section>`);
}
function showModal(title, content, submit=null, eyebrow='PROXMOX AIS') {
document.getElementById('modal-title').textContent=title;
document.getElementById('modal-eyebrow').textContent=eyebrow;
document.getElementById('modal-body').innerHTML=content;
modalSubmit=submit;
if(!modal.open) modal.showModal();
}
function closeModal() {modal.close();modalSubmit=null;document.getElementById('modal-body').replaceChildren();}
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=`<textarea ${attrs} class="${options.type==='json'?'code':''}"${options.rows?` rows="${Number(options.rows)}"`:''}>${esc(typeof value==='object'?json(value):value)}</textarea>`;
else if(options.type==='select') input=`<select ${attrs}>${(options.options||[]).map(o=>`<option value="${esc(o.value)}"${String(o.value)===String(value)?' selected':''}>${esc(o.label)}</option>`).join('')}</select>`;
else if(options.type==='checkbox') return `<label class="checkbox${options.full?' full':''}"><input type="checkbox" ${attrs}${value?' checked':''}><span>${esc(label)}${options.hint?`<br><small>${esc(options.hint)}</small>`:''}</span></label>`;
else input=`<input type="${esc(options.type||'text')}" ${attrs} value="${esc(value)}">`;
return `<label class="${options.full?'full':''}">${esc(label)}${input}${options.hint?`<small>${esc(options.hint)}</small>`:''}</label>`;
}
function form(fields,label='Speichern',intro='') {
return `${intro}<form id="modal-form"><div class="form-grid">${fields}</div><div class="form-error alert alert-danger" role="alert"></div><div class="form-actions"><button type="button" class="button" data-action="close-modal">Abbrechen</button><button type="submit" class="button primary">${esc(label)}</button></div></form>`;
}
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 moduleCatalog() {
const catalog=arr(await api('/modules/builtin'));
showModal('Basismodul als Entwurf übernehmen',`<div class="alert alert-info">Die Vorlagen sind Ausgangspunkte. Prüfen Sie die Parameter, tragen Sie den konkreten Zielbuild ein und dokumentieren Sie vor Veröffentlichung einen Test.</div>${catalog.map(m=>`<div class="run-step"><span class="step-number">${svg('code')}</span><div class="run-step-copy"><strong>${esc(m.name)}</strong><p>${esc(m.description)}</p></div><button type="button" class="button small" data-action="use-module-template" data-id="${esc(m.id)}">Übernehmen</button></div>`).join('')}<div class="form-actions"><button class="button" data-action="close-modal">Schließen</button></div>`,null,'MODULVORLAGEN');
state.catalog=catalog;
}
function previewContent(preview) {
const p=preview||{}, warnings=arr(p.warnings);
const network=p.resolved?.network||{};
return `${warnings.length?`<div class="alert">${warnings.map(w=>esc(typeof w==='string'?w:w.message||json(w))).join('<br>')}</div>`:''}<div class="modal-summary"><dl class="detail-list"><dt>ISO / Zielbuild</dt><dd>${esc(p.iso?.name||'')} · ${esc(p.iso?.build||'')}</dd><dt>Managementnetz</dt><dd>${esc(network.cidr||'')}<br>Gateway ${esc(network.gateway||'')} · DNS ${esc(network.dns||'')}<br><span class="mono">${esc(JSON.stringify(network.filter||{}))}</span></dd><dt>Zieldatenträger</dt><dd><pre class="code-block light">${esc(json(p.disks || p.resolved?.disk_setup || p.resolved?.['disk-setup'] || {}))}</pre></dd><dt>Profilversionen</dt><dd>${arr(p.profiles).map(profile=>`${esc(profile.name)} · v${esc(profile.version)}`).join('<br>')||''}</dd><dt>Skriptversionen</dt><dd>${arr(p.steps).map(step=>`${esc(step.name||step.id)} · v${esc(step.module_version||'')}`).join('<br>')||''}</dd><dt>Konfigurationsdigest</dt><dd class="mono break">${esc(p.digest||'')}</dd></dl></div><details><summary>Aufgelöste Konfiguration & Herkunft</summary><pre class="code-block light">${esc(json({resolved:p.resolved,provenance:p.provenance,profiles:p.profiles,steps:p.steps}))}</pre></details>`;
}
async function approveHost(id, previewOnly=false) {
const [host, preview]=await Promise.all([api(`/hosts/${encodeURIComponent(id)}`),api(`/hosts/${encodeURIComponent(id)}/preview`)]);
if(previewOnly){showModal('Aufgelöste Konfiguration',previewContent(preview)+`<div class="form-actions"><button class="button" data-action="close-modal">Schließen</button></div>`,null,host.fqdn);return;}
const intro=`<div class="alert alert-danger">Die Installation überschreibt die ausgewählten Systemdatenträger. Prüfen Sie Host, Netzwerk, Zielbuild und Datenträger vor der Freigabe.</div><div class="modal-summary"><dl class="detail-list"><dt>Server</dt><dd><strong>${esc(host.fqdn)}</strong></dd><dt>Management-IP</dt><dd>${esc(host.management_ip)}</dd><dt>Standort</dt><dd>${esc(host.site)}</dd></dl></div>${previewContent(preview)}<hr class="form-divider">`;
const fields=field('confirmation','Hostnamen zur Bestätigung eingeben','',{required:true,full:true,placeholder:host.fqdn})+field('valid_minutes','Freigabefenster in Minuten',30,{type:'number',required:true,min:5,max:240})+field('reason','Freigabegrund','',{required:true,placeholder:'Geplante Erstinstallation'})+field('disks_confirmed','Ich habe die Zieldatenträger geprüft und bestätige, dass diese überschrieben werden dürfen.',false,{type:'checkbox',required:true,full:true});
showModal('Installation freigeben',form(fields,'Verbindlich freigeben',intro),async data=>{
if(data.get('confirmation')!==host.fqdn)throw new Error('Der eingegebene Hostname stimmt nicht mit dem Server überein.');
await api(`/hosts/${encodeURIComponent(id)}/approve-install`,{method:'POST',body:{expected_version:host.version,valid_minutes:Number(data.get('valid_minutes')),confirmation:data.get('confirmation'),disks_confirmed:data.get('disks_confirmed')==='on',reason:data.get('reason')}});
closeModal();toast('Installationsfreigabe erteilt. Der Server kann mit der zugewiesenen ISO gestartet werden.');await refresh();
},'ZEITLICH BEGRENZTE INSTALLATIONSFREIGABE');
}
const installationExample = {
global:{keyboard:'de',country:'de',timezone:'Europe/Berlin',mailto:'admin@example.net'},
network:{source:'from-answer',gateway:'192.0.2.1',dns:'192.0.2.53',filter:{ID_NET_NAME_MAC:'enx001122334455'}},
disk_setup:{filesystem:'ext4',filter:{ID_SERIAL:'EXPLICIT_DISK_SERIAL'},expected_count:1,expected_serials:['EXPLICIT_DISK_SERIAL'],inventory_evidence:'Referenz zur geprüften Hardwareinventarisierung'},
root_secret_id:'ID_DES_ROOT_PASSWORT_HASHES'
};
async function profileForm(kind, existing=null) {
const p=existing||{}, install=kind==='installation';
let info='';
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=>{
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 publishObject(type, id) {
const item=arr(state.data).find(x=>x.id===id) || await api(`/${type}/${encodeURIComponent(id)}`);
const intro=`<div class="alert alert-info">${esc(item.name)} · Version ${esc(item.version)}<br>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.</div>`;
showModal(type==='profiles'?'Profil veröffentlichen':'Modul veröffentlichen',form(field('test_evidence','Praktischer Testnachweis','',{type:'textarea',required:true,full:true,placeholder:'Testhost, Build, Datum, Ergebnisse und Referenz zum Prüfprotokoll'})+field('reason','Änderungsgrund','',{type:'textarea',required:true,full:true}),'Veröffentlichen',intro),async data=>{
await api(`/${type}/${encodeURIComponent(id)}/publish`,{method:'POST',body:{test_evidence:data.get('test_evidence'),reason:data.get('reason')}});closeModal();toast('Version veröffentlicht.');await refresh();
},'VERÖFFENTLICHUNG');
}
function inspectProfile(p) {
showModal(p.name,`<div class="modal-summary"><dl class="detail-list"><dt>Version</dt><dd>v${esc(p.version)} ${badge(p.status)}</dd><dt>Zielbuilds</dt><dd>${esc((p.target_builds||[]).join(', ')||'')}</dd><dt>Profil-ID</dt><dd class="mono">${esc(p.id)}</dd><dt>Digest</dt><dd class="mono">${esc(p.digest||'Noch nicht veröffentlicht')}</dd></dl></div><pre class="code-block light">${esc(json({values:p.values,steps:p.steps}))}</pre><div class="form-actions">${canAuthor()?`<button class="button" data-action="version-profile" data-id="${esc(p.id)}">Neue Version</button>`:''}<button class="button" data-action="close-modal">Schließen</button></div>`,null,'PROFILDETAILS');
}
function inspectModule(m) {
showModal(m.name,`<div class="modal-summary"><dl class="detail-list"><dt>Version / Status</dt><dd>v${esc(m.version)} ${badge(m.status)}</dd><dt>Modul-ID</dt><dd class="mono">${esc(m.id)}</dd><dt>Zielbuilds</dt><dd>${esc((m.target_builds||[]).join(', '))}</dd><dt>Digest</dt><dd class="mono">${esc(m.digest||'Noch nicht veröffentlicht')}</dd><dt>Testnachweis</dt><dd>${esc(m.test_evidence||'Noch nicht hinterlegt')}</dd></dl></div>${m.source?`<pre class="code-block">${esc(m.source)}</pre>`:'<div class="alert alert-info">Der Quelltext ist für Skriptautoren und Administratoren sichtbar.</div>'}<details><summary>Parameterschema & Abhängigkeiten</summary><pre class="code-block light">${esc(json({parameters_schema:m.parameters_schema,dependencies:m.dependencies,retry_safe:m.retry_safe,timeout_seconds:m.timeout_seconds}))}</pre></details><div class="form-actions"><button class="button" data-action="close-modal">Schließen</button></div>`,null,'MODULDETAILS');
}
async function isoForm() {
const groups=arr(await api('/groups'));
const fields=field('name','Medienname','',{required:true,placeholder:'Berlin · Proxmox VE'})+field('build','Exakter Proxmox ISO-Build','',{required:true,placeholder:'z. B. 9.1-1'})+field('sha256','SHA256-Prüfsumme des ISO-Mediums','',{required:true,full:true,placeholder:'64 hexadezimale Zeichen'})+field('assistant_version','Version des Auto Install Assistant','',{required:true,placeholder:'Exakte Paketversion'})+field('group_id','Bereitstellungsgruppe','',{required:true,type:'select',options:selectObjects(groups.filter(g=>!g.revoked))})+field('fingerprint','SHA256-Zertifikatsfingerprint','',{required:true,full:true,placeholder:'Fingerprint des HTTPS-Zertifikats des Antwortdienstes'})+field('test_status','Prüfstatus','draft',{type:'select',options:[{value:'draft',label:'Entwurf noch nicht freigegeben'},{value:'passed',label:'Geprüft Nachweis liegt vor'}]})+field('native_token_support','Native Unterstützung von --answer-auth-token ist nachgewiesen.',false,{type:'checkbox',required:true,full:true})+field('test_evidence','Kompatibilitätsnachweis','',{type:'textarea',full:true,placeholder:'Antwortschema, Token-Header, First Boot, Startnetz und Bootverfahren: Testhost, Datum und Prüfprotokoll.'});
showModal('ISO-Medium registrieren',form(fields,'Medium registrieren',groups.length?'':'<div class="alert">Erstellen Sie zuerst eine Bereitstellungsgruppe im Bereich Installationsmedien.</div>'),async data=>{
await api('/iso-records',{method:'POST',body:{name:data.get('name'),build:data.get('build'),sha256:data.get('sha256'),assistant_version:data.get('assistant_version'),group_id:data.get('group_id'),fingerprint:data.get('fingerprint'),test_status:data.get('test_status'),native_token_support:data.get('native_token_support')==='on',test_evidence:data.get('test_evidence')}});closeModal();toast('ISO-Medium registriert.');await refresh();
},'INSTALLATIONSMEDIUM');
}
function buildCommand(iso, token='<gruppenname>:<secret>') {
if(iso.command)return iso.command;
const shellQuote = value => "'"+String(value).replace(/'/g,"'\\''")+"'";
const base=(iso.answer_url||`${window.location.origin}/installer/v1/answer`);
return `proxmox-auto-install-assistant prepare-iso SOURCE.iso \\\n --fetch-from http \\\n --url ${shellQuote(base)} \\\n --cert-fingerprint ${shellQuote(iso.fingerprint||'<SHA256-FINGERPRINT>')} \\\n --answer-auth-token ${shellQuote(token)}`;
}
function inspectISO(iso) {
showModal(iso.name,`<div class="modal-summary"><dl class="detail-list"><dt>ISO-ID</dt><dd class="mono">${esc(iso.id)}</dd><dt>Zielbuild / Status</dt><dd>${esc(iso.build)} ${badge(iso.test_status)}</dd><dt>Assistant-Version</dt><dd>${esc(iso.assistant_version)}</dd><dt>SHA256</dt><dd class="mono">${esc(iso.sha256)}</dd><dt>Fingerprint</dt><dd class="mono">${esc(iso.fingerprint)}</dd><dt>Gruppe</dt><dd class="mono">${esc(iso.group_id)}</dd><dt>Testnachweis</dt><dd>${esc(iso.test_evidence||'Noch nicht hinterlegt')}</dd></dl></div><h3 class="section-label">Vorbereiteter Buildbefehl</h3><p class="small-text muted">SOURCE.iso und den Token-Platzhalter durch Ihre Eingaben ersetzen. Die URL muss aus dem Provisionierungsnetz per HTTPS erreichbar sein.</p><textarea class="code" id="copy-value" readonly aria-label="Buildbefehl">${esc(buildCommand(iso))}</textarea><div class="form-actions"><button class="button" data-action="copy-value">${svg('copy')}Befehl kopieren</button><button class="button" data-action="close-modal">Schließen</button></div>`,null,'ISO-DETAILS');
}
function groupForm() {
showModal('Bereitstellungsgruppe erstellen',form(field('name','Gruppenname','',{required:true,placeholder:'berlin-rack-a'})+field('site','Standort','',{required:true,placeholder:'Rechenzentrum Berlin'})+field('valid_hours','Gültigkeit in Stunden',24,{type:'number',required:true,min:1,max:8760,full:true}),'Token erstellen'),async data=>{
const result=await api('/groups',{method:'POST',body:{name:data.get('name'),site:data.get('site'),valid_hours:Number(data.get('valid_hours'))}});
showModal('Gruppentoken erstellt',`<div class="alert">Der vollständige Token wird nur jetzt angezeigt. Speichern Sie ihn für die ISO-Vorbereitung. Er ist auf die Bereitstellungsgruppe und deren Gültigkeitsfenster beschränkt.</div><dl class="detail-list"><dt>Gruppe</dt><dd>${esc(result.name)}</dd><dt>Referenz-ID</dt><dd class="mono">${esc(result.id)}</dd><dt>Gültig bis</dt><dd>${esc(fmtDate(result.expires_at))}</dd></dl><h3 class="section-label">Token für --answer-auth-token</h3><textarea id="copy-value" class="code token-value" readonly aria-label="Einmalig angezeigter Gruppentoken">${esc(result.token)}</textarea>${result.command?`<h3 class="section-label">Buildbefehl</h3><pre class="code-block light">${esc(result.command)}</pre>`:''}<div class="form-actions"><button class="button" data-action="copy-value">${svg('copy')}Token kopieren</button><button class="button primary" data-action="close-modal">Token gesichert</button></div>`,null,'EINMALIGE TOKENAUSGABE');
await refresh();
},'ISO-ZUGRIFF');
}
function userForm() {
showModal('Benutzer anlegen',form(field('username','Benutzername','',{required:true,autocomplete:'off'})+field('role','Rolle','reader',{type:'select',options:Object.entries(roleNames).map(([value,label])=>({value,label}))})+field('password','Initiales Passwort','',{required:true,type:'password',full:true,autocomplete:'new-password',hint:'Mindestens 12 Zeichen verwenden.'}),'Benutzer anlegen'),async data=>{
await api('/users',{method:'POST',body:{username:data.get('username'),password:data.get('password'),role:data.get('role')}});closeModal();toast('Benutzerkonto angelegt.');await refresh();
},'BENUTZER & ROLLEN');
}
function secretForm() {
showModal('Geheimnis hinterlegen',form(field('name','Bezeichnung','',{required:true,full:true,placeholder:'Root-Hash · PVE Berlin'})+field('value','Geheimniswert','',{required:true,type:'password',full:true,autocomplete:'new-password',hint:'Für den Root-Zugang einen von Ihrem Zielbuild unterstützten Passwort-Hash verwenden.'}),'Verschlüsselt speichern','<div class="alert alert-info">Verwenden Sie die nach dem Speichern angezeigte Referenz-ID im Profil. Der Geheimniswert wird nicht erneut ausgegeben.</div>'),async data=>{
const result=await api('/secrets',{method:'POST',body:{name:data.get('name'),value:data.get('value')}});closeModal();toast('Geheimnis verschlüsselt gespeichert.');state.settingsTab='secrets';await refresh();
if(result?.id)showModal('Geheimnis gespeichert',`<p class="small-text muted">Referenz für <code>root_secret_id</code> oder einen Schritt:</p><input id="copy-value" value="${esc(result.id)}" readonly aria-label="Geheimnisreferenz"><div class="form-actions"><button class="button" data-action="copy-value">${svg('copy')}ID kopieren</button><button class="button primary" data-action="close-modal">Fertig</button></div>`);
},'BETRIEBSGEHEIMNIS');
}
async function runAction(id, action) {
const run=await api(`/runs/${encodeURIComponent(id)}`), resume=action==='resume';
showModal(resume?'Lauf wiederaufnehmen':'Lauf abbrechen',form(field('reason',resume?'Begründung und durchgeführte Prüfung':'Abbruchgrund','',{type:'textarea',full:true,required:true}),resume?'Wiederaufnahme anfordern':'Abbruch anfordern',`<div class="alert${resume?' alert-info':''}">${resume?'Der Runner prüft gespeicherte Checkpoints und Modulzustände vor weiteren Änderungen. Unklare, nicht wiederholbare Schritte benötigen eine manuelle Klärung.':'Der Runner stoppt am nächsten sicheren Übergang. Eine bereits gestartete Datenträgeroperation oder ein Paketmanager wird dadurch nicht rückgängig gemacht.'}</div>`),async data=>{
await api(`/runs/${encodeURIComponent(id)}/${action}`,{method:'POST',body:{reason:data.get('reason'),expected_version:run.version}});closeModal();toast(resume?'Wiederaufnahme angefordert.':'Abbruch angefordert.');await refresh();
},`LAUF ${shortId(id)}`);
}
async function reconcileRun(id) {
const run=await api(`/runs/${encodeURIComponent(id)}`);
const host=await api(`/hosts/${encodeURIComponent(run.host_id)}`);
const fields=field('reason','Durchgeführte Prüfung und Abgleichgrund','',{type:'textarea',full:true,required:true})+field('confirmation','Hostnamen zur Bestätigung eingeben','',{required:true,full:true,placeholder:host.fqdn})+field('execution_stopped','Ich habe am Host geprüft, dass Installer und Runner gestoppt sind.',false,{type:'checkbox',full:true,required:true});
showModal('Lauf manuell abgleichen',form(fields,'Lauf als abgebrochen schließen',`<div class="alert">Dieser Abgleich schließt einen aufgegebenen oder nach Wiederherstellung unklaren Lauf. Prüfen Sie zuvor direkt am Host, dass keine Ausführung mehr stattfindet. Bereits erteilte Laufberechtigungen und das Antwortfenster müssen abgelaufen sein. Für eine neue Installation ist eine neue Freigabe erforderlich.</div>`),async data=>{
if(data.get('confirmation')!==host.fqdn)throw new Error('Der eingegebene Hostname stimmt nicht mit dem Server überein.');
await api(`/runs/${encodeURIComponent(id)}/reconcile`,{method:'POST',body:{expected_version:run.version,reason:data.get('reason'),confirmation:data.get('confirmation'),execution_stopped:data.get('execution_stopped')==='on'}});closeModal();toast('Lauf abgeglichen und als abgebrochen geschlossen.');await refresh();
},host.fqdn);
}
async function renderRoute(showLoading=true) {
const route=window.location.hash.replace(/^#\/?/,'').split('/');
const pages=['dashboard','hosts','installation','postinstall','modules','runs','media','audit','settings'];
state.page=pages.includes(route[0])?route[0]:'dashboard';state.id=route[1]?decodeURIComponent(route[1]):null;
const version=++state.routeVersion;
const labels={dashboard:'Übersicht',hosts:'Serverinventar',installation:'Installationsprofile',postinstall:'Postinstallation',modules:'Skriptmodule',runs:'Installationsläufe',media:'Installationsmedien',audit:'Auditprotokoll',settings:'Einstellungen'};
document.getElementById('breadcrumb').textContent=labels[state.page];document.title=`${labels[state.page]} · Proxmox AIS`;
document.querySelectorAll('[data-nav]').forEach(a=>{a.classList.toggle('active',a.dataset.nav===state.page);if(a.dataset.nav===state.page)a.setAttribute('aria-current','page');else a.removeAttribute('aria-current');});
if(showLoading)main.innerHTML='<div class="loading-screen"><span class="spinner"></span> Daten werden geladen …</div>';
try {
let output, data;
if(state.page==='dashboard'){data=await api('/dashboard');output=dashboard(data);}
if(state.page==='hosts'){if(state.id){data=await api(`/hosts/${encodeURIComponent(state.id)}`);output=hostPage(data);}else{const results=await Promise.all([api('/hosts'),api('/discoveries')]);data={hosts:arr(results[0]),discoveries:arr(results[1])};output=hostsPage(data.hosts,data.discoveries);}}
if(['installation','postinstall'].includes(state.page)){data=arr(await api('/profiles'));output=profilesPage(data,state.page);}
if(state.page==='modules'){data=arr(await api('/modules'));output=modulesPage(data);}
if(state.page==='runs'){data=await api(state.id?`/runs/${encodeURIComponent(state.id)}`:'/runs');output=state.id?runPage(data):runsPage(arr(data));}
if(state.page==='media'){const result=await Promise.all([api('/iso-records'),(canOperate()||canAuthor())?api('/groups'):Promise.resolve([])]);data={records:arr(result[0]),groups:arr(result[1])};output=mediaPage(data.records,data.groups);}
if(state.page==='audit'){data=arr(await api('/audit'));output=auditPage(data);}
if(state.page==='settings'){data=canAdmin()?await Promise.all([api('/users'),api('/secrets')]):[[],[]];output=settingsPage(arr(data[0]),arr(data[1]));}
if(version!==state.routeVersion)return;
state.data=data; main.innerHTML=output;
if(state.page==='media'&&!canOperate()&&!canAuthor())main.querySelector('.groups-card')?.remove();
applyFilters();
document.getElementById('connection').className='connection';document.getElementById('connection').innerHTML='<span class="status-dot"></span> Verbunden';
}catch(error){
if(version!==state.routeVersion)return;
document.getElementById('connection').className='connection disconnected';document.getElementById('connection').innerHTML='<span class="status-dot"></span> Abruf fehlgeschlagen';
if(showLoading)main.innerHTML=header('Daten konnten nicht geladen werden','Prüfen Sie Ihre Verbindung und Zugriffsrechte.',actionButton('Erneut versuchen','refresh','refresh'))+`<div class="alert alert-danger" role="alert">${esc(error.message)}</div>`;
else throw error;
}
}
async function refresh() {
if(state.refreshing)return;
state.refreshing=true;
const filters=['list-search','site-filter','status-filter'].map(id=>[id,document.getElementById(id)?.value]);
try{await renderRoute(false);for(const [id,value]of filters){const el=document.getElementById(id);if(el&&value!==undefined)el.value=value;}applyFilters();}finally{state.refreshing=false;}
}
async function handleAction(button) {
const action=button.dataset.action,id=button.dataset.id;
if(action==='close-modal'){closeModal();return;}
if(action==='menu'){const open=document.getElementById('sidebar').classList.toggle('open');button.setAttribute('aria-expanded',String(open));return;}
if(action==='logout'){await api('/auth/logout',{method:'POST'});window.location.href='/login';return;}
if(action==='refresh'){await refresh();return;}
if(action==='create-host'){await hostForm();return;}
if(action==='import-hosts'){hostImportForm();return;}
if(action==='assign-discovery'){await hostForm(null,state.data.discoveries.find(d=>d.id===id));return;}
if(action==='edit-host'){await hostForm(await api(`/hosts/${encodeURIComponent(id)}`));return;}
if(action==='approve-host'||action==='preview-host'){await approveHost(id,action==='preview-host');return;}
if(action==='toggle-host'){
const host=await api(`/hosts/${encodeURIComponent(id)}`);
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==='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;}
if(action==='create-module'){moduleForm();return;}
if(action==='module-catalog'){await moduleCatalog();return;}
if(action==='use-module-template'){const selected=state.catalog.find(m=>m.id===id);moduleForm({...selected,dependencies:selected.dependencies.map(name=>state.catalog.find(m=>m.id===name)?.name||name),version:0});return;}
if(action==='version-module'||action==='view-module'){const m=arr(state.data).find(x=>x.id===id)||await api(`/modules/${encodeURIComponent(id)}`);if(action==='version-module')moduleForm(m);else inspectModule(m);return;}
if(action==='publish-module'){await publishObject('modules',id);return;}
if(action==='create-iso'){await isoForm();return;}
if(action==='view-iso'){inspectISO(state.data.records.find(x=>x.id===id));return;}
if(action==='create-group'){groupForm();return;}
if(action==='create-user'){userForm();return;}
if(action==='create-secret'){secretForm();return;}
if(action==='settings-tab'){state.settingsTab=button.dataset.tab;await renderRoute(false);return;}
if(action==='run-tab'){state.runTab=button.dataset.tab;main.innerHTML=runPage(state.data);return;}
if(action==='resume-run'||action==='cancel-run'){await runAction(id,action==='resume-run'?'resume':'cancel');return;}
if(action==='reconcile-run'){await reconcileRun(id);return;}
if(action==='copy-value'){
const el=document.getElementById('copy-value');
try{await navigator.clipboard.writeText(el.value);toast('In die Zwischenablage kopiert.');}catch{el.focus();el.select();toast('Text markiert. Mit Strg+C kopieren.');}return;
}
if(action==='view-audit'){const e=arr(state.data).find(x=>String(x.id)===id);showModal('Auditereignis',`<pre class="code-block light">${esc(json(e))}</pre><div class="form-actions"><button class="button" data-action="close-modal">Schließen</button></div>`,null,e?.action||'AUDIT');}
}
document.addEventListener('click',async event=>{
const button=event.target.closest('[data-action]');
if(button){event.preventDefault();if(button.disabled)return;button.disabled=true;try{await handleAction(button);}catch(error){toast(error.message,true);}finally{button.disabled=false;}}
if(event.target.closest('[data-nav]')){document.getElementById('sidebar').classList.remove('open');document.querySelector('[data-action="menu"]').setAttribute('aria-expanded','false');}
});
document.addEventListener('input',event=>{if(event.target.id==='list-search')applyFilters();});
document.addEventListener('change',event=>{if(['site-filter','status-filter'].includes(event.target.id))applyFilters();});
document.addEventListener('submit',async event=>{
if(event.target.id!=='modal-form')return;
event.preventDefault();if(!modalSubmit)return;
const formEl=event.target, submit=formEl.querySelector('[type="submit"]'),errorEl=formEl.querySelector('.form-error');
submit.disabled=true;errorEl.textContent='';
try{await modalSubmit(new FormData(formEl));}catch(error){errorEl.textContent=error.message;errorEl.scrollIntoView({block:'nearest'});}finally{submit.disabled=false;}
});
modal.addEventListener('cancel',()=>{modalSubmit=null;document.getElementById('modal-body').replaceChildren();});
window.addEventListener('hashchange',()=>{renderRoute();});
async function init() {
document.querySelectorAll('[data-icon]').forEach(el=>el.insertAdjacentHTML('afterbegin',svg(el.dataset.icon)));
try{
state.me=await api('/me');
document.getElementById('username').textContent=state.me.username;
document.getElementById('user-role').textContent=roleNames[state.me.role]||state.me.role;
document.getElementById('avatar').textContent=state.me.username.slice(0,2).toUpperCase();
await renderRoute();
setInterval(async()=>{
if(document.hidden||modal.open||state.refreshing||['INPUT','SELECT','TEXTAREA'].includes(document.activeElement?.tagName)||!['dashboard','runs','hosts'].includes(state.page))return;
try{await refresh();}catch{/* The connection indicator reports refresh failures. */}
},15000);
}catch(error){main.innerHTML=`<div class="alert alert-danger" role="alert">${esc(error.message)}</div>`;}
}
init();
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
<p class="affiliation-notice">Proxmox AIS ist ein unabhängiges Projekt und steht in keiner Verbindung zur Proxmox Server Solutions GmbH oder den Entwicklern von Proxmox Virtual Environment.</p>
+45
View File
@@ -0,0 +1,45 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>Proxmox AIS · Provisionierung</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/app.js" defer></script>
</head>
<body>
<div class="app-shell">
<aside class="sidebar" id="sidebar" aria-label="Hauptnavigation">
<a class="brand" href="#/dashboard"><span class="brand-mark">a<span>i</span>s</span><span>PROXMOX<span class="brand-sub">PROVISIONING CONSOLE</span></span></a>
<div class="workspace-label"><span class="workspace-dot"></span> Infrastruktur <span class="workspace-version">v1</span></div>
<nav class="nav-list">
<span class="nav-caption">VERWALTUNG</span>
<a href="#/dashboard" data-nav="dashboard" data-icon="dashboard">Übersicht</a>
<a href="#/hosts" data-nav="hosts" data-icon="server">Serverinventar</a>
<a href="#/runs" data-nav="runs" data-icon="activity">Installationsläufe</a>
<span class="nav-caption">KONFIGURATION</span>
<a href="#/installation" data-nav="installation" data-icon="layers">Installationsprofile</a>
<a href="#/postinstall" data-nav="postinstall" data-icon="workflow">Postinstallation</a>
<a href="#/modules" data-nav="modules" data-icon="code">Skriptmodule</a>
<a href="#/media" data-nav="media" data-icon="disc">Installationsmedien</a>
<span class="nav-caption">SYSTEM</span>
<a href="#/audit" data-nav="audit" data-icon="shield">Auditprotokoll</a>
<a href="#/settings" data-nav="settings" data-icon="settings">Einstellungen</a>
</nav>
<div class="sidebar-bottom"><div class="sidebar-status"><span class="status-dot"></span> Zentrale Provisionierung</div><p>Automatisch installieren.<br>Kontrolliert konfigurieren.</p><a href="/openapi.json" target="_blank" rel="noopener">OpenAPI-Spezifikation <span aria-hidden="true"></span></a></div>
</aside>
<div class="main-shell">
<header class="topbar">
<button class="icon-button mobile-menu" type="button" data-action="menu" aria-label="Navigation öffnen" aria-controls="sidebar" aria-expanded="false"></button>
<div class="breadcrumb">Workspace <span>/</span> <strong id="breadcrumb">Übersicht</strong></div>
<div class="topbar-right"><span class="connection" id="connection"><span class="status-dot"></span> Verbinden …</span><span class="topbar-divider"></span><div class="avatar" id="avatar"></div><div class="user-info"><strong id="username">{{ user.username }}</strong><span id="user-role"></span></div><button class="icon-button" type="button" data-action="logout" aria-label="Abmelden" title="Abmelden"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M9 4H4v16h5M14 8l4 4-4 4M8 12h10"/></svg></button></div>
</header>
<main id="main" tabindex="-1"><div class="loading-screen"><span class="spinner"></span> Konsole wird geladen …</div></main>
<footer class="main-footer"><span>Proxmox AIS <span class="muted">/</span> Automated Installation Service</span><span>Versionierte Konfiguration. Nachvollziehbare Ausführung.</span>{% include "affiliation_notice.html" %}</footer>
</div>
</div>
<dialog id="modal" aria-labelledby="modal-title"><div class="modal-header"><div><span class="eyebrow" id="modal-eyebrow">PROXMOX AIS</span><h2 id="modal-title"></h2></div><button class="icon-button" type="button" data-action="close-modal" aria-label="Dialog schließen">×</button></div><div id="modal-body"></div></dialog>
<div id="toast-region" class="toast-region" role="status" aria-live="polite"></div>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>Anmelden · Proxmox AIS</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-page">
<main class="login-layout">
<section class="login-story"><a class="brand" href="/"><span class="brand-mark">a<span>i</span>s</span><span>PROXMOX<span class="brand-sub">PROVISIONING CONSOLE</span></span></a><div class="login-intro"><span class="eyebrow">AUTOMATED INSTALLATION SERVICE</span><h1>Infrastruktur.<br>Mit System.</h1><p>Von der ersten Installation bis zur geprüften Konfiguration. Ihre Proxmox-Server, zentral verwaltet.</p><div class="login-flow"><span>01 <strong>Vorbereiten</strong></span><span>02 <strong>Freigeben</strong></span><span>03 <strong>Provisionieren</strong></span></div></div><div class="login-footnote"><span class="status-dot"></span> Kontrollierte Abläufe · Fixierte Versionen · Klare Zustände</div></section>
<section class="login-form-panel"><form class="login-form" method="post" action="/auth/login"><div class="login-emblem"></div><span class="eyebrow">WILLKOMMEN ZURÜCK</span><h2>An der Konsole anmelden</h2><p class="muted">Verwenden Sie Ihr lokales Benutzerkonto.</p>{% if error %}<div class="alert alert-danger" role="alert">{{ error }}</div>{% endif %}<label>Benutzername<input name="username" autocomplete="username" required autofocus placeholder="Benutzername"></label><label>Passwort<input name="password" type="password" autocomplete="current-password" required placeholder="Ihr Passwort"></label><button class="button primary login-submit" type="submit">Anmelden <span aria-hidden="true"></span></button><p class="login-help">Konten und Zugriffsrechte werden durch Ihren Administrator verwaltet.</p></form>{% include "affiliation_notice.html" %}<div class="login-copyright">Proxmox AIS <span>Automated Installation Service</span></div></section>
</main>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
[build-system]
requires = ["setuptools==84.0.0", "wheel==0.48.0"]
build-backend = "setuptools.build_meta"
[project]
name = "proxmox-ais-server"
version = "0.1.0"
description = "Controlled Proxmox automated installation and resumable post-installation"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastapi==0.141.1",
"pydantic==2.13.5",
"uvicorn==0.52.4",
"jinja2==3.1.6",
"python-multipart==0.0.32",
"cryptography==50.0.1",
"tomli-w==1.2.0",
"jsonschema==4.26.0",
"tzdata==2026.4",
]
[project.optional-dependencies]
dev = ["pytest==9.1.1", "httpx==0.28.1"]
[project.scripts]
proxmox-ais = "provisioner.cli:main"
[tool.setuptools.packages.find]
include = ["provisioner*"]
[tool.setuptools.package-data]
provisioner = ["templates/*.html", "static/*", "builtin_modules/*.sh", "builtin_modules/*.json"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra"
+100
View File
@@ -0,0 +1,100 @@
"""Standalone container smoke: real TLS, login, persistence and restart.
Run inside the application image. All data lives in a temporary directory;
no installer, runner or provisioning module is executed.
"""
from http.cookiejar import CookieJar
import json
import os
from pathlib import Path
import secrets
import socket
import ssl
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from provisioner import cli
from provisioner.config import Settings
def main():
assert os.getuid() == 10001, "Container must use its unprivileged service account"
with tempfile.TemporaryDirectory(prefix="ais-smoke-") as directory:
root = Path(directory)
with socket.socket() as sock:
sock.bind(("127.0.0.1",0))
port = sock.getsockname()[1]
base = f"https://localhost:{port}"
settings = Settings(data_dir=root / "data",master_key_file=root / "keys" / "master.key",public_url=base)
password = secrets.token_urlsafe(24)
cli.getpass.getpass = lambda _:password
cli.initialize(settings,"smoke-admin")
certificate,private = root / "tls.crt",root / "tls.key"
subprocess.run(["openssl","req","-x509","-newkey","ed25519","-nodes","-keyout",str(private),"-out",str(certificate),"-days","1","-subj","/CN=localhost","-addext","subjectAltName=DNS:localhost"],check=True,capture_output=True)
environment = {**os.environ,"DATA_DIR":str(settings.data_dir),"MASTER_KEY_FILE":str(settings.master_key_file),"PUBLIC_URL":base,"SECURE_COOKIES":"true","TESTING":"false"}
context = ssl.create_default_context(cafile=str(certificate))
opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=context),urllib.request.HTTPCookieProcessor(CookieJar()))
process = None
def start():
child = subprocess.Popen([sys.executable,"-m","provisioner.cli","serve","--host","127.0.0.1","--port",str(port),"--tls-cert",str(certificate),"--tls-key",str(private)],env=environment,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
try:
for _ in range(60):
if child.poll() is not None:
raise AssertionError("Service exited during startup")
try:
with opener.open(base+"/health/ready",timeout=2) as response:
assert json.load(response)["status"] == "ready"
return child
except urllib.error.URLError:
time.sleep(0.1)
raise AssertionError("Service did not become ready")
except BaseException:
child.terminate()
child.wait(timeout=15)
raise
def request(path,payload=None,csrf=None):
headers = {}
body = None
if payload is not None:
body = json.dumps(payload).encode()
headers["Content-Type"] = "application/json"
if csrf:
headers["X-CSRF-Token"] = csrf
return opener.open(urllib.request.Request(base+path,data=body,headers=headers),timeout=5)
try:
process = start()
try:
urllib.request.urlopen(base+"/health/ready",timeout=3)
except urllib.error.URLError as error:
assert isinstance(error.reason,ssl.SSLCertVerificationError),repr(error)
else:
raise AssertionError("An untrusted TLS certificate was accepted")
form = urllib.parse.urlencode({"username":"smoke-admin","password":password}).encode()
with opener.open(urllib.request.Request(base+"/auth/login",data=form,headers={"Origin":base}),timeout=5) as response:
assert response.status == 200
assert b"/static/app.js" in response.read()
with request("/api/v1/me") as response:
csrf = json.load(response)["csrf_token"]
with request("/api/v1/hosts",{"fqdn":"smoke.example.net","site":"isolated-test","management_ip":"192.0.2.11/24","identities":[{"kind":"serial","value":"SMOKE-ONLY"}]},csrf) as response:
host_id = json.load(response)["id"]
with opener.open(base+"/static/app.js",timeout=5) as response:
assert response.status == 200 and len(response.read())>1000
process.terminate()
process.wait(timeout=15)
process = start()
with request("/api/v1/hosts") as response:
assert any(host["id"] == host_id for host in json.load(response))
print(json.dumps({"uid":os.getuid(),"checks":["TLS trusted certificate","untrusted certificate rejected","browser login and CSRF","static assets packaged","host persists across service restart","session persists across service restart"],"status":"passed"}))
finally:
if process and process.poll() is None:
process.terminate()
process.wait(timeout=15)
if __name__ == "__main__":
main()
+392
View File
@@ -0,0 +1,392 @@
"""Acceptance tests exercise authorization and installation state through HTTP."""
import base64
from concurrent.futures import ThreadPoolExecutor
from copy import deepcopy
from dataclasses import replace
import hashlib
import json
from pathlib import Path
import re
import time
import tomllib
from uuid import uuid4
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from fastapi.testclient import TestClient
import pytest
from provisioner.app import create_app
from provisioner.cli import ServiceLock, backup, initialize, restore
from provisioner.config import Settings
from provisioner.db import Database
from provisioner.security import Security
PASSWORD = "Ais-test-admin-only-452!"
ROOT_HASH = "$6$testsalt$" + "A" * 86
HOST_UUID = "d2e59b03-13cf-4ac9-a390-78c55f6a36d3"
HOST_MAC = "02:00:00:00:00:01"
SOURCE = '#!/bin/bash\nset -euo pipefail\ncase "$1" in\ncheck|apply|verify) exit 0;;\n*) exit 64;;\nesac\n'
def login(client, username="admin", password=PASSWORD):
response = client.post("/auth/login", data={"username": username, "password": password}, follow_redirects=False)
assert response.status_code == 303, response.text
response = client.get("/api/v1/me")
assert response.status_code == 200, response.text
return {"X-CSRF-Token": response.json()["csrf_token"]}
def post(client, path, data, csrf):
response = client.post(path, json=data, headers=csrf)
assert response.status_code in {200, 201}, f"{path}: {response.status_code} {response.text}"
return response.json()
@pytest.fixture
def environment(tmp_path):
settings = Settings(
data_dir=tmp_path / "data", master_key_file=tmp_path / "keys" / "master.key",
public_url="https://testserver", secure_cookies=False, bootstrap_username="admin",
bootstrap_password=PASSWORD, testing=True, four_eyes=False,
)
app = create_app(settings)
with TestClient(app, base_url="https://testserver") as client:
csrf = login(client)
yield app, client, csrf
@pytest.fixture
def prepared(environment):
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)
iso = post(client, "/api/v1/iso-records", {
"name": "Simulated ISO; not a real hardware certification", "build": "9.1-1", "sha256": "1" * 64,
"assistant_version": "test-only", "fingerprint": "2" * 64, "group_id": group["id"],
"native_token_support": True, "test_status": "passed", "test_evidence": "Synthetic HTTP acceptance fixture",
}, csrf)
module = post(client, "/api/v1/modules", {"name": "test-verification", "source": SOURCE,
"target_builds": ["9.1-1"], "retry_safe": True}, csrf)
publication = {"test_evidence": "Synthetic HTTP acceptance fixture", "reason": "Testing publication"}
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"]
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",
"target_builds": ["9.1-1"], "steps": [{"id": "verify", "module_id": module["id"], "required": True}]}, csrf)
post(client, f"/api/v1/profiles/{postinstall['id']}/publish", publication, csrf)
identities = [{"kind": "uuid", "value": HOST_UUID}, {"kind": "serial", "value": "LAB-HOST-001"},
{"kind": "mac", "value": HOST_MAC}]
host = post(client, "/api/v1/hosts", {"fqdn": "pve01.lab.example.net", "site": "lab",
"management_ip": "192.0.2.10/24", "identities": identities,
"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)
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}]}
return {"app": app, "client": client, "csrf": csrf, "group": group, "iso": iso,
"module": module, "installation": installation, "profile_data": profile_data,
"host": host, "run": run, "payload": payload, "identities": identities}
def answer(prepared, payload=None):
return prepared["client"].post("/installer/v1/answer", json=payload or prepared["payload"],
headers={"Authorization": f"Bearer {prepared['group']['token']}"})
def enroll(prepared):
response = answer(prepared)
assert response.status_code == 200, response.text
config = tomllib.loads(response.text)
bootstrap = prepared["client"].get(config["first-boot"]["url"])
assert bootstrap.status_code == 200
encoded = re.search(r"config = base64.b64decode\('([^']+)'\)", bootstrap.text).group(1)
runtime = json.loads(base64.b64decode(encoded))
key = Ed25519PrivateKey.generate()
public_key = base64.b64encode(key.public_key().public_bytes_raw()).decode()
registration = {"run_id": prepared["run"]["id"], "enrollment_secret": runtime["enrollment_secret"],
"public_key": public_key, "identities": prepared["identities"], "boot_id": "boot-test-1"}
response = prepared["client"].post("/agent/v1/enroll", json=registration)
assert response.status_code == 200, response.text
return key, public_key, registration
def signed(prepared, key, method, path, payload=None, headers=None):
body = b"" if payload is None else json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
timestamp, nonce = str(int(time.time())), uuid4().hex
message = f"{method}\n{path}\n{timestamp}\n{nonce}\n{hashlib.sha256(body).hexdigest()}".encode()
request_headers = {"X-Run-ID": prepared["run"]["id"],
"X-Device-Key": base64.b64encode(key.public_key().public_bytes_raw()).decode(),
"X-Timestamp": timestamp, "X-Nonce": nonce, "X-Signature": base64.b64encode(key.sign(message)).decode(),
"Content-Type": "application/json"}
request_headers.update(headers or {})
return prepared["client"].request(method, path, content=body, headers=request_headers)
def test_authentication_csrf_and_reader_permissions(environment):
app, client, csrf = environment
anonymous = TestClient(app, base_url="https://testserver")
try:
assert anonymous.get("/api/v1/hosts").status_code == 401
finally:
anonymous.close()
assert client.post("/api/v1/groups", json={"name": "denied", "site": "lab"}).status_code == 403
post(client, "/api/v1/users", {"username": "reader", "password": PASSWORD, "role": "reader"}, csrf)
reader_csrf = login(client, "reader")
assert client.get("/api/v1/hosts").status_code == 200
assert client.post("/api/v1/groups", json={"name": "denied", "site": "lab"}, headers=reader_csrf).status_code == 403
assert client.get("/api/v1/secrets").status_code == 403
def test_unknown_conflicting_and_blocked_hosts_never_receive_answer(prepared):
unknown = deepcopy(prepared["payload"])
unknown["dmi"]["system"] = {"uuid": str(uuid4()), "serial": "UNKNOWN-HOST"}
unknown["network-interfaces"] = [{"mac": "02:00:00:00:ff:fe"}]
assert answer(prepared, unknown).status_code == 403
contradictory = deepcopy(prepared["payload"])
contradictory["dmi"]["system"]["uuid"] = str(uuid4())
assert answer(prepared, contradictory).status_code == 409
host = prepared["client"].get(f"/api/v1/hosts/{prepared['host']['id']}").json()
response = prepared["client"].patch(f"/api/v1/hosts/{host['id']}", json={"expected_version": host["version"], "blocked": True}, headers=prepared["csrf"])
assert response.status_code == 200, response.text
assert answer(prepared).status_code == 403
def test_expired_approval_and_wrong_group_cannot_install(prepared):
other = post(prepared["client"], "/api/v1/groups", {"name": "other-group", "site": "lab"}, prepared["csrf"])
denied = prepared["client"].post("/installer/v1/answer", json=prepared["payload"], headers={"Authorization": f"Bearer {other['token']}"})
assert denied.status_code == 403
with prepared["app"].state.db.connection(write=True) as connection:
connection.execute("UPDATE approvals SET expires_at=?", (time.time() - 1,))
assert answer(prepared).status_code == 410
def test_author_cannot_publish_own_version_when_four_eyes_enabled(environment):
app, client, csrf = environment
app.state.settings.four_eyes = True
draft = post(client, "/api/v1/profiles", {"name": "self-publish-denied", "kind": "installation"}, csrf)
response = client.post(f"/api/v1/profiles/{draft['id']}/publish", json={
"test_evidence": "A laboratory test report", "reason": "Self publication attempt"}, headers=csrf)
assert response.status_code == 403
def test_concurrent_installer_retries_reserve_one_immutable_answer(prepared):
with ThreadPoolExecutor(max_workers=10) as executor:
responses = list(executor.map(lambda _: answer(prepared), range(10)))
assert {response.status_code for response in responses} == {200}, [r.text for r in responses]
assert len({response.text for response in responses}) == 1
assert all(response.headers["cache-control"] == "no-store" for response in responses)
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 "expected_count" not in native["disk-setup"]
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"
def test_new_profile_version_cannot_change_prepared_run(prepared):
original = prepared["run"]["snapshot"]
data = deepcopy(prepared["profile_data"])
data["values"]["network"]["dns"] = "192.0.2.54"
version2 = post(prepared["client"], "/api/v1/profiles", data, prepared["csrf"])
assert version2["version"] == prepared["installation"]["version"] + 1
assert version2["id"] != prepared["installation"]["id"]
current = prepared["client"].get(f"/api/v1/runs/{prepared['run']['id']}").json()
assert current["snapshot"] == original
response = answer(prepared)
assert response.status_code == 200, response.text
assert tomllib.loads(response.text)["network"]["dns"] == "192.0.2.53"
def test_enrollment_signature_sequence_and_verified_completion(prepared):
key, _, registration = enroll(prepared)
run_id = prepared["run"]["id"]
assert answer(prepared).status_code in {403, 410}
# Retrying a lost enrollment response must not generate a second device identity.
assert prepared["client"].post("/agent/v1/enroll", json=registration).status_code == 200
lease = signed(prepared, key, "POST", "/agent/v1/lease", {"run_id": run_id})
assert lease.status_code == 200 and lease.json()["action"] == "run"
manifest = signed(prepared, key, "GET", f"/agent/v1/runs/{run_id}/manifest")
assert manifest.status_code == 200, manifest.text
wrong_key = Ed25519PrivateKey.generate()
assert signed(prepared, wrong_key, "GET", f"/agent/v1/runs/{run_id}/manifest").status_code in {401, 403}
premature = signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/complete", {"verification": {"verify": {"passed": True}}})
assert premature.status_code == 409
def event(sequence, kind, **extra):
return {"sequence": sequence, "type": kind, "boot_id": "boot-test-1", "step_id": "verify",
"occurred_at": "2026-09-13T12:00:00Z", **extra}
out_of_order = {"events": [event(2, "step.started")]}
assert signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/events", out_of_order).status_code == 409
started = {"events": [event(1, "step.started")]}
assert signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/events", started).status_code == 200
assert signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/events", started).status_code == 200
false_success = {"events": [event(2, "step.succeeded", exit_code=0, verification={"passed": False})]}
assert signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/events", false_success).status_code == 409
succeeded = {"events": [event(2, "step.succeeded", exit_code=0, verification={"passed": True})]}
response = signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/events", succeeded)
assert response.status_code == 200, response.text
false_completion = signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/complete", {"verification": {"verify": {"passed": False}}})
assert false_completion.status_code == 409
completion = signed(prepared, key, "POST", f"/agent/v1/runs/{run_id}/complete", {"verification": {"verify": {"passed": True}}})
assert completion.status_code == 200, completion.text
status = prepared["client"].get(f"/api/v1/runs/{run_id}").json()["status"]
assert status == "succeeded"
assert answer(prepared).status_code in {403, 410}
assert signed(prepared, key, "GET", f"/agent/v1/runs/{run_id}/manifest").status_code in {403, 410}
def test_device_signature_replay_and_cross_run_access_are_denied(prepared):
key, _, _ = enroll(prepared)
run_id = prepared["run"]["id"]
assert signed(prepared, key, "POST", "/agent/v1/lease", {"run_id": run_id}).status_code == 200
original = signed(prepared, key, "GET", f"/agent/v1/runs/{run_id}/manifest")
assert original.status_code == 200
repeated = prepared["client"].request(original.request.method, original.request.url, content=original.request.content, headers=original.request.headers)
assert repeated.status_code == 409
foreign_path = "/agent/v1/runs/another-run/manifest"
tampered = prepared["client"].get(foreign_path, headers=original.request.headers)
assert tampered.status_code == 401
assert signed(prepared, key, "GET", foreign_path).status_code == 403
def test_tampered_module_is_not_delivered_to_runner(prepared):
key, _, _ = enroll(prepared)
run_id = prepared["run"]["id"]
assert signed(prepared, key, "POST", "/agent/v1/lease", {"run_id": run_id}).status_code == 200
checksum = prepared["module"]["digest"]
endpoint = f"/agent/v1/artifacts/{checksum}"
assert signed(prepared, key, "GET", endpoint).status_code == 200
artifact = prepared["app"].state.settings.data_dir / "artifacts" / checksum
artifact.write_bytes(b"#!/bin/bash\nexit 99\n")
response = signed(prepared, key, "GET", endpoint)
assert response.status_code == 503
assert "exit 99" not in response.text
def test_reconciliation_waits_for_issued_lease_and_requires_local_confirmation(prepared):
key, _, _ = enroll(prepared)
client, csrf = prepared["client"], prepared["csrf"]
run_id = prepared["run"]["id"]
lease = signed(prepared, key, "POST", "/agent/v1/lease", {"run_id": run_id})
assert lease.status_code == 200 and lease.json()["action"] == "run"
issued_until = lease.json()["expires_at"]
current = client.get(f"/api/v1/runs/{run_id}").json()
cancelled = post(client, f"/api/v1/runs/{run_id}/cancel", {
"expected_version": current["version"], "reason": "Stop before checking local host state"}, csrf)
stop = signed(prepared, key, "POST", "/agent/v1/lease", {"run_id": run_id})
assert stop.status_code == 200 and stop.json()["action"] == "stop"
with prepared["app"].state.db.connection(write=True) as connection:
assert connection.execute("SELECT lease_until FROM runs WHERE id=?", (run_id,)).fetchone()[0] == issued_until
connection.execute("UPDATE runs SET answer_until=? WHERE id=?", (time.time() - 1, run_id))
request = {"expected_version": cancelled["version"], "reason": "Installer and runner stopped locally and checked",
"confirmation": prepared["host"]["fqdn"], "execution_stopped": True}
endpoint = f"/api/v1/runs/{run_id}/reconcile"
assert client.post(endpoint, json=request, headers=csrf).status_code == 409
with prepared["app"].state.db.connection(write=True) as connection:
connection.execute("UPDATE runs SET lease_until=? WHERE id=?", (time.time() - 1, run_id))
assert client.post(endpoint, json={**request, "confirmation": "wrong.lab.example.net"}, headers=csrf).status_code == 422
assert client.post(endpoint, json={**request, "execution_stopped": False}, headers=csrf).status_code == 422
reconciled = post(client, endpoint, request, csrf)
assert reconciled["status"] == "cancelled"
with prepared["app"].state.db.connection() as connection:
row = connection.execute("SELECT * FROM runs WHERE id=?", (run_id,)).fetchone()
assert row["device_key"] is None and row["enrollment_hash"] is None
assert connection.execute("SELECT status FROM approvals WHERE id=?", (row["approval_id"],)).fetchone()[0] == "revoked"
assert connection.execute("SELECT 1 FROM audit WHERE action='run.reconciled' AND object_id=?", (run_id,)).fetchone()
assert signed(prepared, key, "POST", "/agent/v1/lease", {"run_id": run_id}).status_code == 401
host = client.get(f"/api/v1/hosts/{prepared['host']['id']}").json()
next_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": "Explicit new simulation after verified local stop"}, csrf)
assert next_run["id"] != run_id and next_run["status"] == "prepared"
def test_sensitive_values_are_encrypted_and_absent_from_management(prepared):
response = answer(prepared)
assert response.status_code == 200, response.text
bootstrap_url = tomllib.loads(response.text)["first-boot"]["url"]
for endpoint in ("/api/v1/hosts", "/api/v1/profiles", "/api/v1/runs", "/api/v1/audit", "/api/v1/groups"):
management = prepared["client"].get(endpoint)
assert management.status_code == 200, management.text
assert ROOT_HASH not in management.text
assert prepared["group"]["token"] not in management.text
assert bootstrap_url not in management.text
with prepared["app"].state.db.connection() as connection:
row = connection.execute("SELECT * FROM runs").fetchone()
assert ROOT_HASH not in row["answer_ciphertext"]
assert ROOT_HASH in prepared["app"].state.security.decrypt(row["answer_ciphertext"])
secret = connection.execute("SELECT ciphertext FROM secrets").fetchone()[0]
assert secret != ROOT_HASH
assert prepared["app"].state.security.decrypt(secret) == ROOT_HASH
def test_live_backup_offline_restore_revokes_all_active_credentials(prepared, tmp_path):
enroll(prepared)
settings = prepared["app"].state.settings
destination = tmp_path / "snapshot"
backup(settings, destination)
key_bytes = settings.master_key_file.read_bytes()
for path in destination.rglob("*"):
if path.is_file():
assert key_bytes not in path.read_bytes()
restored = replace(settings, data_dir=tmp_path / "restored")
restore(restored, destination)
db = Database(restored)
with db.connection() as connection:
assert connection.execute("SELECT count(*) FROM sessions").fetchone()[0] == 0
assert connection.execute("SELECT count(*) FROM groups WHERE revoked=0").fetchone()[0] == 0
row = connection.execute("SELECT * FROM runs").fetchone()
assert row["status"] == "needs_review"
assert all(row[name] is None for name in ("device_key", "bootstrap_hash", "enrollment_hash", "report_hash", "answer_ciphertext"))
assert Security(restored).decrypt(connection.execute("SELECT ciphertext FROM secrets").fetchone()[0]) == ROOT_HASH
assert list((restored.data_dir / "artifacts").iterdir())
with pytest.raises((RuntimeError, ValueError), match="in use|empty"):
restore(settings, destination)
restored_app = create_app(restored)
with TestClient(restored_app, base_url="https://testserver") as restored_client:
restored_csrf = login(restored_client)
current = restored_client.get(f"/api/v1/runs/{prepared['run']['id']}").json()
reconciled = post(restored_client, f"/api/v1/runs/{current['id']}/reconcile", {
"expected_version": current["version"], "confirmation": prepared["host"]["fqdn"],
"execution_stopped": True, "reason": "Physical host state checked after restore"}, restored_csrf)
assert reconciled["status"] == "cancelled"
def test_init_creates_only_password_hash_and_separate_key(tmp_path, monkeypatch):
settings = Settings(data_dir=tmp_path / "data", master_key_file=tmp_path / "keys" / "master.key")
monkeypatch.setattr("provisioner.cli.getpass.getpass", lambda _: PASSWORD)
initialize(settings, "admin")
with Database(settings).connection() as connection:
stored = connection.execute("SELECT password_hash FROM users").fetchone()[0]
assert PASSWORD not in stored
assert Security.verify_password(PASSWORD, stored)
assert settings.master_key_file.is_file()
with ServiceLock(settings.data_dir):
with pytest.raises(RuntimeError, match="in use"):
with ServiceLock(settings.data_dir):
pass
def test_restore_rejects_wrong_key_and_modified_backup(tmp_path, monkeypatch):
settings = Settings(data_dir=tmp_path / "original", master_key_file=tmp_path / "keys" / "master.key")
monkeypatch.setattr("provisioner.cli.getpass.getpass", lambda _: PASSWORD)
initialize(settings, "admin")
destination = tmp_path / "backup"
backup(settings, destination)
wrong_key = tmp_path / "keys" / "wrong.key"
wrong_key.write_bytes(Fernet.generate_key())
wrong_settings = replace(settings, data_dir=tmp_path / "wrong-restore", master_key_file=wrong_key)
with pytest.raises(ValueError, match="does not match"):
restore(wrong_settings, destination)
assert not wrong_settings.data_dir.exists()
config = destination / "settings.json"
config.write_text(config.read_text() + "\n", encoding="utf-8")
with pytest.raises(ValueError, match="checksum mismatch"):
restore(replace(settings, data_dir=tmp_path / "corrupt-restore"), destination)
+393
View File
@@ -0,0 +1,393 @@
"""Exercise publication gates with a fake Docker CLI, never a real daemon."""
import os
from pathlib import Path
import shutil
import subprocess
import tomllib
from types import SimpleNamespace
import pytest
ROOT = Path(__file__).resolve().parents[1]
VERSION = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"]["version"]
SHA = "1234567890abcdef1234567890abcdef12345678"
REGISTRY_IMAGE = "ghcr.io/team/proxmox-ais"
DIGEST = f"{REGISTRY_IMAGE}@sha256:{'a' * 64}"
PASSWORD = "dummy-ci-token-$()-do-not-print"
def posix_shell():
if os.name == "nt":
shell = Path(os.environ.get("ProgramFiles", "C:/Program Files")) / "Git/bin/bash.exe"
if not shell.is_file():
pytest.skip("CI shell tests need Git Bash on Windows")
else:
shell = shutil.which("sh")
if not shell:
pytest.skip("CI shell tests need a POSIX shell")
return str(shell)
@pytest.fixture
def container_ci(tmp_path):
shell = posix_shell()
(tmp_path / "ci").mkdir()
(tmp_path / "tests").mkdir()
(tmp_path / "bin").mkdir()
(tmp_path / "tmp").mkdir()
shutil.copyfile(ROOT / "ci/container.sh", tmp_path / "ci/container.sh")
shutil.copyfile(ROOT / "pyproject.toml", tmp_path / "pyproject.toml")
smoke_source = (ROOT / "tests/container_smoke.py").read_bytes()
(tmp_path / "tests/container_smoke.py").write_bytes(smoke_source)
docker = tmp_path / "bin/docker"
docker.write_text(
"""#!/bin/sh
set -eu
{
printf '%s' "$1"
for argument in "$@"; do printf '\\t%s' "$argument"; done
printf '\\n'
} >> "$MOCK_DOCKER_LOG"
if [ "$1" = "${MOCK_FAIL_COMMAND:-}" ]; then exit 37; fi
case "$1" in
run)
cat > "$MOCK_SMOKE_STDIN"
exit "${MOCK_RUN_EXIT:-0}"
;;
login)
cat > "$MOCK_LOGIN_STDIN"
printf '%s' "$DOCKER_CONFIG" > "$MOCK_DOCKER_CONFIG"
printf '{"auths":{"test":"dummy-credentials"}}' > "$DOCKER_CONFIG/config.json"
;;
image)
printf '%s\\n' "$MOCK_REPO_DIGESTS"
;;
esac
""",
encoding="utf-8",
newline="\n",
)
docker.chmod(0o755)
environment = {
**os.environ,
"PATH": str(tmp_path / "bin") + os.pathsep + os.environ["PATH"],
"TMPDIR": (tmp_path / "tmp").as_posix(),
"GITHUB_SHA": SHA,
"GITHUB_RUN_ID": "210",
"GITHUB_RUN_ATTEMPT": "1",
"GITHUB_JOB": "container_publish",
"GITHUB_REPOSITORY": "Team/Proxmox-AIS",
"GITHUB_SERVER_URL": "https://github.com",
"GITHUB_EVENT_NAME": "push",
"GITHUB_REF_PROTECTED": "true",
"GITHUB_REF_TYPE": "branch",
"GITHUB_REF_NAME": "main",
"DEFAULT_BRANCH": "main",
"GITHUB_ACTOR": "ci-user",
"GHCR_TOKEN": PASSWORD,
"MOCK_DOCKER_LOG": (tmp_path / "docker.log").as_posix(),
"MOCK_SMOKE_STDIN": (tmp_path / "smoke.stdin").as_posix(),
"MOCK_LOGIN_STDIN": (tmp_path / "login.stdin").as_posix(),
"MOCK_DOCKER_CONFIG": (tmp_path / "docker-config.path").as_posix(),
"MOCK_REPO_DIGESTS": DIGEST,
}
def run(mode="publish", **overrides):
effective_environment = {**environment, **overrides}
for name in [name for name, value in effective_environment.items() if value is None]:
del effective_environment[name]
result = subprocess.run(
[str(shell), "ci/container.sh", mode],
cwd=tmp_path,
env=effective_environment,
capture_output=True,
text=True,
timeout=30,
)
log = tmp_path / "docker.log"
calls = [line.split("\t")[1:] for line in log.read_text().splitlines()] if log.exists() else []
return SimpleNamespace(result=result, calls=calls, root=tmp_path, smoke_source=smoke_source)
return run
def test_default_branch_publishes_only_after_hardened_image_smoke(container_ci):
outcome = container_ci()
assert outcome.result.returncode == 0, outcome.result.stderr
commands = [call[0] for call in outcome.calls]
assert commands == ["build", "run", "login", "tag", "push", "tag", "push", "image", "rm", "image"]
build_image = f"{REGISTRY_IMAGE}:ci-210-1-container_publish"
assert outcome.calls[-1] == ["image", "rm", build_image]
build = outcome.calls[0]
assert build[build.index("--platform") + 1] == "linux/amd64"
assert "--provenance=false" in build and "--sbom=false" in build
assert f"org.opencontainers.image.revision={SHA}" in build
assert "org.opencontainers.image.source=https://github.com/Team/Proxmox-AIS" in build
assert build[build.index("--tag") + 1] == build_image
smoke = outcome.calls[1]
for option in ["--read-only", "--cap-drop", "ALL", "no-new-privileges:true", "/tmp:rw,noexec,nosuid,size=128m"]:
assert option in smoke
assert smoke[smoke.index("--entrypoint") + 1] == "python"
assert smoke[smoke.index("--entrypoint") + 2] == build_image
assert all(call[1] == build_image for call in outcome.calls if call[0] == "tag")
assert "--volume" not in smoke and "-v" not in smoke
assert (outcome.root / "smoke.stdin").read_bytes() == outcome.smoke_source
assert [call[1] for call in outcome.calls if call[0] == "push"] == [
f"{REGISTRY_IMAGE}:sha-{SHA}", f"{REGISTRY_IMAGE}:edge"
]
assert (outcome.root / "deploy.env").read_text() == f"PROVISIONER_IMAGE={DIGEST}\n"
assert "ci-210-1-container_publish" in (outcome.root / "build.env").read_text()
assert outcome.calls[2] == ["login", "ghcr.io", "--username", "ci-user", "--password-stdin"]
assert (outcome.root / "login.stdin").read_text() == PASSWORD
assert PASSWORD not in outcome.result.stdout + outcome.result.stderr + (outcome.root / "docker.log").read_text()
config_path = Path((outcome.root / "docker-config.path").read_text())
assert not config_path.exists(), "Temporary registry credentials must be removed"
def test_release_tag_matches_project_version_and_does_not_move_edge(container_ci):
outcome = container_ci(GITHUB_REF_TYPE="tag", GITHUB_REF_NAME=f"v{VERSION}")
assert outcome.result.returncode == 0, outcome.result.stderr
assert [call[1] for call in outcome.calls if call[0] == "push"] == [
f"{REGISTRY_IMAGE}:sha-{SHA}", f"{REGISTRY_IMAGE}:{VERSION}"
]
def test_pull_request_verifies_without_registry_credentials(container_ci):
outcome = container_ci(
"verify", GITHUB_EVENT_NAME="pull_request", GITHUB_REF_NAME="1/merge",
GITHUB_REF_PROTECTED="false", GITHUB_ACTOR=None, GHCR_TOKEN=None,
)
assert outcome.result.returncode == 0, outcome.result.stderr
assert [call[0] for call in outcome.calls] == ["build", "run", "rm", "image"]
assert "--provenance=false" in outcome.calls[0] and "--sbom=false" in outcome.calls[0]
assert outcome.calls[-1] == ["image", "rm", f"{REGISTRY_IMAGE}:ci-210-1-container_publish"]
assert (outcome.root / "build.env").is_file()
assert not (outcome.root / "deploy.env").exists()
@pytest.mark.parametrize("overrides", [
{"GITHUB_REF_NAME": "feature/ci"},
{"GITHUB_REF_NAME": f"v{VERSION}"},
{"GITHUB_REF_NAME": None},
{"DEFAULT_BRANCH": ""},
{"GITHUB_REF_PROTECTED": "false"},
{"GITHUB_REF_PROTECTED": None},
{"GITHUB_REF_PROTECTED": "TRUE"},
{"GITHUB_EVENT_NAME": "pull_request"},
{"GITHUB_EVENT_NAME": "pull_request_target"},
{"GITHUB_EVENT_NAME": "workflow_run"},
{"GITHUB_EVENT_NAME": "schedule"},
{"GITHUB_EVENT_NAME": "web"},
{"GITHUB_EVENT_NAME": None},
{"GITHUB_REF_TYPE": None},
{"GITHUB_REF_TYPE": "pull_request"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "main"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v9999.9999.9999"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "nightly"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.1.0-rc1"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v00.1.0"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.01.0"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.1.00"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.1"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": "v0.1.0.0"},
{"GITHUB_REF_TYPE": "tag", "GITHUB_REF_NAME": f"v{VERSION}", "GITHUB_REF_PROTECTED": "false"},
{"GHCR_TOKEN": None},
{"GITHUB_ACTOR": None},
{"GITHUB_SHA": "1234"},
{"GITHUB_RUN_ID": "invalid"},
{"GITHUB_RUN_ATTEMPT": "../2"},
{"GITHUB_RUN_ATTEMPT": None},
{"GITHUB_JOB": "../invalid"},
{"GITHUB_JOB": "has space"},
{"GITHUB_JOB": "-invalid"},
{"GITHUB_JOB": ""},
{"GITHUB_REPOSITORY": "team"},
{"GITHUB_REPOSITORY": "team/repo/extra"},
{"GITHUB_REPOSITORY": "team/repo:tag"},
])
def test_unauthorized_or_invalid_publication_fails_before_build(container_ci, overrides):
outcome = container_ci(**overrides)
assert outcome.result.returncode != 0
assert not outcome.calls
assert not (outcome.root / "deploy.env").exists()
@pytest.mark.parametrize("ref_type, ref_name", [("branch", "main"), ("tag", f"v{VERSION}")])
def test_manual_run_can_publish_protected_default_branch_or_release(container_ci, ref_type, ref_name):
outcome = container_ci(
GITHUB_EVENT_NAME="workflow_dispatch", GITHUB_REF_TYPE=ref_type, GITHUB_REF_NAME=ref_name,
)
assert outcome.result.returncode == 0, outcome.result.stderr
assert (outcome.root / "deploy.env").is_file()
def test_manual_feature_branch_cannot_publish(container_ci):
outcome = container_ci(GITHUB_EVENT_NAME="workflow_dispatch", GITHUB_REF_NAME="feature/ci")
assert outcome.result.returncode != 0
assert not outcome.calls
@pytest.mark.parametrize("attempt, job", [("2", "container_publish"), ("1", "container-verify")])
def test_container_names_include_run_attempt_and_job(container_ci, attempt, job):
outcome = container_ci("verify", GITHUB_RUN_ATTEMPT=attempt, GITHUB_JOB=job)
assert outcome.result.returncode == 0, outcome.result.stderr
build_image = f"{REGISTRY_IMAGE}:ci-210-{attempt}-{job}"
build, smoke = outcome.calls[:2]
assert build[build.index("--tag") + 1] == build_image
assert smoke[smoke.index("--name") + 1] == f"ais-smoke-210-{attempt}-{job}"
assert outcome.calls[-1] == ["image", "rm", build_image]
@pytest.mark.parametrize("overrides", [
{"MOCK_FAIL_COMMAND": "build"},
{"MOCK_RUN_EXIT": "19"},
])
def test_failed_build_or_smoke_never_logs_in_or_pushes(container_ci, overrides):
outcome = container_ci(**overrides)
assert outcome.result.returncode != 0
assert not any(call[0] in {"login", "tag", "push"} for call in outcome.calls)
assert not (outcome.root / "build.env").exists()
assert not (outcome.root / "deploy.env").exists()
if "MOCK_RUN_EXIT" in overrides:
assert outcome.calls[-2] == ["rm", "--force", "ais-smoke-210-1-container_publish"]
assert outcome.calls[-1] == ["image", "rm", f"{REGISTRY_IMAGE}:ci-210-1-container_publish"]
def test_push_failure_does_not_create_deployment_artifact_and_cleans_credentials(container_ci):
outcome = container_ci(MOCK_FAIL_COMMAND="push")
assert outcome.result.returncode != 0
assert not (outcome.root / "deploy.env").exists()
assert not Path((outcome.root / "docker-config.path").read_text()).exists()
assert len([call for call in outcome.calls if call[0] == "push"]) == 1
@pytest.mark.parametrize("digests", ["", "other.example.test/image@sha256:" + "a" * 64, REGISTRY_IMAGE + "@sha256:invalid"])
def test_missing_or_invalid_registry_digest_fails_without_artifact(container_ci, digests):
outcome = container_ci(MOCK_REPO_DIGESTS=digests)
assert outcome.result.returncode != 0
assert not (outcome.root / "deploy.env").exists()
@pytest.fixture
def python_ci(tmp_path):
shell = posix_shell()
(tmp_path / "ci").mkdir()
(tmp_path / "bin").mkdir()
shutil.copyfile(ROOT / "ci/python-tests.sh", tmp_path / "ci/python-tests.sh")
shutil.copyfile(ROOT / "pyproject.toml", tmp_path / "pyproject.toml")
venv_python = tmp_path / "venv-python"
venv_python.write_text(
"""#!/bin/sh
set -eu
printf '%s\\n' "$*" >> "$MOCK_PYTHON_LOG"
case "$2" in
pip) exit "${MOCK_PIP_EXIT:-0}" ;;
pytest)
printf '<testsuite tests="1" />\\n' > reports/pytest.xml
exit "${MOCK_PYTEST_EXIT:-0}"
;;
*) exit 65 ;;
esac
""", encoding="utf-8", newline="\n",
)
python = tmp_path / "bin/python3"
python.write_text(
"""#!/bin/sh
set -eu
case "$1" in
-c) exit "${MOCK_PYTHON_VERSION_EXIT:-0}" ;;
-m)
[ "$2" = venv ]
printf '%s' "$3" > "$MOCK_VENV_PATH"
mkdir -p "$3/bin"
cp "$MOCK_VENV_PYTHON" "$3/bin/python"
chmod +x "$3/bin/python"
;;
*) exit 65 ;;
esac
""", encoding="utf-8", newline="\n",
)
python.chmod(0o755)
for name in ["bash", "openssl", "ssh-keygen"]:
executable = tmp_path / "bin" / name
executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8", newline="\n")
executable.chmod(0o755)
environment = {
**os.environ,
"PATH": str(tmp_path / "bin") + os.pathsep + os.environ["PATH"],
"GITHUB_WORKSPACE": tmp_path.as_posix(),
"GITHUB_RUN_ID": "543",
"GITHUB_RUN_ATTEMPT": "1",
"GITHUB_JOB": "python_tests",
"MOCK_PYTHON_LOG": (tmp_path / "python.log").as_posix(),
"MOCK_VENV_PATH": (tmp_path / "venv.path").as_posix(),
"MOCK_VENV_PYTHON": venv_python.as_posix(),
}
def run(missing_tool=None, **overrides):
effective_environment = {**environment, **overrides}
effective_environment = {name: value for name, value in effective_environment.items() if value is not None}
command = [shell, "ci/python-tests.sh"]
if missing_tool:
(tmp_path / "bin" / missing_tool).unlink()
# Git Bash adds its own tools to PATH on Windows startup. Limit
# PATH inside the shell to model a genuinely missing system tool.
command = [shell, "-c", 'PATH="$(pwd -P)/bin"\nexport PATH\n. ci/python-tests.sh']
result = subprocess.run(
command, cwd=tmp_path, env=effective_environment,
capture_output=True, text=True, timeout=30,
)
log = tmp_path / "python.log"
calls = log.read_text().splitlines() if log.exists() else []
return SimpleNamespace(result=result, calls=calls, root=tmp_path)
return run
@pytest.mark.parametrize("overrides, expected_code, expected_calls", [
({}, 0, ["-m pip install .[dev]", "-m pytest --junitxml=reports/pytest.xml"]),
({"MOCK_PYTEST_EXIT": "1"}, 1, ["-m pip install .[dev]", "-m pytest --junitxml=reports/pytest.xml"]),
({"MOCK_PIP_EXIT": "42"}, 42, ["-m pip install .[dev]"]),
])
def test_python_job_isolates_dependencies_and_cleans_venv(python_ci, overrides, expected_code, expected_calls):
outcome = python_ci(**overrides)
assert outcome.result.returncode == expected_code, outcome.result.stderr
assert outcome.calls == expected_calls
assert (outcome.root / "venv.path").is_file()
assert ".venv-ci-543-1-python_tests." in (outcome.root / "venv.path").read_text()
assert not list(outcome.root.glob(".venv-ci-*")), "The job must clean its virtual environment even when tests or installation fail"
assert (outcome.root / "reports/pytest.xml").exists() == ("MOCK_PIP_EXIT" not in overrides)
@pytest.mark.parametrize("required_tool", ["python3", "bash", "openssl", "ssh-keygen"])
def test_python_job_requires_system_tools_instead_of_skipping_tests(python_ci, required_tool):
outcome = python_ci(missing_tool=required_tool)
assert outcome.result.returncode != 0
assert f"Required runner tool is missing: {required_tool}" in outcome.result.stderr
assert not outcome.calls
assert not list(outcome.root.glob(".venv-ci-*"))
@pytest.mark.parametrize("overrides", [
{"GITHUB_RUN_ID": "../invalid"},
{"GITHUB_RUN_ATTEMPT": "invalid"},
{"GITHUB_RUN_ATTEMPT": None},
{"GITHUB_JOB": "../invalid"},
{"GITHUB_JOB": "has space"},
{"GITHUB_JOB": "-invalid"},
{"GITHUB_JOB": ""},
{"MOCK_PYTHON_VERSION_EXIT": "1"},
])
def test_python_job_rejects_invalid_job_or_python_version_before_installing(python_ci, overrides):
outcome = python_ci(**overrides)
assert outcome.result.returncode != 0
assert not outcome.calls
assert not list(outcome.root.glob(".venv-ci-*"))
def test_python_job_names_venv_for_the_run_attempt_and_job(python_ci):
outcome = python_ci(GITHUB_RUN_ATTEMPT="2", GITHUB_JOB="python-tests")
assert outcome.result.returncode == 0, outcome.result.stderr
assert ".venv-ci-543-2-python-tests." in (outcome.root / "venv.path").read_text()
assert not list(outcome.root.glob(".venv-ci-*"))
+165
View File
@@ -0,0 +1,165 @@
"""Actual Runner/API protocol with Ed25519; only target-side phases are mocked."""
import base64
from copy import deepcopy
import io
import json
import re
import urllib.error
import urllib.parse
from unittest.mock import Mock
import tomllib
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
import pytest
from provisioner.runner import API, Runner, TransportError
from test_acceptance import environment, prepared, answer, post, enroll, signed
class Key:
def __init__(self):
self.key = Ed25519PrivateKey.generate()
self.public_key = base64.b64encode(self.key.public_key().public_bytes_raw()).decode()
def sign(self, message):
return base64.b64encode(self.key.sign(message)).decode()
class TestOpener:
__test__ = False
def __init__(self, client):
self.client = client
self.drop_complete = False
self.drop_cancel = False
def open(self, request, timeout):
path = urllib.parse.urlsplit(request.full_url).path
result = self.client.request(request.method, path, content=request.data, headers=dict(request.header_items()))
if result.status_code >= 400:
raise urllib.error.HTTPError(request.full_url,result.status_code,result.text,result.headers,io.BytesIO(result.content))
if self.drop_complete and path.endswith("/complete"):
self.drop_complete = False
raise TransportError("Simulated lost completion acknowledgement")
if self.drop_cancel and path.endswith("/events") and b"run.cancelled" in (request.data or b""):
self.drop_cancel = False
raise TransportError("Simulated lost cancellation acknowledgement")
return io.BytesIO(result.content)
def make_runner(prepared,tmp_path,monkeypatch):
response = answer(prepared)
assert response.status_code == 200,response.text
bootstrap = prepared["client"].get(tomllib.loads(response.text)["first-boot"]["url"]).text
encoded = re.search(r"config = base64.b64decode\('([^']+)'\)",bootstrap).group(1)
config = json.loads(base64.b64decode(encoded))
key = Key()
api = API(config,key)
api.opener = TestOpener(prepared["client"])
runner = Runner(config,tmp_path / "target",api)
runner.key = key
monkeypatch.setattr("provisioner.runner.discover_identities",lambda *args:prepared["identities"])
return runner,api
@pytest.mark.parametrize("phases",[[0,0],[1,0,0]])
def test_actual_protocol_completes_verified_check_or_apply(prepared,tmp_path,monkeypatch,phases):
runner,api = make_runner(prepared,tmp_path,monkeypatch)
runner.execute = Mock(side_effect=phases)
result = runner.run()
assert result == 0,runner.state
assert runner.state["status"] == "succeeded"
detail = prepared["client"].get(f"/api/v1/runs/{runner.config['run_id']}").json()
assert detail["status"] == "succeeded"
assert detail["steps"][0]["status"] == "succeeded"
assert [item["type"] for item in detail["events"]] == ["step.started","step.succeeded"]
def test_runner_retries_lost_completion_without_running_scripts(prepared,tmp_path,monkeypatch):
runner,api = make_runner(prepared,tmp_path,monkeypatch)
runner.execute = Mock(return_value=0)
api.opener.drop_complete = True
assert runner.run() == 75,runner.state
assert runner.state["status"] == "completion_pending"
resumed = Runner(runner.config,runner.directory,api)
resumed.execute = Mock()
assert resumed.run() == 0,resumed.state
resumed.execute.assert_not_called()
def test_actual_reboot_resumes_same_verified_step(prepared,tmp_path,monkeypatch):
runner,api = make_runner(prepared,tmp_path,monkeypatch)
runner.execute = Mock(side_effect=[1,194])
assert runner.run() == 194,runner.state
detail = prepared["client"].get(f"/api/v1/runs/{runner.config['run_id']}").json()
assert detail["status"] == "reboot_pending"
resumed = Runner(runner.config,runner.directory,api)
resumed.boot_id = "new-boot-id"
resumed.execute = Mock(return_value=0)
assert resumed.run() == 0,resumed.state
assert [call.args[1] for call in resumed.execute.call_args_list] == ["check","verify"]
def test_failed_verification_requires_explicit_operator_resume(prepared,tmp_path,monkeypatch):
runner,api = make_runner(prepared,tmp_path,monkeypatch)
runner.execute = Mock(side_effect=[1,0,2])
assert runner.run() == 75,runner.state
detail = prepared["client"].get(f"/api/v1/runs/{runner.config['run_id']}").json()
assert detail["status"] == "needs_review"
waiting = Runner(runner.config,runner.directory,api)
waiting.execute = Mock()
assert waiting.run() == 75,waiting.state
waiting.execute.assert_not_called()
detail = prepared["client"].get(f"/api/v1/runs/{runner.config['run_id']}").json()
post(prepared["client"],f"/api/v1/runs/{runner.config['run_id']}/resume",{"expected_version":detail["version"],"reason":"Examined interrupted test step"},prepared["csrf"])
resumed = Runner(runner.config,runner.directory,api)
resumed.execute = Mock(return_value=0)
assert resumed.run() == 0,resumed.state
def test_lost_cancellation_acknowledgement_is_idempotent(prepared,tmp_path,monkeypatch):
runner,api = make_runner(prepared,tmp_path,monkeypatch)
# Enrollment before cancellation, without executing any module.
api.request("POST","/agent/v1/enroll",{"run_id":runner.config["run_id"],"enrollment_secret":runner.config["enrollment_secret"],"public_key":runner.key.public_key,"identities":prepared["identities"],"boot_id":runner.boot_id},signed=False)
runner.state["enrolled"] = True
runner.save()
detail = prepared["client"].get(f"/api/v1/runs/{runner.config['run_id']}").json()
post(prepared["client"],f"/api/v1/runs/{runner.config['run_id']}/cancel",{"expected_version":detail["version"],"reason":"Cancel test at safe boundary"},prepared["csrf"])
api.opener.drop_cancel = True
runner.execute = Mock()
assert runner.run() == 75,runner.state
resumed = Runner(runner.config,runner.directory,api)
resumed.execute = Mock()
assert resumed.run() == 0,resumed.state
resumed.execute.assert_not_called()
assert resumed.state["status"] == "cancelled"
def test_step_logs_redact_secrets_without_corrupting_json(prepared):
key,_,_ = enroll(prepared)
run_id = prepared["run"]["id"]
from test_acceptance import ROOT_HASH
result = signed(prepared,key,"POST",f"/agent/v1/runs/{run_id}/logs",{"chunks":[{"sequence":1,"step_id":"verify","text":f'password=abc\nquoted secret: "{ROOT_HASH}" token=xyz'}]})
assert result.status_code == 200,result.text
logs = prepared["client"].get(f"/api/v1/runs/{run_id}").json()["logs"]
assert ROOT_HASH not in json.dumps(logs)
assert "[REDACTED]" in logs[0]["text"]
def test_optional_failure_does_not_bypass_required_final_verification(prepared,tmp_path,monkeypatch):
client,csrf = prepared["client"],prepared["csrf"]
old = client.get(f"/api/v1/runs/{prepared['run']['id']}").json()
post(client,f"/api/v1/runs/{old['id']}/cancel",{"expected_version":old["version"],"reason":"Replace prepared test profile"},csrf)
profile = post(client,"/api/v1/profiles",{"name":"optional-then-required","kind":"postinstall","target_builds":["9.1-1"],"steps":[{"id":"optional","module_id":prepared["module"]["id"],"required":False},{"id":"final","module_id":prepared["module"]["id"],"required":True}]},csrf)
post(client,f"/api/v1/profiles/{profile['id']}/publish",{"test_evidence":"Synthetic optional failure protocol test","reason":"Test full runner protocol"},csrf)
host = client.get(f"/api/v1/hosts/{prepared['host']['id']}").json()
updated = client.patch(f"/api/v1/hosts/{host['id']}",json={"expected_version":host["version"],"postinstall_profile_id":profile["id"]},headers=csrf)
assert updated.status_code == 200,updated.text
host = updated.json()
prepared["run"] = post(client,f"/api/v1/hosts/{host['id']}/approve-install",{"expected_version":host["version"],"confirmation":host["fqdn"],"disks_confirmed":True,"reason":"Approve simulated optional failure run"},csrf)
runner,api = make_runner(prepared,tmp_path,monkeypatch)
runner.execute = Mock(side_effect=[2,0,0])
assert runner.run() == 0,runner.state
detail = client.get(f"/api/v1/runs/{runner.config['run_id']}").json()
assert [(s["step_id"],s["status"]) for s in detail["steps"]] == [("optional","failed"),("final","succeeded")]
assert detail["status"] == "succeeded"
+396
View File
@@ -0,0 +1,396 @@
"""Runner recovery tests use mocked phases: no provisioning code runs on this host."""
import base64
import ast
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import time
from unittest.mock import Mock
import pytest
from provisioner.bootstrap import render_bootstrap
from provisioner.builtin_modules import catalog
from provisioner.runner import API, DeviceKey, Halt, RebootRequested, Runner, TransportError, canonical_json, discover_identities
SOURCE = b"#!/bin/bash\nexit 0\n"
DIGEST = hashlib.sha256(SOURCE).hexdigest()
class FakeAPI:
def __init__(self, manifest):
self.manifest = manifest
self.artifact = SOURCE
self.events = []
self.logs = []
self.secrets = {"password": "secret-value-do-not-log"}
self.calls = []
self.action = "run"
self.version = 1
self.offline = False
self.drop_completion = False
def request(self, method, path, payload=None, **kwargs):
self.calls.append((method, path, payload))
if self.offline:
raise TransportError("offline")
if path.endswith("/lease"):
return {"action": self.action, "expires_at": time.time() + 900, "run_version": self.version}
if path.endswith("/manifest"):
return self.manifest
if "/artifacts/" in path:
return self.artifact
if "/secrets/" in path:
return self.secrets
if path.endswith("/events"):
self.events.extend(payload["events"])
return {"ack_sequence": payload["events"][-1]["sequence"]}
if path.endswith("/logs"):
self.logs.extend(payload["chunks"])
return {"ack_sequence": payload["chunks"][-1]["sequence"]}
if path.endswith("/complete"):
if self.drop_completion:
self.drop_completion = False
raise TransportError("completion response lost")
return {"status": "succeeded"}
raise AssertionError(path)
@pytest.fixture
def setup_runner(tmp_path):
step = {"id": "example", "name": "Example", "digest": DIGEST, "parameters": {},
"timeout_seconds": 60, "retry_safe": False, "dependencies": [], "required": True}
manifest = {"run_id": "run-example", "steps": [step], "reboot_budget": 1}
config = {"api_url": "https://provision.example.test", "run_id": manifest["run_id"],
"enrollment_secret": "enrollment-only", "identities": [],
"manifest_digest": hashlib.sha256(canonical_json(manifest)).hexdigest()}
api = FakeAPI(manifest)
runner = Runner(config, tmp_path, api)
runner.state["enrolled"] = True
runner.save()
return runner, api, step
def test_digest_mismatch_never_executes(setup_runner):
runner, api, step = setup_runner
api.artifact = b"tampered"
runner.execute = Mock()
assert runner.run() == 75
assert runner.state["status"] == "needs_review"
assert "digest mismatch" in runner.state["reason"]
runner.execute.assert_not_called()
def test_cached_artifact_is_revalidated_before_phase(setup_runner):
runner, api, step = setup_runner
path = runner.artifact(step)
path.write_bytes(b"corrupted cached content")
with pytest.raises(Halt, match="digest mismatch"):
runner.execute(step, "check", path, runner.directory / "unused-parameters.json")
def test_apply_checkpoint_is_durable_before_mutation(setup_runner):
runner, api, step = setup_runner
phases = []
def execute(step, phase, artifact, parameters):
phases.append(phase)
if phase == "apply":
state = json.loads(runner.state_path.read_text())
assert state["steps"][step["id"]]["status"] == "applying"
assert api.events[-1]["type"] == "step.started"
assert json.loads(parameters.read_text())["secrets"] == api.secrets
return 1 if phase == "check" else 0
runner.execute = execute
assert runner.run() == 0
assert phases == ["check", "apply", "verify"]
assert runner.state["status"] == "succeeded"
assert not (runner.directory / "step-parameters.json").exists()
assert "secret-value" not in runner.state_path.read_text()
def test_interrupted_non_repeatable_step_requires_review(setup_runner):
runner, api, step = setup_runner
runner.state["steps"]["example"] = {"status": "applying", "attempt": 1}
runner.execute = Mock(side_effect=[1, 1])
runner.run()
assert [call.args[1] for call in runner.execute.call_args_list] == ["check", "verify"]
assert runner.state["status"] == "needs_review"
assert "cannot be repeated safely" in runner.state["reason"]
def test_interrupted_converged_step_is_verified_without_apply(setup_runner):
runner, api, step = setup_runner
runner.state["steps"]["example"] = {"status": "applying", "attempt": 1}
runner.execute = Mock(return_value=0)
runner.run()
assert [call.args[1] for call in runner.execute.call_args_list] == ["check", "verify"]
assert runner.state["status"] == "succeeded"
def test_retry_safe_interrupted_step_can_reapply(setup_runner):
runner, api, step = setup_runner
step["retry_safe"] = True
runner.state["steps"]["example"] = {"status": "applying", "attempt": 1}
runner.execute = Mock(side_effect=[1, 1, 0, 0])
runner.run_step(step, 1)
assert [call.args[1] for call in runner.execute.call_args_list] == ["check", "verify", "apply", "verify"]
assert runner.state["steps"]["example"]["status"] == "succeeded"
assert runner.state["steps"]["example"]["attempt"] == 2
def test_success_exit_without_verification_is_not_success(setup_runner):
runner, api, step = setup_runner
runner.execute = Mock(side_effect=[1, 0, 2])
runner.run()
assert runner.state["status"] == "needs_review"
assert runner.state["steps"]["example"]["status"] == "failed"
assert not any(path.endswith("/complete") for _, path, _ in api.calls)
def test_terminal_response_loss_retries_completion_without_module_execution(setup_runner):
runner, api, step = setup_runner
runner.execute = Mock(return_value=0)
api.drop_completion = True
assert runner.run() == 75
assert runner.state["status"] == "completion_pending"
restarted = Runner(runner.config, runner.directory, api)
restarted.execute = Mock()
assert restarted.run() == 0
restarted.execute.assert_not_called()
assert restarted.state["status"] == "succeeded"
assert len([path for _, path, _ in api.calls if path.endswith("/complete")]) == 2
def test_event_queue_survives_network_loss_and_acknowledges(setup_runner):
runner, api, step = setup_runner
runner.event("step.started", "example")
api.offline = True
with pytest.raises(TransportError):
runner.flush()
restarted = Runner(runner.config, runner.directory, api)
assert restarted.state["events"][0]["sequence"] == 1
api.offline = False
restarted.flush()
assert restarted.state["events"] == []
assert api.events[0]["sequence"] == 1
def test_reboot_checkpoint_waits_for_changed_boot_id(setup_runner):
runner, api, step = setup_runner
runner.execute = Mock(side_effect=[1, 194])
assert runner.run() == 194
assert runner.state["status"] == "reboot_pending"
restarted = Runner(runner.config, runner.directory, api)
restarted.execute = Mock(return_value=0)
assert restarted.run() == 194
restarted.execute.assert_not_called()
restarted.boot_id = "next-boot"
assert restarted.run() == 0
assert [call.args[1] for call in restarted.execute.call_args_list] == ["check", "verify"]
assert any(event["type"] == "run.resumed" for event in api.events)
def test_reboot_budget_cannot_be_exceeded(setup_runner):
runner, api, step = setup_runner
runner.execute = Mock(side_effect=[1, 194])
runner.state["reboot_count"] = 1
with pytest.raises(Halt, match="budget exhausted"):
runner.run_step(step, 1)
def test_review_requires_explicit_server_resume_version(setup_runner):
runner, api, step = setup_runner
runner.state.update(status="needs_review", halted_version=1, review_started_at=time.time())
runner.execute = Mock(return_value=0)
assert runner.run() == 75
runner.execute.assert_not_called()
api.version = 2
assert runner.run() == 0
assert runner.state["status"] == "succeeded"
def test_secret_redaction_happens_before_durable_log_write(setup_runner):
runner, api, step = setup_runner
runner.secret_values = ["super-secret", "secret"]
runner.log("example", "output super-secret and secret")
state = runner.state_path.read_text()
assert "super-secret" not in state
assert runner.state["logs"][0]["text"] == "output [REDACTED] and [REDACTED]"
def test_expired_lease_prevents_any_module_execution(setup_runner):
runner, api, step = setup_runner
api.action = "wait"
runner.execute = Mock()
assert runner.run() == 75
runner.execute.assert_not_called()
def test_tls_cannot_be_disabled(setup_runner):
runner, api, step = setup_runner
with pytest.raises(Halt, match="HTTPS"):
API({**runner.config, "api_url": "http://example.test"}, Mock())
with pytest.raises(ValueError, match="HTTPS"):
render_bootstrap({**runner.config, "api_url": "http://example.test"})
def test_bootstrap_is_self_contained_persistent_and_bounded(setup_runner):
runner, api, step = setup_runner
result = render_bootstrap({**runner.config, "ca_pem": "TEST CA"})
assert len(result.encode()) < 1024 * 1024
assert result.index("persist(etc / 'config.json'") < result.index("systemctl enable --now")
embedded_python = result.split("<<'PVE_BOOTSTRAP_PY'\n", 1)[1].split("\nPVE_BOOTSTRAP_PY", 1)[0]
compile(embedded_python, "bootstrap-embedded", "exec")
assert "Restart=on-failure" in result
assert "trusted-ca.pem" in result
def test_module_drafts_have_compilable_embedded_python_and_no_release_claims():
modules = catalog()
assert len(modules) == 8
for module in modules:
assert module["status"] == "draft"
assert not module["test_evidence"] and not module["target_builds"]
python = module["source"].split("<<'PY'\n", 1)[1].rsplit("\nPY", 1)[0]
compile(python, module["id"], "exec")
assert 'case "${1:-}" in check|apply|verify)' in module["source"]
def test_identity_binding_uses_observed_target_data(tmp_path):
dmi = tmp_path / "class/dmi/id"
dmi.mkdir(parents=True)
(dmi / "product_serial").write_text("SERIAL-123\n")
observed = discover_identities([{"kind": "serial", "value": "Serial-123"}], tmp_path)
assert observed == [{"kind": "serial", "value": "serial-123"}]
with pytest.raises(Halt, match="do not match"):
discover_identities([{"kind": "serial", "value": "different-host"}], tmp_path)
def test_logs_remain_bounded_with_contiguous_sequence(setup_runner):
runner, api, step = setup_runner
for index in range(12):
runner.log("example", "x" * 131072)
chunks = runner.state["logs"]
assert sum(len(chunk["text"].encode()) for chunk in chunks) <= 1024 * 1024
assert all(len(chunk["text"]) <= 16384 for chunk in chunks)
assert [chunk["sequence"] for chunk in chunks] == list(range(1, len(chunks) + 1))
runner.flush()
assert not runner.state["logs"]
def test_openssl_device_key_signatures_and_key_reuse(tmp_path, monkeypatch):
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
git_bin = Path("C:/Program Files/Git/usr/bin")
if not shutil.which("openssl") and (git_bin / "openssl.exe").exists():
monkeypatch.setenv("PATH", str(git_bin) + os.pathsep + os.environ["PATH"])
if not shutil.which("openssl"):
pytest.skip("OpenSSL is unavailable on this test workstation")
key = DeviceKey(tmp_path)
key.ensure()
public_key = key.public_key
body = canonical_json({"run_id": "run-test"})
message = f"POST\n/agent/v1/lease\n1234567890\nonce-123456789\n{hashlib.sha256(body).hexdigest()}".encode()
signature = base64.b64decode(key.sign(message))
Ed25519PublicKey.from_public_bytes(base64.b64decode(public_key)).verify(signature, message)
key.ensure()
assert key.public_key == public_key
if os.name == "posix":
assert key.path.stat().st_mode & 0o777 == 0o600
def test_bash_syntax_without_executing_provisioning_modules(setup_runner):
runner, api, step = setup_runner
git_bash = Path("C:/Program Files/Git/usr/bin/bash.exe")
bash = str(git_bash) if git_bash.exists() else shutil.which("bash")
if not bash:
pytest.skip("Bash parser unavailable")
sources = [module["source"] for module in catalog()] + [render_bootstrap(runner.config)]
for source in sources:
result = subprocess.run([bash, "-n"], input=source, text=True, capture_output=True, timeout=15)
assert result.returncode == 0, result.stderr
def module_helper(module_id, function_name, namespace=None):
"""Load one pure/helper function without executing the module's host actions."""
source = next(module["source"] for module in catalog() if module["id"] == module_id)
python = source.split("<<'PY'\n", 1)[1].rsplit("\nPY", 1)[0]
function = next(node for node in ast.parse(python).body if isinstance(node, ast.FunctionDef) and node.name == function_name)
scope = {} if namespace is None else namespace
exec(compile(ast.Module(body=[function], type_ignores=[]), module_id, "exec"), scope)
return scope[function_name]
def test_ssh_rejects_truncated_public_key_before_writing_accounts(monkeypatch):
git_bin = Path("C:/Program Files/Git/usr/bin")
if not shutil.which("ssh-keygen") and (git_bin / "ssh-keygen.exe").exists():
monkeypatch.setenv("PATH", str(git_bin) + os.pathsep + os.environ["PATH"])
if not shutil.which("ssh-keygen"):
pytest.skip("OpenSSH public-key validator unavailable")
validate = module_helper("ssh", "validate_public_key", {"base64": base64, "os": os,
"subprocess": subprocess, "tempfile": tempfile})
malformed = base64.b64encode((11).to_bytes(4, "big") + b"ssh-ed25519" + b"x").decode()
with pytest.raises(SystemExit, match="OpenSSH rejected"):
validate("ssh-ed25519 " + malformed)
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
valid = Ed25519PrivateKey.generate().public_key().public_bytes(Encoding.OpenSSH, PublicFormat.OpenSSH).decode()
assert validate(valid) is None
def test_repository_verification_rejects_stale_other_suite_indexes():
verify = module_helper("repositories", "has_repository_indexes")
policy = " 500 https://repo.example.test/debian bookworm/main amd64 Packages\n"
assert verify(policy, "https://repo.example.test/debian", "bookworm", ["main"])
assert not verify(policy, "https://repo.example.test/debian", "trixie", ["main"])
assert not verify(policy, "https://repo.example.test/debian", "bookworm", ["main", "contrib"])
assert not verify(policy, "https://repo.example.test/deb", "bookworm", ["main"])
def test_pending_reboot_waits_for_permission(setup_runner):
runner, api, step = setup_runner
runner.state.update(status="reboot_pending", reboot_boot_id=runner.boot_id)
api.action = "wait"
assert runner.run() == 75
assert runner.state["status"] == "reboot_pending"
def test_review_deadline_stops_even_when_authorization_is_unavailable(setup_runner):
runner, api, step = setup_runner
runner.state.update(status="needs_review", review_started_at=time.time() - 86401)
api.offline = True
assert runner.run() == 0
assert api.calls == []
@pytest.mark.skipif(os.name != "posix", reason="Requires native Linux subprocess supervision")
def test_native_phase_obeys_shared_timeout(setup_runner):
runner, api, step = setup_runner
api.artifact = b"#!/bin/bash\nprintf 'timeout-probe\\n'\nsleep 30\n"
step["digest"] = hashlib.sha256(api.artifact).hexdigest()
runner.heartbeat = Mock()
artifact = runner.artifact(step)
runner.step_deadline = time.monotonic() + 1
before = time.monotonic()
result = runner.execute(step, "apply", artifact, runner.directory / "unused.json")
assert result == 124
assert time.monotonic() - before < 5
assert "timeout-probe" in runner.state["logs"][0]["text"]
@pytest.mark.skipif(os.name != "posix", reason="Requires native Linux subprocess supervision")
def test_native_output_redacts_secret_crossing_capture_boundary(setup_runner):
runner, api, step = setup_runner
secret = "sensitive-value-across-output-boundary"
payload = "x" * (131072 - 5) + secret
api.artifact = ("#!/bin/bash\nprintf '%s' '" + payload + "'\n").encode()
step["digest"] = hashlib.sha256(api.artifact).hexdigest()
runner.secret_values = [secret]
runner.heartbeat = Mock()
result = runner.execute(step, "check", runner.artifact(step), runner.directory / "unused.json")
assert result == 0
assert "sensi" not in "".join(chunk["text"] for chunk in runner.state["logs"])