# Migración: soporte multiregión de Cloudian (`nube_services_dao`)

Para quien ejecute esto: no hace falta más contexto que el de este documento.
Se explica todo lo necesario, incluida la evidencia real que motiva el cambio.
Esta versión ya fue revisada por `dao_regions_cloudian` (dueño del repo) antes
de convertirse en instrucción final — sección 4 y el naming de columnas vienen
de esa revisión; la sección 3.1 (no reutilizar `org_buckets`/`bucket_properties`)
también quedó resuelta con esa revisión, no de inferencia externa.

## 0. Qué es esta base y cómo se consume — por si no la conocés

`nube_services_dao` (servicio `csharp-dao-service`, expuesto por gRPC en el
puerto 8440) **no es una base SQL plana que cualquier cliente consulta
directo.** Es un CRUD genérico: cada tabla se expone como un "Model" a través
de una entidad de Entity Framework, y los consumidores (`client_bkps`,
`client_dplymnts`, etc.) llaman `GetAll`/`Search`/`Update`/`Count` pasando el
nombre del modelo como string (ej. `Model: "cluster"`) — nunca SQL directo.

Esto importa mucho para esta migración: **crear las tablas en la base no
alcanza — confirmado por el dueño del repo, ver sección 4.**

## 1. Por qué — evidencia real, no hipotética

`client_bkps` da de alta grupo/usuario/access-key en Cloudian y crea
bucket + secret + BackupStorageLocation de Velero por tenant. Hoy existe un
solo Cloudian efectivo (QRO), fijo por variable de entorno en cada servicio
consumidor (`client_bkps`, `service_s3_cloudian`). Se está agregando una
segunda región (MTY) y **el naming actual de bucket/secret/storage location no
distingue región en absoluto** — es tenant-only.

Prueba concreta, sacada en vivo del DAO de staging (tenant SGM_STG,
`orgId=714`, vía `Search` sobre `backup_schedule` filtrando `BucketName LIKE
'%sgm%stg%'`):

```
19 filas en backup_schedule, TODAS con:
  BucketName=sgm-stg-daas  SecretName=sgm-stg-scrt  StorageLocationName=sgm-stg-bckp-lctn

  18 de esas filas -> K8sContext=deployments-stg-daas-qr   (cluster QRO)
   1 de esas filas -> K8sContext=tkgs-prod-daas-01-ap       (cluster MTY, BackupScheduleID=209)
```

El schedule 209 corre en el cluster de MTY pero su Secret/bucket/BSL se
crearon contra el Cloudian de QRO — porque hoy es el único que existe desde el
punto de vista del código (`config.EndpointCloudian`/`config.RegionCloudian`
son variables de entorno globales, sin ningún concepto de región). Esta
migración agrega el catálogo que le falta a `region` para que eso se resuelva
por dato, no por variable de entorno fija. Hoy `region` solo tiene
`(idregion, region)` — dos filas: `1=QRO`, `4=MTY` — sin ningún dato de
conectividad hacia Cloudian.

**IMPORTANTE, pendiente de verificar antes de aplicar cualquier fix
automático sobre el schedule 209:** ya se confirmó que ese schedule NO tiene
backups vivos en QRO (4 filas en backup_tracking, todas Phase=Expired) — bajo
riesgo para ese caso puntual, pero el plan de remediación en `client_bkps`
debe repetir este chequeo por fila antes de tocar cualquier otra.

Endpoints ya confirmados que alimentan el seed de la sección 5:
- QRO: `s3-mxqr.almnube.telmex.com`, sin esquema, TLS desactivado.
- MTY: `s3-mxap.almnube.telmex.com`, con esquema `https://` explícito (fuerza TLS).

## 2. Qué se agrega — 6 tablas nuevas

No se toca `region` ni `cluster`. `cluster` es el destino Kubernetes/Velero
(no tiene nada que ver con Cloudian); todo lo nuevo cuelga de `region.idregion`
por FK, igual que ya hace `cluster`.

El DDL real y aplicable (idéntico a lo de abajo, ya en formato `CREATE TABLE
IF NOT EXISTS` + seed idempotente) vive en
[`migrations/2026-09_cloudian_multiregion.sql`](../migrations/2026-09_cloudian_multiregion.sql).
El bloque siguiente es la versión de lectura, para entender el esquema sin
abrir el archivo:

