Compare commits

...

2 commits

Author SHA1 Message Date
Cursor Origin
fc5c2c0791 Pre-computed merge for change #1 1970-01-01 00:00:00 +00:00
Cursor Agent
ab20701fea
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 <alberto.martinez@mrdev.mx>
2026-09-03 05:42:13 +00:00
5 changed files with 54 additions and 16 deletions

View file

@ -78,10 +78,10 @@ export const config = {
redisUrlCore: isDev ? (Deno.env.get("REDIS_URL_CORE") ?? "") : required("REDIS_URL_CORE"), redisUrlCore: isDev ? (Deno.env.get("REDIS_URL_CORE") ?? "") : required("REDIS_URL_CORE"),
// --- Contabo Object Storage (S3-compatible) -- expedientes/PDFs/logos --- // --- Contabo Object Storage (S3-compatible) -- expedientes/PDFs/logos ---
s3Endpoint: Deno.env.get("S3_ENDPOINT") ?? "", s3Endpoint: (Deno.env.get("S3_ENDPOINT") ?? "").trim().replace(/\/$/, ""),
s3Bucket: Deno.env.get("S3_BUCKET") ?? "", s3Bucket: (Deno.env.get("S3_BUCKET") ?? "").trim(),
s3Region: Deno.env.get("S3_REGION") ?? "us-east-1", s3Region: (Deno.env.get("S3_REGION") ?? "auto").trim() || "auto",
s3AccessKeyId: Deno.env.get("S3_ACCESS_KEY_ID") ?? "", s3AccessKeyId: (Deno.env.get("S3_ACCESS_KEY_ID") ?? "").trim(),
s3SecretAccessKey: Deno.env.get("S3_SECRET_ACCESS_KEY") ?? "", s3SecretAccessKey: Deno.env.get("S3_SECRET_ACCESS_KEY") ?? "",
isDev, isDev,

View file

@ -1277,7 +1277,9 @@ await Promise.all([
} }
})), })),
startup("storage S3/R2", pingStorage().then((s) => { 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) { if (!config.isDev && !s.configured) {
throw new Error("S3_ENDPOINT/S3_BUCKET/S3_* son obligatorios fuera de desarrollo"); throw new Error("S3_ENDPOINT/S3_BUCKET/S3_* son obligatorios fuera de desarrollo");
} }

View file

@ -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/: * Desde api/:
* deno run --allow-net --allow-env --allow-read --allow-write scripts/verify-storage.ts * 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}`); console.log(`S3 endpoint=${config.s3Endpoint} bucket=${config.s3Bucket} region=${config.s3Region}`);
const ping = await pingStorage(); const ping = await pingStorage();
if (!ping.ok) { 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); Deno.exit(1);
} }
console.log("OK - HeadBucket"); console.log("OK - ping bucket (ListObjects o sonda write)");
await probeStorageReadWrite(); await probeStorageReadWrite();
console.log("OK - put/get/delete de objeto sonda"); console.log("OK - put/get/delete de objeto sonda");

View file

@ -2,7 +2,7 @@ import {
S3Client, S3Client,
PutObjectCommand, PutObjectCommand,
GetObjectCommand, GetObjectCommand,
HeadBucketCommand, ListObjectsV2Command,
DeleteObjectCommand, DeleteObjectCommand,
} from "npm:@aws-sdk/client-s3@3"; } from "npm:@aws-sdk/client-s3@3";
import { mkdir, readFile, writeFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
@ -32,23 +32,58 @@ export type StoragePing = {
configured: boolean; configured: boolean;
ok: boolean; ok: boolean;
backend: "s3" | "local"; 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<StoragePing> { export async function pingStorage(): Promise<StoragePing> {
if (!s3Configured()) { if (!s3Configured()) {
try { try {
await mkdir(join(DATA_DIR, "local-objects"), { recursive: true }); await mkdir(join(DATA_DIR, "local-objects"), { recursive: true });
return { configured: false, ok: true, backend: "local" }; return { configured: false, ok: true, backend: "local" };
} catch { } catch (e) {
return { configured: false, ok: false, backend: "local" }; return { configured: false, ok: false, backend: "local", error: s3Err(e) };
} }
} }
try { 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" }; return { configured: true, ok: true, backend: "s3" };
} catch { } catch (listErr) {
return { configured: true, ok: false, backend: "s3" }; 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 };
}
} }
} }

View file

@ -131,7 +131,7 @@ sudo docker logs api-<uuid>-<timestamp>
Busca `[startup] FAIL …`. Causas típicas: 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). - **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://<ACCOUNT_ID>.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). - **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`… ### Coolify pide `PLATFORM_OWNER_PASSWORD`, `IAM_APP_PASSWORD`, `*_REDIS_PASSWORD`…