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:
2026-10-12 09:21:41 +02:00
co-authored by Claude Opus 5.5
parent bfa0d574aa
commit 4a23930b54
16 changed files with 567 additions and 9 deletions
+13
View File
@@ -0,0 +1,13 @@
.git
.claude
.next
node_modules
dist
coverage
test-results
playwright-report
.env
.env.*
geheim
*.key
docs
+11
View File
@@ -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=
+1
View File
@@ -7,3 +7,4 @@ pnpm-lock.yaml
src/generated src/generated
prisma/migrations prisma/migrations
.claude .claude
dist
+39
View File
@@ -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"]
+20
View File
@@ -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
+8
View File
@@ -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);
+114
View File
@@ -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:
+12 -7
View File
@@ -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`).
+3
View File
@@ -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
View File
@@ -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}": [
+57
View File
@@ -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,
},
});
}
+62
View File
@@ -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);
}
+84
View File
@@ -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() },
});
}
}
+7
View File
@@ -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 = {};
+43
View File
@@ -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);
});
+90
View File
@@ -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");
});
});