feat(worker): Worker mit pg-boss, Vorgaenge und Docker-Betrieb (#007)
Die Oberflaeche legt einen Vorgang an und schickt nur dessen ID in die Warteschlange; der Worker fuehrt ihn aus und haelt Status, Ergebnis oder Fehler fest. So bleiben lange SSH-Vorgaenge aus dem Webprozess heraus, und der Fortschritt ist ohne Job-Interna sichtbar. Kein automatisches Wiederholen: ein halb ausgefuehrter Schreibvorgang auf einem Host gehoert vor einen Menschen. Fehlertexte aus fremden Bibliotheken erreichen die Oberflaeche nie. Dockerfile und Compose fuer den Betrieb: Master-Key als Secret nur im Worker, Datenbank in einem internen Netz. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
.git
|
||||||
|
.claude
|
||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
geheim
|
||||||
|
*.key
|
||||||
|
docs
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
# Alle eigenen Variablen tragen das Praefix SSHKV_ (Ausnahme: DATABASE_URL fuer Prisma).
|
# Alle eigenen Variablen tragen das Praefix SSHKV_ (Ausnahme: DATABASE_URL fuer Prisma).
|
||||||
|
|
||||||
# --- Datenbank -----------------------------------------------------------------
|
# --- Datenbank -----------------------------------------------------------------
|
||||||
|
# Betrieb (docker-compose.yml): Passwort der Datenbank; DATABASE_URL setzt Compose selbst.
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
|
# Entwicklung:
|
||||||
DATABASE_URL=postgresql://sshkv:sshkv@localhost:5436/sshkv?schema=public
|
DATABASE_URL=postgresql://sshkv:sshkv@localhost:5436/sshkv?schema=public
|
||||||
# Nur fuer Tests (wird von Vitest geleert!)
|
# Nur fuer Tests (wird von Vitest geleert!)
|
||||||
TEST_DATABASE_URL=postgresql://sshkv:sshkv@localhost:5436/sshkv_test?schema=public
|
TEST_DATABASE_URL=postgresql://sshkv:sshkv@localhost:5436/sshkv_test?schema=public
|
||||||
@@ -12,10 +15,18 @@ SSHKV_APP_URL=http://localhost:3400
|
|||||||
# Signiert Sitzungen. Erzeugen mit: openssl rand -base64 48
|
# Signiert Sitzungen. Erzeugen mit: openssl rand -base64 48
|
||||||
SSHKV_APP_SECRET=
|
SSHKV_APP_SECRET=
|
||||||
|
|
||||||
|
# --- Betrieb -------------------------------------------------------------------
|
||||||
|
# Adresse/Port, an die die Weboberflaeche gebunden wird (Reverse Proxy davor).
|
||||||
|
SSHKV_BIND=127.0.0.1
|
||||||
|
SSHKV_PORT=3400
|
||||||
|
|
||||||
# --- Master-Key (nur Worker!) --------------------------------------------------
|
# --- Master-Key (nur Worker!) --------------------------------------------------
|
||||||
# Datei mit 32 zufaelligen Bytes. Erzeugen mit: head -c 32 /dev/urandom > master.key
|
# Datei mit 32 zufaelligen Bytes. Erzeugen mit: head -c 32 /dev/urandom > master.key
|
||||||
# Ohne diese Datei ist jedes Datenbank-Backup wertlos – getrennt sichern!
|
# Ohne diese Datei ist jedes Datenbank-Backup wertlos – getrennt sichern!
|
||||||
SSHKV_MASTER_KEY_FILE=./geheim/master.key
|
SSHKV_MASTER_KEY_FILE=./geheim/master.key
|
||||||
|
# Betrieb: Datei auf dem Docker-Host, die als Secret nur in den Worker kommt.
|
||||||
|
# Rechte: chown 1001:1001, chmod 400 (der Worker verweigert lesbare Schluessel).
|
||||||
|
SSHKV_MASTER_KEY_HOST_FILE=./geheim/master.key
|
||||||
|
|
||||||
# --- OIDC (authentik) ----------------------------------------------------------
|
# --- OIDC (authentik) ----------------------------------------------------------
|
||||||
SSHKV_OIDC_ISSUER=
|
SSHKV_OIDC_ISSUER=
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ pnpm-lock.yaml
|
|||||||
src/generated
|
src/generated
|
||||||
prisma/migrations
|
prisma/migrations
|
||||||
.claude
|
.claude
|
||||||
|
dist
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
# SSHKeyVault – ein Abbild fuer Weboberflaeche und Worker; sie unterscheiden sich nur im Startbefehl.
|
||||||
|
|
||||||
|
FROM node:24-alpine AS basis
|
||||||
|
WORKDIR /app
|
||||||
|
# openssl braucht Prisma, tini beendet Prozesse sauber.
|
||||||
|
RUN apk add --no-cache openssl tini && corepack enable
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Abhaengigkeiten
|
||||||
|
FROM basis AS abhaengigkeiten
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
COPY prisma ./prisma
|
||||||
|
COPY prisma.config.ts ./
|
||||||
|
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Bauen
|
||||||
|
FROM abhaengigkeiten AS bauen
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm prisma generate && pnpm build && pnpm build:worker
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Laufzeit
|
||||||
|
FROM basis AS laufzeit
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3400
|
||||||
|
# Sonst bindet Next an den von Docker gesetzten HOSTNAME und der Healthcheck ueber
|
||||||
|
# 127.0.0.1 schlaegt fehl.
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
RUN addgroup -g 1001 -S sshkv && adduser -S sshkv -u 1001 -G sshkv
|
||||||
|
|
||||||
|
COPY --from=bauen --chown=sshkv:sshkv /app/.next/standalone ./
|
||||||
|
COPY --from=bauen --chown=sshkv:sshkv /app/.next/static ./.next/static
|
||||||
|
COPY --from=bauen --chown=sshkv:sshkv /app/public ./public
|
||||||
|
COPY --from=bauen --chown=sshkv:sshkv /app/dist ./dist
|
||||||
|
COPY --chown=sshkv:sshkv deploy/start.sh ./start.sh
|
||||||
|
|
||||||
|
USER sshkv
|
||||||
|
EXPOSE 3400
|
||||||
|
ENTRYPOINT ["/sbin/tini", "--"]
|
||||||
|
CMD ["./start.sh", "web"]
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Startet Weboberflaeche oder Worker. Migrationen laufen vorher im Dienst „migrate“.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
rolle="${1:-web}"
|
||||||
|
|
||||||
|
case "$rolle" in
|
||||||
|
web)
|
||||||
|
echo "Starte SSHKeyVault-Weboberfläche auf Port ${PORT:-3400}."
|
||||||
|
exec node server.js
|
||||||
|
;;
|
||||||
|
worker)
|
||||||
|
echo "Starte sshkv-worker."
|
||||||
|
exec node dist/worker.mjs
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unbekannte Rolle: $rolle (erwartet: web oder worker)" >&2
|
||||||
|
exit 64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// esbuild-Banner: stellt `require` in der ESM-Ausgabe bereit, damit eingebundene
|
||||||
|
// Pakete weiterhin CommonJS-Module wie `fs` laden können.
|
||||||
|
import { createRequire as __createRequire } from "node:module";
|
||||||
|
import { fileURLToPath as __fileURLToPath } from "node:url";
|
||||||
|
import { dirname as __dirname_fn } from "node:path";
|
||||||
|
const require = __createRequire(import.meta.url);
|
||||||
|
const __filename = __fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = __dirname_fn(__filename);
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# SSHKeyVault – Betrieb mit Weboberflaeche, Worker und Datenbank.
|
||||||
|
# 1. cp .env.example .env und Werte eintragen (mindestens SSHKV_APP_SECRET, POSTGRES_PASSWORD,
|
||||||
|
# SSHKV_APP_URL, SSHKV_BREAKGLASS_PASSWORD)
|
||||||
|
# 2. Master-Key erzeugen (nur fuer den Worker lesbar, Container-Benutzer 1001):
|
||||||
|
# mkdir -p geheim && head -c 32 /dev/urandom > geheim/master.key
|
||||||
|
# sudo chown 1001:1001 geheim/master.key && sudo chmod 400 geheim/master.key
|
||||||
|
# 3. docker compose up -d
|
||||||
|
# Nur im internen Netz/VPN betreiben und einen Reverse Proxy mit TLS davorsetzen (docs/betrieb).
|
||||||
|
|
||||||
|
name: sshkv
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
container_name: sshkv-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: sshkv
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt in der .env}
|
||||||
|
POSTGRES_DB: sshkv
|
||||||
|
volumes:
|
||||||
|
- datenbank:/var/lib/postgresql/data
|
||||||
|
networks: [datenbank]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U sshkv"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
# Laeuft einmal je Start: Schema einspielen, Notfall-Admin und Vorlagen anlegen.
|
||||||
|
migrate:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: bauen
|
||||||
|
image: sshkv-migrate
|
||||||
|
restart: "no"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
command: ["sh", "-c", "pnpm prisma migrate deploy && pnpm prisma db seed"]
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://sshkv:${POSTGRES_PASSWORD}@db:5432/sshkv?schema=public
|
||||||
|
SSHKV_BREAKGLASS_USER: ${SSHKV_BREAKGLASS_USER:-notfall}
|
||||||
|
SSHKV_BREAKGLASS_PASSWORD: ${SSHKV_BREAKGLASS_PASSWORD:-}
|
||||||
|
networks: [datenbank]
|
||||||
|
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
image: sshkv
|
||||||
|
container_name: sshkv-web
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://sshkv:${POSTGRES_PASSWORD}@db:5432/sshkv?schema=public
|
||||||
|
SSHKV_APP_URL: ${SSHKV_APP_URL:?SSHKV_APP_URL fehlt in der .env}
|
||||||
|
SSHKV_APP_SECRET: ${SSHKV_APP_SECRET:?SSHKV_APP_SECRET fehlt in der .env}
|
||||||
|
SSHKV_OIDC_ISSUER: ${SSHKV_OIDC_ISSUER:-}
|
||||||
|
SSHKV_OIDC_CLIENT_ID: ${SSHKV_OIDC_CLIENT_ID:-}
|
||||||
|
SSHKV_OIDC_CLIENT_SECRET: ${SSHKV_OIDC_CLIENT_SECRET:-}
|
||||||
|
SSHKV_OIDC_NAME: ${SSHKV_OIDC_NAME:-authentik}
|
||||||
|
SSHKV_OIDC_GROUP_ADMIN: ${SSHKV_OIDC_GROUP_ADMIN:-sshkv-admins}
|
||||||
|
SSHKV_OIDC_GROUP_OPERATOR: ${SSHKV_OIDC_GROUP_OPERATOR:-sshkv-operators}
|
||||||
|
SSHKV_OIDC_GROUP_READER: ${SSHKV_OIDC_GROUP_READER:-sshkv-readers}
|
||||||
|
# Absichtlich KEIN Master-Key: eine Luecke im Webprozess allein entschluesselt keine
|
||||||
|
# Private Keys. Nach aussen braucht er nur den OIDC-Anbieter.
|
||||||
|
ports:
|
||||||
|
- "${SSHKV_BIND:-127.0.0.1}:${SSHKV_PORT:-3400}:3400"
|
||||||
|
networks: [datenbank, aussen]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3400/api/gesundheit"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
worker:
|
||||||
|
image: sshkv
|
||||||
|
container_name: sshkv-worker
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["./start.sh", "worker"]
|
||||||
|
depends_on:
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
web:
|
||||||
|
condition: service_started
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://sshkv:${POSTGRES_PASSWORD}@db:5432/sshkv?schema=public
|
||||||
|
SSHKV_APP_SECRET: ${SSHKV_APP_SECRET}
|
||||||
|
SSHKV_MASTER_KEY_FILE: /run/secrets/master_key
|
||||||
|
SSHKV_SYNC_CRON: ${SSHKV_SYNC_CRON:-30 2 * * *}
|
||||||
|
secrets:
|
||||||
|
- master_key
|
||||||
|
# Der Worker braucht die Datenbank und den Weg zu den Hosts.
|
||||||
|
networks: [datenbank, aussen]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pgrep -f dist/worker.mjs > /dev/null"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
master_key:
|
||||||
|
file: ${SSHKV_MASTER_KEY_HOST_FILE:-./geheim/master.key}
|
||||||
|
|
||||||
|
networks:
|
||||||
|
# Die Datenbank hat keinen Weg nach draussen.
|
||||||
|
datenbank:
|
||||||
|
internal: true
|
||||||
|
aussen: {}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
datenbank:
|
||||||
@@ -1,22 +1,27 @@
|
|||||||
# #007 Worker und Vorgänge
|
# #007 Worker und Vorgänge
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.1.0
|
**Meilenstein:** v0.1.0
|
||||||
**Labels:** backend
|
**Labels:** backend
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 15 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Eigener Worker-Prozess mit pg-boss; die Oberfläche legt Vorgänge an und zeigt deren Fortschritt.
|
Eigener Worker-Prozess mit pg-boss; die Oberfläche legt Vorgänge an und zeigt deren Fortschritt.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Worker-Einstieg, gebündelt per esbuild
|
- [x] Worker-Einstieg, gebündelt per esbuild
|
||||||
- [ ] pg-boss-Queues je Vorgangsart, Zeitpläne
|
- [x] pg-boss-Queues je Vorgangsart, Zeitpläne
|
||||||
- [ ] Modell `Vorgang` für Status/Ergebnis in der Oberfläche
|
- [x] Modell `Vorgang` für Status/Ergebnis in der Oberfläche
|
||||||
- [ ] Docker-Compose produktiv: web, worker, db, migrate; Master-Key nur im Worker
|
- [x] Docker-Compose produktiv: web, worker, db, migrate; Master-Key nur im Worker
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Vorgang wird im Web angelegt, im Worker ausgeführt, Ergebnis sichtbar
|
- [x] Vorgang wird im Web angelegt, im Worker ausgeführt, Ergebnis sichtbar
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#003
|
#003
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Eine Warteschlange `sshkv-vorgang` statt einer je Art:** Der Job trägt nur die Vorgangs-ID; die Art steht im `Vorgang`. Eine Handler-Tabelle (`src/worker/handler.ts`) wächst mit den Issues.
|
||||||
|
- **Kein automatisches Wiederholen** (`retryLimit: 0`): Ein halb ausgeführter Schreibvorgang auf einem Host soll ein Mensch ansehen.
|
||||||
|
- **Start nur aus WARTEND** per bedingtem Update – zugleich die Sperre gegen doppelte Ausführung.
|
||||||
|
- **Fehlermeldungen:** Nur `VorgangsFehler` erreichen die Oberfläche, alles andere wird „Interner Fehler“; ins Log nur Name und Meldung.
|
||||||
|
- **Compose:** Datenbank in einem `internal`-Netz; Master-Key als Secret ausschließlich im Worker. Docker-Secrets ohne Swarm sind Bind-Mounts mit den Rechten der Host-Datei – deshalb `chown 1001:1001` und `chmod 400` (siehe Kopf von `docker-compose.yml`).
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export default defineConfig([
|
|||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
// Worktrees der Sub-Agenten liegen im Projektverzeichnis.
|
// Worktrees der Sub-Agenten liegen im Projektverzeichnis.
|
||||||
".claude/**",
|
".claude/**",
|
||||||
|
// Gebuendelter Worker und sein esbuild-Banner.
|
||||||
|
"dist/**",
|
||||||
|
"deploy/worker-banner.js",
|
||||||
// Erzeugter Prisma-Client.
|
// Erzeugter Prisma-Client.
|
||||||
"src/generated/**",
|
"src/generated/**",
|
||||||
"test-results/**",
|
"test-results/**",
|
||||||
|
|||||||
+3
-2
@@ -11,7 +11,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3400",
|
"dev": "next dev -p 3400",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"build:worker": "esbuild src/worker/index.ts --bundle --platform=node --target=node24 --format=esm --outfile=dist/worker.mjs --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\" --external:@prisma/client --external:@node-rs/argon2 --external:pg-native --external:ssh2 --external:cpu-features",
|
"build:worker": "esbuild src/worker/index.ts --bundle --platform=node --target=node24 --format=esm --outfile=dist/worker.mjs --banner:js=\"$(cat deploy/worker-banner.js)\" --external:@prisma/client --external:@node-rs/argon2 --external:pg-native --external:cpu-features --external:*.node",
|
||||||
"start": "next start -p 3400",
|
"start": "next start -p 3400",
|
||||||
"worker": "tsx src/worker/index.ts",
|
"worker": "tsx src/worker/index.ts",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
@@ -31,7 +31,8 @@
|
|||||||
"issues:sync": "tsx scripts/gitea-issues.ts",
|
"issues:sync": "tsx scripts/gitea-issues.ts",
|
||||||
"icons": "node scripts/icons.mjs",
|
"icons": "node scripts/icons.mjs",
|
||||||
"postinstall": "prisma generate",
|
"postinstall": "prisma generate",
|
||||||
"prepare": "husky"
|
"prepare": "husky",
|
||||||
|
"build:all": "pnpm build && pnpm build:worker"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,tsx,mts,mjs}": [
|
"*.{ts,tsx,mts,mjs}": [
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import type { VorgangsArt } from "@/generated/prisma/enums";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { jobSchicken } from "@/server/vorgaenge/warteschlange";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorgaenge anlegen und lesen (#007). Ein Vorgang ist die fachliche Klammer um einen Job:
|
||||||
|
* die Oberflaeche zeigt ihn an, der Worker fuehrt ihn aus.
|
||||||
|
*/
|
||||||
|
export type VorgangAnlegen = {
|
||||||
|
readonly art: VorgangsArt;
|
||||||
|
readonly angefordertVonId: string | null;
|
||||||
|
readonly hostId?: string | null;
|
||||||
|
readonly schluesselId?: string | null;
|
||||||
|
/** Nie Geheimnisse – die Eingabe ist in der Oberflaeche sichtbar. */
|
||||||
|
readonly eingabe?: Prisma.InputJsonValue;
|
||||||
|
readonly geheim?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function vorgangEinreihen(daten: VorgangAnlegen): Promise<string> {
|
||||||
|
const vorgang = await prisma.vorgang.create({
|
||||||
|
data: {
|
||||||
|
art: daten.art,
|
||||||
|
angefordertVonId: daten.angefordertVonId,
|
||||||
|
hostId: daten.hostId ?? null,
|
||||||
|
schluesselId: daten.schluesselId ?? null,
|
||||||
|
eingabe: daten.eingabe,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const jobId = await jobSchicken({
|
||||||
|
vorgangId: vorgang.id,
|
||||||
|
...(daten.geheim ? { geheim: daten.geheim } : {}),
|
||||||
|
});
|
||||||
|
await prisma.vorgang.update({ where: { id: vorgang.id }, data: { jobId } });
|
||||||
|
return vorgang.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was die Oberflaeche ueber einen Vorgang erfahren darf – ohne Job-Interna. */
|
||||||
|
export async function vorgangLesen(id: string) {
|
||||||
|
return prisma.vorgang.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
art: true,
|
||||||
|
status: true,
|
||||||
|
hostId: true,
|
||||||
|
schluesselId: true,
|
||||||
|
eingabe: true,
|
||||||
|
plan: true,
|
||||||
|
ergebnis: true,
|
||||||
|
fehler: true,
|
||||||
|
angelegtAm: true,
|
||||||
|
gestartetAm: true,
|
||||||
|
beendetAm: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { PgBoss } from "pg-boss";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemeinsame Anbindung an pg-boss fuer Web und Worker (#007).
|
||||||
|
*
|
||||||
|
* Der Webprozess **schickt** nur; abgearbeitet wird ausschliesslich im Worker. Beide
|
||||||
|
* teilen die Datenbank, pg-boss legt sein Schema `pgboss` daneben an.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Eine Queue je Vorgangsart haelt Wiederholungen und Gleichzeitigkeit getrennt einstellbar. */
|
||||||
|
export const WARTESCHLANGE = "sshkv-vorgang";
|
||||||
|
export const ZEITPLAN_ABGLEICH = "sshkv-abgleich-zeitplan";
|
||||||
|
|
||||||
|
/** Was im Job steht. Absichtlich nur die ID: alles Weitere liegt im `Vorgang`. */
|
||||||
|
export type VorgangsJob = {
|
||||||
|
readonly vorgangId: string;
|
||||||
|
/**
|
||||||
|
* Nur fuer den Bootstrap per Passwort (#010): mit SSHKV_APP_SECRET verschluesselt. Der
|
||||||
|
* Worker loescht den Job nach dem Lauf, damit auch das Chiffrat nicht liegen bleibt.
|
||||||
|
*/
|
||||||
|
readonly geheim?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let instanz: Promise<PgBoss> | undefined;
|
||||||
|
|
||||||
|
function verbindung(): string {
|
||||||
|
const url = process.env["DATABASE_URL"];
|
||||||
|
if (!url) throw new Error("DATABASE_URL ist nicht gesetzt. Siehe .env.example.");
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy und einmal je Prozess – wie der Prisma-Client, damit `next build` ohne Datenbank
|
||||||
|
* durchlaeuft und Hot Reload keine Verbindungen stapelt.
|
||||||
|
*/
|
||||||
|
export function warteschlangeHolen(): Promise<PgBoss> {
|
||||||
|
const zwischenspeicher = globalThis as typeof globalThis & { sshkvBoss?: Promise<PgBoss> };
|
||||||
|
const vorhanden = process.env["NODE_ENV"] !== "production" ? zwischenspeicher.sshkvBoss : instanz;
|
||||||
|
if (vorhanden) return vorhanden;
|
||||||
|
|
||||||
|
const neu = (async () => {
|
||||||
|
const boss = new PgBoss({ connectionString: verbindung(), schema: "pgboss" });
|
||||||
|
boss.on("error", (fehler: Error) => console.error("pg-boss:", fehler.message));
|
||||||
|
await boss.start();
|
||||||
|
await boss.createQueue(WARTESCHLANGE, {
|
||||||
|
// Kein automatisches Wiederholen: ein halb ausgefuehrter Schreibvorgang auf einem Host
|
||||||
|
// soll ein Mensch ansehen, nicht der Worker blind noch einmal starten.
|
||||||
|
retryLimit: 0,
|
||||||
|
// Abgeschlossene Jobs nicht lange aufheben – der Verlauf steht im Vorgang.
|
||||||
|
deleteAfterSeconds: 60 * 60,
|
||||||
|
});
|
||||||
|
return boss;
|
||||||
|
})();
|
||||||
|
if (process.env["NODE_ENV"] !== "production") zwischenspeicher.sshkvBoss = neu;
|
||||||
|
else instanz = neu;
|
||||||
|
return neu;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function jobSchicken(job: VorgangsJob): Promise<string | null> {
|
||||||
|
const boss = await warteschlangeHolen();
|
||||||
|
return boss.send(WARTESCHLANGE, job);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { Prisma, Vorgang } from "@/generated/prisma/client";
|
||||||
|
import type { VorgangsArt } from "@/generated/prisma/enums";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuehrt einen Vorgang aus (#007): Status setzen, Handler rufen, Ergebnis oder Fehler
|
||||||
|
* festhalten. Ohne pg-boss geschrieben, damit es sich mit einer Handler-Tabelle testen
|
||||||
|
* laesst; src/worker/index.ts verdrahtet es mit der Warteschlange.
|
||||||
|
*/
|
||||||
|
export type HandlerErgebnis = {
|
||||||
|
readonly status?: "ERLEDIGT" | "WARTET_AUF_BESTAETIGUNG";
|
||||||
|
readonly ergebnis?: Prisma.InputJsonValue;
|
||||||
|
readonly plan?: Prisma.InputJsonValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HandlerKontext = {
|
||||||
|
readonly vorgang: Vorgang;
|
||||||
|
/** Entschluesselbares Geheimnis aus dem Job (nur Bootstrap per Passwort). */
|
||||||
|
readonly geheim?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Handler = (kontext: HandlerKontext) => Promise<HandlerErgebnis>;
|
||||||
|
export type HandlerTabelle = Partial<Record<VorgangsArt, Handler>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehler, deren Meldung fuer die Oberflaeche gedacht ist. Alle anderen werden zu einer
|
||||||
|
* allgemeinen Meldung – ein Stacktrace oder eine ssh2-Meldung kann Pfade, Adressen oder
|
||||||
|
* im schlimmsten Fall Schluesselmaterial tragen.
|
||||||
|
*/
|
||||||
|
export class VorgangsFehler extends Error {
|
||||||
|
constructor(nachricht: string, optionen?: ErrorOptions) {
|
||||||
|
super(nachricht, optionen);
|
||||||
|
this.name = "VorgangsFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fehlerText(fehler: unknown): string {
|
||||||
|
if (fehler instanceof VorgangsFehler) return fehler.message;
|
||||||
|
return "Interner Fehler im Worker. Details stehen im Worker-Log.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vorgangAusfuehren(
|
||||||
|
vorgangId: string,
|
||||||
|
handler: HandlerTabelle,
|
||||||
|
geheim?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
// Nur WARTEND darf starten. Das bedingte Update ist zugleich die Sperre gegen einen
|
||||||
|
// zweiten Worker, der denselben Job bekommt.
|
||||||
|
const gestartet = await prisma.vorgang.updateMany({
|
||||||
|
where: { id: vorgangId, status: "WARTEND" },
|
||||||
|
data: { status: "LAEUFT", gestartetAm: new Date() },
|
||||||
|
});
|
||||||
|
if (gestartet.count === 0) return;
|
||||||
|
const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgangId } });
|
||||||
|
|
||||||
|
const ausfuehrer = handler[vorgang.art];
|
||||||
|
try {
|
||||||
|
if (!ausfuehrer) {
|
||||||
|
throw new VorgangsFehler(`Für Vorgänge der Art ${vorgang.art} gibt es noch keine Ausführung.`);
|
||||||
|
}
|
||||||
|
const ergebnis = await ausfuehrer({ vorgang, ...(geheim ? { geheim } : {}) });
|
||||||
|
await prisma.vorgang.update({
|
||||||
|
where: { id: vorgangId },
|
||||||
|
data: {
|
||||||
|
status: ergebnis.status ?? "ERLEDIGT",
|
||||||
|
ergebnis: ergebnis.ergebnis,
|
||||||
|
plan: ergebnis.plan,
|
||||||
|
beendetAm: ergebnis.status === "WARTET_AUF_BESTAETIGUNG" ? null : new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
if (!(fehler instanceof VorgangsFehler)) {
|
||||||
|
// Ins Log nur Name und Meldung, nie das ganze Objekt (cause-Ketten, Puffer).
|
||||||
|
console.error(
|
||||||
|
`Vorgang ${vorgangId} (${vorgang.art}) fehlgeschlagen:`,
|
||||||
|
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await prisma.vorgang.update({
|
||||||
|
where: { id: vorgangId },
|
||||||
|
data: { status: "FEHLGESCHLAGEN", fehler: fehlerText(fehler), beendetAm: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { HandlerTabelle } from "@/worker/ausfuehren";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Host-Key (#009), Bootstrap
|
||||||
|
* (#010), Ausrollen (#016), Abgleich (#021) …
|
||||||
|
*/
|
||||||
|
export const HANDLER: HandlerTabelle = {};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import type { Job } from "pg-boss";
|
||||||
|
import { VERSION } from "@/lib/version";
|
||||||
|
import { WARTESCHLANGE, warteschlangeHolen, type VorgangsJob } from "@/server/vorgaenge/warteschlange";
|
||||||
|
import { vorgangAusfuehren } from "@/worker/ausfuehren";
|
||||||
|
import { HANDLER } from "@/worker/handler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einstieg des Worker-Prozesses (#007). Nur hier liegen Master-Key und Deployment-Key,
|
||||||
|
* und nur von hier aus spricht sshkv SSH mit den Hosts.
|
||||||
|
*/
|
||||||
|
async function starten(): Promise<void> {
|
||||||
|
const boss = await warteschlangeHolen();
|
||||||
|
|
||||||
|
await boss.work<VorgangsJob>(
|
||||||
|
WARTESCHLANGE,
|
||||||
|
{ batchSize: 1, localConcurrency: 4 },
|
||||||
|
async (jobs: Job<VorgangsJob>[]) => {
|
||||||
|
for (const job of jobs) {
|
||||||
|
await vorgangAusfuehren(job.data.vorgangId, HANDLER, job.data.geheim);
|
||||||
|
if (job.data.geheim) {
|
||||||
|
// Das verschluesselte Geheimnis soll nicht bis zur Aufraeumrunde liegen bleiben.
|
||||||
|
await boss.deleteJob(WARTESCHLANGE, job.id).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`sshkv-worker ${VERSION} bereit.`);
|
||||||
|
|
||||||
|
const beenden = async (signal: string) => {
|
||||||
|
console.log(`${signal} empfangen, beende laufende Vorgänge …`);
|
||||||
|
await boss.stop({ graceful: true, timeout: 30_000 });
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on("SIGTERM", () => void beenden("SIGTERM"));
|
||||||
|
process.on("SIGINT", () => void beenden("SIGINT"));
|
||||||
|
}
|
||||||
|
|
||||||
|
starten().catch((fehler: unknown) => {
|
||||||
|
console.error("Worker konnte nicht starten:", fehler instanceof Error ? fehler.message : String(fehler));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { vorgangEinreihen, vorgangLesen } from "@/server/vorgaenge/vorgaenge";
|
||||||
|
import { WARTESCHLANGE, warteschlangeHolen, type VorgangsJob } from "@/server/vorgaenge/warteschlange";
|
||||||
|
import { VorgangsFehler, vorgangAusfuehren, type HandlerTabelle } from "@/worker/ausfuehren";
|
||||||
|
import { datenbankLeeren } from "./hilfen/db";
|
||||||
|
|
||||||
|
/** Vorgaenge zwischen Web und Worker (#007). */
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const boss = await warteschlangeHolen();
|
||||||
|
await boss.stop({ graceful: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function anlegen() {
|
||||||
|
return prisma.vorgang.create({ data: { art: "ERREICHBARKEIT" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Vorgang ausfuehren", () => {
|
||||||
|
it("haelt Ergebnis und Status fest", async () => {
|
||||||
|
const vorgang = await anlegen();
|
||||||
|
const handler: HandlerTabelle = { ERREICHBARKEIT: async () => ({ ergebnis: { erreichbar: true } }) };
|
||||||
|
await vorgangAusfuehren(vorgang.id, handler);
|
||||||
|
const fertig = await vorgangLesen(vorgang.id);
|
||||||
|
expect(fertig?.status).toBe("ERLEDIGT");
|
||||||
|
expect(fertig?.ergebnis).toEqual({ erreichbar: true });
|
||||||
|
expect(fertig?.beendetAm).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zeigt nur Meldungen von VorgangsFehlern, alles andere allgemein", async () => {
|
||||||
|
const a = await anlegen();
|
||||||
|
await vorgangAusfuehren(a.id, {
|
||||||
|
ERREICHBARKEIT: async () => {
|
||||||
|
throw new VorgangsFehler("Host antwortet nicht.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect((await vorgangLesen(a.id))?.fehler).toBe("Host antwortet nicht.");
|
||||||
|
|
||||||
|
const b = await anlegen();
|
||||||
|
await vorgangAusfuehren(b.id, {
|
||||||
|
ERREICHBARKEIT: async () => {
|
||||||
|
throw new Error("-----BEGIN OPENSSH PRIVATE KEY----- geheim");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fehler = (await vorgangLesen(b.id))?.fehler ?? "";
|
||||||
|
expect(fehler).not.toContain("PRIVATE KEY");
|
||||||
|
expect(fehler).toMatch(/Interner Fehler/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("startet einen Vorgang nur einmal", async () => {
|
||||||
|
const vorgang = await anlegen();
|
||||||
|
let aufrufe = 0;
|
||||||
|
const handler: HandlerTabelle = {
|
||||||
|
ERREICHBARKEIT: async () => {
|
||||||
|
aufrufe += 1;
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await Promise.all([vorgangAusfuehren(vorgang.id, handler), vorgangAusfuehren(vorgang.id, handler)]);
|
||||||
|
expect(aufrufe).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet fehlende Handler verstaendlich", async () => {
|
||||||
|
const vorgang = await anlegen();
|
||||||
|
await vorgangAusfuehren(vorgang.id, {});
|
||||||
|
expect((await vorgangLesen(vorgang.id))?.fehler).toMatch(/noch keine Ausführung/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Warteschlange", () => {
|
||||||
|
it("bringt einen eingereihten Vorgang bis in den Worker", async () => {
|
||||||
|
const boss = await warteschlangeHolen();
|
||||||
|
const id = await vorgangEinreihen({
|
||||||
|
art: "ERREICHBARKEIT",
|
||||||
|
angefordertVonId: null,
|
||||||
|
eingabe: { probe: 1 },
|
||||||
|
});
|
||||||
|
// Die Warteschlange ueberlebt das Leeren der Tabellen; deshalb gezielt den eigenen Job suchen.
|
||||||
|
const jobs = await boss.fetch<VorgangsJob>(WARTESCHLANGE, { batchSize: 100 });
|
||||||
|
const job = jobs.find((kandidat) => kandidat.data.vorgangId === id);
|
||||||
|
expect(job?.data).toEqual({ vorgangId: id });
|
||||||
|
await vorgangAusfuehren(job!.data.vorgangId, { ERREICHBARKEIT: async () => ({ ergebnis: "ok" }) });
|
||||||
|
await boss.complete(WARTESCHLANGE, job!.id);
|
||||||
|
expect((await vorgangLesen(id))?.status).toBe("ERLEDIGT");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user