import "dotenv/config"; /** * IM/Garage-Migration — idempotentes Provisioning eines Single-Node-Garage * (docs/_certvia-archiv/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 { 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 { 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 { 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 { 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 { 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); });