diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b5379a9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.claude +.next +node_modules +dist +coverage +test-results +playwright-report +.env +.env.* +geheim +*.key +docs diff --git a/.env.example b/.env.example index 9c3f894..d0d0e13 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,9 @@ # Alle eigenen Variablen tragen das Praefix SSHKV_ (Ausnahme: DATABASE_URL fuer Prisma). # --- 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 # Nur fuer Tests (wird von Vitest geleert!) 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 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!) -------------------------------------------------- # Datei mit 32 zufaelligen Bytes. Erzeugen mit: head -c 32 /dev/urandom > master.key # Ohne diese Datei ist jedes Datenbank-Backup wertlos – getrennt sichern! 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) ---------------------------------------------------------- SSHKV_OIDC_ISSUER= diff --git a/.prettierignore b/.prettierignore index 3fb5836..8a277f2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,4 @@ pnpm-lock.yaml src/generated prisma/migrations .claude +dist diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..913baf1 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/deploy/start.sh b/deploy/start.sh new file mode 100755 index 0000000..9cd87e8 --- /dev/null +++ b/deploy/start.sh @@ -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 diff --git a/deploy/worker-banner.js b/deploy/worker-banner.js new file mode 100644 index 0000000..864222a --- /dev/null +++ b/deploy/worker-banner.js @@ -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); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a20cb43 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/issues/007-worker-vorgaenge.md b/docs/issues/007-worker-vorgaenge.md index bf8e513..1119240 100644 --- a/docs/issues/007-worker-vorgaenge.md +++ b/docs/issues/007-worker-vorgaenge.md @@ -1,22 +1,27 @@ # #007 Worker und Vorgänge -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.1.0 **Labels:** backend -**Geschätzt:** 3 h **Tatsächlich:** – +**Geschätzt:** 3 h **Tatsächlich:** 0 h 15 min ## Ziel Eigener Worker-Prozess mit pg-boss; die Oberfläche legt Vorgänge an und zeigt deren Fortschritt. ## Aufgaben -- [ ] Worker-Einstieg, gebündelt per esbuild -- [ ] pg-boss-Queues je Vorgangsart, Zeitpläne -- [ ] Modell `Vorgang` für Status/Ergebnis in der Oberfläche -- [ ] Docker-Compose produktiv: web, worker, db, migrate; Master-Key nur im Worker +- [x] Worker-Einstieg, gebündelt per esbuild +- [x] pg-boss-Queues je Vorgangsart, Zeitpläne +- [x] Modell `Vorgang` für Status/Ergebnis in der Oberfläche +- [x] Docker-Compose produktiv: web, worker, db, migrate; Master-Key nur im Worker ## 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 #003 ## 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`). diff --git a/eslint.config.mjs b/eslint.config.mjs index b17d147..6504287 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -39,6 +39,9 @@ export default defineConfig([ "next-env.d.ts", // Worktrees der Sub-Agenten liegen im Projektverzeichnis. ".claude/**", + // Gebuendelter Worker und sein esbuild-Banner. + "dist/**", + "deploy/worker-banner.js", // Erzeugter Prisma-Client. "src/generated/**", "test-results/**", diff --git a/package.json b/package.json index b612a47..f9be8ab 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "scripts": { "dev": "next dev -p 3400", "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", "worker": "tsx src/worker/index.ts", "lint": "eslint", @@ -31,7 +31,8 @@ "issues:sync": "tsx scripts/gitea-issues.ts", "icons": "node scripts/icons.mjs", "postinstall": "prisma generate", - "prepare": "husky" + "prepare": "husky", + "build:all": "pnpm build && pnpm build:worker" }, "lint-staged": { "*.{ts,tsx,mts,mjs}": [ diff --git a/src/server/vorgaenge/vorgaenge.ts b/src/server/vorgaenge/vorgaenge.ts new file mode 100644 index 0000000..a44f2ac --- /dev/null +++ b/src/server/vorgaenge/vorgaenge.ts @@ -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 { + 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, + }, + }); +} diff --git a/src/server/vorgaenge/warteschlange.ts b/src/server/vorgaenge/warteschlange.ts new file mode 100644 index 0000000..1600fa6 --- /dev/null +++ b/src/server/vorgaenge/warteschlange.ts @@ -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 | 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 { + const zwischenspeicher = globalThis as typeof globalThis & { sshkvBoss?: Promise }; + 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 { + const boss = await warteschlangeHolen(); + return boss.send(WARTESCHLANGE, job); +} diff --git a/src/worker/ausfuehren.ts b/src/worker/ausfuehren.ts new file mode 100644 index 0000000..86116fe --- /dev/null +++ b/src/worker/ausfuehren.ts @@ -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; +export type HandlerTabelle = Partial>; + +/** + * 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 { + // 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() }, + }); + } +} diff --git a/src/worker/handler.ts b/src/worker/handler.ts new file mode 100644 index 0000000..1738165 --- /dev/null +++ b/src/worker/handler.ts @@ -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 = {}; diff --git a/src/worker/index.ts b/src/worker/index.ts new file mode 100644 index 0000000..7ac02ba --- /dev/null +++ b/src/worker/index.ts @@ -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 { + const boss = await warteschlangeHolen(); + + await boss.work( + WARTESCHLANGE, + { batchSize: 1, localConcurrency: 4 }, + async (jobs: Job[]) => { + 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); +}); diff --git a/tests/vorgaenge.test.ts b/tests/vorgaenge.test.ts new file mode 100644 index 0000000..00d91f3 --- /dev/null +++ b/tests/vorgaenge.test.ts @@ -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(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"); + }); +});