feat: storage abstraction (fs volume default | s3), health via storageOk
This commit is contained in:
+5
-1
@@ -3,7 +3,11 @@
|
|||||||
# Datenbank (Postgres)
|
# Datenbank (Postgres)
|
||||||
DATABASE_URL=postgres://klarbild:CHANGEME@klarbild-db:5432/klarbild
|
DATABASE_URL=postgres://klarbild:CHANGEME@klarbild-db:5432/klarbild
|
||||||
|
|
||||||
# Objektspeicher (S3-kompatibel, z. B. MinIO)
|
# Objektspeicher: fs (Volume, Default) oder s3 (MinIO)
|
||||||
|
STORAGE_DRIVER=fs
|
||||||
|
STORAGE_DIR=/data
|
||||||
|
|
||||||
|
# Nur bei STORAGE_DRIVER=s3 (MinIO):
|
||||||
S3_ENDPOINT=http://klarbild-minio:9000
|
S3_ENDPOINT=http://klarbild-minio:9000
|
||||||
S3_REGION=us-east-1
|
S3_REGION=us-east-1
|
||||||
S3_BUCKET=klarbild
|
S3_BUCKET=klarbild
|
||||||
|
|||||||
+66
-27
@@ -1,48 +1,87 @@
|
|||||||
import {
|
// Objektspeicher-Abstraktion. STORAGE_DRIVER=fs (Volume, Default) | s3 (MinIO/S3).
|
||||||
S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand,
|
// Presigned URLs werden nicht benötigt — Originale gehen als Base64-Data-URL an die Bild-API.
|
||||||
CreateBucketCommand, HeadBucketCommand,
|
import { mkdir, readFile, writeFile, unlink } from 'node:fs/promises';
|
||||||
} from '@aws-sdk/client-s3';
|
import { dirname, join } from 'node:path';
|
||||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
||||||
|
|
||||||
|
const DRIVER = (process.env.STORAGE_DRIVER || 'fs').toLowerCase();
|
||||||
|
const DIR = process.env.STORAGE_DIR || '/data';
|
||||||
const bucket = process.env.S3_BUCKET || 'klarbild';
|
const bucket = process.env.S3_BUCKET || 'klarbild';
|
||||||
|
|
||||||
export const s3 = new S3Client({
|
// --- S3 (lazy, nur wenn genutzt) --------------------------------------------
|
||||||
endpoint: process.env.S3_ENDPOINT,
|
let _s3: any = null;
|
||||||
region: process.env.S3_REGION || 'us-east-1',
|
async function s3client() {
|
||||||
forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? 'true') === 'true',
|
if (!_s3) {
|
||||||
credentials: {
|
const { S3Client } = await import('@aws-sdk/client-s3');
|
||||||
accessKeyId: process.env.S3_ACCESS_KEY || '',
|
_s3 = new S3Client({
|
||||||
secretAccessKey: process.env.S3_SECRET_KEY || '',
|
endpoint: process.env.S3_ENDPOINT,
|
||||||
},
|
region: process.env.S3_REGION || 'us-east-1',
|
||||||
});
|
forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? 'true') === 'true',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.S3_ACCESS_KEY || '',
|
||||||
|
secretAccessKey: process.env.S3_SECRET_KEY || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _s3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API --------------------------------------------------------------------
|
||||||
export async function ensureBucket(): Promise<void> {
|
export async function ensureBucket(): Promise<void> {
|
||||||
try {
|
if (DRIVER === 's3') {
|
||||||
await s3.send(new HeadBucketCommand({ Bucket: bucket }));
|
const { CreateBucketCommand, HeadBucketCommand } = await import('@aws-sdk/client-s3');
|
||||||
} catch {
|
const s3 = await s3client();
|
||||||
await s3.send(new CreateBucketCommand({ Bucket: bucket }));
|
try { await s3.send(new HeadBucketCommand({ Bucket: bucket })); }
|
||||||
|
catch { await s3.send(new CreateBucketCommand({ Bucket: bucket })); }
|
||||||
|
} else {
|
||||||
|
await mkdir(DIR, { recursive: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function putObject(key: string, body: Buffer, contentType: string): Promise<string> {
|
export async function putObject(key: string, body: Buffer, contentType: string): Promise<string> {
|
||||||
await s3.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: body, ContentType: contentType }));
|
if (DRIVER === 's3') {
|
||||||
|
const { PutObjectCommand } = await import('@aws-sdk/client-s3');
|
||||||
|
await (await s3client()).send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: body, ContentType: contentType }));
|
||||||
|
} else {
|
||||||
|
const p = join(DIR, key);
|
||||||
|
await mkdir(dirname(p), { recursive: true });
|
||||||
|
await writeFile(p, body);
|
||||||
|
}
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getObject(key: string): Promise<Buffer> {
|
export async function getObject(key: string): Promise<Buffer> {
|
||||||
const res = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
if (DRIVER === 's3') {
|
||||||
const chunks: Buffer[] = [];
|
const { GetObjectCommand } = await import('@aws-sdk/client-s3');
|
||||||
for await (const c of res.Body as any) chunks.push(Buffer.from(c));
|
const res = await (await s3client()).send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
||||||
return Buffer.concat(chunks);
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const c of res.Body as any) chunks.push(Buffer.from(c));
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
return readFile(join(DIR, key));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteObject(key: string): Promise<void> {
|
export async function deleteObject(key: string): Promise<void> {
|
||||||
await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
|
if (DRIVER === 's3') {
|
||||||
|
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3');
|
||||||
|
await (await s3client()).send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
|
||||||
|
} else {
|
||||||
|
await unlink(join(DIR, key)).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vorsignierte GET-URL (Standard 15 Min) — u.a. für OpenRouter input_references. */
|
/** Storage-Healthcheck (Verzeichnis beschreibbar bzw. Bucket erreichbar). */
|
||||||
export function presignGet(key: string, expiresIn = 900): Promise<string> {
|
export async function storageOk(): Promise<boolean> {
|
||||||
return getSignedUrl(s3, new GetObjectCommand({ Bucket: bucket, Key: key }), { expiresIn });
|
try {
|
||||||
|
if (DRIVER === 's3') {
|
||||||
|
const { HeadBucketCommand } = await import('@aws-sdk/client-s3');
|
||||||
|
await (await s3client()).send(new HeadBucketCommand({ Bucket: bucket }));
|
||||||
|
} else {
|
||||||
|
await mkdir(DIR, { recursive: true });
|
||||||
|
const probe = join(DIR, '.health');
|
||||||
|
await writeFile(probe, 'ok'); await unlink(probe).catch(() => {});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sourceKey(uuid: string, ext: string): string {
|
export function sourceKey(uuid: string, ext: string): string {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { pool } from '../../lib/db';
|
import { pool } from '../../lib/db';
|
||||||
import { s3 } from '../../lib/storage';
|
import { storageOk } from '../../lib/storage';
|
||||||
import { HeadBucketCommand } from '@aws-sdk/client-s3';
|
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
@@ -12,12 +11,9 @@ export const GET: APIRoute = async () => {
|
|||||||
try { await pool.query('SELECT 1'); out.db = 'ok'; }
|
try { await pool.query('SELECT 1'); out.db = 'ok'; }
|
||||||
catch { out.db = 'fehler'; ok = false; }
|
catch { out.db = 'fehler'; ok = false; }
|
||||||
|
|
||||||
try {
|
out.storage = (await storageOk()) ? 'ok' : 'fehler';
|
||||||
await s3.send(new HeadBucketCommand({ Bucket: process.env.S3_BUCKET || 'klarbild' }));
|
if (out.storage !== 'ok') ok = false;
|
||||||
out.storage = 'ok';
|
|
||||||
} catch { out.storage = 'fehler'; ok = false; }
|
|
||||||
|
|
||||||
out.queue = 'todo'; // wird mit pg-boss-Phase geprüft
|
|
||||||
out.status = ok ? 'ok' : 'degraded';
|
out.status = ok ? 'ok' : 'degraded';
|
||||||
return new Response(JSON.stringify(out), {
|
return new Response(JSON.stringify(out), {
|
||||||
status: ok ? 200 : 503,
|
status: ok ? 200 : 503,
|
||||||
|
|||||||
Reference in New Issue
Block a user