```sql
CREATE TABLE cloudian_endpoint (
  id            INT AUTO_INCREMENT PRIMARY KEY,
  idregion      INT NOT NULL,
  kind          VARCHAR(10) NOT NULL,
  host          VARCHAR(255) NOT NULL,
  region_code   VARCHAR(20) NOT NULL,
  ssl           TINYINT(1) NOT NULL DEFAULT 0,
  active        TINYINT(1) NOT NULL DEFAULT 1,
  created_at    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (idregion) REFERENCES region(idregion),
  UNIQUE KEY uq_endpoint_region_kind_active (idregion, kind, active)
);

CREATE TABLE cloudian_bucket_policy (
  id            INT AUTO_INCREMENT PRIMARY KEY,
  purpose       VARCHAR(50) NOT NULL,
  idregion      INT NOT NULL,
  policy_id     VARCHAR(64) NULL,
  created_at    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (idregion) REFERENCES region(idregion),
  UNIQUE KEY uq_policy_purpose_region (purpose, idregion)
);

CREATE TABLE cloudian_tenant (
  id            INT AUTO_INCREMENT PRIMARY KEY,
  tenant        VARCHAR(100) NOT NULL,
  user_name     VARCHAR(150) NOT NULL,
  created_at    DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uq_cloudian_tenant (tenant)
);

-- Separada de cloudian_tenant a propósito: un tenant puede necesitar más de
-- un bucket (uno por "purpose" consumidor, igual que cloudian_bucket_policy).
-- Ponerlo como columna fija en cloudian_tenant repetiría la limitación de
-- "un tenant = un bucket" que ya tiene org_buckets (ver sección 3.1) y que
-- rompe en cuanto un consumidor que no sea daas-backup necesite el suyo.
CREATE TABLE cloudian_tenant_bucket (
  id                  INT AUTO_INCREMENT PRIMARY KEY,
  cloudian_tenant_id  INT NOT NULL,
  purpose             VARCHAR(50) NOT NULL,   -- 'daas-backup' es el primer consumidor
  bucket_name         VARCHAR(150) NOT NULL,
  created_at          DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (cloudian_tenant_id) REFERENCES cloudian_tenant(id),
  UNIQUE KEY uq_tenant_bucket_purpose (cloudian_tenant_id, purpose)
);

CREATE TABLE cloudian_tenant_group (
  id                  INT AUTO_INCREMENT PRIMARY KEY,
  cloudian_tenant_id  INT NOT NULL,
  subscription_id     BIGINT NOT NULL,
  group_name          VARCHAR(150) NOT NULL,
  created_at          DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (cloudian_tenant_id) REFERENCES cloudian_tenant(id),
  UNIQUE KEY uq_tenant_subscription (cloudian_tenant_id, subscription_id)
);

CREATE TABLE cloudian_tenant_provision (
  id                      INT AUTO_INCREMENT PRIMARY KEY,
  cloudian_tenant_id      INT NOT NULL,
  cluster_id              INT NOT NULL,
  namespace               VARCHAR(100) NOT NULL,
  secret_name             VARCHAR(150) NOT NULL,
  storage_location_name   VARCHAR(150) NOT NULL,
  created_at              DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (cloudian_tenant_id) REFERENCES cloudian_tenant(id),
  FOREIGN KEY (cluster_id) REFERENCES cluster(idcluster),
  UNIQUE KEY uq_tenant_cluster (cloudian_tenant_id, cluster_id)
);
```

`cloudian_tenant` queda deliberadamente sin ninguna columna de organización.
La trazabilidad hacia el org ya existe vía `contracted_services.user_id` →
`user_vcd.org_id` → `org_vcd` (verificado contra las entidades EF reales);
duplicarla en `cloudian_tenant` sería una segunda fuente de verdad para el
mismo dato.

Deliberadamente NO se guarda accessKey/secretKey en ninguna tabla — vive solo
en el Secret de Kubernetes. Estas tablas son punteros, no una copia de la
credencial.

### 3.1 Por qué no se reutilizan `org_buckets`/`bucket_properties`

El esquema ya tenía algo con la misma forma: `org_buckets` (org + `idregion`
nullable + `groupId`/`groupName`) y `bucket_properties` (bucket + `accessKY`/
`secretKY`, con FK a `org_buckets`). Se investigó antes de diseñar las tablas
nuevas — verificado en vivo contra el DAO:

