From ab20701fea7211014b1448acc7ba33380e67d4e5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 05:42:13 +0000 Subject: [PATCH] api: ping R2 con ListObjects, no HeadBucket Cloudflare R2 suele devolver 403 en HeadBucket con token acotado al bucket, y eso tumba el fail-fast de arranque. El ping ahora lista o escribe una sonda y registra el error S3. Co-authored-by: alberto.martinez --- api/config.ts | 8 +++--- api/main.ts | 4 ++- api/scripts/verify-storage.ts | 7 ++--- api/storage.ts | 49 ++++++++++++++++++++++++++++++----- docs/coolify.md | 2 +- 5 files changed, 54 insertions(+), 16 deletions(-) diff --git a/api/config.ts b/api/config.ts index 6f57396..6dcbb84 100644 --- a/api/config.ts +++ b/api/config.ts @@ -78,10 +78,10 @@ export const config = { redisUrlCore: isDev ? (Deno.env.get("REDIS_URL_CORE") ?? "") : required("REDIS_URL_CORE"), // --- Contabo Object Storage (S3-compatible) -- expedientes/PDFs/logos --- - s3Endpoint: Deno.env.get("S3_ENDPOINT") ?? "", - s3Bucket: Deno.env.get("S3_BUCKET") ?? "", - s3Region: Deno.env.get("S3_REGION") ?? "us-east-1", - s3AccessKeyId: Deno.env.get("S3_ACCESS_KEY_ID") ?? "", + s3Endpoint: (Deno.env.get("S3_ENDPOINT") ?? "").trim().replace(/\/$/, ""), + s3Bucket: (Deno.env.get("S3_BUCKET") ?? "").trim(), + s3Region: (Deno.env.get("S3_REGION") ?? "auto").trim() || "auto", + s3AccessKeyId: (Deno.env.get("S3_ACCESS_KEY_ID") ?? "").trim(), s3SecretAccessKey: Deno.env.get("S3_SECRET_ACCESS_KEY") ?? "", isDev, diff --git a/api/main.ts b/api/main.ts index 002dfa9..0ac52b5 100644 --- a/api/main.ts +++ b/api/main.ts @@ -1277,7 +1277,9 @@ await Promise.all([ } })), startup("storage S3/R2", pingStorage().then((s) => { - if (!s.ok) throw new Error("Storage no responde (HeadBucket R2 o disco local)"); + if (!s.ok) { + throw new Error(`Storage no responde: ${s.error ?? "sin detalle"}`); + } if (!config.isDev && !s.configured) { throw new Error("S3_ENDPOINT/S3_BUCKET/S3_* son obligatorios fuera de desarrollo"); } diff --git a/api/scripts/verify-storage.ts b/api/scripts/verify-storage.ts index c80c5ad..d2c2c81 100644 --- a/api/scripts/verify-storage.ts +++ b/api/scripts/verify-storage.ts @@ -1,5 +1,6 @@ /** - * Sonda de Contabo (S3): HeadBucket + put/get/delete. + * Sonda S3/R2: ListObjects (o put/get/delete). HeadBucket no se usa: + * R2 suele devolver 403 con tokens acotados al bucket. * * Desde api/: * deno run --allow-net --allow-env --allow-read --allow-write scripts/verify-storage.ts @@ -27,9 +28,9 @@ if (!s3Configured()) { console.log(`S3 endpoint=${config.s3Endpoint} bucket=${config.s3Bucket} region=${config.s3Region}`); const ping = await pingStorage(); if (!ping.ok) { - console.error("FAIL - HeadBucket: no se pudo alcanzar el bucket (credenciales, red o nombre)."); + console.error(`FAIL - ping bucket: ${ping.error ?? "sin detalle"}`); Deno.exit(1); } -console.log("OK - HeadBucket"); +console.log("OK - ping bucket (ListObjects o sonda write)"); await probeStorageReadWrite(); console.log("OK - put/get/delete de objeto sonda"); diff --git a/api/storage.ts b/api/storage.ts index 0d13e60..79aedb1 100644 --- a/api/storage.ts +++ b/api/storage.ts @@ -2,7 +2,7 @@ import { S3Client, PutObjectCommand, GetObjectCommand, - HeadBucketCommand, + ListObjectsV2Command, DeleteObjectCommand, } from "npm:@aws-sdk/client-s3@3"; import { mkdir, readFile, writeFile } from "node:fs/promises"; @@ -32,23 +32,58 @@ export type StoragePing = { configured: boolean; ok: boolean; backend: "s3" | "local"; + error?: string; }; -/** HeadBucket (S3) o escritura de prueba en disco local. */ +function s3Err(e: unknown): string { + if (e && typeof e === "object") { + const o = e as { + name?: string; + message?: string; + Code?: string; + $metadata?: { httpStatusCode?: number }; + }; + return [o.name, o.Code, o.$metadata?.httpStatusCode, o.message].filter(Boolean).join(": "); + } + return String(e); +} + +/** + * R2: HeadBucket suele dar 403 con token de Account API acotado al bucket. + * ListObjectsV2 (o un put/get sonda) es lo que realmente usamos en runtime. + */ export async function pingStorage(): Promise { if (!s3Configured()) { try { await mkdir(join(DATA_DIR, "local-objects"), { recursive: true }); return { configured: false, ok: true, backend: "local" }; - } catch { - return { configured: false, ok: false, backend: "local" }; + } catch (e) { + return { configured: false, ok: false, backend: "local", error: s3Err(e) }; } } try { - await getClient().send(new HeadBucketCommand({ Bucket: config.s3Bucket })); + await getClient().send( + new ListObjectsV2Command({ Bucket: config.s3Bucket, MaxKeys: 1 }), + ); return { configured: true, ok: true, backend: "s3" }; - } catch { - return { configured: true, ok: false, backend: "s3" }; + } catch (listErr) { + try { + await probeStorageReadWrite(); + return { configured: true, ok: true, backend: "s3" }; + } catch (writeErr) { + const error = `list=${s3Err(listErr)}; write=${s3Err(writeErr)}`; + console.error( + "[storage] ping failed", + error, + "endpoint=", + config.s3Endpoint, + "bucket=", + config.s3Bucket, + "region=", + config.s3Region, + ); + return { configured: true, ok: false, backend: "s3", error }; + } } } diff --git a/docs/coolify.md b/docs/coolify.md index e844f4c..220bea7 100644 --- a/docs/coolify.md +++ b/docs/coolify.md @@ -131,7 +131,7 @@ sudo docker logs api-- Busca `[startup] FAIL …`. Causas típicas: - **Redis:** `REDIS_URL_IAM` / `REDIS_URL_CORE` con el UUID de **Postgres**. Redis tiene el suyo (Redis URL internal). -- **S3/R2:** faltan `S3_*` o HeadBucket falla (token Account API, Object Read & Write, bucket correcto, `S3_REGION=auto`). +- **S3/R2:** endpoint `https://.r2.cloudflarestorage.com` (sin barra final, **sin** el nombre del bucket), `S3_REGION=auto`, token **Account API** con Object Read & Write al bucket. R2 a menudo responde 403 a HeadBucket; el ping de la API usa ListObjects. - **Postgres `_app`:** Liquibase usa `*_OWNER`; el runtime usa `DATABASE_URL_PLATFORM`, `DATABASE_URL_IAM`, `DATABASE_URL_CORE` (passwords distintos). ### Coolify pide `PLATFORM_OWNER_PASSWORD`, `IAM_APP_PASSWORD`, `*_REDIS_PASSWORD`…