Kontowerkzeug ins Abbild, Containerabnahme

Die README nannte `node werkzeuge/konto.js`, im Abbild lag aber nur das
Sicherungswerkzeug, und tsx gibt es dort nicht. Ein frisch aufgesetzter
Server stand ohne jedes Konto da. Das Kontoskript wird jetzt beim Bau mit
esbuild zu einer Datei gebuendelt; die nativen Module bleiben aussen vor,
sie bringt die Standalone-Ausgabe mit.

Aufgefallen ist das nicht, weil der Abnahmelauf gegen `next start` prueft.
Neu ist deshalb `npm run abnahme:container`: Abbild bauen, mit leerem
Volume starten, erstes Konto anlegen, anmelden, Passwort neu setzen,
sichern.

Ausserdem trennt saubererName Windows-Pfade jetzt auch unter Linux ab
(path.win32.basename). Der Test dazu war nur unter Windows gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-03 00:50:59 +02:00
co-authored by Claude Opus 5.5
parent 6181ec4bd7
commit adb93a722f
7 changed files with 216 additions and 9 deletions
+1
View File
@@ -1,6 +1,7 @@
node_modules/ node_modules/
.next/ .next/
out/ out/
build/
next-env.d.ts next-env.d.ts
*.tsbuildinfo *.tsbuildinfo
+23
View File
@@ -26,6 +26,29 @@ Vor `npm version` wandert der Abschnitt „Unveröffentlicht" unter eine neue
## [Unveröffentlicht] ## [Unveröffentlicht]
## [1.0.1] – 2026-10-03
Das Docker-Abbild ist jetzt aus dem Nichts aufsetzbar.
### Behoben
- **Im Docker-Abbild ließ sich kein erstes Konto anlegen.** Die README nannte
`node werkzeuge/konto.js`, das Abbild enthielt aber nur die Sicherungsskripte,
und `tsx` gibt es dort nicht. Ein frisch aufgesetzter Server stand vor einer
Anmeldeseite ohne Konto. Das Kontowerkzeug wird jetzt beim Bau mit esbuild zu
einer Datei gebündelt (`npm run werkzeuge:bauen`) und liegt unter
`/app/werkzeuge/konto.js`
- **Windows-Pfade in Dateinamen von Anhängen** wurden unter Linux nicht
abgetrennt – aus `C:\Users\x\foto.jpg` wurde `C:Usersxfoto.jpg`. Nur die
Anzeige war betroffen, gespeichert wird unter der UUID. Der Test dazu war
unter Windows grün und unter Linux rot
### Hinzugefügt
- `npm run abnahme:container` – Abnahmelauf gegen das gebaute Docker-Abbild:
Lebenszeichen, Betrieb ohne root, erstes Konto, Anmeldung, Passwort neu
setzen, Sicherung im Volume
## [1.0.0] – 2026-09-11 ## [1.0.0] – 2026-09-11
Die erste abnahmefähige Fassung. Alle zehn Bausteine sind fertig, die Die erste abnahmefähige Fassung. Alle zehn Bausteine sind fertig, die
+5 -1
View File
@@ -24,6 +24,9 @@ ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=abhaengigkeiten /app/node_modules ./node_modules COPY --from=abhaengigkeiten /app/node_modules ./node_modules
COPY . . COPY . .
RUN npm run build RUN npm run build
# Das Kontowerkzeug als eine einzige JS-Datei – im Abbild gibt es kein tsx.
# Die nativen Module bleiben draußen, sie bringt die Standalone-Ausgabe mit.
RUN npm run werkzeuge:bauen
# --- Laufzeit ------------------------------------------------------------- # --- Laufzeit -------------------------------------------------------------
FROM node:${NODE_VERSION} AS laufzeit FROM node:${NODE_VERSION} AS laufzeit
@@ -51,8 +54,9 @@ COPY --from=bau /app/public ./public
COPY --from=bau /app/content ./content COPY --from=bau /app/content ./content
COPY --from=bau /app/src/lib/db/migrationen ./migrationen COPY --from=bau /app/src/lib/db/migrationen ./migrationen
# Werkzeuge für Sicherung und Wiederherstellung im Container. # Werkzeuge für Sicherung, Wiederherstellung und das erste Konto.
COPY --from=bau /app/scripts/im-container ./werkzeuge COPY --from=bau /app/scripts/im-container ./werkzeuge
COPY --from=bau /app/build/werkzeuge/konto.js ./werkzeuge/konto.js
RUN chmod +x ./werkzeuge/*.sh RUN chmod +x ./werkzeuge/*.sh
RUN mkdir -p /daten && chown -R node:node /daten /app RUN mkdir -p /daten && chown -R node:node /daten /app
+16 -6
View File
@@ -39,14 +39,13 @@ Die Anwendung lauscht danach auf `127.0.0.1:3000`. Erstes Ausbilderkonto
anlegen: anlegen:
```bash ```bash
docker compose exec lernbar node -e "1" >/dev/null # Container erreichbar? docker compose exec lernbar node werkzeuge/konto.js \
docker compose exec lernbar sh -c \ --name "Vorname Nachname" --email ausbilder@betrieb.de --rolle ausbilder
'node werkzeuge/konto.js --name "Vorname Nachname" --email ausbilder@betrieb.de --rolle ausbilder'
``` ```
> Solange das Konto-Werkzeug im Abbild noch nicht liegt, geht es vom Ohne `--passwort` wird eines erzeugt und ausgegeben. Gibt es die Adresse
> Entwicklungsrechner aus: schon, setzt derselbe Aufruf das Passwort neu – das ist zugleich der Weg, wenn
> `npm run konto:anlegen -- --name "…" --email … --rolle ausbilder` das einzige Ausbilderkonto sein Passwort vergessen hat.
Danach im Browser anmelden, unter **Verwaltung** das Azubikonto anlegen und Danach im Browser anmelden, unter **Verwaltung** das Azubikonto anlegen und
das Ausbildungsverhältnis mit Beginndatum einrichten. das Ausbildungsverhältnis mit Beginndatum einrichten.
@@ -244,6 +243,7 @@ npm run typecheck # TypeScript
npm run lint # ESLint npm run lint # ESLint
npm run test # Vitest – Logik, die wehtut (315 Tests) npm run test # Vitest – Logik, die wehtut (315 Tests)
npm run abnahme # der Abnahmelauf, siehe unten npm run abnahme # der Abnahmelauf, siehe unten
npm run abnahme:container # derselbe Weg gegen das Docker-Abbild
``` ```
### Der Abnahmelauf ### Der Abnahmelauf
@@ -269,6 +269,16 @@ Voraussetzung: `npm run build` ist gelaufen, und Port 3399 ist frei – sonst
bricht der Lauf mit einer Meldung ab, statt gegen einen fremden Datenbestand zu bricht der Lauf mit einer Meldung ab, statt gegen einen fremden Datenbestand zu
prüfen. prüfen.
### Der Containerlauf
`npm run abnahme` prüft über `next start`, betrieben wird aber der Container.
`npm run abnahme:container` baut deshalb das Abbild, startet es mit leerem
Volume und geht den Weg aus dieser README: Lebenszeichen, erstes Konto über
`werkzeuge/konto.js`, Anmeldung, Passwort neu setzen, Sicherung im Container.
Danach werden Container, Volume und Abbild wieder entfernt.
Voraussetzung: Docker läuft, Port 3398 ist frei (`LERNBAR_CONTAINER_PORT`).
### Einzelne Zusagen ### Einzelne Zusagen
```bash ```bash
+3 -1
View File
@@ -17,11 +17,13 @@
"e2e": "playwright test", "e2e": "playwright test",
"inhalte:pruefen": "tsx scripts/inhalte-pruefen.ts", "inhalte:pruefen": "tsx scripts/inhalte-pruefen.ts",
"konto:anlegen": "tsx scripts/konto-anlegen.ts", "konto:anlegen": "tsx scripts/konto-anlegen.ts",
"werkzeuge:bauen": "esbuild scripts/konto-anlegen.ts --bundle --platform=node --target=node22 --format=cjs --external:better-sqlite3 --external:@node-rs/argon2 --outfile=build/werkzeuge/konto.js",
"backup": "bash scripts/im-container/sicherung.sh", "backup": "bash scripts/im-container/sicherung.sh",
"restore": "bash scripts/im-container/wiederherstellen.sh", "restore": "bash scripts/im-container/wiederherstellen.sh",
"screenshots": "tsx scripts/screenshots.ts", "screenshots": "tsx scripts/screenshots.ts",
"symbole": "tsx scripts/symbole.ts", "symbole": "tsx scripts/symbole.ts",
"abnahme": "node scripts/pruefungen/abnahme.mjs" "abnahme": "node scripts/pruefungen/abnahme.mjs",
"abnahme:container": "node scripts/pruefungen/container.mjs"
}, },
"dependencies": { "dependencies": {
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",
+165
View File
@@ -0,0 +1,165 @@
import { chromium } from '@playwright/test';
import { spawnSync } from 'node:child_process';
/**
* Der Abnahmelauf gegen das Docker-Abbild.
*
* `abnahme.mjs` prüft die Anwendung über `next start`. Betrieben wird sie aber
* als Container – und was dort fehlt, fällt dabei nicht auf. So war es mit dem
* Kontowerkzeug: Die README beschrieb `node werkzeuge/konto.js`, im Abbild lag
* es nie, und ein frisch aufgesetzter Server stand ohne jedes Konto da.
*
* Dieser Lauf baut das Abbild, startet es mit leerem Volume und geht den Weg
* aus der README: Lebenszeichen, erstes Konto, Anmeldung, Sicherung.
*
* node scripts/pruefungen/container.mjs
*
* Voraussetzung: Docker läuft, Port 3398 ist frei (LERNBAR_CONTAINER_PORT).
*/
const PORT = Number(process.env.LERNBAR_CONTAINER_PORT ?? 3398);
const BASIS = `http://localhost:${PORT}`;
const ABBILD = 'lernbar:abnahme';
const NAME = `lernbar-abnahme-${Date.now()}`;
const AUSBILDER = { name: 'Petra Kessler', email: 'petra.kessler@betrieb.test', pw: 'container-ausbilder' };
let fehler = 0;
function pruefe(bedingung, text, zusatz = '') {
console.log(` ${bedingung ? '✓' : '✗'} ${text}${zusatz ? ` – ${zusatz}` : ''}`);
if (!bedingung) fehler += 1;
}
function abschnitt(titel) {
console.log(`\n${titel}`);
}
function docker(argumente, zusatz = {}) {
return spawnSync('docker', argumente, { encoding: 'utf8', ...zusatz });
}
function imContainer(...argumente) {
const ergebnis = docker(['exec', NAME, ...argumente]);
return { ende: ergebnis.status, ausgabe: `${ergebnis.stdout}${ergebnis.stderr}` };
}
let browser = null;
function aufraeumen() {
// -v nimmt das namenlose Volume mit – sonst bleibt bei jedem Lauf eins liegen.
docker(['rm', '-fv', NAME], { stdio: 'ignore' });
docker(['rmi', ABBILD], { stdio: 'ignore' });
}
async function portBelegt() {
try {
await fetch(`${BASIS}/api/gesundheit`, { signal: AbortSignal.timeout(1000) });
return true;
} catch {
return false;
}
}
async function gesund(sekunden = 60) {
for (let i = 0; i < sekunden; i += 1) {
try {
const antwort = await fetch(`${BASIS}/api/gesundheit`);
if (antwort.ok) return true;
} catch {
/* noch nicht da */
}
await new Promise((r) => setTimeout(r, 1000));
}
return false;
}
async function anmelden(email, passwort) {
const kontext = await browser.newContext({ locale: 'de-DE' });
const seite = await kontext.newPage();
seite.setDefaultTimeout(30000);
await seite.goto(`${BASIS}/anmelden`);
await seite.fill('#email', email);
await seite.fill('#passwort', passwort);
await seite.click('button[type=submit]');
const angekommen = await seite
.waitForURL(`${BASIS}/`, { timeout: 15000 })
.then(() => true)
.catch(() => false);
await kontext.close();
return angekommen;
}
async function main() {
if (docker(['info'], { stdio: 'ignore' }).status !== 0) {
console.error('Docker ist nicht erreichbar.');
process.exit(2);
}
if (await portBelegt()) {
console.error(`Auf Port ${PORT} antwortet schon etwas. Anderen Port über LERNBAR_CONTAINER_PORT setzen.`);
process.exit(2);
}
// --- C1 Das Abbild entsteht ----------------------------------------------
abschnitt('C1 · Das Abbild entsteht und startet');
console.log(' … baue das Abbild, das dauert beim ersten Mal einige Minuten');
const bau = docker(['build', '-t', ABBILD, '.'], { stdio: ['ignore', 'ignore', 'pipe'] });
pruefe(bau.status === 0, 'Das Abbild lässt sich bauen', bau.status === 0 ? '' : bau.stderr.split('\n').slice(-5).join(' '));
if (bau.status !== 0) return;
const start = docker(['run', '-d', '--name', NAME, '-p', `127.0.0.1:${PORT}:3000`, ABBILD]);
pruefe(start.status === 0, 'Der Container startet');
if (start.status !== 0) return;
pruefe(await gesund(), 'Die Anwendung meldet sich gesund');
pruefe(imContainer('id', '-un').ausgabe.trim() === 'node', 'Sie läuft nicht als root');
// --- C2 Das erste Konto ---------------------------------------------------
abschnitt('C2 · Das erste Konto entsteht im Container');
const werkzeuge = imContainer('ls', '/app/werkzeuge').ausgabe;
pruefe(werkzeuge.includes('konto.js'), 'Das Kontowerkzeug liegt im Abbild');
const anlegen = imContainer(
'node', 'werkzeuge/konto.js',
'--name', AUSBILDER.name, '--email', AUSBILDER.email, '--rolle', 'ausbilder', '--passwort', AUSBILDER.pw,
);
pruefe(anlegen.ende === 0 && anlegen.ausgabe.includes('Konto angelegt'), 'Der Aufruf aus der README legt das Konto an', anlegen.ausgabe.split('\n')[0]);
browser = await chromium.launch();
pruefe(await anmelden(AUSBILDER.email, AUSBILDER.pw), 'Der Ausbilder meldet sich mit diesem Konto an');
pruefe(!(await anmelden(AUSBILDER.email, 'falsch-falsch')), 'Ein falsches Passwort wird abgewiesen');
// Derselbe Aufruf ist der Rettungsweg, wenn das einzige Ausbilderkonto sein
// Passwort vergessen hat.
const neu = imContainer('node', 'werkzeuge/konto.js', '--name', AUSBILDER.name, '--email', AUSBILDER.email);
const neuesPw = neu.ausgabe.match(/Passwort: (\S+)/)?.[1];
pruefe(neu.ende === 0 && neu.ausgabe.includes('gab es schon') && neuesPw, 'Ein zweiter Aufruf setzt das Passwort neu');
pruefe(neuesPw && (await anmelden(AUSBILDER.email, neuesPw)), 'Mit dem neuen Passwort geht die Anmeldung');
pruefe(!(await anmelden(AUSBILDER.email, AUSBILDER.pw)), 'Das alte Passwort gilt nicht mehr');
// --- C3 Sicherung im Container -------------------------------------------
abschnitt('C3 · Sicherung im Container');
const sicherung = imContainer('/app/werkzeuge/sicherung.sh', '/daten/sicherungen');
pruefe(sicherung.ende === 0, 'Die Sicherung läuft im Container durch', sicherung.ausgabe.trim().split('\n').at(-1));
const archive = imContainer('sh', '-c', 'ls /daten/sicherungen/lernbar-*.tar.gz').ausgabe;
pruefe(/lernbar-\d{8}-\d{6}\.tar\.gz/.test(archive), 'Das Archiv liegt im Volume');
}
try {
await main();
} catch (e) {
console.error(e);
fehler += 1;
} finally {
if (browser) await browser.close();
aufraeumen();
}
console.log(fehler === 0 ? '\n✓ Containerabnahme bestanden.' : `\n✗ ${fehler} Prüfung(en) nicht erfüllt.`);
process.exit(fehler === 0 ? 0 : 1);
+3 -1
View File
@@ -67,7 +67,9 @@ export function megabyte(bytes: number): string {
* etwas verloren. * etwas verloren.
*/ */
export function saubererName(name: string): string { export function saubererName(name: string): string {
return path // win32.basename trennt an / und \ – der Name kommt vom Browser, nicht vom
// Server, und ein Windows-Pfad darf auch unter Linux nicht durchrutschen.
return path.win32
.basename(name) .basename(name)
.replace(/[\u0000-\u001f\u007f]/g, '') .replace(/[\u0000-\u001f\u007f]/g, '')
.replace(/["\\]/g, '') .replace(/["\\]/g, '')