Files
craftvia/scripts/garage-provision.ts
msolarczekandClaude Opus 5 c8e6f30a27
CI / build-and-check (push) Canceled after 0s
CI / audit (push) Canceled after 0s
CI / sbom (push) Canceled after 0s
Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation
und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:05:39 +02:00

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 isms-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() || "isms-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: "isms-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);
});