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).
|
||||
|
||||
# --- 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=
|
||||
|
||||
@@ -7,3 +7,4 @@ pnpm-lock.yaml
|
||||
src/generated
|
||||
prisma/migrations
|
||||
.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
|
||||
**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`).
|
||||
|
||||
@@ -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/**",
|
||||
|
||||
+3
-2
@@ -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}": [
|
||||
|
||||
@@ -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