From 1ac04996b6fc2fde27f5fe090546339258fdf74d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 21:22:39 +0000 Subject: [PATCH] ops: generar accesos por ambiente y verificar Postgres, Redis y Contabo Scripts create-accesses / verify-connectivity para un Postgres+Redis+bucket por ambiente. /v1/health y el arranque de la API incluyen sonda de storage. Co-authored-by: alberto.martinez --- .github/workflows/ci.yml | 12 +++ api/main.ts | 16 +++- api/scripts/verify-storage.ts | 35 ++++++++ api/storage.ts | 48 +++++++++- db/provision/README.md | 36 +++++++- db/provision/create-accesses.sh | 131 ++++++++++++++++++++++++++++ db/provision/verify-connectivity.sh | 79 +++++++++++++++++ docs/coolify.md | 6 +- 8 files changed, 351 insertions(+), 12 deletions(-) create mode 100644 api/scripts/verify-storage.ts create mode 100755 db/provision/create-accesses.sh create mode 100755 db/provision/verify-connectivity.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89619bd..8428850 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,18 @@ jobs: REDIS_URL_CORE: redis://panels_core_redis:ci-core-redis@localhost:6379 run: ./db/provision/verify-isolation.sh + - name: Verificar conectividad Postgres/Redis (S3 opcional en CI) + env: + DATABASE_URL_PLATFORM: postgresql://panels_platform_app:ci-platform-app@localhost:5432/panels_platform + DATABASE_URL_PLATFORM_OWNER: postgresql://panels_platform_owner:ci-platform-owner@localhost:5432/panels_platform + DATABASE_URL_IAM: postgresql://panels_iam_app:ci-iam-app@localhost:5432/panels_product + DATABASE_URL_IAM_OWNER: postgresql://panels_iam_owner:ci-iam-owner@localhost:5432/panels_product + DATABASE_URL_CORE: postgresql://panels_core_app:ci-core-app@localhost:5432/panels_product + DATABASE_URL_CORE_OWNER: postgresql://panels_core_owner:ci-core-owner@localhost:5432/panels_product + REDIS_URL_IAM: redis://panels_iam_redis:ci-iam-redis@localhost:6379 + REDIS_URL_CORE: redis://panels_core_redis:ci-core-redis@localhost:6379 + run: ./db/provision/verify-connectivity.sh + - name: deno check working-directory: api run: deno check main.ts diff --git a/api/main.ts b/api/main.ts index cb401f1..614a157 100644 --- a/api/main.ts +++ b/api/main.ts @@ -61,7 +61,7 @@ import { lineAmount, listBudget, } from "./budget.ts"; -import { companyDocKey, getObject, projectDocKey, workerDocKey } from "./storage.ts"; +import { companyDocKey, getObject, pingStorage, projectDocKey, workerDocKey } from "./storage.ts"; import { cacheCore, cacheKeyCore } from "./cache.ts"; import { pingRedis } from "./redis.ts"; @@ -105,13 +105,15 @@ app.use( // Postgres/Redis, /v1/health de verdad toca las tres conexiones Postgres y // las dos de Redis, no solo responde estático. app.get("/v1/health", async (c) => { - const [core, platform, redis] = await Promise.all([ + const [core, platform, redis, storage] = await Promise.all([ pingCoreDb().then(() => true).catch(() => false), pingPlatformDb().then(() => true).catch(() => false), pingRedis(), + pingStorage(), ]); - const ok = core && platform && redis.iam && redis.core; - return c.json({ ok, core, platform, redis }, ok ? 200 : 503); + const storageOk = storage.ok && (storage.backend === "local" || storage.configured); + const ok = core && platform && redis.iam && redis.core && storageOk; + return c.json({ ok, core, platform, redis, storage }, ok ? 200 : 503); }); app.post("/v1/auth/login", async (c) => { @@ -1260,6 +1262,12 @@ await Promise.all([ pingRedis().then((r) => { if (!r.iam || !r.core) throw new Error("Redis (iam/core) no responde"); }), + pingStorage().then((s) => { + if (!s.ok) throw new Error("Storage (Contabo o disco local) no responde"); + if (!config.isDev && !s.configured) { + throw new Error("S3_ENDPOINT/S3_BUCKET/S3_* son obligatorios fuera de desarrollo"); + } + }), ]); Deno.serve({ port, hostname: "0.0.0.0" }, app.fetch); diff --git a/api/scripts/verify-storage.ts b/api/scripts/verify-storage.ts new file mode 100644 index 0000000..c80c5ad --- /dev/null +++ b/api/scripts/verify-storage.ts @@ -0,0 +1,35 @@ +/** + * Sonda de Contabo (S3): HeadBucket + put/get/delete. + * + * Desde api/: + * deno run --allow-net --allow-env --allow-read --allow-write scripts/verify-storage.ts + * + * Requiere S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY. + * Si REQUIRE_S3=1 y no hay credenciales, sale con error (staging/prod). + * Si no están, sale 0 y avisa (dev local con disco). + */ +import { config } from "../config.ts"; +import { pingStorage, probeStorageReadWrite, s3Configured } from "../storage.ts"; + +const requireS3 = ["1", "true", "yes"].includes((Deno.env.get("REQUIRE_S3") ?? "").toLowerCase()); + +if (!s3Configured()) { + const msg = + "S3 no configurado (S3_ENDPOINT / S3_BUCKET / S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY)."; + if (requireS3) { + console.error(`FAIL - ${msg} Obligatorio en staging/producción.`); + Deno.exit(1); + } + console.log(`SKIP - ${msg} En este ambiente se usará disco local.`); + Deno.exit(0); +} + +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)."); + Deno.exit(1); +} +console.log("OK - HeadBucket"); +await probeStorageReadWrite(); +console.log("OK - put/get/delete de objeto sonda"); diff --git a/api/storage.ts b/api/storage.ts index 0563039..ccde499 100644 --- a/api/storage.ts +++ b/api/storage.ts @@ -1,4 +1,10 @@ -import { S3Client, PutObjectCommand, GetObjectCommand } from "npm:@aws-sdk/client-s3@3"; +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + HeadBucketCommand, + DeleteObjectCommand, +} from "npm:@aws-sdk/client-s3@3"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { config, DATA_DIR } from "./config.ts"; @@ -20,10 +26,48 @@ import { config, DATA_DIR } from "./config.ts"; let client: S3Client | null = null; -function s3Configured(): boolean { +export function s3Configured(): boolean { return !!(config.s3Endpoint && config.s3Bucket && config.s3AccessKeyId && config.s3SecretAccessKey); } +export type StoragePing = { + configured: boolean; + ok: boolean; + backend: "s3" | "local"; +}; + +/** HeadBucket (S3) o escritura de prueba en disco local. */ +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" }; + } + } + try { + await getClient().send(new HeadBucketCommand({ Bucket: config.s3Bucket })); + return { configured: true, ok: true, backend: "s3" }; + } catch { + return { configured: true, ok: false, backend: "s3" }; + } +} + +/** Put + get + delete de un objeto sonda. No dejar basura en el bucket. */ +export async function probeStorageReadWrite(): Promise { + const key = `_panels/connectivity-probe-${crypto.randomUUID()}`; + const payload = new TextEncoder().encode("panels-connectivity-probe"); + await putObject(key, payload); + const got = await getObject(key); + if (new TextDecoder().decode(got) !== "panels-connectivity-probe") { + throw new Error("El objeto sonda no coincide con lo escrito"); + } + if (s3Configured()) { + await getClient().send(new DeleteObjectCommand({ Bucket: config.s3Bucket, Key: key })); + } +} + function getClient(): S3Client { if (!client) { client = new S3Client({ diff --git a/db/provision/README.md b/db/provision/README.md index 5c3a909..a6de426 100644 --- a/db/provision/README.md +++ b/db/provision/README.md @@ -60,6 +60,35 @@ El script verifica automáticamente que cruzar de módulo devuelva `NOPERM` (ver "Riesgos y mitigaciones" del plan: un prefijo de llave sin ACL detrás no aísla nada). +## Accesos de un ambiente (Coolify + Contabo) + +**1 Postgres + 1 Redis + 1 bucket por ambiente.** El servidor lo crea +Coolify/Contabo; este repo solo genera roles y valida que contesten. + +1. En Coolify: recurso Postgres y recurso Redis de **ese** ambiente. + En Contabo: bucket (ej. `panels-prod` / `panels-staging`) + access key. +2. Generar secretos y crear roles/ACLs contra esos hosts: + +```bash +export PGHOST=... PGUSER=postgres PGPASSWORD=... # admin que da Coolify +export REDIS_ADMIN_URL="redis://:...@host:6379" +export S3_ENDPOINT=https://usc1.contabostorage.com +export S3_BUCKET=panels-prod +export S3_ACCESS_KEY_ID=... S3_SECRET_ACCESS_KEY=... +./db/provision/create-accesses.sh --apply --verify --out .env.prod.local +``` + +3. Pegar el contenido de `.env.prod.local` (gitignored) en las env del `api`. +4. Sin `--apply`, el script solo imprime el bloque; no toca servidores. + +Comprobar conectividad después, sin reprovisionar: + +```bash +set -a && source .env.prod.local && set +a +REQUIRE_S3=1 ./db/provision/verify-connectivity.sh # staging/prod +./db/provision/verify-isolation.sh +``` + ## Qué falta hacer manualmente en Coolify (staging/producción) Estos scripts asumen que ya existe un servidor Postgres y un servidor Redis @@ -68,9 +97,10 @@ tiene acceso a la cuenta de Coolify del usuario, así que: 1. Crear el recurso Postgres gestionado en Coolify para el ambiente. 2. Crear el recurso Redis gestionado en Coolify para el ambiente. -3. Correr estos scripts contra ambos usando las credenciales admin que da Coolify. -4. Cargar los secrets resultantes (`DATABASE_URL_*`, `REDIS_URL_*`) en la - configuración del servicio `api` de ese ambiente. +3. Crear el bucket Contabo de ese ambiente. +4. Correr `create-accesses.sh --apply --verify` (o los SQL 01-04 + `05-redis-acl.sh`). +5. Cargar los secrets resultantes (`DATABASE_URL_*`, `REDIS_URL_*`, `S3_*`) + en la configuración del servicio `api` de ese ambiente. ## Desarrollo local diff --git a/db/provision/create-accesses.sh b/db/provision/create-accesses.sh new file mode 100755 index 0000000..7dc28f7 --- /dev/null +++ b/db/provision/create-accesses.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# PANELS · genera secretos de UN ambiente y, si hay superusuario, crea +# roles/bases/ACLs. No crea el servidor Postgres, Redis ni el bucket: +# esos se levantan en Coolify / Contabo; este script solo deja los accesos. +# +# Uso (solo imprimir bloque .env, sin tocar servidores): +# PGHOST=pg.interno REDIS_HOST=redis.interno S3_BUCKET=panels-prod \ +# ./db/provision/create-accesses.sh +# +# Uso (crear roles en un Postgres/Redis ya levantados): +# PGHOST=... PGUSER=postgres PGPASSWORD=... REDIS_ADMIN_URL=redis://:...@host:6379 \ +# ./db/provision/create-accesses.sh --apply --verify +# +# Flags: +# --apply corre 01-04 SQL + 05 Redis ACL con las claves generadas +# --verify corre verify-connectivity.sh al final (implica tener URLs) +# --out FILE escribe el bloque .env (FILE debe estar gitignored, ej. .env.prod.local) +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +APPLY=0 +VERIFY=0 +OUT="" +while [[ $# -gt 0 ]]; do + case "$1" in + --apply) APPLY=1; shift ;; + --verify) VERIFY=1; shift ;; + --out) OUT="$2"; shift 2 ;; + -h|--help) sed -n '2,20p' "$0"; exit 0 ;; + *) echo "Flag desconocido: $1" >&2; exit 1 ;; + esac +done + +rand24() { openssl rand -hex 24; } +rand32() { openssl rand -hex 32; } + +PGHOST="${PGHOST:-127.0.0.1}" +PGPORT="${PGPORT:-5432}" +REDIS_HOST="${REDIS_HOST:-127.0.0.1}" +REDIS_PORT="${REDIS_PORT:-6379}" +PGUSER="${PGUSER:-postgres}" + +PLATFORM_OWNER_PASSWORD="${PLATFORM_OWNER_PASSWORD:-$(rand24)}" +PLATFORM_APP_PASSWORD="${PLATFORM_APP_PASSWORD:-$(rand24)}" +IAM_OWNER_PASSWORD="${IAM_OWNER_PASSWORD:-$(rand24)}" +IAM_APP_PASSWORD="${IAM_APP_PASSWORD:-$(rand24)}" +CORE_OWNER_PASSWORD="${CORE_OWNER_PASSWORD:-$(rand24)}" +CORE_APP_PASSWORD="${CORE_APP_PASSWORD:-$(rand24)}" +IAM_REDIS_PASSWORD="${IAM_REDIS_PASSWORD:-$(rand24)}" +CORE_REDIS_PASSWORD="${CORE_REDIS_PASSWORD:-$(rand24)}" +SESSION_SECRET="${SESSION_SECRET:-$(rand32)}" +DOCS_KEY="${DOCS_KEY:-$(rand32)}" + +block() { + cat < "$OUT" + echo "Escrito $OUT (permisos 600). No lo subas a git." >&2 +fi + +if [[ "$VERIFY" == "1" ]]; then + echo "== Verificando conectividad ==" >&2 + tmp="$(mktemp)" + umask 077 + printf '%s\n' "$ENV_TEXT" > "$tmp" + set -a + # shellcheck disable=SC1090 + source "$tmp" + set +a + rm -f "$tmp" + "$HERE/verify-connectivity.sh" +fi diff --git a/db/provision/verify-connectivity.sh b/db/provision/verify-connectivity.sh new file mode 100755 index 0000000..cd74dda --- /dev/null +++ b/db/provision/verify-connectivity.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# PANELS · comprueba que ESTE ambiente responde: Postgres (6 roles), +# Redis (2 ACL) y Contabo (opcional; obligatorio si REQUIRE_S3=1). +# +# No crea recursos. Carga URLs desde el entorno (source .env.dev-local o +# las vars de Coolify). +# +# Uso: +# set -a && source .env.dev-local && set +a +# ./db/provision/verify-connectivity.sh +# REQUIRE_S3=1 ./db/provision/verify-connectivity.sh # staging/prod +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +fail=0 +check() { + local desc="$1"; shift + if "$@" >/tmp/panels-conn-out.$$ 2>&1; then + echo "OK - $desc" + else + echo "FAIL - $desc" + cat /tmp/panels-conn-out.$$ + fail=1 + fi + rm -f /tmp/panels-conn-out.$$ +} + +need() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + echo "FAIL - falta $name" + fail=1 + return 1 + fi + return 0 +} + +echo "== Postgres ==" +need DATABASE_URL_PLATFORM && check "platform_app SELECT 1" psql "$DATABASE_URL_PLATFORM" -c "SELECT 1;" +need DATABASE_URL_IAM && check "iam_app SELECT 1" psql "$DATABASE_URL_IAM" -c "SELECT 1;" +need DATABASE_URL_CORE && check "core_app SELECT 1" psql "$DATABASE_URL_CORE" -c "SELECT 1;" +if [[ -n "${DATABASE_URL_PLATFORM_OWNER:-}" ]]; then + check "platform_owner SELECT 1" psql "$DATABASE_URL_PLATFORM_OWNER" -c "SELECT 1;" +else + echo "SKIP - DATABASE_URL_PLATFORM_OWNER (solo hace falta para Liquibase)" +fi +if [[ -n "${DATABASE_URL_IAM_OWNER:-}" ]]; then + check "iam_owner SELECT 1" psql "$DATABASE_URL_IAM_OWNER" -c "SELECT 1;" +else + echo "SKIP - DATABASE_URL_IAM_OWNER" +fi +if [[ -n "${DATABASE_URL_CORE_OWNER:-}" ]]; then + check "core_owner SELECT 1" psql "$DATABASE_URL_CORE_OWNER" -c "SELECT 1;" +else + echo "SKIP - DATABASE_URL_CORE_OWNER" +fi + +echo "== Redis ==" +need REDIS_URL_IAM && check "iam redis PING" redis-cli -u "$REDIS_URL_IAM" PING +need REDIS_URL_CORE && check "core redis PING" redis-cli -u "$REDIS_URL_CORE" PING + +echo "== Contabo (S3) ==" +if command -v deno >/dev/null 2>&1; then + if (cd "$ROOT/api" && REQUIRE_S3="${REQUIRE_S3:-}" deno run --allow-net --allow-env --allow-read --allow-write scripts/verify-storage.ts); then + : + else + fail=1 + fi +else + echo "FAIL - deno no está en PATH; no se pudo probar el bucket" + fail=1 +fi + +echo "" +if [[ "$fail" == "0" ]]; then + echo "Conectividad de este ambiente: OK" +else + echo "Hay fallos de conectividad -- no desplegar la API contra este ambiente todavía." + exit 1 +fi diff --git a/docs/coolify.md b/docs/coolify.md index a7e32a6..37b228e 100644 --- a/docs/coolify.md +++ b/docs/coolify.md @@ -81,8 +81,8 @@ En Coolify el servicio de PANELS se llama **`web-panel`** (FQDN ej. `panels.mrde ## Paso a paso en Coolify -1. **Provisionar Postgres y Redis** como recursos gestionados de Coolify para el ambiente (uno de cada, no por módulo). -2. **Aprovisionar roles/esquemas/ACLs**: correr los scripts de [`db/provision/`](../db/provision/README.md) contra ese Postgres/Redis (una vez, desde tu máquina o un job manual -- Coolify no lo hace por ti). +1. **Provisionar Postgres, Redis y bucket** — 1 de cada **por ambiente** (no por módulo; no compartir prod con staging). +2. **Crear accesos y validar**: `./db/provision/create-accesses.sh --apply --verify --out .env..local` contra esos hosts (ver [`db/provision/README.md`](../db/provision/README.md)). Coolify no crea los roles `panels_*` ni los ACL de Redis solo. 3. **Aplicar Liquibase** (paso explícito, NO ocurre al arrancar la app): `./db/update.sh all --context-filter='!dev'` con las credenciales `_owner`. En staging/producción, **nunca** olvidar el `--context-filter` -- sin él, Liquibase corre TAMBIÉN los changesets de demo (`context=dev`). 4. **Bootstrap del primer admin**: `deno run ... api/scripts/bootstrap-admin.ts platform` y `... tenant --tenant-id=... --company-code=...` (ver `db/README.md`). 5. **Push** este repo (sin `.env` ni `data/`). @@ -91,7 +91,7 @@ En Coolify el servicio de PANELS se llama **`web-panel`** (FQDN ej. `panels.mrde 8. **Dominios:** `web-panel` → panels; `web-saas` → saas; `api` sin FQDN (proxy `/v1`). 9. Cargar en Coolify todas las variables **obligatorias** de la tabla de arriba + `COOKIE_SECURE=true`. 10. Deploy. -11. Verificar `https://app…/v1/health` → debe responder `{"ok":true,"core":true,"platform":true,"redis":{"iam":true,"core":true}}`. Si algo es `false`, la app ni siquiera debería haber arrancado (fail-fast, Fase 7). +11. Verificar `https://app…/v1/health` → `ok`, `core`, `platform`, `redis.iam`, `redis.core` y `storage` (en prod `backend:"s3"`). Si algo falla, la API no arranca. 12. Login SaaS `admin` / tu `SEED_PASSWORD`. 13. SMTP en `/smtp` o por `SMTP_*`.