- Las dos tienen **0 filas**.
- Ningún repo Go (`client_bkps`, `client_dplymnts`, `services_backups_daas`,
  `service_s3_cloudian`, `k8s_dplymnts_daas`, …) las referencia.
- El portal PHP tampoco las usa (confirmado directamente por el dueño del
  repo, no inferido).

Ambas se agregaron en el mismo commit que expuso *todas* las tablas
preexistentes de la base como modelos CRUD (`d2a6aab`, "implements: crud for
all models in db") — no fueron diseñadas para Cloudian ni para ningún flujo
en particular, así que no hay caso de uso real que reemplazar ni fuente de
verdad que preservar.

Decisión: no reutilizarlas. Además, como quedaron completamente huérfanas,
se eliminan por separado — ver
[`migrations/2026-09_drop_legacy_org_buckets.sql`](../migrations/2026-09_drop_legacy_org_buckets.sql),
en este mismo repo. Es limpieza de código muerto, no parte de esta migración,
y no la bloquea: puede aplicarse antes, después o nunca sin afectar nada de
lo que describe este documento.

## 4. Trabajo de código en `nube_services_dao` — confirmado por vos mismos

3 partes obligatorias, ninguna alcanza sola:
1. Clase de entidad EF Core por tabla (`CloudianEndpoint`, `CloudianBucketPolicy`,
   `CloudianTenant`, `CloudianTenantBucket`, `CloudianTenantGroup`,
   `CloudianTenantProvision`) con `[Table]`/`[Column]` explícitos y
   navegaciones (`CloudianTenant.Buckets`, `CloudianTenant.Groups`,
   `CloudianTenant.Provisions`, referencias de vuelta, `CloudianEndpoint`/
   `CloudianBucketPolicy`→`Region`, `CloudianTenantProvision`→`Cluster`).
2. Agregar cada tipo a `ModelRegistry._map` con clave = nombre físico de tabla
   (`"cloudian_endpoint"`, etc.).
3. `DbSet<T>` en `CloudServicesDbContext`.

Con eso, CRUD genérico funciona sin código adicional — confirmado por ustedes.

## 5. Nota sobre `kind='iam'`

Sin consumidor real hoy (confirmado por `cloudian_consumer_regions_cloudian`:
un solo `CLOUDIAN_API_URL` fijo, sin ramas por región en código ni historial).
No insertar filas `kind='iam'` todavía.

## 6. Seed inicial sugerido

```sql
INSERT INTO cloudian_endpoint (idregion, kind, host, region_code, ssl, active) VALUES
  (1, 's3', 's3-mxqr.almnube.telmex.com', 'mxqr', 0, 1),
  (4, 's3', 's3-mxap.almnube.telmex.com', 'mxap', 1, 1);

INSERT INTO cloudian_bucket_policy (purpose, idregion, policy_id) VALUES
  ('daas-backup', 1, '5f601e4964a56f641f50838d1d03eea3'),
  ('daas-backup', 4, '5f601e4964a56f641f50838d1d03eea3');
```

Ojo con el `policy_id` de MTY: mismo valor que QRO por ahora, sin confirmar si
Cloudian MTY lo reconoce — solo importa la primera vez que un bucket se crea
de cero ahí, no afecta tenants existentes.

## 7. Cómo verificar que quedó bien

1. `GetFields` sobre cada modelo nuevo — confirmar columnas/tipos.
2. `GetAll` sobre `cloudian_endpoint`/`cloudian_bucket_policy` — deben traer
   las 2 filas del seed.
3. Dar de alta bien las 6 entidades ya da cobertura de lectura gratis: la
   suite `grpc` de pruebas (solo lectura por diseño, ver `tests/DynamicCrud.Tests/README.md`)
   itera `ModelRegistry.All` (`Grpc/ModelMetadata.cs`, `AllModels`) para sus
   pruebas parametrizadas — si el SQL de este documento no se aplicó en un
   ambiente donde el código ya espera estos modelos, esas pruebas fallan solas.
4. Un `Create`/`Search`/`Delete` de prueba sobre `cloudian_tenant` (crear,
   buscar, borrar) NO cabe en esa suite — es de solo lectura por diseño. Esa
   verificación va en la suite `test-db` o a mano, antes de que `client_bkps`
   dependa de esto en producción.

## 8. Fuera de alcance

- Backfill de tenants ya provisionados — lo resuelve `client_bkps` al
  reprocesar, no requiere script de datos.
- Cambios en `services_backups_daas` — van en instructivo aparte.
- Confirmar si QRO/MTY comparten IAM — no bloquea esta migración.
- Eliminar `org_buckets`/`bucket_properties` — ver sección 3.1 y el checklist
  de la sección 9; es limpieza independiente, no bloquea nada de lo de arriba.

## 9. Checklist de lo que falta generar

**Estado: todo el trabajo de código de este repo ya está hecho; falta
únicamente aplicar los dos `.sql` contra las bases.** Quien retome el trabajo
debería poder confiar en esta lista en vez de tener que releer todo el
documento para saber qué falta.

Orden de despliegue (los dos bloques de código van en direcciones opuestas,
por lo explicado al final de esta sección):
1. Aplicar `migrations/2026-09_cloudian_multiregion.sql` — **antes** de
   desplegar este código: al estar las 6 entidades en `ModelRegistry`, la
   suite `grpc` las itera vía `AllModels` y falla sola si las tablas no
   existen (sección 7.3).
2. Desplegar este código (trae las 6 entidades nuevas y ya no expone
   `org_buckets`/`bucket_properties`).
3. Aplicar `migrations/2026-09_drop_legacy_org_buckets.sql` — **después** del
   despliegue, para que el DROP nunca ocurra mientras el DAO todavía expone
   esos modelos.

**Base de datos**
- [ ] Aplicar `migrations/2026-09_cloudian_multiregion.sql` (crea las 6 tablas
      nuevas + seed de `cloudian_endpoint`/`cloudian_bucket_policy`)
- [ ] Aplicar `migrations/2026-09_drop_legacy_org_buckets.sql` (backup +
      `DROP TABLE` de `org_buckets`/`bucket_properties` — ver sección 3.1;
      no bloquea ni depende de lo anterior)

**Código en `nube_services_dao` (sección 4) — las tablas nuevas** — HECHO
- [x] Escribir las 6 clases de entidad EF Core (`CloudianEndpoint`,
      `CloudianBucketPolicy`, `CloudianTenant`, `CloudianTenantBucket`,
      `CloudianTenantGroup`, `CloudianTenantProvision`) con `[Table]`/`[Column]`
      explícitos — `ServicesDAO/models/Cloudian*.cs`
- [x] Registrar cada una en `ModelRegistry._map`
      (`ServicesDAO/Repositories/ModelRegistry.cs`)
- [x] Agregar cada `DbSet<T>` en `CloudServicesDbContext`
      (`ServicesDAO/Data/CloudServicesDbContext.cs`)

**Código en `nube_services_dao` — limpieza de las tablas legadas** — HECHO
- [x] Sacar `OrgBucket.cs`/`BucketProperties.cs`
      (`ServicesDAO/models/`)
- [x] Sacar sus entradas (`"org_buckets"`, `"bucket_properties"`) de
      `ModelRegistry._map`
- [x] Sacar sus `DbSet<OrgBucket>`/`DbSet<BucketProperties>` de
      `CloudServicesDbContext`

Nota de implementación: la columna `created_at` se mapea a una propiedad
llamada exactamente `CreatedAt` porque `BaseRepository.ApplyAuditCreate` la
puebla por reflexión sobre ese nombre; con cualquier otro nombre EF mandaría
`0001-01-01` y MySQL rechazaría el INSERT pese al `DEFAULT CURRENT_TIMESTAMP`.
`ssl` y `active` (`TINYINT(1)`) se mapean a `bool`.

Ninguno de los tres ítems de "código — tablas nuevas" alcanza solo (ver
sección 4); los tres son necesarios para que `client_bkps` pueda usar los
modelos vía `GetAll`/`Search`/`Update`. Los de "limpieza" son independientes
entre sí y de todo lo anterior — pueden hacerse en cualquier orden relativo
a esa lista, pero el DROP de la base y el código deberían ir juntos: si se
elimina el código sin el DROP, `ModelRegistry` simplemente deja de conocer
esas tablas (no rompe nada); si se hace el DROP sin sacar el código, el DAO
sigue exponiendo `org_buckets`/`bucket_properties` como modelos y
`GetAll`/`Search` sobre ellos empieza a fallar con error de SQL en vez de
simplemente no existir.
