feat(auftraege): worker, Phasen und Live-Fortschritt per SSE

Aufträge laufen als pg-boss-Jobs im worker. Der Phasen-Runner der Engine
kennt weder Proxmox noch Datenbank; eine Senke schreibt Phasen und Log
in die DB und meldet per NOTIFY, die app streamt per SSE an Web und CLI
(Last-Event-ID, Herzschlag, Polling-Fallback im Browser).

VMIDs vergibt eine Advisory-Sperre je Server zusammen mit einer
Reservierungstabelle; zehn parallele Reservierungen im Test bekommen
zehn verschiedene IDs. Der Proxmox-Client hat einen Trockenmodus für
den Dry-Run: Lesendes echt, Schreibendes nur aufgezeichnet.

Live-Ansicht im Aufbau der CLI-Ausgabe. Im echten Betrieb aufgefallen:
ssh2 und pg sind CommonJS und brauchen im worker Default-Importe.

Closes #006

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:54:41 +02:00
co-authored by Claude Opus 5.5
parent 01d22aac3a
commit 76bc0e4269
38 changed files with 2274 additions and 22 deletions
+17 -10
View File
@@ -1,25 +1,32 @@
# #006 Aufträge, Phasen, worker und Live-Fortschritt
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.2.0
**Labels:** feature, engine
**Geschätzt:** 8 h **Tatsächlich:** –
**Geschätzt:** 8 h **Tatsächlich:** 0 h 15 min
## Ziel
Hintergrund-Jobs mit Phasen in der DB, live per SSE in der Web-GUI.
## Aufgaben
- [ ] pg-boss im worker, Modelle `Auftrag`, `Phase`, `Logeintrag`, `VmidReservierung`
- [ ] Phasen-Runner mit Ereignis-Senke (DB + NOTIFY), Fehler mit Ursache + Hinweis
- [ ] SSE-Endpunkt mit Last-Event-ID, Polling-Fallback
- [ ] VMID-Vergabe mit Advisory-Lock, Test mit parallelen Aufträgen
- [ ] Live-Ansicht als Terminal-Karte, Detail-Log, Ergebnis-Karte
- [x] pg-boss im worker, Modelle `Auftrag`, `Phase`, `Logeintrag`, `VmidReservierung`
- [x] Phasen-Runner (frameworkfrei) mit Ereignis-Senke (DB + NOTIFY), Fehler mit Ursache + Hinweis, Abbruch zwischen Phasen
- [x] SSE-Endpunkt mit Last-Event-ID, Herzschlag, `X-Accel-Buffering: no`; Polling-Fallback im Client
- [x] VMID-Vergabe mit Advisory-Lock, Test mit zehn parallelen Reservierungen
- [x] Live-Ansicht als Terminal-Karte (Aufbau der CLI-Ausgabe), Detail-Log, Ergebnis-Box; Verlaufsseite
- [x] Verwaiste Aufträge nach worker-Neustart als fehlgeschlagen markieren; doppelt gelieferte Jobs laufen nur einmal
## Akzeptanzkriterien
- [ ] Zwei parallele Aufträge bekommen nie dieselbe VMID (Test)
- [ ] Neuladen der Seite mitten im Auftrag zeigt den vollständigen Stand
- [x] Zwei parallele Aufträge bekommen nie dieselbe VMID (Test)
- [x] Neuladen der Seite mitten im Auftrag zeigt den vollständigen Stand (Stand kommt immer ganz, Log ab Last-Event-ID)
- [x] Live geprüft mit einem echten Template-Bau auf pve-demo (#007)
## Abhängigkeiten
#003
## Notizen / Entscheidungen
–
- **ssh2 und pg sind CommonJS:** benannte Importe gehen im Next-Bundle, aber nicht im worker unter reinem Node-ESM – jetzt Default-Importe.
- **Der worker lädt `.env` selbst** (`process.loadEnvFile`, falls vorhanden); im Container kommt die Umgebung von außen.
- **Kein automatisches Wiederholen** (`retryLimit: 0`): halb angelegte Gäste soll ein Mensch ansehen.
- **Abfrageschleifen** (Task-Status, VM-Status) erscheinen im Detail-Log nur bei Fehlern.
- **Kopfzeile** (`Auftrag.kopf`) setzt die Engine – Web und CLI zeigen denselben Text.
- Client-Komponenten importieren nichts aus `src/server` (struktureller Test in `tests/zugang.test.ts`).
+1 -1
View File
@@ -9,7 +9,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#003](003-verbindungstest.md) | Verbindungstest und Fähigkeitserkennung | erledigt | v0.1.0 | 6 h | 0 h 25 min |
| [#004](004-katalog.md) | Katalog: Distributionen und Profile als Daten | erledigt | v0.2.0 | 5 h | 0 h 09 min |
| [#005](005-adapter-profile.md) | Adapter debian und Profil-Engine | erledigt | v0.2.0 | 6 h | 0 h 20 min |
| [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | offen | v0.2.0 | 8 h | – |
| [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | erledigt | v0.2.0 | 8 h | 0 h 15 min |
| [#007](007-template-bau.md) | Template-Bau für VMs | offen | v0.2.0 | 8 h | – |
| [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | offen | v0.2.0 | 10 h | – |
| [#009](009-lxc.md) | LXC-Container ausrollen | offen | v0.3.0 | 8 h | – |
+4 -1
View File
@@ -24,7 +24,9 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit"
"test:container": "tests/container/profile-pruefen.sh",
"typecheck": "tsc --noEmit",
"worker": "tsx src/worker/main.ts"
},
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.3.0",
@@ -37,6 +39,7 @@
"lucide-react": "1.47.0",
"next": "16.4.0",
"pg": "^8.23.0",
"pg-boss": "12.37.1",
"pino": "10.4.0",
"react": "19.3.0",
"react-dom": "19.3.0",
+87 -4
View File
@@ -192,10 +192,13 @@ importers:
version: 1.47.0(react@19.3.0)
next:
specifier: 16.4.0
version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
version: 16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
pg:
specifier: ^8.23.0
version: 8.23.1
pg-boss:
specifier: 12.37.1
version: 12.37.1(@opentelemetry/api@1.9.1)
pino:
specifier: 10.4.0
version: 10.4.0
@@ -286,7 +289,7 @@ importers:
version: 8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)
vitest:
specifier: 5.0.1
version: 5.0.1(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
version: 5.0.1(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
packages:
@@ -1199,6 +1202,10 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@opentelemetry/api@1.9.1':
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
engines: {node: '>=8.0.0'}
'@oxc-project/types@0.153.0':
resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==}
@@ -2109,6 +2116,10 @@ packages:
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
engines: {node: '>=10.0.0'}
cron-parser@5.10.2:
resolution: {integrity: sha512-JfAYMY0LZaWQTuict8PhtF/ksSnq8xefSDmQUnnoqvkPYkU/y3UP0DTWzJKFBz0P1IXsDG8q0vY3zHkoFZOhvw==}
engines: {node: '>=18'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -3080,6 +3091,10 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -3164,6 +3179,10 @@ packages:
resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==}
engines: {node: '>=18'}
non-error@0.1.0:
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
engines: {node: '>=20'}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -3248,6 +3267,13 @@ packages:
perfect-debounce@2.1.0:
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
pg-boss@12.37.1:
resolution: {integrity: sha512-LghWeOIbMCiLcTY3+8MInXaJ0wk0AOkbeUZ8oE+cGO0uoheGSW3GK57eDqEbrDGpwTIpX9BVphvdvWEDYTcJ9A==}
engines: {node: '>=22.12.0'}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.9.0
pg-cloudflare@1.4.1:
resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==}
@@ -3469,6 +3495,9 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
rrule-temporal@2.2.8:
resolution: {integrity: sha512-+wWUe0J02oMh1UWKIt1RTu2H0Cz+gP11O/iweK0MN2UP6JLGeepTUkbSBa90Hxq0W+ywSxM9IlJNouIUdMOwhg==}
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -3510,6 +3539,10 @@ packages:
seq-queue@0.0.5:
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
serialize-error@13.0.2:
resolution: {integrity: sha512-G+YKwp9I6IvWMe5RlwqxdmTPIcq/G8HK/sU51gdV1XAJznYkRSTB0ph+zB9AfSxG0fx6xrqYA6i9HeIcHFTXow==}
engines: {node: '>=20'}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -3668,6 +3701,10 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
tailwindcss@4.3.3:
resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
@@ -3675,6 +3712,9 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
temporal-spec@1.0.1:
resolution: {integrity: sha512-wxVoanmDeavXie1vu2JaQ3WIc3JZnWAOYFBsJyATaVsXsycKYUflGsyBmrRSnoCpZJpwPyr38VpgSUlQ8CbFxg==}
thread-stream@4.2.0:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'}
@@ -3723,6 +3763,10 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
type-fest@5.10.0:
resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==}
engines: {node: '>=20'}
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -4663,6 +4707,8 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@opentelemetry/api@1.9.1': {}
'@oxc-project/types@0.153.0': {}
'@pinojs/redact@0.4.0': {}
@@ -5557,6 +5603,10 @@ snapshots:
nan: 2.29.0
optional: true
cron-parser@5.10.2:
dependencies:
luxon: 3.7.2
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -6640,6 +6690,8 @@ snapshots:
dependencies:
react: 19.3.0
luxon@3.7.2: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
@@ -6694,7 +6746,7 @@ snapshots:
natural-compare@1.4.0: {}
next@16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
next@16.4.0(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies:
'@next/env': 16.4.0
'@swc/helpers': 0.5.23
@@ -6713,6 +6765,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.4.0
'@next/swc-win32-arm64-msvc': 16.4.0
'@next/swc-win32-x64-msvc': 16.4.0
'@opentelemetry/api': 1.9.1
'@playwright/test': 1.63.0
sharp: 0.35.5(@types/node@24.19.2)
transitivePeerDependencies:
@@ -6729,6 +6782,8 @@ snapshots:
node-releases@2.0.58: {}
non-error@0.1.0: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -6822,6 +6877,16 @@ snapshots:
perfect-debounce@2.1.0: {}
pg-boss@12.37.1(@opentelemetry/api@1.9.1):
dependencies:
'@opentelemetry/api': 1.9.1
cron-parser: 5.10.2
pg: 8.23.1
rrule-temporal: 2.2.8
serialize-error: 13.0.2
transitivePeerDependencies:
- pg-native
pg-cloudflare@1.4.1:
optional: true
@@ -7052,6 +7117,10 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.2.13
'@rolldown/binding-win32-x64-msvc': 1.2.13
rrule-temporal@2.2.8:
dependencies:
temporal-spec: 1.0.1
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -7091,6 +7160,11 @@ snapshots:
seq-queue@0.0.5: {}
serialize-error@13.0.2:
dependencies:
non-error: 0.1.0
type-fest: 5.10.0
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -7303,10 +7377,14 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
tagged-tag@1.0.0: {}
tailwindcss@4.3.3: {}
tapable@2.3.3: {}
temporal-spec@1.0.1: {}
thread-stream@4.2.0:
dependencies:
real-require: 1.0.0
@@ -7351,6 +7429,10 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
type-fest@5.10.0:
dependencies:
tagged-tag: 1.0.0
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -7467,7 +7549,7 @@ snapshots:
tsx: 4.23.15
yaml: 2.9.1
vitest@5.0.1(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)):
vitest@5.0.1(@opentelemetry/api@1.9.1)(@types/node@24.19.2)(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))
@@ -7484,6 +7566,7 @@ snapshots:
vite: 8.3.0(@types/node@24.19.2)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 24.19.2
transitivePeerDependencies:
- msw
@@ -0,0 +1,118 @@
-- CreateEnum
CREATE TYPE "GastTyp" AS ENUM ('VM', 'LXC');
-- CreateEnum
CREATE TYPE "AuftragsArt" AS ENUM ('AUSROLLEN', 'TEMPLATE_BAUEN', 'AUFRAEUMEN');
-- CreateEnum
CREATE TYPE "AuftragsStatus" AS ENUM ('WARTEND', 'LAEUFT', 'ERFOLGREICH', 'FEHLGESCHLAGEN', 'ABGEBROCHEN', 'AUFGERAEUMT');
-- CreateEnum
CREATE TYPE "PhasenStatus" AS ENUM ('OFFEN', 'LAEUFT', 'ERLEDIGT', 'FEHLER', 'UEBERSPRUNGEN');
-- CreateTable
CREATE TABLE "Auftrag" (
"id" TEXT NOT NULL,
"kurzId" TEXT NOT NULL,
"art" "AuftragsArt" NOT NULL,
"status" "AuftragsStatus" NOT NULL DEFAULT 'WARTEND',
"serverId" TEXT NOT NULL,
"knoten" TEXT,
"gastTyp" "GastTyp",
"vmid" INTEGER,
"name" TEXT,
"distributionId" TEXT,
"profile" TEXT[],
"anfrage" JSONB NOT NULL,
"plan" JSONB,
"kopf" TEXT,
"ergebnis" JSONB,
"angelegt" JSONB,
"fehler" JSONB,
"quelle" "Quelle" NOT NULL,
"erstelltVonId" TEXT NOT NULL,
"wiederholtVonId" TEXT,
"bezugId" TEXT,
"abbrechen" BOOLEAN NOT NULL DEFAULT false,
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"gestartetAm" TIMESTAMP(3),
"beendetAm" TIMESTAMP(3),
CONSTRAINT "Auftrag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Phase" (
"id" TEXT NOT NULL,
"auftragId" TEXT NOT NULL,
"reihenfolge" INTEGER NOT NULL,
"schluessel" TEXT NOT NULL,
"titel" TEXT NOT NULL,
"status" "PhasenStatus" NOT NULL DEFAULT 'OFFEN',
"detail" TEXT,
"ursache" TEXT,
"hinweis" TEXT,
"gestartetAm" TIMESTAMP(3),
"beendetAm" TIMESTAMP(3),
CONSTRAINT "Phase_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Logeintrag" (
"id" BIGSERIAL NOT NULL,
"auftragId" TEXT NOT NULL,
"zeit" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"stufe" TEXT NOT NULL,
"phase" TEXT,
"nachricht" TEXT NOT NULL,
"daten" JSONB,
CONSTRAINT "Logeintrag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VmidReservierung" (
"serverId" TEXT NOT NULL,
"vmid" INTEGER NOT NULL,
"auftragId" TEXT NOT NULL,
"ablaufAm" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VmidReservierung_pkey" PRIMARY KEY ("serverId","vmid")
);
-- CreateIndex
CREATE UNIQUE INDEX "Auftrag_kurzId_key" ON "Auftrag"("kurzId");
-- CreateIndex
CREATE INDEX "Auftrag_serverId_erstelltAm_idx" ON "Auftrag"("serverId", "erstelltAm");
-- CreateIndex
CREATE INDEX "Auftrag_status_idx" ON "Auftrag"("status");
-- CreateIndex
CREATE INDEX "Auftrag_erstelltAm_idx" ON "Auftrag"("erstelltAm");
-- CreateIndex
CREATE INDEX "Phase_auftragId_reihenfolge_idx" ON "Phase"("auftragId", "reihenfolge");
-- CreateIndex
CREATE UNIQUE INDEX "Phase_auftragId_schluessel_key" ON "Phase"("auftragId", "schluessel");
-- CreateIndex
CREATE INDEX "Logeintrag_auftragId_id_idx" ON "Logeintrag"("auftragId", "id");
-- CreateIndex
CREATE INDEX "VmidReservierung_auftragId_idx" ON "VmidReservierung"("auftragId");
-- AddForeignKey
ALTER TABLE "Auftrag" ADD CONSTRAINT "Auftrag_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Auftrag" ADD CONSTRAINT "Auftrag_erstelltVonId_fkey" FOREIGN KEY ("erstelltVonId") REFERENCES "Benutzer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Phase" ADD CONSTRAINT "Phase_auftragId_fkey" FOREIGN KEY ("auftragId") REFERENCES "Auftrag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Logeintrag" ADD CONSTRAINT "Logeintrag_auftragId_fkey" FOREIGN KEY ("auftragId") REFERENCES "Auftrag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "Template" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"knoten" TEXT NOT NULL,
"vmid" INTEGER NOT NULL,
"distributionId" TEXT NOT NULL,
"storage" TEXT NOT NULL,
"imageDatei" TEXT NOT NULL,
"pruefsumme" TEXT NOT NULL,
"gebautVonAuftragId" TEXT,
"gebautAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Template_serverId_distributionId_idx" ON "Template"("serverId", "distributionId");
-- CreateIndex
CREATE UNIQUE INDEX "Template_serverId_vmid_key" ON "Template"("serverId", "vmid");
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "Server"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+138
View File
@@ -38,6 +38,7 @@ model Benutzer {
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
protokoll Protokoll[]
auftraege Auftrag[]
}
/// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE.
@@ -99,6 +100,8 @@ model Server {
geprueftAm DateTime?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
auftraege Auftrag[]
templates Template[]
}
enum Herkunft {
@@ -139,3 +142,138 @@ model Profil {
geaendertVonId String?
geaendertAm DateTime @updatedAt
}
enum GastTyp {
VM
LXC
}
enum AuftragsArt {
AUSROLLEN
TEMPLATE_BAUEN
AUFRAEUMEN
}
enum AuftragsStatus {
WARTEND
LAEUFT
ERFOLGREICH
FEHLGESCHLAGEN
ABGEBROCHEN
AUFGERAEUMT
}
enum PhasenStatus {
OFFEN
LAEUFT
ERLEDIGT
FEHLER
UEBERSPRUNGEN
}
/// Ein Hintergrundjob: Ausrollen, Template bauen oder Aufräumen. Läuft im worker.
model Auftrag {
id String @id @default(cuid())
/// Kurz und tippbar für CLI und URLs: „d-7f3k2“.
kurzId String @unique
art AuftragsArt
status AuftragsStatus @default(WARTEND)
serverId String
server Server @relation(fields: [serverId], references: [id], onDelete: Restrict)
knoten String?
gastTyp GastTyp?
vmid Int?
name String?
distributionId String?
profile String[]
/// Validierte Anfrage – Grundlage für „Wiederholen“.
anfrage Json
/// Plan zum Zeitpunkt des Starts (Phasen und geplante API-Aufrufe).
plan Json?
/// Kopfzeile wie in der CLI: „Server rz1 · Node pve01 · Debian 13 (Template 9013)“ – von der Engine gesetzt.
kopf String?
/// IP, SSH-Befehl, Dauer …
ergebnis Json?
/// Was ProxDeploy angelegt hat – Grundlage fürs Aufräumen.
angelegt Json?
/// { phase, ursache, hinweis }
fehler Json?
quelle Quelle
erstelltVonId String
erstelltVon Benutzer @relation(fields: [erstelltVonId], references: [id], onDelete: Restrict)
wiederholtVonId String?
/// Bei AUFRAEUMEN: der Auftrag, dessen Reste entfernt werden.
bezugId String?
abbrechen Boolean @default(false)
erstelltAm DateTime @default(now())
gestartetAm DateTime?
beendetAm DateTime?
phasen Phase[]
log Logeintrag[]
@@index([serverId, erstelltAm])
@@index([status])
@@index([erstelltAm])
}
model Phase {
id String @id @default(cuid())
auftragId String
auftrag Auftrag @relation(fields: [auftragId], references: [id], onDelete: Cascade)
reihenfolge Int
schluessel String
titel String
status PhasenStatus @default(OFFEN)
/// Der graue Detailtext hinter dem Phasennamen – in Web und CLI derselbe.
detail String?
ursache String?
hinweis String?
gestartetAm DateTime?
beendetAm DateTime?
@@unique([auftragId, schluessel])
@@index([auftragId, reihenfolge])
}
model Logeintrag {
/// Zugleich die Event-ID im SSE-Strom (Last-Event-ID).
id BigInt @id @default(autoincrement())
auftragId String
auftrag Auftrag @relation(fields: [auftragId], references: [id], onDelete: Cascade)
zeit DateTime @default(now())
stufe String
phase String?
nachricht String
daten Json?
@@index([auftragId, id])
}
/// Vergebene VMIDs, solange ein Auftrag läuft – verhindert Doppelvergabe zwischen parallelen Aufträgen.
model VmidReservierung {
serverId String
vmid Int
auftragId String
ablaufAm DateTime
@@id([serverId, vmid])
@@index([auftragId])
}
/// Von ProxDeploy gebaute Templates. Maßgeblich ist Proxmox (Tag proxdeploy-template); hier steht, woraus gebaut wurde.
model Template {
id String @id @default(cuid())
serverId String
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
knoten String
vmid Int
distributionId String
storage String
imageDatei String
pruefsumme String
gebautVonAuftragId String?
gebautAm DateTime @default(now())
@@unique([serverId, vmid])
@@index([serverId, distributionId])
}
@@ -0,0 +1,194 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Code } from "@/components/formular";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { STATUS_ANZEIGE } from "@/lib/auftrag-text";
import { TerminalKarte, type TerminalErgebnis } from "@/components/terminal-karte";
import { apiAufruf } from "@/lib/api-client";
import { dauer } from "@/lib/formate";
import type { AuftragSicht, LogZeile } from "@/lib/schemas/auftrag";
const ENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
function ergebnisAus(a: AuftragSicht): TerminalErgebnis | null {
if (!ENDE.has(a.status)) return null;
const e = (a.ergebnis ?? {}) as Record<string, unknown>;
const gesamt =
a.gestartetAm && a.beendetAm
? dauer(new Date(a.beendetAm).getTime() - new Date(a.gestartetAm).getTime())
: null;
const name = a.name ?? a.kurzId;
if (a.status !== "ERFOLGREICH") {
return {
ok: false,
titel: `${name} ${a.status === "ABGEBROCHEN" ? "abgebrochen" : "fehlgeschlagen"}`,
dauer: gesamt,
zeilen: [],
};
}
const zeilen: Array<[string, string]> = [];
if (a.vmid) zeilen.push([a.gastTyp === "LXC" ? "CTID" : "VMID", String(a.vmid)]);
if (typeof e["ip"] === "string") zeilen.push(["IP", e["ip"]]);
if (a.profile.length > 0) zeilen.push(["Profile", a.profile.join(", ")]);
if (typeof e["ssh"] === "string") zeilen.push(["SSH", e["ssh"]]);
return { ok: true, titel: `${name} bereit`, dauer: gesamt, zeilen };
}
/**
* Hängt sich per SSE an den Auftrag. Fällt SSE aus (z. B. ein Proxy, der Streams puffert), wird
* alle 3 s abgefragt – die Anzeige bleibt dieselbe.
*/
export function LiveAnsicht({
anfang,
anfangsLog,
version,
abbrechenErlaubt,
}: {
anfang: AuftragSicht;
anfangsLog: LogZeile[];
version: string;
abbrechenErlaubt: boolean;
}) {
const [auftrag, setAuftrag] = useState(anfang);
const [log, setLog] = useState(anfangsLog);
const [verbindung, setVerbindung] = useState<"live" | "abfrage" | "beendet">(
ENDE.has(anfang.status) ? "beendet" : "live",
);
const logBis = useRef(anfangsLog.at(-1)?.id ?? "0");
useEffect(() => {
if (ENDE.has(anfang.status)) return;
let fehlschlaege = 0;
let abfrage: ReturnType<typeof setInterval> | undefined;
const quelle = new EventSource(`/api/v1/auftraege/${anfang.kurzId}/ereignisse?ab=${logBis.current}`);
const zeilenAnhaengen = (neu: LogZeile[]) => {
const bekannt = BigInt(logBis.current);
const frisch = neu.filter((z) => BigInt(z.id) > bekannt);
if (frisch.length === 0) return;
logBis.current = frisch.at(-1)!.id;
setLog((alt) => [...alt, ...frisch]);
};
quelle.addEventListener("stand", (e) => {
fehlschlaege = 0;
setAuftrag(JSON.parse((e as MessageEvent).data));
});
quelle.addEventListener("log", (e) => zeilenAnhaengen(JSON.parse((e as MessageEvent).data)));
quelle.addEventListener("ende", () => {
quelle.close();
setVerbindung("beendet");
});
quelle.onerror = () => {
fehlschlaege += 1;
if (fehlschlaege < 3 || abfrage) return;
quelle.close();
setVerbindung("abfrage");
abfrage = setInterval(async () => {
try {
const stand = await apiAufruf<AuftragSicht>("GET", `/api/v1/auftraege/${anfang.kurzId}`);
setAuftrag(stand);
zeilenAnhaengen(
await apiAufruf<LogZeile[]>("GET", `/api/v1/auftraege/${anfang.kurzId}/log?ab=${logBis.current}`),
);
if (ENDE.has(stand.status)) {
clearInterval(abfrage);
setVerbindung("beendet");
}
} catch {
// nächster Versuch in 3 s
}
}, 3000);
};
return () => {
quelle.close();
if (abfrage) clearInterval(abfrage);
};
}, [anfang.kurzId, anfang.status]);
const [art, text] =
auftrag.status === "WARTEND"
? (["offen", "wartet auf den worker"] as const)
: STATUS_ANZEIGE[auftrag.status];
const ergebnis = ergebnisAus(auftrag);
const ssh = (auftrag.ergebnis as Record<string, unknown> | null)?.["ssh"];
return (
<div className="flex flex-col gap-6">
<header className="flex flex-wrap items-end justify-between gap-4">
<div className="min-w-0">
<p className="text-muted font-mono text-sm">
{auftrag.kurzId} · gestartet von {auftrag.erstelltVon}
</p>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
{auftrag.art === "TEMPLATE_BAUEN"
? "Template bauen"
: auftrag.art === "AUFRAEUMEN"
? "Aufräumen"
: (auftrag.name ?? "Ausrollen")}
</h1>
</div>
<div className="flex items-center gap-4">
<Status art={art}>{text}</Status>
{abbrechenErlaubt && !ENDE.has(auftrag.status) ? (
<Knopf
variante="gefahr"
onClick={() => void apiAufruf("POST", `/api/v1/auftraege/${auftrag.kurzId}/abbrechen`)}
>
Abbrechen
</Knopf>
) : null}
</div>
</header>
<TerminalKarte version={version} kopf={auftrag.kopf} phasen={auftrag.phasen} ergebnis={ergebnis} />
{typeof ssh === "string" ? (
<div className="flex flex-col gap-1.5">
<span className="text-muted text-sm">SSH-Befehl</span>
<Code>{ssh}</Code>
</div>
) : null}
<details
className="bg-panel border-linie rounded-karte border"
open={auftrag.status === "FEHLGESCHLAGEN"}
>
<summary className="cursor-pointer px-5 py-3.5 font-semibold">
Detail-Log <span className="text-muted font-normal">({log.length} Zeilen)</span>
</summary>
<ol className="border-linie max-h-[60vh] overflow-auto border-t px-5 py-3 font-mono text-xs leading-5">
{log.map((zeile) => (
<li key={zeile.id} className="flex gap-3">
<time className="text-muted shrink-0">{new Date(zeile.zeit).toLocaleTimeString("de-DE")}</time>
<span
className={
zeile.stufe === "error"
? "text-fehler"
: zeile.stufe === "warn"
? "text-laeuft"
: zeile.stufe === "debug"
? "text-muted"
: "text-text"
}
>
{zeile.phase ? <span className="text-muted">[{zeile.phase}] </span> : null}
<span className="break-all whitespace-pre-wrap">{zeile.nachricht}</span>
</span>
</li>
))}
</ol>
</details>
<p className="text-muted text-xs" aria-live="polite">
{verbindung === "live"
? "Live verbunden."
: verbindung === "abfrage"
? "Live-Verbindung nicht möglich – Aktualisierung alle 3 s."
: null}
</p>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { versionText } from "@/lib/version";
import { darf } from "@/server/auth/rechte";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { AnwendungsFehler } from "@/server/fehler";
import { auftragHolen, logAb } from "@/server/services/auftraege";
import { LiveAnsicht } from "./live-ansicht";
export async function generateMetadata({
params,
}: {
params: Promise<{ kurzId: string }>;
}): Promise<Metadata> {
return { title: (await params).kurzId };
}
export default async function AuftragSeite({ params }: { params: Promise<{ kurzId: string }> }) {
const { kurzId } = await params;
const benutzer = await seiteSchuetzen("lesen", `/auftraege/${kurzId}`);
const auftrag = await auftragHolen(kurzId).catch((fehler) => {
if (fehler instanceof AnwendungsFehler && fehler.status === 404) notFound();
throw fehler;
});
const log = await logAb(auftrag.id, BigInt(0), 2000);
return (
<LiveAnsicht
anfang={auftrag}
anfangsLog={log}
version={versionText()}
abbrechenErlaubt={darf(benutzer.rolle, "ausrollen")}
/>
);
}
+55
View File
@@ -0,0 +1,55 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import { ART_TEXT, STATUS_ANZEIGE } from "@/lib/auftrag-text";
import { zeitpunkt } from "@/lib/formate";
import { seiteSchuetzen } from "@/server/auth/zugang";
import { auftraegeListe } from "@/server/services/auftraege";
export const metadata: Metadata = { title: "Verlauf" };
/** Verlauf aller Aufträge. Filter und Wiederholen kommen mit #014. */
export default async function Verlauf() {
await seiteSchuetzen("lesen", "/auftraege");
const auftraege = await auftraegeListe({ grenze: 100 });
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Verlauf</h1>
<Karte>
{auftraege.length === 0 ? (
<Status art="offen">Noch keine Aufträge.</Status>
) : (
<ul className="divide-linie flex flex-col divide-y">
{auftraege.map((a) => {
const [art, text] = STATUS_ANZEIGE[a.status];
return (
<li key={a.id}>
<Link
href={`/auftraege/${a.kurzId}`}
className="hover:bg-panel-hoch/60 -mx-2 flex flex-col gap-1 rounded-lg px-2 py-3 sm:flex-row sm:items-center sm:gap-4"
>
<span className="font-mono text-sm sm:w-20">{a.kurzId}</span>
<span className="min-w-0 flex-1">
<span className="font-medium">{a.name ?? ART_TEXT[a.art]}</span>
<span className="text-muted text-sm">
{" "}
· {ART_TEXT[a.art]} · {a.server.name}
{a.distributionId ? ` · ${a.distributionId}` : ""}
{a.vmid ? ` · ${a.vmid}` : ""}
</span>
</span>
<span className="text-muted text-sm sm:w-28">{zeitpunkt(a.erstelltAm)}</span>
<Status art={art} className="text-sm sm:w-32">
{text}
</Status>
</Link>
</li>
);
})}
</ul>
)}
</Karte>
</div>
);
}
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { auftragAbbrechen } from "@/server/services/auftraege";
type Kontext = { params: Promise<{ id: string }> };
export const POST = api(async (anfrage: Request, { params }: Kontext) => {
const aufrufer = await rechtErforderlich(anfrage, "ausrollen");
await auftragAbbrechen((await params).id, aufrufer, aufruferAdresse(anfrage.headers));
return NextResponse.json({ ok: true });
});
@@ -0,0 +1,101 @@
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { abonnieren } from "@/server/ereignisse/hoeren";
import { auftragHolen, ENDZUSTAENDE, logAb } from "@/server/services/auftraege";
type Kontext = { params: Promise<{ id: string }> };
export const dynamic = "force-dynamic";
/**
* Live-Fortschritt als Server-Sent Events – für Web-GUI und CLI derselbe Strom.
*
* - `stand`: der vollständige Auftrag mit allen Phasen (klein, deshalb immer ganz)
* - `log`: neue Detail-Log-Zeilen; ihre ID ist die Event-ID, damit ein Wiederverbinden mit
* `Last-Event-ID` (oder `?ab=`) genau dort weitermacht
* - `ende`: der Auftrag ist fertig, der Strom schließt
*/
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
await rechtErforderlich(anfrage, "lesen");
const anfangsStand = await auftragHolen((await params).id);
const auftragId = anfangsStand.id;
const letzte = anfrage.headers.get("last-event-id") ?? new URL(anfrage.url).searchParams.get("ab") ?? "0";
let logBis = BigInt(/^\d+$/.test(letzte) ? letzte : "0");
const kodierer = new TextEncoder();
let aufraeumen = () => {};
const strom = new ReadableStream<Uint8Array>({
async start(steuerung) {
let offen = true;
const senden = (text: string) => {
if (offen) steuerung.enqueue(kodierer.encode(text));
};
const ereignis = (art: string, daten: unknown, id?: string) =>
senden(`${id ? `id: ${id}\n` : ""}event: ${art}\ndata: ${JSON.stringify(daten)}\n\n`);
let laeuft = false;
let nochmal = false;
const aktualisieren = async () => {
// Signale können dicht kommen; zusammenfassen statt parallel abzufragen.
if (laeuft) {
nochmal = true;
return;
}
laeuft = true;
try {
do {
nochmal = false;
const stand = await auftragHolen(auftragId);
const zeilen = await logAb(auftragId, logBis);
if (zeilen.length > 0) {
logBis = BigInt(zeilen.at(-1)!.id);
ereignis("log", zeilen, zeilen.at(-1)!.id);
}
ereignis("stand", stand);
if (ENDZUSTAENDE.has(stand.status)) {
ereignis("ende", { status: stand.status });
schliessen();
return;
}
} while (nochmal);
} finally {
laeuft = false;
}
};
const abmelden = await abonnieren(auftragId, () => void aktualisieren());
// Herzschlag: hält Proxys davon ab, die Verbindung als tot zu schließen.
const herz = setInterval(() => senden(": ping\n\n"), 15_000);
const schliessen = () => {
if (!offen) return;
offen = false;
clearInterval(herz);
abmelden();
try {
steuerung.close();
} catch {
// schon geschlossen
}
};
aufraeumen = schliessen;
anfrage.signal.addEventListener("abort", schliessen);
senden("retry: 3000\n\n");
await aktualisieren();
},
cancel() {
aufraeumen();
},
});
return new Response(strom, {
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
// nginx puffert sonst und der Fortschritt käme in Brocken an.
"x-accel-buffering": "no",
},
});
});
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { auftragHolen, logAb } from "@/server/services/auftraege";
type Kontext = { params: Promise<{ id: string }> };
/** Detail-Log ab einer Zeilen-ID (`?ab=`), für Polling und `proxdeploy logs`. */
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
await rechtErforderlich(anfrage, "lesen");
const auftrag = await auftragHolen((await params).id);
const ab = new URL(anfrage.url).searchParams.get("ab");
return NextResponse.json(await logAb(auftrag.id, BigInt(/^\d+$/.test(ab ?? "") ? ab! : "0")));
});
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { auftragHolen } from "@/server/services/auftraege";
type Kontext = { params: Promise<{ id: string }> };
export const GET = api(async (anfrage: Request, { params }: Kontext) => {
await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(await auftragHolen((await params).id));
});
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { api } from "@/server/api";
import { rechtErforderlich } from "@/server/auth/zugang";
import { auftraegeListe } from "@/server/services/auftraege";
/** Verlauf. Filter und Seitenblättern folgen mit #014. */
export const GET = api(async (anfrage: Request) => {
await rechtErforderlich(anfrage, "lesen");
return NextResponse.json(await auftraegeListe());
});
+179
View File
@@ -0,0 +1,179 @@
"use client";
import { clsx } from "clsx";
import { useEffect, useState } from "react";
import { StatusSymbol, type StatusArt } from "@/components/status";
import { dauer } from "@/lib/formate";
/**
* Die Live-Ansicht im Aufbau der CLI-Ausgabe: Kopfzeile, eine Zeile je Phase (Symbol, Name in
* fester Breite, Detail grau), am Ende die Ergebnis-Box. Alle Texte kommen aus der Engine.
*/
export type TerminalPhase = {
schluessel: string;
titel: string;
status: "OFFEN" | "LAEUFT" | "ERLEDIGT" | "FEHLER" | "UEBERSPRUNGEN";
detail: string | null;
ursache: string | null;
hinweis: string | null;
gestartetAm: string | null;
};
export type TerminalErgebnis = {
ok: boolean;
titel: string;
dauer: string | null;
zeilen: Array<[string, string]>;
};
const ART: Record<TerminalPhase["status"], StatusArt> = {
OFFEN: "offen",
LAEUFT: "laeuft",
ERLEDIGT: "erledigt",
FEHLER: "fehler",
UEBERSPRUNGEN: "offen",
};
const FARBE: Record<StatusArt, string> = {
erledigt: "text-akzent",
laeuft: "text-laeuft",
fehler: "text-fehler",
offen: "text-muted",
};
function Laufzeit({ seit }: { seit: string }) {
const [jetzt, setJetzt] = useState(() => Date.now());
useEffect(() => {
const uhr = setInterval(() => setJetzt(Date.now()), 1000);
return () => clearInterval(uhr);
}, []);
return <span>{dauer(jetzt - new Date(seit).getTime())}</span>;
}
export function TerminalKarte({
version,
kopf,
trockenlauf = false,
phasen,
ergebnis,
}: {
version: string;
kopf: string;
trockenlauf?: boolean;
phasen: TerminalPhase[];
ergebnis: TerminalErgebnis | null;
}) {
const breite = Math.max(12, ...phasen.map((p) => p.titel.length)) + 2;
return (
<div className="bg-panel border-linie rounded-karte overflow-hidden border font-mono text-[13px] leading-6 sm:text-sm">
<div
className="flex items-center gap-1.5 border-b border-[var(--farbe-linie)] px-4 py-2"
aria-hidden="true"
>
<span className="bg-linie h-2.5 w-2.5 rounded-full" />
<span className="bg-linie h-2.5 w-2.5 rounded-full" />
<span className="bg-linie h-2.5 w-2.5 rounded-full" />
</div>
<div className="flex flex-col gap-4 p-4 sm:p-5">
<p className="break-words">
<span className="text-muted">⬡ </span>
<span className="text-text">Prox</span>
<span className="text-akzent font-bold">Deploy</span>
<span className="text-muted">
{" "}
{version} · {kopf}
</span>
{trockenlauf ? <span className="text-laeuft font-bold"> · DRY-RUN</span> : null}
</p>
<ol className="flex flex-col" aria-label="Phasen">
{phasen.map((phase) => {
const art = ART[phase.status];
return (
<li key={phase.schluessel} className="flex flex-col sm:flex-row sm:gap-2">
<span
className={clsx("inline-flex shrink-0 items-baseline gap-2", FARBE[art])}
style={{ minWidth: `${breite + 2}ch` }}
>
<StatusSymbol
art={art}
className={clsx(
"translate-y-[0.15em]",
phase.status === "LAEUFT" && "motion-safe:animate-spin",
)}
/>
<span
className={
phase.status === "OFFEN" || phase.status === "UEBERSPRUNGEN"
? "text-muted"
: "text-text"
}
>
{phase.titel}
</span>
<span className="sr-only">
{" "}
–{" "}
{
{
OFFEN: "offen",
LAEUFT: "läuft",
ERLEDIGT: "erledigt",
FEHLER: "Fehler",
UEBERSPRUNGEN: "übersprungen",
}[phase.status]
}
</span>
</span>
<span className="text-muted min-w-0 pl-6 break-words sm:pl-0">
{phase.status === "FEHLER" ? (
<>
<span className="text-fehler">{phase.ursache}</span>
{phase.hinweis ? <span className="block">→ {phase.hinweis}</span> : null}
</>
) : (
<>
{phase.detail}
{phase.status === "LAEUFT" && phase.gestartetAm ? (
<>
{phase.detail ? " … " : ""}
<Laufzeit seit={phase.gestartetAm} />
</>
) : null}
</>
)}
</span>
</li>
);
})}
</ol>
{ergebnis ? (
<div
className={clsx(
"max-w-xl rounded-lg border px-4 py-3",
ergebnis.ok ? "border-akzent" : "border-fehler",
)}
>
<p className={ergebnis.ok ? "text-akzent" : "text-fehler"}>
{ergebnis.ok ? "● " : "✗ "}
<span className="text-text font-bold">{ergebnis.titel}</span>
{ergebnis.dauer ? <span className="text-muted"> · {ergebnis.dauer}</span> : null}
</p>
{ergebnis.zeilen.length > 0 ? (
<dl className="mt-1 grid grid-cols-[9ch_1fr] gap-x-2">
{ergebnis.zeilen.map(([name, wert]) => (
<div key={name} className="contents">
<dt className="text-muted">{name}</dt>
<dd className="text-text break-all">{wert}</dd>
</div>
))}
</dl>
) : null}
</div>
) : null}
</div>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { AbbruchFehler, fehlerBeschreiben, type FehlerBeschreibung } from "./fehler";
/**
* Der Phasen-Runner. Er kennt weder Proxmox noch die Datenbank: Phasen sind Funktionen, Ereignisse
* gehen an eine Senke. Der worker hängt eine Senke an, die in die Datenbank schreibt und per
* NOTIFY meldet; Tests hängen eine an, die mitschreibt.
*
* Die Texte (Titel, Detail, Ursache, Hinweis) entstehen nur hier und in den Phasen – Web und CLI
* zeigen sie unverändert an.
*/
export type PhasenStatusWert = "OFFEN" | "LAEUFT" | "ERLEDIGT" | "FEHLER" | "UEBERSPRUNGEN";
export type LogStufe = "debug" | "info" | "warn" | "error";
export interface EreignisSenke {
phasenAnlegen(phasen: Array<{ schluessel: string; titel: string }>): Promise<void>;
phaseGestartet(schluessel: string, detail?: string): Promise<void>;
/** Zwischenstand, z. B. „Download 43 %“ oder „cloud-init läuft …“. */
phaseDetail(schluessel: string, detail: string): Promise<void>;
phaseBeendet(
schluessel: string,
status: Exclude<PhasenStatusWert, "OFFEN" | "LAEUFT">,
angaben: { detail?: string; fehler?: FehlerBeschreibung },
): Promise<void>;
log(stufe: LogStufe, nachricht: string, angaben?: { phase?: string; daten?: unknown }): Promise<void>;
}
export type PhasenKontext = {
/** Detailtext der laufenden Phase aktualisieren. */
detail(text: string): Promise<void>;
log(nachricht: string, stufe?: LogStufe, daten?: unknown): Promise<void>;
/** Wirft AbbruchFehler, wenn der Auftrag abgebrochen werden soll. */
pruefeAbbruch(): Promise<void>;
};
export type PhasenErgebnis = { detail?: string; uebersprungen?: boolean };
export type PhasenSchritt = {
schluessel: string;
titel: string;
ausfuehren: (kontext: PhasenKontext) => Promise<PhasenErgebnis | void>;
};
export type AblaufErgebnis =
{ ok: true } | { ok: false; abgebrochen: boolean; phase: string; fehler: FehlerBeschreibung };
export async function phasenAusfuehren(
schritte: PhasenSchritt[],
senke: EreignisSenke,
optionen: { abbruchGewuenscht?: () => Promise<boolean> } = {},
): Promise<AblaufErgebnis> {
await senke.phasenAnlegen(schritte.map(({ schluessel, titel }) => ({ schluessel, titel })));
for (const schritt of schritte) {
const kontext: PhasenKontext = {
detail: (text) => senke.phaseDetail(schritt.schluessel, text),
log: (nachricht, stufe = "info", daten) =>
senke.log(stufe, nachricht, { phase: schritt.schluessel, daten }),
pruefeAbbruch: async () => {
if (await optionen.abbruchGewuenscht?.()) throw new AbbruchFehler();
},
};
try {
await kontext.pruefeAbbruch();
await senke.phaseGestartet(schritt.schluessel);
const ergebnis = (await schritt.ausfuehren(kontext)) ?? {};
await senke.phaseBeendet(schritt.schluessel, ergebnis.uebersprungen ? "UEBERSPRUNGEN" : "ERLEDIGT", {
detail: ergebnis.detail,
});
} catch (fehler) {
const beschreibung = fehlerBeschreiben(fehler);
await senke.phaseBeendet(schritt.schluessel, "FEHLER", { fehler: beschreibung });
await senke.log("error", `${schritt.titel}: ${beschreibung.ursache}`, {
phase: schritt.schluessel,
daten: fehler instanceof Error ? { name: fehler.name, stack: fehler.stack } : undefined,
});
return {
ok: false,
abgebrochen: fehler instanceof AbbruchFehler,
phase: schritt.schluessel,
fehler: beschreibung,
};
}
}
return { ok: true };
}
+46
View File
@@ -0,0 +1,46 @@
import { AdapterFehler } from "@/engine/adapter/debian";
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
import { VorlagenFehler } from "@/engine/profile/vorlage";
import { SshFehler } from "@/engine/ssh/verbindung";
/**
* Ein Fehler, den eine Phase bewusst wirft – mit Ursache und Lösungshinweis, wie sie unter der
* Phase in Web und CLI stehen.
*/
export class AblaufFehler extends Error {
constructor(
readonly ursache: string,
readonly hinweis: string,
) {
super(ursache);
this.name = "AblaufFehler";
}
}
/** Der Auftrag wurde abgebrochen – kein Fehler im eigentlichen Sinn. */
export class AbbruchFehler extends Error {
constructor() {
super("Abgebrochen");
this.name = "AbbruchFehler";
}
}
export type FehlerBeschreibung = { ursache: string; hinweis: string };
/** Macht aus jedem Fehler Ursache und Hinweis; Unbekanntes wird ehrlich als solches benannt. */
export function fehlerBeschreiben(fehler: unknown): FehlerBeschreibung {
if (fehler instanceof AblaufFehler || fehler instanceof ProxmoxFehler || fehler instanceof SshFehler) {
return { ursache: fehler.ursache, hinweis: fehler.hinweis };
}
if (fehler instanceof AdapterFehler || fehler instanceof VorlagenFehler) {
return { ursache: fehler.message, hinweis: "Eingaben bzw. Profil-Variablen prüfen." };
}
if (fehler instanceof AbbruchFehler) {
return { ursache: "Abgebrochen.", hinweis: "Bereits Angelegtes lässt sich über „Aufräumen“ entfernen." };
}
const text = fehler instanceof Error ? fehler.message : String(fehler);
return {
ursache: `Unerwarteter Fehler: ${text}`,
hinweis: "Detail-Log prüfen; das ist vermutlich ein Fehler in ProxDeploy.",
};
}
+3 -1
View File
@@ -1,4 +1,4 @@
import { pfad, type ProxmoxClient } from "./client";
import { istTrockenUpid, pfad, type ProxmoxClient } from "./client";
import { ProxmoxFehler } from "./fehler";
/**
@@ -10,6 +10,7 @@ type LogZeile = { n: number; t: string };
/** UPID:node:pid:pstart:starttime:type:id:user: – der Node steht an zweiter Stelle. */
export function nodeAusUpid(upid: string): string {
if (istTrockenUpid(upid)) return upid.split(":")[2] ?? "node";
const teile = upid.split(":");
if (teile[0] !== "UPID" || !teile[1]) {
throw new ProxmoxFehler("api", `„${upid}“ ist keine UPID.`, "Antwort von Proxmox prüfen.");
@@ -32,6 +33,7 @@ export async function aufgabeAbwarten(
upid: string,
optionen: AbwartenOptionen = {},
): Promise<void> {
if (istTrockenUpid(upid)) return;
const node = nodeAusUpid(upid);
const intervall = optionen.intervallMs ?? 1000;
const zeitlimit = optionen.zeitlimitMs ?? 30 * 60 * 1000;
+29 -1
View File
@@ -26,6 +26,17 @@ export type AufrufBeobachter = (
ergebnis: { dauerMs: number; fehler?: ProxmoxFehler },
) => void;
/**
* Trockenlauf (Dry-Run): lesende Aufrufe gehen wirklich an Proxmox, schreibende werden nur
* aufgezeichnet und mit einer Platzhalter-UPID beantwortet. Dieselben Phasen laufen so im
* Trockenlauf und im Ernstfall – der angezeigte Plan kann nicht vom tatsächlichen Ablauf abweichen.
*/
export const TROCKEN_UPID_PRAEFIX = "UPID:trocken:";
export function istTrockenUpid(upid: unknown): boolean {
return typeof upid === "string" && upid.startsWith(TROCKEN_UPID_PRAEFIX);
}
const STANDARD_ZEITLIMIT_MS = 30_000;
/** Formular-Kodierung wie pvesh: Wahrheitswerte als 1/0, Listen als wiederholte Schlüssel. */
@@ -60,7 +71,11 @@ export class ProxmoxClient {
constructor(
private readonly verbindung: ProxmoxVerbindung,
private readonly optionen: { zeitlimitMs?: number; beobachter?: AufrufBeobachter } = {},
private readonly optionen: {
zeitlimitMs?: number;
beobachter?: AufrufBeobachter;
trocken?: boolean;
} = {},
) {
if (!TOKEN_ID_MUSTER.test(verbindung.tokenId)) {
throw new ProxmoxFehler(
@@ -90,7 +105,20 @@ export class ProxmoxClient {
return await this.aufrufen<T>({ methode: "DELETE", pfad, parameter });
}
/** Im Trockenlauf aufgezeichnete schreibende Aufrufe, in Reihenfolge. */
readonly geplant: Aufruf[] = [];
get trocken(): boolean {
return Boolean(this.optionen.trocken);
}
async aufrufen<T>(aufruf: Aufruf): Promise<T> {
if (this.optionen.trocken && aufruf.methode !== "GET") {
this.geplant.push(aufruf);
this.optionen.beobachter?.(aufruf, { dauerMs: 0 });
const knoten = /^\/nodes\/([^/]+)/.exec(aufruf.pfad)?.[1] ?? "node";
return `${TROCKEN_UPID_PRAEFIX}${knoten}:${this.geplant.length}` as T;
}
const beginn = Date.now();
try {
const ergebnis = await this.senden<T>(aufruf);
+4 -1
View File
@@ -1,5 +1,8 @@
import { createHash } from "node:crypto";
import { Client } from "ssh2";
// ssh2 ist CommonJS: unter reinem Node-ESM (worker) gibt es keine benannten Exporte.
import ssh2 from "ssh2";
const { Client } = ssh2;
/**
* SSH zu Proxmox-Nodes oder Gästen. Der Host-Key wird wie das TLS-Zertifikat gepinnt: Ist kein
+18
View File
@@ -0,0 +1,18 @@
import type { StatusArt } from "@/components/status";
import type { AuftragSicht } from "@/lib/schemas/auftrag";
/** Beschriftungen für Aufträge – geteilt von Verlauf, Übersicht und Live-Ansicht. */
export const STATUS_ANZEIGE: Record<AuftragSicht["status"], [StatusArt, string]> = {
WARTEND: ["offen", "wartet"],
LAEUFT: ["laeuft", "läuft"],
ERFOLGREICH: ["erledigt", "erfolgreich"],
FEHLGESCHLAGEN: ["fehler", "fehlgeschlagen"],
ABGEBROCHEN: ["fehler", "abgebrochen"],
AUFGERAEUMT: ["offen", "aufgeräumt"],
};
export const ART_TEXT: Record<AuftragSicht["art"], string> = {
AUSROLLEN: "Ausrollen",
TEMPLATE_BAUEN: "Template",
AUFRAEUMEN: "Aufräumen",
};
+1 -1
View File
@@ -23,7 +23,7 @@ export type NavigationsEintrag = {
export const NAVIGATION: readonly NavigationsEintrag[] = [
{ pfad: "/", titel: "Übersicht", symbol: "uebersicht", recht: "lesen" },
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen", ab: "#008" },
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen", ab: "#006" },
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen" },
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen", ab: "#007" },
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" },
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen" },
+35
View File
@@ -0,0 +1,35 @@
import type { AuftragsArt, AuftragsStatus, GastTyp, PhasenStatus } from "@/generated/prisma/enums";
/** Was die API über einen Auftrag herausgibt – geteilt von Server, Web-GUI und (als JSON) CLI. */
export type AuftragSicht = {
id: string;
kurzId: string;
art: AuftragsArt;
status: AuftragsStatus;
kopf: string;
server: { name: string; titel: string };
knoten: string | null;
gastTyp: GastTyp | null;
vmid: number | null;
name: string | null;
distributionId: string | null;
profile: string[];
ergebnis: unknown;
fehler: unknown;
erstelltVon: string;
erstelltAm: string;
gestartetAm: string | null;
beendetAm: string | null;
phasen: Array<{
schluessel: string;
titel: string;
status: PhasenStatus;
detail: string | null;
ursache: string | null;
hinweis: string | null;
gestartetAm: string | null;
beendetAm: string | null;
}>;
};
export type LogZeile = { id: string; zeit: string; stufe: string; phase: string | null; nachricht: string };
+49
View File
@@ -0,0 +1,49 @@
// pg ist CommonJS – Default-Import, damit es auch im worker (reines Node-ESM) lädt.
import pg from "pg";
import { logger } from "@/lib/logger";
import { KANAL } from "./senke";
/**
* Eine LISTEN-Verbindung je Prozess, an der beliebig viele SSE-Ströme hängen. Bricht sie ab, wird
* sie neu aufgebaut; die Ströme holen sich beim nächsten Signal ohnehin den vollen Stand.
*/
type Hoerer = (auftragId: string) => void;
const hoerer = new Set<Hoerer>();
let verbindung: Promise<pg.Client> | undefined;
async function verbinden(): Promise<pg.Client> {
const client = new pg.Client({
connectionString: process.env["DATABASE_URL"],
application_name: "proxdeploy-listen",
});
client.on("notification", (meldung) => {
if (meldung.channel !== KANAL || !meldung.payload) return;
for (const h of hoerer) h(meldung.payload);
});
client.on("error", (fehler) => {
logger.warn({ err: fehler }, "LISTEN-Verbindung verloren, baue neu auf");
verbindung = undefined;
setTimeout(() => void sicherstellen(), 1000);
});
await client.connect();
await client.query(`LISTEN ${KANAL}`);
return client;
}
function sicherstellen(): Promise<pg.Client> {
verbindung ??= verbinden().catch((fehler) => {
verbindung = undefined;
throw fehler;
});
return verbindung;
}
export async function abonnieren(auftragId: string, rueckruf: () => void): Promise<() => void> {
await sicherstellen();
const h: Hoerer = (id) => {
if (id === auftragId) rueckruf();
};
hoerer.add(h);
return () => hoerer.delete(h);
}
+83
View File
@@ -0,0 +1,83 @@
import type { EreignisSenke, LogStufe } from "@/engine/ablauf/ablauf";
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { logger } from "@/lib/logger";
/**
* Schreibt die Ereignisse eines Auftrags in die Datenbank und meldet sie per NOTIFY. Die app hört
* mit (LISTEN) und schiebt sie an alle offenen SSE-Verbindungen – Web und CLI sehen dasselbe.
*/
export const KANAL = "proxdeploy_auftrag";
export async function melden(auftragId: string): Promise<void> {
await prisma.$executeRaw`SELECT pg_notify(${KANAL}, ${auftragId})`;
}
export class DatenbankSenke implements EreignisSenke {
constructor(private readonly auftragId: string) {}
async phasenAnlegen(phasen: Array<{ schluessel: string; titel: string }>): Promise<void> {
await prisma.$transaction([
prisma.phase.deleteMany({ where: { auftragId: this.auftragId } }),
prisma.phase.createMany({
data: phasen.map((p, reihenfolge) => ({ auftragId: this.auftragId, reihenfolge, ...p })),
}),
]);
await melden(this.auftragId);
}
async phaseGestartet(schluessel: string, detail?: string): Promise<void> {
await prisma.phase.update({
where: { auftragId_schluessel: { auftragId: this.auftragId, schluessel } },
data: { status: "LAEUFT", gestartetAm: new Date(), ...(detail ? { detail } : {}) },
});
await melden(this.auftragId);
}
async phaseDetail(schluessel: string, detail: string): Promise<void> {
await prisma.phase.update({
where: { auftragId_schluessel: { auftragId: this.auftragId, schluessel } },
data: { detail },
});
await melden(this.auftragId);
}
async phaseBeendet(
schluessel: string,
status: "ERLEDIGT" | "FEHLER" | "UEBERSPRUNGEN",
angaben: { detail?: string; fehler?: { ursache: string; hinweis: string } },
): Promise<void> {
await prisma.phase.update({
where: { auftragId_schluessel: { auftragId: this.auftragId, schluessel } },
data: {
status,
beendetAm: new Date(),
...(angaben.detail !== undefined ? { detail: angaben.detail } : {}),
...(angaben.fehler ? { ursache: angaben.fehler.ursache, hinweis: angaben.fehler.hinweis } : {}),
},
});
await melden(this.auftragId);
}
async log(
stufe: LogStufe,
nachricht: string,
angaben: { phase?: string; daten?: unknown } = {},
): Promise<void> {
await prisma.logeintrag.create({
data: {
auftragId: this.auftragId,
stufe,
nachricht,
phase: angaben.phase ?? null,
daten: (angaben.daten ?? undefined) as Prisma.InputJsonValue | undefined,
},
});
// Ins Betriebslog (JSON) geht jede Zeile mit Auftrags-ID – auch ohne GUI nachvollziehbar.
logger[stufe === "debug" ? "debug" : stufe](
{ auftragId: this.auftragId, phase: angaben.phase },
nachricht,
);
await melden(this.auftragId);
}
}
+191
View File
@@ -0,0 +1,191 @@
import { randomInt } from "node:crypto";
import type { Auftrag, Prisma } from "@/generated/prisma/client";
import type { AuftragsArt, GastTyp, Quelle } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import type { Aufrufer } from "@/server/auth/zugang";
import { AnwendungsFehler, nichtGefunden } from "@/server/fehler";
import { melden } from "@/server/ereignisse/senke";
import { protokollieren } from "@/server/services/protokoll";
import { auftragEinstellen } from "@/server/warteschlange";
import type { AuftragSicht, LogZeile } from "@/lib/schemas/auftrag";
export type { AuftragSicht, LogZeile };
/** Kurz-IDs ohne Verwechsler (kein 0/o, 1/l/i): „d-7f3k2“ für Ausrollen, „t-…“ für Templates. */
const ZEICHEN = "23456789abcdefghjkmnpqrstuvwxyz";
const PRAEFIX: Record<AuftragsArt, string> = { AUSROLLEN: "d", TEMPLATE_BAUEN: "t", AUFRAEUMEN: "c" };
export function kurzIdErzeugen(art: AuftragsArt): string {
let id = "";
for (let i = 0; i < 5; i += 1) id += ZEICHEN[randomInt(ZEICHEN.length)];
return `${PRAEFIX[art]}-${id}`;
}
export type NeuerAuftrag = {
art: AuftragsArt;
serverId: string;
knoten?: string;
gastTyp?: GastTyp;
name?: string;
distributionId?: string;
profile?: string[];
anfrage: Prisma.InputJsonValue;
plan?: Prisma.InputJsonValue;
bezugId?: string;
wiederholtVonId?: string;
};
export async function auftragAnlegen(
neu: NeuerAuftrag,
aufrufer: Aufrufer,
ip: string | null,
einstellen: (id: string) => Promise<void> = auftragEinstellen,
): Promise<Auftrag> {
let auftrag: Auftrag | undefined;
for (let versuch = 0; versuch < 5 && !auftrag; versuch += 1) {
try {
auftrag = await prisma.$transaction(async (tx) => {
const angelegt = await tx.auftrag.create({
data: {
...neu,
profile: neu.profile ?? [],
kurzId: kurzIdErzeugen(neu.art),
quelle: aufrufer.quelle as Quelle,
erstelltVonId: aufrufer.benutzer.id,
},
});
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion:
neu.art === "AUSROLLEN"
? "Ausrollen gestartet"
: neu.art === "TEMPLATE_BAUEN"
? "Template-Bau gestartet"
: "Aufräumen gestartet",
ziel: `auftrag:${angelegt.kurzId}`,
details: { name: neu.name, distribution: neu.distributionId },
ip,
},
tx,
);
return angelegt;
});
} catch (fehler) {
// Kurz-ID zufällig doppelt – selten, dann einfach neu würfeln.
if ((fehler as { code?: string }).code !== "P2002") throw fehler;
}
}
if (!auftrag) throw new AnwendungsFehler(500, "intern", "Konnte keine freie Auftrags-ID erzeugen.");
await einstellen(auftrag.id);
return auftrag;
}
const MIT = {
server: { select: { name: true, titel: true } },
erstelltVon: { select: { benutzername: true } },
phasen: { orderBy: { reihenfolge: "asc" as const } },
};
type MitBeziehungen = Prisma.AuftragGetPayload<{ include: typeof MIT }>;
export function auftragSicht(a: MitBeziehungen): AuftragSicht {
const iso = (d: Date | null) => d?.toISOString() ?? null;
return {
id: a.id,
kurzId: a.kurzId,
art: a.art,
status: a.status,
kopf:
a.kopf ?? [`Server ${a.server.name}`, a.knoten ? `Node ${a.knoten}` : null].filter(Boolean).join(" · "),
server: a.server,
knoten: a.knoten,
gastTyp: a.gastTyp,
vmid: a.vmid,
name: a.name,
distributionId: a.distributionId,
profile: a.profile,
ergebnis: a.ergebnis,
fehler: a.fehler,
erstelltVon: a.erstelltVon.benutzername,
erstelltAm: a.erstelltAm.toISOString(),
gestartetAm: iso(a.gestartetAm),
beendetAm: iso(a.beendetAm),
phasen: a.phasen.map((p) => ({
schluessel: p.schluessel,
titel: p.titel,
status: p.status,
detail: p.detail,
ursache: p.ursache,
hinweis: p.hinweis,
gestartetAm: iso(p.gestartetAm),
beendetAm: iso(p.beendetAm),
})),
};
}
/** Sucht per Kurz-ID oder interner ID – die CLI nimmt, was der Benutzer tippt. */
export async function auftragHolen(kennung: string): Promise<AuftragSicht> {
const auftrag = await prisma.auftrag.findFirst({
where: { OR: [{ kurzId: kennung }, { id: kennung }] },
include: MIT,
});
if (!auftrag) throw nichtGefunden(`Den Auftrag „${kennung}“`);
return auftragSicht(auftrag);
}
export async function auftraegeListe(
filter: { serverId?: string; grenze?: number } = {},
): Promise<AuftragSicht[]> {
const zeilen = await prisma.auftrag.findMany({
where: filter.serverId ? { serverId: filter.serverId } : {},
include: MIT,
orderBy: { erstelltAm: "desc" },
take: filter.grenze ?? 50,
});
return zeilen.map(auftragSicht);
}
export async function logAb(auftragId: string, abId: bigint, grenze = 500): Promise<LogZeile[]> {
const zeilen = await prisma.logeintrag.findMany({
where: { auftragId, id: { gt: abId } },
orderBy: { id: "asc" },
take: grenze,
});
return zeilen.map((z) => ({
id: z.id.toString(),
zeit: z.zeit.toISOString(),
stufe: z.stufe,
phase: z.phase,
nachricht: z.nachricht,
}));
}
export const ENDZUSTAENDE = new Set(["ERFOLGREICH", "FEHLGESCHLAGEN", "ABGEBROCHEN", "AUFGERAEUMT"]);
/** Bittet den worker um Abbruch; er prüft das zwischen den Phasen. */
export async function auftragAbbrechen(
kennung: string,
aufrufer: Aufrufer,
ip: string | null,
): Promise<void> {
const auftrag = await auftragHolen(kennung);
if (ENDZUSTAENDE.has(auftrag.status)) {
throw new AnwendungsFehler(409, "beendet", "Der Auftrag ist bereits beendet.");
}
await prisma.$transaction(async (tx) => {
await tx.auftrag.update({ where: { id: auftrag.id }, data: { abbrechen: true } });
await protokollieren(
{
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "Abbruch angefordert",
ziel: `auftrag:${auftrag.kurzId}`,
ip,
},
tx,
);
});
await melden(auftrag.id);
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { utils as sshUtils } from "ssh2";
import ssh2 from "ssh2";
import type { Server } from "@/generated/prisma/client";
import { ProxmoxClient, type ProxmoxVerbindung } from "@/engine/proxmox/client";
import { erkunden, type VerbindungsBericht } from "@/engine/proxmox/erkundung";
@@ -170,7 +170,7 @@ export async function sshSchluesselErzeugen(
ip: string | null,
): Promise<ServerSicht> {
const server = await serverHolen(name);
const paar = sshUtils.generateKeyPairSync("ed25519", { comment: `proxdeploy@${server.name}` });
const paar = ssh2.utils.generateKeyPairSync("ed25519", { comment: `proxdeploy@${server.name}` });
const neu = await prisma.$transaction(async (tx) => {
const aktualisiert = await tx.server.update({
where: { id: server.id },
+72
View File
@@ -0,0 +1,72 @@
import { prisma } from "@/lib/db";
import { AblaufFehler } from "@/engine/ablauf/fehler";
/**
* Vergibt VMIDs ohne Doppelungen zwischen parallelen Aufträgen: unter einer Advisory-Sperre je
* Server wird die kleinste freie ID ≥ `von` gesucht, die weder in Proxmox belegt noch von einem
* anderen laufenden Auftrag reserviert ist, und sofort reserviert. Die Sperre fällt mit der
* Transaktion; die Reservierung bleibt bis zum Auftragsende (Sicherheitsnetz: Ablauf nach 4 h).
*
* Gegen Gäste, die gleichzeitig außerhalb von ProxDeploy entstehen, hilft das nicht – dort
* scheitert `clone`/`create` mit „already exists“, und die Phase wiederholt einmal mit neuer ID.
*/
const RESERVIERUNG_STUNDEN = 4;
export async function vmidReservieren(eingabe: {
serverId: string;
auftragId: string;
belegt: ReadonlySet<number>;
von: number;
bis?: number;
/** Eine bestimmte ID statt der nächsten freien (vom Benutzer gewählt). */
wunsch?: number;
}): Promise<number> {
const bis = eingabe.bis ?? 999_999_999;
return await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('proxdeploy:vmid'), hashtext(${eingabe.serverId}))`;
await tx.vmidReservierung.deleteMany({ where: { ablaufAm: { lt: new Date() } } });
const reserviert = new Set(
(
await tx.vmidReservierung.findMany({ where: { serverId: eingabe.serverId }, select: { vmid: true } })
).map((r) => r.vmid),
);
const frei = (id: number) => !eingabe.belegt.has(id) && !reserviert.has(id);
let vmid: number | null = null;
if (eingabe.wunsch !== undefined) {
if (!frei(eingabe.wunsch)) {
throw new AblaufFehler(
`VMID ${eingabe.wunsch} ist bereits belegt oder reserviert.`,
"Eine andere ID wählen oder das Feld leer lassen – dann nimmt ProxDeploy die nächste freie.",
);
}
vmid = eingabe.wunsch;
} else {
for (let id = eingabe.von; id <= bis; id += 1) {
if (frei(id)) {
vmid = id;
break;
}
}
}
if (vmid === null) {
throw new AblaufFehler(
`Im Bereich ${eingabe.von}–${bis} ist keine VMID frei.`,
"Bereich in den Server-Einstellungen erweitern.",
);
}
await tx.vmidReservierung.create({
data: {
serverId: eingabe.serverId,
vmid,
auftragId: eingabe.auftragId,
ablaufAm: new Date(Date.now() + RESERVIERUNG_STUNDEN * 3600 * 1000),
},
});
return vmid;
});
}
export async function reservierungenFreigeben(auftragId: string): Promise<void> {
await prisma.vmidReservierung.deleteMany({ where: { auftragId } });
}
+43
View File
@@ -0,0 +1,43 @@
import { PgBoss } from "pg-boss";
import { logger } from "@/lib/logger";
/**
* Die Auftragswarteschlange (pg-boss auf derselben PostgreSQL, Schema `pgboss`). Die app stellt
* nur ein, der worker arbeitet ab. Ein Auftrag wird nie automatisch wiederholt: halb angelegte
* Gäste soll ein Mensch ansehen, nicht eine Schleife.
*/
export const WARTESCHLANGE = "auftrag";
/** Template-Bau kann bei langsamem Download dauern; danach gilt ein Job als verwaist. */
const LAUFZEIT_SEKUNDEN = 4 * 3600;
export type AuftragsJob = { auftragId: string };
let instanz: Promise<PgBoss> | undefined;
export function boss(rolle: "app" | "worker"): Promise<PgBoss> {
instanz ??= (async () => {
const url = process.env["DATABASE_URL"];
if (!url) throw new Error("DATABASE_URL ist nicht gesetzt.");
const b = new PgBoss({
connectionString: url,
application_name: `proxdeploy-${rolle}`,
// Wartung und Zeitpläne macht nur der worker; die app stellt nur ein.
supervise: rolle === "worker",
schedule: false,
});
b.on("error", (fehler) => logger.error({ err: fehler }, "pg-boss"));
await b.start();
await b.createQueue(WARTESCHLANGE, { retryLimit: 0, expireInSeconds: LAUFZEIT_SEKUNDEN });
return b;
})();
return instanz;
}
export async function auftragEinstellen(auftragId: string): Promise<void> {
const b = await boss("app");
await b.send(WARTESCHLANGE, { auftragId } satisfies AuftragsJob, {
retryLimit: 0,
expireInSeconds: LAUFZEIT_SEKUNDEN,
});
}
+4
View File
@@ -0,0 +1,4 @@
/**
* Meldet alle Abläufe beim worker an. Template-Bau (#007), Ausrollen (#008) und Aufräumen folgen.
*/
export {};
+20
View File
@@ -0,0 +1,20 @@
import type { EreignisSenke } from "@/engine/ablauf/ablauf";
import type { AufrufBeobachter } from "@/engine/proxmox/client";
/**
* Schreibt Proxmox-Aufrufe ins Detail-Log. Die Abfrageschleifen (Task-Status, Task-Log,
* VM-Status) erscheinen nur bei Fehlern – sonst stünden bei jedem Download Hunderte gleicher
* Zeilen da, und das Wichtige ginge unter.
*/
const SCHLEIFE = /\/tasks\/[^/]+\/(status|log)$|\/status\/current$/;
export function aufrufLogger(senke: EreignisSenke): AufrufBeobachter {
return (aufruf, ergebnis) => {
const pfad = decodeURIComponent(aufruf.pfad);
if (!ergebnis.fehler && aufruf.methode === "GET" && SCHLEIFE.test(pfad)) return;
void senke.log(
ergebnis.fehler ? "warn" : "debug",
`${aufruf.methode} ${pfad} · ${ergebnis.dauerMs} ms${ergebnis.fehler ? ` · ${ergebnis.fehler.ursache}` : ""}`,
);
};
}
+127
View File
@@ -0,0 +1,127 @@
import type { Auftrag, Prisma } from "@/generated/prisma/client";
import type { AuftragsArt } from "@/generated/prisma/enums";
import type { AblaufErgebnis, EreignisSenke } from "@/engine/ablauf/ablauf";
import { prisma } from "@/lib/db";
import { logger } from "@/lib/logger";
import { DatenbankSenke, melden } from "@/server/ereignisse/senke";
import { reservierungenFreigeben } from "@/server/services/vmid";
/**
* Führt einen Auftrag aus: Status setzen, den passenden Ablauf starten, Ergebnis festhalten.
* Die Abläufe selbst (Template bauen, VM/LXC ausrollen, aufräumen) melden sich hier an.
*/
export type AblaufKontext = {
auftrag: Auftrag;
senke: EreignisSenke;
abbruchGewuenscht: () => Promise<boolean>;
/** Merkt sich, was angelegt wurde – fürs Aufräumen, auch wenn später etwas scheitert. */
angelegt: (eintrag: Record<string, unknown>) => Promise<void>;
};
export type AblaufAusgang = AblaufErgebnis & { ergebnis?: Record<string, unknown> };
export type AblaufFunktion = (kontext: AblaufKontext) => Promise<AblaufAusgang>;
const ABLAEUFE = new Map<AuftragsArt, AblaufFunktion>();
export function ablaufRegistrieren(art: AuftragsArt, funktion: AblaufFunktion): void {
ABLAEUFE.set(art, funktion);
}
export async function auftragAusfuehren(
auftragId: string,
senke: EreignisSenke = new DatenbankSenke(auftragId),
) {
// Nur einmal starten, auch wenn pg-boss einen Job doppelt liefern sollte.
const gestartet = await prisma.auftrag.updateMany({
where: { id: auftragId, status: "WARTEND" },
data: { status: "LAEUFT", gestartetAm: new Date() },
});
if (gestartet.count === 0) {
logger.warn({ auftragId }, "Auftrag ist nicht (mehr) wartend – übersprungen");
return;
}
const auftrag = await prisma.auftrag.findUniqueOrThrow({ where: { id: auftragId } });
await melden(auftragId);
let ausgang: AblaufAusgang;
const ablauf = ABLAEUFE.get(auftrag.art);
try {
if (!ablauf) throw new Error(`Für ${auftrag.art} ist kein Ablauf registriert.`);
ausgang = await ablauf({
auftrag,
senke,
abbruchGewuenscht: async () =>
(await prisma.auftrag.findUnique({ where: { id: auftragId }, select: { abbrechen: true } }))
?.abbrechen ?? false,
angelegt: async (eintrag) => {
const aktuell = (
await prisma.auftrag.findUnique({ where: { id: auftragId }, select: { angelegt: true } })
)?.angelegt as Record<string, unknown> | null;
await prisma.auftrag.update({
where: { id: auftragId },
data: { angelegt: { ...(aktuell ?? {}), ...eintrag } as Prisma.InputJsonValue },
});
},
});
} catch (fehler) {
// Fehler außerhalb einer Phase (Programmfehler) – trotzdem sauber beenden.
logger.error({ err: fehler, auftragId }, "Ablauf ist abgestürzt");
const text = fehler instanceof Error ? fehler.message : String(fehler);
ausgang = {
ok: false,
abgebrochen: false,
phase: "",
fehler: { ursache: `Unerwarteter Fehler: ${text}`, hinweis: "Detail-Log prüfen." },
};
await senke.log("error", `Unerwarteter Fehler: ${text}`);
}
await prisma.auftrag.update({
where: { id: auftragId },
data: {
status: ausgang.ok ? "ERFOLGREICH" : ausgang.abgebrochen ? "ABGEBROCHEN" : "FEHLGESCHLAGEN",
beendetAm: new Date(),
...(ausgang.ok
? { ergebnis: (ausgang.ergebnis ?? {}) as Prisma.InputJsonValue }
: { fehler: { phase: ausgang.phase, ...ausgang.fehler } }),
},
});
await reservierungenFreigeben(auftragId);
await melden(auftragId);
}
/**
* Nach einem Absturz oder Neustart des workers hängen Aufträge auf LAEUFT. Sie werden ehrlich als
* fehlgeschlagen markiert – fortsetzen wäre riskanter als neu starten.
*/
export async function verwaisteAuftraegeBeenden(): Promise<number> {
const verwaist = await prisma.auftrag.findMany({ where: { status: "LAEUFT" }, select: { id: true } });
for (const { id } of verwaist) {
await prisma.$transaction([
prisma.phase.updateMany({
where: { auftragId: id, status: "LAEUFT" },
data: {
status: "FEHLER",
beendetAm: new Date(),
ursache: "Der worker wurde neu gestartet.",
hinweis: "Auftrag wiederholen; Reste ggf. aufräumen.",
},
}),
prisma.auftrag.update({
where: { id },
data: {
status: "FEHLGESCHLAGEN",
beendetAm: new Date(),
fehler: {
ursache: "Der worker wurde während des Auftrags neu gestartet.",
hinweis: "Auftrag wiederholen; Reste ggf. aufräumen.",
},
},
}),
]);
await reservierungenFreigeben(id);
await melden(id);
}
return verwaist.length;
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Der worker: arbeitet die Auftragswarteschlange ab. Derselbe Codestand wie die app, eigener
* Prozess – ein langer Template-Bau blockiert so nie eine Web-Anfrage.
*/
import "./umgebung";
import { schluesselPruefen } from "@/lib/geheimnisse";
import { logger } from "@/lib/logger";
import { katalogSynchronisieren } from "@/server/services/katalog";
import { boss, WARTESCHLANGE, type AuftragsJob } from "@/server/warteschlange";
import { auftragAusfuehren, verwaisteAuftraegeBeenden } from "./ausfuehren";
import "./ablaeufe";
async function starten(): Promise<void> {
schluesselPruefen();
const sync = await katalogSynchronisieren();
logger.info({ distributionen: sync.distributionen, profile: sync.profile }, "Katalog abgeglichen");
const verwaist = await verwaisteAuftraegeBeenden();
if (verwaist > 0) logger.warn({ anzahl: verwaist }, "Verwaiste Aufträge als fehlgeschlagen markiert");
const parallel = Math.max(1, Number(process.env["WORKER_PARALLEL"] ?? 4));
const b = await boss("worker");
await b.work<AuftragsJob>(WARTESCHLANGE, { batchSize: 1, localConcurrency: parallel }, async ([job]) => {
if (job) await auftragAusfuehren(job.data.auftragId);
});
logger.info({ parallel }, "worker bereit");
const beenden = async (signal: string) => {
logger.info({ signal }, "worker fährt herunter");
await b.stop({ graceful: true, timeout: 30_000 });
process.exit(0);
};
process.on("SIGTERM", () => void beenden("SIGTERM"));
process.on("SIGINT", () => void beenden("SIGINT"));
}
starten().catch((fehler) => {
logger.fatal({ err: fehler }, "worker konnte nicht starten");
process.exit(1);
});
+11
View File
@@ -0,0 +1,11 @@
/**
* Muss das erste Modul des workers sein: der Logger liest LOG_LEVEL und den Dienstnamen beim
* Import. Next lädt `.env` von selbst, der worker nicht – im Container kommt die Umgebung von
* außen, dann gibt es keine Datei und nichts passiert.
*/
try {
process.loadEnvFile();
} catch {
// keine .env – Umgebung kommt von außen
}
process.env["PROXDEPLOY_DIENST"] = "worker";
+270
View File
@@ -0,0 +1,270 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { GET as ereignisseGet } from "@/app/api/v1/auftraege/[id]/ereignisse/route";
import { GET as auftragGet } from "@/app/api/v1/auftraege/[id]/route";
import { POST as abbrechenPost } from "@/app/api/v1/auftraege/[id]/abbrechen/route";
import { verschluesseln } from "@/lib/geheimnisse";
import { prisma } from "@/lib/db";
import { phasenAusfuehren } from "@/engine/ablauf/ablauf";
import { auftragAnlegen } from "@/server/services/auftraege";
import { vmidReservieren } from "@/server/services/vmid";
import { ablaufRegistrieren, auftragAusfuehren, verwaisteAuftraegeBeenden } from "@/worker/ausfuehren";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
import { angemeldet, anfrage, kontext } from "./helpers/anfragen";
async function serverAnlegen() {
return await testPrisma.server.create({
data: {
name: "rz1",
titel: "RZ 1",
url: "https://127.0.0.1:8006",
tokenId: "a@pve!b",
tokenGeheimnis: verschluesseln("x"),
fingerabdruck: "AA",
},
});
}
/** Liest einen SSE-Strom, bis `ende` kommt, und gibt die Ereignisse zurück. */
async function stromLesen(
antwort: Response,
nebenbei?: () => Promise<void>,
): Promise<Array<{ art: string; daten: unknown; id?: string }>> {
const leser = antwort.body!.getReader();
const dekodierer = new TextDecoder();
let puffer = "";
const ereignisse: Array<{ art: string; daten: unknown; id?: string }> = [];
const parallel = nebenbei?.();
for (;;) {
const { value, done } = await leser.read();
if (done) break;
puffer += dekodierer.decode(value, { stream: true });
let grenze: number;
while ((grenze = puffer.indexOf("\n\n")) >= 0) {
const block = puffer.slice(0, grenze);
puffer = puffer.slice(grenze + 2);
const art = /^event: (.+)$/m.exec(block)?.[1];
const daten = /^data: (.+)$/m.exec(block)?.[1];
const id = /^id: (.+)$/m.exec(block)?.[1];
if (art && daten) ereignisse.push({ art, daten: JSON.parse(daten), id });
}
}
await parallel;
return ereignisse;
}
describe.runIf(datenbankVerfuegbar)("Aufträge, worker und Live-Strom", () => {
afterAll(async () => {
await prisma.$disconnect();
});
beforeEach(async () => {
await datenbankLeeren();
});
it("VMID-Vergabe: zehn parallele Reservierungen bekommen zehn verschiedene IDs, Belegtes wird übersprungen", async () => {
const server = await serverAnlegen();
const ids = await Promise.all(
Array.from({ length: 10 }, (_, i) =>
vmidReservieren({
serverId: server.id,
auftragId: `a${i}`,
belegt: new Set([100, 101, 103]),
von: 100,
}),
),
);
expect(new Set(ids).size).toBe(10);
expect(ids).not.toContain(100);
expect(ids).not.toContain(103);
expect(Math.min(...ids)).toBe(102);
await expect(
vmidReservieren({ serverId: server.id, auftragId: "x", belegt: new Set(), von: 100, wunsch: 102 }),
).rejects.toThrow(/bereits belegt/);
});
it("worker führt einen Auftrag aus; SSE liefert Stand, Log und Ende; Reservierungen werden frei", async () => {
const server = await serverAnlegen();
const { benutzer, cookie } = await angemeldet("OPERATOR");
ablaufRegistrieren("AUFRAEUMEN", async ({ auftrag, senke }) => {
await vmidReservieren({
serverId: auftrag.serverId,
auftragId: auftrag.id,
belegt: new Set(),
von: 100,
});
const ergebnis = await phasenAusfuehren(
[
{
schluessel: "eins",
titel: "Eins",
ausfuehren: async (k) => {
await k.log("Zeile 1");
return { detail: "fertig" };
},
},
{
schluessel: "zwei",
titel: "Zwei",
ausfuehren: async (k) => {
await k.log("Zeile 2");
return {};
},
},
],
senke,
);
return { ...ergebnis, ergebnis: { ip: "10.0.0.5" } };
});
const auftrag = await auftragAnlegen(
{ art: "AUFRAEUMEN", serverId: server.id, anfrage: {} },
{ benutzer, quelle: "WEB" },
null,
async () => {},
);
expect(auftrag.kurzId).toMatch(/^c-[2-9a-z]{5}$/);
const strom = await ereignisseGet(
anfrage("GET", `/api/v1/auftraege/${auftrag.kurzId}/ereignisse`, cookie),
kontext({ id: auftrag.kurzId }),
);
expect(strom.headers.get("content-type")).toContain("text/event-stream");
const ereignisse = await stromLesen(strom, () => auftragAusfuehren(auftrag.id));
const staende = ereignisse
.filter((e) => e.art === "stand")
.map((e) => (e.daten as { status: string }).status);
// Je nach Takt sieht der erste Stand den Auftrag wartend oder schon laufend – nie fertig.
expect(["WARTEND", "LAEUFT"]).toContain(staende[0]);
expect(staende.at(-1)).toBe("ERFOLGREICH");
const logs = ereignisse
.filter((e) => e.art === "log")
.flatMap((e) => (e.daten as Array<{ nachricht: string }>).map((z) => z.nachricht));
expect(logs).toEqual(["Zeile 1", "Zeile 2"]);
expect(ereignisse.at(-1)?.art).toBe("ende");
const fertig = await (
await auftragGet(
anfrage("GET", `/api/v1/auftraege/${auftrag.kurzId}`, cookie),
kontext({ id: auftrag.kurzId }),
)
).json();
expect(
fertig.phasen.map((p: { schluessel: string; status: string }) => `${p.schluessel}:${p.status}`),
).toEqual(["eins:ERLEDIGT", "zwei:ERLEDIGT"]);
expect(fertig.ergebnis).toEqual({ ip: "10.0.0.5" });
expect(await testPrisma.vmidReservierung.count()).toBe(0);
});
it("Wiederverbinden mit Last-Event-ID liefert nur neue Log-Zeilen", async () => {
const server = await serverAnlegen();
const { benutzer, cookie } = await angemeldet("OPERATOR");
ablaufRegistrieren("AUFRAEUMEN", async ({ senke }) =>
phasenAusfuehren(
[
{
schluessel: "a",
titel: "A",
ausfuehren: async (k) => {
await k.log("alt");
await k.log("neu");
return {};
},
},
],
senke,
),
);
const auftrag = await auftragAnlegen(
{ art: "AUFRAEUMEN", serverId: server.id, anfrage: {} },
{ benutzer, quelle: "WEB" },
null,
async () => {},
);
await auftragAusfuehren(auftrag.id);
const erste = await testPrisma.logeintrag.findFirstOrThrow({
where: { auftragId: auftrag.id, nachricht: "alt" },
});
const strom = await ereignisseGet(
new Request(`http://localhost/api/v1/auftraege/${auftrag.kurzId}/ereignisse`, {
headers: { cookie, "last-event-id": erste.id.toString() },
}),
kontext({ id: auftrag.kurzId }),
);
const ereignisse = await stromLesen(strom);
const logs = ereignisse
.filter((e) => e.art === "log")
.flatMap((e) => (e.daten as Array<{ nachricht: string }>).map((z) => z.nachricht));
expect(logs).toEqual(["neu"]);
});
it("Abbruch: Operator fordert an, der Ablauf endet vor der nächsten Phase als ABGEBROCHEN", async () => {
const server = await serverAnlegen();
const { benutzer, cookie } = await angemeldet("OPERATOR");
let auftragKurz = "";
ablaufRegistrieren("AUFRAEUMEN", async ({ senke, abbruchGewuenscht }) =>
phasenAusfuehren(
[
{
schluessel: "a",
titel: "A",
ausfuehren: async () => {
await abbrechenPost(
anfrage("POST", `/api/v1/auftraege/${auftragKurz}/abbrechen`, cookie),
kontext({ id: auftragKurz }),
);
return {};
},
},
{ schluessel: "b", titel: "B", ausfuehren: async () => ({}) },
],
senke,
{ abbruchGewuenscht },
),
);
const auftrag = await auftragAnlegen(
{ art: "AUFRAEUMEN", serverId: server.id, anfrage: {} },
{ benutzer, quelle: "WEB" },
null,
async () => {},
);
auftragKurz = auftrag.kurzId;
await auftragAusfuehren(auftrag.id);
const fertig = await testPrisma.auftrag.findUniqueOrThrow({
where: { id: auftrag.id },
include: { phasen: { orderBy: { reihenfolge: "asc" } } },
});
expect(fertig.status).toBe("ABGEBROCHEN");
expect(fertig.phasen.map((p) => p.status)).toEqual(["ERLEDIGT", "FEHLER"]);
expect(await testPrisma.protokoll.count({ where: { aktion: "Abbruch angefordert" } })).toBe(1);
});
it("ein doppelt gelieferter Job läuft nur einmal; verwaiste Aufträge werden beim Start beendet", async () => {
const server = await serverAnlegen();
const { benutzer } = await angemeldet("OPERATOR");
let laeufe = 0;
ablaufRegistrieren("AUFRAEUMEN", async () => {
laeufe += 1;
return { ok: true };
});
const auftrag = await auftragAnlegen(
{ art: "AUFRAEUMEN", serverId: server.id, anfrage: {} },
{ benutzer, quelle: "WEB" },
null,
async () => {},
);
await Promise.all([auftragAusfuehren(auftrag.id), auftragAusfuehren(auftrag.id)]);
expect(laeufe).toBe(1);
const haengt = await auftragAnlegen(
{ art: "AUFRAEUMEN", serverId: server.id, anfrage: {} },
{ benutzer, quelle: "WEB" },
null,
async () => {},
);
await testPrisma.auftrag.update({ where: { id: haengt.id }, data: { status: "LAEUFT" } });
expect(await verwaisteAuftraegeBeenden()).toBe(1);
expect((await testPrisma.auftrag.findUniqueOrThrow({ where: { id: haengt.id } })).status).toBe(
"FEHLGESCHLAGEN",
);
});
});
+130
View File
@@ -0,0 +1,130 @@
import { describe, expect, it } from "vitest";
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
import { AblaufFehler } from "@/engine/ablauf/fehler";
import { ProxmoxFehler } from "@/engine/proxmox/fehler";
/** Eine Senke, die mitschreibt – so sieht ein Test genau, was Web und CLI zu sehen bekämen. */
function mitschreiber() {
const ereignisse: string[] = [];
const senke: EreignisSenke = {
phasenAnlegen: async (p) => void ereignisse.push(`anlegen ${p.map((x) => x.schluessel).join(",")}`),
phaseGestartet: async (s) => void ereignisse.push(`start ${s}`),
phaseDetail: async (s, d) => void ereignisse.push(`detail ${s}: ${d}`),
phaseBeendet: async (s, status, a) =>
void ereignisse.push(
`ende ${s} ${status}${a.detail ? `: ${a.detail}` : ""}${a.fehler ? ` – ${a.fehler.ursache} → ${a.fehler.hinweis}` : ""}`,
),
log: async (stufe, n) => void ereignisse.push(`log ${stufe} ${n}`),
};
return { senke, ereignisse };
}
describe("Phasen-Runner", () => {
it("führt Phasen der Reihe nach aus und meldet Detail, Überspringen und Erfolg", async () => {
const { senke, ereignisse } = mitschreiber();
const ergebnis = await phasenAusfuehren(
[
{ schluessel: "pruefen", titel: "Prüfungen", ausfuehren: async () => ({ detail: "VMID 120 frei" }) },
{
schluessel: "template",
titel: "Template",
ausfuehren: async () => ({ uebersprungen: true, detail: "9013 vorhanden" }),
},
{
schluessel: "klonen",
titel: "Klonen",
ausfuehren: async (k) => {
await k.detail("läuft …");
await k.log("clone gestartet");
return { detail: "full clone 9013 → 120" };
},
},
],
senke,
);
expect(ergebnis).toEqual({ ok: true });
expect(ereignisse).toEqual([
"anlegen pruefen,template,klonen",
"start pruefen",
"ende pruefen ERLEDIGT: VMID 120 frei",
"start template",
"ende template UEBERSPRUNGEN: 9013 vorhanden",
"start klonen",
"detail klonen: läuft …",
"log info clone gestartet",
"ende klonen ERLEDIGT: full clone 9013 → 120",
]);
});
it("bricht bei einem Fehler ab: Ursache und Hinweis, folgende Phasen bleiben offen", async () => {
const { senke, ereignisse } = mitschreiber();
let danach = false;
const ergebnis = await phasenAusfuehren(
[
{
schluessel: "klonen",
titel: "Klonen",
ausfuehren: async () => {
throw new ProxmoxFehler(
"recht",
"Permission check failed (/vms/120, VM.Clone)",
"Der Rolle fehlt VM.Clone auf /vms/120.",
);
},
},
{ schluessel: "start", titel: "Start", ausfuehren: async () => void (danach = true) },
],
senke,
);
expect(ergebnis).toMatchObject({ ok: false, abgebrochen: false, phase: "klonen" });
expect(danach).toBe(false);
expect(ereignisse).toContain(
"ende klonen FEHLER – Permission check failed (/vms/120, VM.Clone) → Der Rolle fehlt VM.Clone auf /vms/120.",
);
expect(ereignisse.some((e) => e.startsWith("start start"))).toBe(false);
});
it("prüft vor jeder Phase auf Abbruch", async () => {
const { senke } = mitschreiber();
let abbrechen = false;
const ergebnis = await phasenAusfuehren(
[
{ schluessel: "a", titel: "A", ausfuehren: async () => void (abbrechen = true) },
{ schluessel: "b", titel: "B", ausfuehren: async () => ({}) },
],
senke,
{ abbruchGewuenscht: async () => abbrechen },
);
expect(ergebnis).toMatchObject({ ok: false, abgebrochen: true, phase: "b" });
});
it("benennt unerwartete Fehler ehrlich", async () => {
const { senke } = mitschreiber();
const ergebnis = await phasenAusfuehren(
[
{
schluessel: "x",
titel: "X",
ausfuehren: async () => {
throw new TypeError("kaputt");
},
},
],
senke,
);
expect(ergebnis.ok || ergebnis.fehler.ursache).toBe("Unerwarteter Fehler: kaputt");
const bewusst = await phasenAusfuehren(
[
{
schluessel: "y",
titel: "Y",
ausfuehren: async () => {
throw new AblaufFehler("U", "H");
},
},
],
senke,
);
expect(bewusst.ok || bewusst.fehler).toEqual({ ursache: "U", hinweis: "H" });
});
});
+11
View File
@@ -51,3 +51,14 @@ describe("Zugangsprüfung", () => {
expect(istOeffentlich("/server")).toBe(false);
});
});
describe("Client-Komponenten", () => {
const clientDateien = dateien(join(wurzel, "src"))
.filter((pfad) => /\.tsx?$/.test(pfad) && !pfad.includes("/generated/"))
.filter((pfad) => /^["']use client["']/m.test(readFileSync(pfad, "utf8")));
it.each(clientDateien.map((p) => relative(wurzel, p)))("%s importiert nichts aus src/server", (pfad) => {
// Auch reine Typimporte nicht: sonst wandert beim nächsten Umbau doch Servercode ins Browser-Bündel.
expect(readFileSync(join(wurzel, pfad), "utf8")).not.toMatch(/from ["']@\/server\//);
});
});