- docker-compose.yml: explizite Build-Targets (runner/migrate) — Ursache für "npx not found" bei garage-provision war das Default-Target (letzte Stage = Garage-Image ohne Node); worker startet npm run worker:mail; Defaults craftvia - Coolify-Compose: incident-inbound-worker und sync-policy-templates entfernt, Rollen-/Bucket-/Image-Namen auf craftvia - Dockerfile: seed/ und docs/wizard-uebergabe entfernt, messages/ im Runner - npm: Name craftvia, Scripts test (scripts/run-tests.ts) und gate, ISMS-Pakete entfernt (handlebars, marked, sanitize-html, @xyflow/react, @dagrejs/dagre, html-to-image, imapflow, mailparser, exceljs, @dnd-kit/core) - .env-Beispiele, launch.json (craftvia-dev), CI-Kommentare umbenannt Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
266 lines
11 KiB
TypeScript
266 lines
11 KiB
TypeScript
import "dotenv/config";
|
|
|
|
/**
|
|
* IM/Garage-Migration — idempotentes Provisioning eines Single-Node-Garage
|
|
* (docs/KONZEPT-garage-migration.md §6 Lane B, §7 Runbook).
|
|
*
|
|
* Garage verwaltet Buckets/Keys/Rechte NICHT über die S3-API (`CreateBucket` gibt es
|
|
* dort nicht), sondern out-of-band. Dieser Init-Job (Compose-Service „garage-provision",
|
|
* restart:no, analog „migrate") stellt aus einer leeren Garage reproduzierbar den
|
|
* betriebsbereiten Zustand her:
|
|
* 1. Layout: dem Node einmalig Zone + Kapazität zuweisen und anwenden (ohne Layout
|
|
* lehnt Garage jeden Schreibzugriff mit „no capacity" ab).
|
|
* 2. Bucket `S3_BUCKET` (Default craftvia-documents) anlegen.
|
|
* 3. Access-Key deterministisch IMPORTIEREN — aus S3_ACCESS_KEY/S3_SECRET_KEY der
|
|
* Coolify-Env, damit App-Env und Garage denselben Schlüssel teilen (kein
|
|
* Nachpflegen erzeugter Keys).
|
|
* 4. Key→Bucket-Rechte read/write setzen.
|
|
* 5. Optional denselben Ablauf für einen separaten Backup-Bucket (BACKUP_S3_BUCKET),
|
|
* falls der Backup-Store auf S3 statt BACKUP_LOCAL_DIR läuft.
|
|
*
|
|
* WARUM Admin-API (HTTP) statt `garage`-CLI: Der Job läuft im schlanken „migrate"-Image
|
|
* (Node/tsx) — kein Garage-Binary, kein geteiltes meta-Volume, kein Node-Key nötig. Die
|
|
* Admin-API v1 ist gegen die gepinnte Garage-Version (v1.2.0) stabil und liefert klare
|
|
* Statuscodes (409 = „existiert bereits" → als Erfolg gewertet). Alle Schritte sind
|
|
* idempotent: bei JEDEM Deploy lauffähig, „already exists" ist kein Fehler.
|
|
*
|
|
* Fail-/No-op-Verhalten (Deployment-Falle: ein Init-Container darf den Stack nicht
|
|
* reißen): Ist `GARAGE_ADMIN_TOKEN` NICHT gesetzt, gilt Garage als nicht in Betrieb
|
|
* (z. B. lokaler/Stub-Betrieb) → sauberer No-op (Exit 0). Ist der Token gesetzt, aber
|
|
* Pflicht-Config (S3-Key) fehlt oder die Garage ist nicht erreichbar → klare Meldung +
|
|
* Exit ≠ 0. Es werden KEINE Secrets geloggt (Access-Key-ID ist der öffentliche Teil).
|
|
*/
|
|
|
|
interface Cfg {
|
|
adminUrl: string;
|
|
adminToken: string;
|
|
accessKeyId: string;
|
|
secretKey: string;
|
|
bucket: string;
|
|
backupBucket: string | null;
|
|
zone: string;
|
|
capacityBytes: number;
|
|
}
|
|
|
|
function log(msg: string): void {
|
|
// stdout mit Zeitstempel; Coolify erfasst die Logs. Bei „Container weg ohne Logs"
|
|
// zusätzlich in eine Datei spiegeln (GARAGE_PROVISION_LOG), siehe DEPLOY-COOLIFY.
|
|
const line = `[garage-provision] ${msg}`;
|
|
console.log(line);
|
|
}
|
|
|
|
function readCfg(): Cfg | null {
|
|
const adminToken = process.env.GARAGE_ADMIN_TOKEN?.trim();
|
|
if (!adminToken) {
|
|
// Garage nicht in Betrieb → No-op (siehe Datei-Doc, Deployment-Falle).
|
|
log("GARAGE_ADMIN_TOKEN nicht gesetzt — Garage-Provisioning übersprungen (No-op).");
|
|
return null;
|
|
}
|
|
const accessKeyId = process.env.S3_ACCESS_KEY?.trim();
|
|
const secretKey = process.env.S3_SECRET_KEY?.trim();
|
|
const bucket = process.env.S3_BUCKET?.trim() || "craftvia-documents";
|
|
if (!accessKeyId || !secretKey) {
|
|
throw new Error(
|
|
"GARAGE_ADMIN_TOKEN ist gesetzt, aber S3_ACCESS_KEY/S3_SECRET_KEY fehlen. " +
|
|
"Beide müssen den zu importierenden Garage-Key definieren (App-Env == Garage).",
|
|
);
|
|
}
|
|
// Garage erzwingt beim Key-Import ein festes Format (sonst HTTP 400). Früh & klar
|
|
// prüfen statt kryptisch beim Import scheitern:
|
|
// Access-Key-ID = "GK" + 24 Hex · Secret = 64 Hex.
|
|
// S3_ACCESS_KEY: echo "GK$(openssl rand -hex 12)"
|
|
// S3_SECRET_KEY: openssl rand -hex 32
|
|
if (!/^GK[0-9a-f]{24}$/.test(accessKeyId)) {
|
|
throw new Error(
|
|
`S3_ACCESS_KEY hat nicht das von Garage geforderte Format „GK" + 24 Hex-Zeichen ` +
|
|
`(erhalten: „${accessKeyId}"). Erzeugen: echo "GK$(openssl rand -hex 12)".`,
|
|
);
|
|
}
|
|
if (!/^[0-9a-f]{64}$/i.test(secretKey)) {
|
|
throw new Error("S3_SECRET_KEY muss 64 Hex-Zeichen sein (openssl rand -hex 32).");
|
|
}
|
|
const capacityBytes = Number(process.env.GARAGE_CAPACITY_BYTES ?? "100000000000"); // 100 GB nominal
|
|
if (!Number.isFinite(capacityBytes) || capacityBytes <= 0) {
|
|
throw new Error(`GARAGE_CAPACITY_BYTES ungültig: ${process.env.GARAGE_CAPACITY_BYTES}`);
|
|
}
|
|
return {
|
|
adminUrl: (process.env.GARAGE_ADMIN_URL?.trim() || "http://garage:3903").replace(/\/+$/, ""),
|
|
adminToken,
|
|
accessKeyId,
|
|
secretKey,
|
|
bucket,
|
|
backupBucket: process.env.BACKUP_S3_BUCKET?.trim() || null,
|
|
zone: process.env.GARAGE_ZONE?.trim() || "dc1",
|
|
capacityBytes,
|
|
};
|
|
}
|
|
|
|
/** Admin-API-Aufruf. `expectMissing` erlaubt 404 (Existenzprüfung) ohne Wurf. */
|
|
async function admin(
|
|
cfg: Cfg,
|
|
method: string,
|
|
path: string,
|
|
body?: unknown,
|
|
): Promise<{ status: number; json: unknown }> {
|
|
const res = await fetch(`${cfg.adminUrl}${path}`, {
|
|
method,
|
|
headers: {
|
|
Authorization: `Bearer ${cfg.adminToken}`,
|
|
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
},
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
});
|
|
let json: unknown = null;
|
|
const text = await res.text();
|
|
if (text) {
|
|
try {
|
|
json = JSON.parse(text);
|
|
} catch {
|
|
json = text;
|
|
}
|
|
}
|
|
return { status: res.status, json };
|
|
}
|
|
|
|
/** Wartet, bis die Admin-API /health mit 200 antwortet (Garage-Start abwarten). */
|
|
async function waitForHealth(cfg: Cfg, timeoutMs = 90_000): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastErr = "";
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const res = await fetch(`${cfg.adminUrl}/health`);
|
|
if (res.status === 200) {
|
|
log("Garage Admin-API erreichbar (/health 200).");
|
|
return;
|
|
}
|
|
lastErr = `HTTP ${res.status}`;
|
|
} catch (err) {
|
|
lastErr = err instanceof Error ? err.message : String(err);
|
|
}
|
|
await new Promise((r) => setTimeout(r, 2000));
|
|
}
|
|
throw new Error(`Garage Admin-API nicht erreichbar (${cfg.adminUrl}/health): ${lastErr}`);
|
|
}
|
|
|
|
/** Layout einmalig sicherstellen: Node bekommt Zone + Kapazität (nur falls noch ohne Rolle). */
|
|
async function ensureLayout(cfg: Cfg): Promise<void> {
|
|
const status = await admin(cfg, "GET", "/v1/status");
|
|
if (status.status !== 200) throw new Error(`GET /v1/status fehlgeschlagen (HTTP ${status.status}).`);
|
|
const nodeId = (status.json as { node?: string })?.node;
|
|
if (!nodeId) throw new Error("Konnte die Node-ID nicht aus /v1/status lesen.");
|
|
|
|
const layout = await admin(cfg, "GET", "/v1/layout");
|
|
if (layout.status !== 200) throw new Error(`GET /v1/layout fehlgeschlagen (HTTP ${layout.status}).`);
|
|
const l = layout.json as { version: number; roles: { id: string }[] };
|
|
if (l.roles.some((r) => r.id === nodeId)) {
|
|
log(`Layout bereits gesetzt (Node hat eine Rolle, Version ${l.version}) — übersprungen.`);
|
|
return;
|
|
}
|
|
|
|
log(`Layout wird zugewiesen: Zone „${cfg.zone}", Kapazität ${cfg.capacityBytes} Bytes.`);
|
|
const stage = await admin(cfg, "POST", "/v1/layout", [
|
|
{ id: nodeId, zone: cfg.zone, capacity: cfg.capacityBytes, tags: [] },
|
|
]);
|
|
if (stage.status !== 200) throw new Error(`Layout-Staging fehlgeschlagen (HTTP ${stage.status}).`);
|
|
const apply = await admin(cfg, "POST", "/v1/layout/apply", { version: l.version + 1 });
|
|
if (apply.status !== 200) throw new Error(`Layout-Apply fehlgeschlagen (HTTP ${apply.status}).`);
|
|
log(`Layout angewendet (Version ${l.version + 1}).`);
|
|
}
|
|
|
|
/** Bucket sicherstellen (idempotent). Gibt die Bucket-ID zurück. */
|
|
async function ensureBucket(cfg: Cfg, alias: string): Promise<string> {
|
|
const existing = await admin(cfg, "GET", `/v1/bucket?globalAlias=${encodeURIComponent(alias)}`);
|
|
if (existing.status === 200) {
|
|
log(`Bucket „${alias}" existiert bereits.`);
|
|
return (existing.json as { id: string }).id;
|
|
}
|
|
if (existing.status !== 404) {
|
|
throw new Error(`GET /v1/bucket (${alias}) unerwartet (HTTP ${existing.status}).`);
|
|
}
|
|
const created = await admin(cfg, "POST", "/v1/bucket", { globalAlias: alias });
|
|
if (created.status === 200) {
|
|
log(`Bucket „${alias}" angelegt.`);
|
|
return (created.json as { id: string }).id;
|
|
}
|
|
if (created.status === 409) {
|
|
// Rennen: parallel angelegt → erneut lesen.
|
|
const again = await admin(cfg, "GET", `/v1/bucket?globalAlias=${encodeURIComponent(alias)}`);
|
|
if (again.status === 200) {
|
|
log(`Bucket „${alias}" existierte bereits (409, Race).`);
|
|
return (again.json as { id: string }).id;
|
|
}
|
|
}
|
|
throw new Error(`Bucket „${alias}" konnte nicht angelegt werden (HTTP ${created.status}).`);
|
|
}
|
|
|
|
/** Access-Key deterministisch importieren (idempotent). */
|
|
async function ensureKey(cfg: Cfg): Promise<void> {
|
|
const existing = await admin(
|
|
cfg,
|
|
"GET",
|
|
`/v1/key?id=${encodeURIComponent(cfg.accessKeyId)}&showSecretKey=false`,
|
|
);
|
|
if (existing.status === 200) {
|
|
log(`Access-Key ${cfg.accessKeyId} existiert bereits — Import übersprungen.`);
|
|
return;
|
|
}
|
|
if (existing.status !== 404) {
|
|
throw new Error(`GET /v1/key unerwartet (HTTP ${existing.status}).`);
|
|
}
|
|
const imported = await admin(cfg, "POST", "/v1/key/import", {
|
|
accessKeyId: cfg.accessKeyId,
|
|
secretAccessKey: cfg.secretKey,
|
|
name: "craftvia-app",
|
|
});
|
|
if (imported.status === 200) {
|
|
log(`Access-Key ${cfg.accessKeyId} importiert.`);
|
|
return;
|
|
}
|
|
if (imported.status === 409) {
|
|
log(`Access-Key ${cfg.accessKeyId} existierte bereits (409).`);
|
|
return;
|
|
}
|
|
throw new Error(
|
|
`Access-Key-Import fehlgeschlagen (HTTP ${imported.status}). ` +
|
|
`Hinweis: Ist der Key mit anderem Secret bereits vorhanden, zuerst löschen und neu importieren.`,
|
|
);
|
|
}
|
|
|
|
/** Key→Bucket-Rechte read/write setzen (idempotent — Garage bestätigt erneut). */
|
|
async function allow(cfg: Cfg, bucketId: string): Promise<void> {
|
|
const res = await admin(cfg, "POST", "/v1/bucket/allow", {
|
|
bucketId,
|
|
accessKeyId: cfg.accessKeyId,
|
|
permissions: { read: true, write: true, owner: false },
|
|
});
|
|
if (res.status !== 200) {
|
|
throw new Error(`bucket/allow fehlgeschlagen (HTTP ${res.status}).`);
|
|
}
|
|
log(`Rechte read/write für ${cfg.accessKeyId} auf Bucket ${bucketId} gesetzt.`);
|
|
}
|
|
|
|
async function main() {
|
|
const cfg = readCfg();
|
|
if (!cfg) return; // No-op (kein Admin-Token)
|
|
|
|
log(`Ziel: ${cfg.adminUrl} · Bucket „${cfg.bucket}"${cfg.backupBucket ? ` + Backup „${cfg.backupBucket}"` : ""}.`);
|
|
await waitForHealth(cfg);
|
|
await ensureLayout(cfg);
|
|
|
|
const bucketId = await ensureBucket(cfg, cfg.bucket);
|
|
await ensureKey(cfg);
|
|
await allow(cfg, bucketId);
|
|
|
|
if (cfg.backupBucket && cfg.backupBucket !== cfg.bucket) {
|
|
const backupId = await ensureBucket(cfg, cfg.backupBucket);
|
|
await allow(cfg, backupId);
|
|
}
|
|
|
|
log("Provisioning abgeschlossen — Garage ist betriebsbereit.");
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(`[garage-provision] FEHLER: ${err instanceof Error ? err.message : String(err)}`);
|
|
process.exit(1);
|
|
});
|