@cicore/cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ci.js +13 -0
- package/dist/commands/addon/api-actions.d.ts +45 -0
- package/dist/commands/addon/api-actions.d.ts.map +1 -0
- package/dist/commands/addon/api-actions.js +281 -0
- package/dist/commands/addon/api-actions.js.map +1 -0
- package/dist/commands/addon/build.d.ts +11 -0
- package/dist/commands/addon/build.d.ts.map +1 -0
- package/dist/commands/addon/build.js +182 -0
- package/dist/commands/addon/build.js.map +1 -0
- package/dist/commands/addon/create.d.ts +11 -0
- package/dist/commands/addon/create.d.ts.map +1 -0
- package/dist/commands/addon/create.js +1186 -0
- package/dist/commands/addon/create.js.map +1 -0
- package/dist/commands/addon/delete.d.ts +13 -0
- package/dist/commands/addon/delete.d.ts.map +1 -0
- package/dist/commands/addon/delete.js +83 -0
- package/dist/commands/addon/delete.js.map +1 -0
- package/dist/commands/addon/deploy.d.ts +27 -0
- package/dist/commands/addon/deploy.d.ts.map +1 -0
- package/dist/commands/addon/deploy.js +459 -0
- package/dist/commands/addon/deploy.js.map +1 -0
- package/dist/commands/addon/dev-deploy.d.ts +31 -0
- package/dist/commands/addon/dev-deploy.d.ts.map +1 -0
- package/dist/commands/addon/dev-deploy.js +128 -0
- package/dist/commands/addon/dev-deploy.js.map +1 -0
- package/dist/commands/addon/dev.d.ts +36 -0
- package/dist/commands/addon/dev.d.ts.map +1 -0
- package/dist/commands/addon/dev.js +323 -0
- package/dist/commands/addon/dev.js.map +1 -0
- package/dist/commands/addon/extract-classes.d.ts +23 -0
- package/dist/commands/addon/extract-classes.d.ts.map +1 -0
- package/dist/commands/addon/extract-classes.js +281 -0
- package/dist/commands/addon/extract-classes.js.map +1 -0
- package/dist/commands/addon/generate-safelist.d.ts +24 -0
- package/dist/commands/addon/generate-safelist.d.ts.map +1 -0
- package/dist/commands/addon/generate-safelist.js +276 -0
- package/dist/commands/addon/generate-safelist.js.map +1 -0
- package/dist/commands/addon/index.d.ts +19 -0
- package/dist/commands/addon/index.d.ts.map +1 -0
- package/dist/commands/addon/index.js +296 -0
- package/dist/commands/addon/index.js.map +1 -0
- package/dist/commands/addon/init-repo.d.ts +25 -0
- package/dist/commands/addon/init-repo.d.ts.map +1 -0
- package/dist/commands/addon/init-repo.js +171 -0
- package/dist/commands/addon/init-repo.js.map +1 -0
- package/dist/commands/addon/install.d.ts +23 -0
- package/dist/commands/addon/install.d.ts.map +1 -0
- package/dist/commands/addon/install.js +84 -0
- package/dist/commands/addon/install.js.map +1 -0
- package/dist/commands/addon/list.d.ts +10 -0
- package/dist/commands/addon/list.d.ts.map +1 -0
- package/dist/commands/addon/list.js +102 -0
- package/dist/commands/addon/list.js.map +1 -0
- package/dist/commands/addon/manifest-refresh.d.ts +17 -0
- package/dist/commands/addon/manifest-refresh.d.ts.map +1 -0
- package/dist/commands/addon/manifest-refresh.js +48 -0
- package/dist/commands/addon/manifest-refresh.js.map +1 -0
- package/dist/commands/addon/migrate.d.ts +40 -0
- package/dist/commands/addon/migrate.d.ts.map +1 -0
- package/dist/commands/addon/migrate.js +236 -0
- package/dist/commands/addon/migrate.js.map +1 -0
- package/dist/commands/addon/publish.d.ts +33 -0
- package/dist/commands/addon/publish.d.ts.map +1 -0
- package/dist/commands/addon/publish.js +236 -0
- package/dist/commands/addon/publish.js.map +1 -0
- package/dist/commands/addon/scaffold-quality.d.ts +21 -0
- package/dist/commands/addon/scaffold-quality.d.ts.map +1 -0
- package/dist/commands/addon/scaffold-quality.js +90 -0
- package/dist/commands/addon/scaffold-quality.js.map +1 -0
- package/dist/commands/addon/sign.d.ts +9 -0
- package/dist/commands/addon/sign.d.ts.map +1 -0
- package/dist/commands/addon/sign.js +83 -0
- package/dist/commands/addon/sign.js.map +1 -0
- package/dist/commands/addon/toggle.d.ts +6 -0
- package/dist/commands/addon/toggle.d.ts.map +1 -0
- package/dist/commands/addon/toggle.js +46 -0
- package/dist/commands/addon/toggle.js.map +1 -0
- package/dist/commands/agent/index.d.ts +34 -0
- package/dist/commands/agent/index.d.ts.map +1 -0
- package/dist/commands/agent/index.js +564 -0
- package/dist/commands/agent/index.js.map +1 -0
- package/dist/commands/brand/index.d.ts +54 -0
- package/dist/commands/brand/index.d.ts.map +1 -0
- package/dist/commands/brand/index.js +367 -0
- package/dist/commands/brand/index.js.map +1 -0
- package/dist/commands/build/index.d.ts +53 -0
- package/dist/commands/build/index.d.ts.map +1 -0
- package/dist/commands/build/index.js +726 -0
- package/dist/commands/build/index.js.map +1 -0
- package/dist/commands/cache/flush-local.d.ts +31 -0
- package/dist/commands/cache/flush-local.d.ts.map +1 -0
- package/dist/commands/cache/flush-local.js +161 -0
- package/dist/commands/cache/flush-local.js.map +1 -0
- package/dist/commands/cache/index.d.ts +14 -0
- package/dist/commands/cache/index.d.ts.map +1 -0
- package/dist/commands/cache/index.js +453 -0
- package/dist/commands/cache/index.js.map +1 -0
- package/dist/commands/check/index.d.ts +8 -0
- package/dist/commands/check/index.d.ts.map +1 -0
- package/dist/commands/check/index.js +1316 -0
- package/dist/commands/check/index.js.map +1 -0
- package/dist/commands/cloudflare/index.d.ts +8 -0
- package/dist/commands/cloudflare/index.d.ts.map +1 -0
- package/dist/commands/cloudflare/index.js +453 -0
- package/dist/commands/cloudflare/index.js.map +1 -0
- package/dist/commands/core/create.d.ts +12 -0
- package/dist/commands/core/create.d.ts.map +1 -0
- package/dist/commands/core/create.js +206 -0
- package/dist/commands/core/create.js.map +1 -0
- package/dist/commands/core/delete.d.ts +11 -0
- package/dist/commands/core/delete.d.ts.map +1 -0
- package/dist/commands/core/delete.js +64 -0
- package/dist/commands/core/delete.js.map +1 -0
- package/dist/commands/core/env.d.ts +12 -0
- package/dist/commands/core/env.d.ts.map +1 -0
- package/dist/commands/core/env.js +95 -0
- package/dist/commands/core/env.js.map +1 -0
- package/dist/commands/core/health.d.ts +6 -0
- package/dist/commands/core/health.d.ts.map +1 -0
- package/dist/commands/core/health.js +215 -0
- package/dist/commands/core/health.js.map +1 -0
- package/dist/commands/core/index.d.ts +15 -0
- package/dist/commands/core/index.d.ts.map +1 -0
- package/dist/commands/core/index.js +86 -0
- package/dist/commands/core/index.js.map +1 -0
- package/dist/commands/core/list.d.ts +11 -0
- package/dist/commands/core/list.d.ts.map +1 -0
- package/dist/commands/core/list.js +58 -0
- package/dist/commands/core/list.js.map +1 -0
- package/dist/commands/core/rebuild.d.ts +13 -0
- package/dist/commands/core/rebuild.d.ts.map +1 -0
- package/dist/commands/core/rebuild.js +119 -0
- package/dist/commands/core/rebuild.js.map +1 -0
- package/dist/commands/db/index.d.ts +23 -0
- package/dist/commands/db/index.d.ts.map +1 -0
- package/dist/commands/db/index.js +355 -0
- package/dist/commands/db/index.js.map +1 -0
- package/dist/commands/db/promote-silo.d.ts +320 -0
- package/dist/commands/db/promote-silo.d.ts.map +1 -0
- package/dist/commands/db/promote-silo.js +930 -0
- package/dist/commands/db/promote-silo.js.map +1 -0
- package/dist/commands/db/relocate.d.ts +41 -0
- package/dist/commands/db/relocate.d.ts.map +1 -0
- package/dist/commands/db/relocate.js +482 -0
- package/dist/commands/db/relocate.js.map +1 -0
- package/dist/commands/db/rollback-silo.d.ts +44 -0
- package/dist/commands/db/rollback-silo.d.ts.map +1 -0
- package/dist/commands/db/rollback-silo.js +402 -0
- package/dist/commands/db/rollback-silo.js.map +1 -0
- package/dist/commands/deploy/index.d.ts +26 -0
- package/dist/commands/deploy/index.d.ts.map +1 -0
- package/dist/commands/deploy/index.js +107 -0
- package/dist/commands/deploy/index.js.map +1 -0
- package/dist/commands/devops/index.d.ts +6 -0
- package/dist/commands/devops/index.d.ts.map +1 -0
- package/dist/commands/devops/index.js +220 -0
- package/dist/commands/devops/index.js.map +1 -0
- package/dist/commands/domain/index.d.ts +8 -0
- package/dist/commands/domain/index.d.ts.map +1 -0
- package/dist/commands/domain/index.js +386 -0
- package/dist/commands/domain/index.js.map +1 -0
- package/dist/commands/image/index.d.ts +8 -0
- package/dist/commands/image/index.d.ts.map +1 -0
- package/dist/commands/image/index.js +308 -0
- package/dist/commands/image/index.js.map +1 -0
- package/dist/commands/install/factory-reset.d.ts +21 -0
- package/dist/commands/install/factory-reset.d.ts.map +1 -0
- package/dist/commands/install/factory-reset.js +83 -0
- package/dist/commands/install/factory-reset.js.map +1 -0
- package/dist/commands/install/index.d.ts +17 -0
- package/dist/commands/install/index.d.ts.map +1 -0
- package/dist/commands/install/index.js +44 -0
- package/dist/commands/install/index.js.map +1 -0
- package/dist/commands/install/install.d.ts +35 -0
- package/dist/commands/install/install.d.ts.map +1 -0
- package/dist/commands/install/install.js +171 -0
- package/dist/commands/install/install.js.map +1 -0
- package/dist/commands/login/index.d.ts +15 -0
- package/dist/commands/login/index.d.ts.map +1 -0
- package/dist/commands/login/index.js +58 -0
- package/dist/commands/login/index.js.map +1 -0
- package/dist/commands/nginx/index.d.ts +11 -0
- package/dist/commands/nginx/index.d.ts.map +1 -0
- package/dist/commands/nginx/index.js +580 -0
- package/dist/commands/nginx/index.js.map +1 -0
- package/dist/commands/server/bootstrap.d.ts +25 -0
- package/dist/commands/server/bootstrap.d.ts.map +1 -0
- package/dist/commands/server/bootstrap.js +260 -0
- package/dist/commands/server/bootstrap.js.map +1 -0
- package/dist/commands/server/index.d.ts +8 -0
- package/dist/commands/server/index.d.ts.map +1 -0
- package/dist/commands/server/index.js +2524 -0
- package/dist/commands/server/index.js.map +1 -0
- package/dist/commands/setup/index.d.ts +34 -0
- package/dist/commands/setup/index.d.ts.map +1 -0
- package/dist/commands/setup/index.js +423 -0
- package/dist/commands/setup/index.js.map +1 -0
- package/dist/commands/ssl/index.d.ts +8 -0
- package/dist/commands/ssl/index.d.ts.map +1 -0
- package/dist/commands/ssl/index.js +275 -0
- package/dist/commands/ssl/index.js.map +1 -0
- package/dist/commands/superadmin/index.d.ts +16 -0
- package/dist/commands/superadmin/index.d.ts.map +1 -0
- package/dist/commands/superadmin/index.js +81 -0
- package/dist/commands/superadmin/index.js.map +1 -0
- package/dist/commands/tenant/index.d.ts +6 -0
- package/dist/commands/tenant/index.d.ts.map +1 -0
- package/dist/commands/tenant/index.js +192 -0
- package/dist/commands/tenant/index.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +107 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/addon-sign.d.ts +23 -0
- package/dist/lib/addon-sign.d.ts.map +1 -0
- package/dist/lib/addon-sign.js +39 -0
- package/dist/lib/addon-sign.js.map +1 -0
- package/dist/lib/addon-sign.test.d.ts +2 -0
- package/dist/lib/addon-sign.test.d.ts.map +1 -0
- package/dist/lib/addon-sign.test.js +27 -0
- package/dist/lib/addon-sign.test.js.map +1 -0
- package/dist/lib/cdn.d.ts +25 -0
- package/dist/lib/cdn.d.ts.map +1 -0
- package/dist/lib/cdn.js +131 -0
- package/dist/lib/cdn.js.map +1 -0
- package/dist/lib/cloudflare.d.ts +133 -0
- package/dist/lib/cloudflare.d.ts.map +1 -0
- package/dist/lib/cloudflare.js +435 -0
- package/dist/lib/cloudflare.js.map +1 -0
- package/dist/lib/config.d.ts +96 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +132 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/env.d.ts +8 -0
- package/dist/lib/env.d.ts.map +1 -0
- package/dist/lib/env.js +64 -0
- package/dist/lib/env.js.map +1 -0
- package/dist/lib/hosts.d.ts +194 -0
- package/dist/lib/hosts.d.ts.map +1 -0
- package/dist/lib/hosts.js +183 -0
- package/dist/lib/hosts.js.map +1 -0
- package/dist/lib/logger.d.ts +68 -0
- package/dist/lib/logger.d.ts.map +1 -0
- package/dist/lib/logger.js +130 -0
- package/dist/lib/logger.js.map +1 -0
- package/dist/lib/nginx-config.d.ts +78 -0
- package/dist/lib/nginx-config.d.ts.map +1 -0
- package/dist/lib/nginx-config.js +736 -0
- package/dist/lib/nginx-config.js.map +1 -0
- package/dist/lib/ops/addon-dev.d.ts +93 -0
- package/dist/lib/ops/addon-dev.d.ts.map +1 -0
- package/dist/lib/ops/addon-dev.js +237 -0
- package/dist/lib/ops/addon-dev.js.map +1 -0
- package/dist/lib/ops/addon-quality.d.ts +38 -0
- package/dist/lib/ops/addon-quality.d.ts.map +1 -0
- package/dist/lib/ops/addon-quality.js +338 -0
- package/dist/lib/ops/addon-quality.js.map +1 -0
- package/dist/lib/ops/addon-routes.d.ts +49 -0
- package/dist/lib/ops/addon-routes.d.ts.map +1 -0
- package/dist/lib/ops/addon-routes.js +189 -0
- package/dist/lib/ops/addon-routes.js.map +1 -0
- package/dist/lib/ops/addon.d.ts +120 -0
- package/dist/lib/ops/addon.d.ts.map +1 -0
- package/dist/lib/ops/addon.js +260 -0
- package/dist/lib/ops/addon.js.map +1 -0
- package/dist/lib/ops/cdn.d.ts +87 -0
- package/dist/lib/ops/cdn.d.ts.map +1 -0
- package/dist/lib/ops/cdn.js +170 -0
- package/dist/lib/ops/cdn.js.map +1 -0
- package/dist/lib/ops/cf.d.ts +36 -0
- package/dist/lib/ops/cf.d.ts.map +1 -0
- package/dist/lib/ops/cf.js +114 -0
- package/dist/lib/ops/cf.js.map +1 -0
- package/dist/lib/ops/compose.d.ts +95 -0
- package/dist/lib/ops/compose.d.ts.map +1 -0
- package/dist/lib/ops/compose.js +165 -0
- package/dist/lib/ops/compose.js.map +1 -0
- package/dist/lib/ops/core.d.ts +117 -0
- package/dist/lib/ops/core.d.ts.map +1 -0
- package/dist/lib/ops/core.js +322 -0
- package/dist/lib/ops/core.js.map +1 -0
- package/dist/lib/ops/db.d.ts +116 -0
- package/dist/lib/ops/db.d.ts.map +1 -0
- package/dist/lib/ops/db.js +351 -0
- package/dist/lib/ops/db.js.map +1 -0
- package/dist/lib/ops/dns.d.ts +111 -0
- package/dist/lib/ops/dns.d.ts.map +1 -0
- package/dist/lib/ops/dns.js +306 -0
- package/dist/lib/ops/dns.js.map +1 -0
- package/dist/lib/ops/image.d.ts +94 -0
- package/dist/lib/ops/image.d.ts.map +1 -0
- package/dist/lib/ops/image.js +159 -0
- package/dist/lib/ops/image.js.map +1 -0
- package/dist/lib/ops/nginx.d.ts +114 -0
- package/dist/lib/ops/nginx.d.ts.map +1 -0
- package/dist/lib/ops/nginx.js +388 -0
- package/dist/lib/ops/nginx.js.map +1 -0
- package/dist/lib/ops/redis.d.ts +7 -0
- package/dist/lib/ops/redis.d.ts.map +1 -0
- package/dist/lib/ops/redis.js +35 -0
- package/dist/lib/ops/redis.js.map +1 -0
- package/dist/lib/ops/ssh.d.ts +127 -0
- package/dist/lib/ops/ssh.d.ts.map +1 -0
- package/dist/lib/ops/ssh.js +269 -0
- package/dist/lib/ops/ssh.js.map +1 -0
- package/dist/lib/prompts.d.ts +46 -0
- package/dist/lib/prompts.d.ts.map +1 -0
- package/dist/lib/prompts.js +113 -0
- package/dist/lib/prompts.js.map +1 -0
- package/dist/lib/sast.d.ts +43 -0
- package/dist/lib/sast.d.ts.map +1 -0
- package/dist/lib/sast.js +79 -0
- package/dist/lib/sast.js.map +1 -0
- package/dist/lib/sast.test.d.ts +2 -0
- package/dist/lib/sast.test.d.ts.map +1 -0
- package/dist/lib/sast.test.js +33 -0
- package/dist/lib/sast.test.js.map +1 -0
- package/dist/lib/shell.d.ts +61 -0
- package/dist/lib/shell.d.ts.map +1 -0
- package/dist/lib/shell.js +183 -0
- package/dist/lib/shell.js.map +1 -0
- package/dist/lib/ssh-config.d.ts +37 -0
- package/dist/lib/ssh-config.d.ts.map +1 -0
- package/dist/lib/ssh-config.js +122 -0
- package/dist/lib/ssh-config.js.map +1 -0
- package/dist/lib/tenant-scope.d.ts +38 -0
- package/dist/lib/tenant-scope.d.ts.map +1 -0
- package/dist/lib/tenant-scope.js +129 -0
- package/dist/lib/tenant-scope.js.map +1 -0
- package/dist/lib/tenant-scope.test.d.ts +2 -0
- package/dist/lib/tenant-scope.test.d.ts.map +1 -0
- package/dist/lib/tenant-scope.test.js +223 -0
- package/dist/lib/tenant-scope.test.js.map +1 -0
- package/package.json +58 -0
- package/templates/bootstrap/.env.template +54 -0
- package/templates/bootstrap/docker-compose.yml +145 -0
- package/templates/vhost.conf.tmpl +446 -0
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CiCore CLI — `ci db promote-silo` (F5 / Faz 6).
|
|
3
|
+
*
|
|
4
|
+
* 9-step orchestrator that promotes a pool-tier brand to a dedicated silo DB.
|
|
5
|
+
* LOCKED spec: docs/planning/2026-06-01-faz6-promote-silo.md.
|
|
6
|
+
*
|
|
7
|
+
* Mustafa LOCK'ları (mimari sözleşme — bypass YASAK):
|
|
8
|
+
* K1 — COPY pipe + FK-aware sıralama (parents→children) veya post-load
|
|
9
|
+
* constraints VALIDATE. FK ihlali = sessiz kayıp.
|
|
10
|
+
* K2 — Otomatik tablo discovery + child FK kapsama. Manifest üretilir +
|
|
11
|
+
* --confirm-manifest gate (insan-gözden-geçirme; CI'da --yes ile bypass).
|
|
12
|
+
* K3 — Pool RLS bypass (superuser) AMA her COPY EXPLICIT `WHERE brand_id=:b`.
|
|
13
|
+
* Kapalı RLS'e güvenme; her COPY filtre TAŞIR.
|
|
14
|
+
* + Anti-sızıntı assertion (Step 7): silo'da brand_id != :b satır = 0.
|
|
15
|
+
* K4 — Per-step idempotent guard; cp_provisioning_jobs.steps jsonb resumable.
|
|
16
|
+
* Mevcut provision-agent çatısını REUSE et.
|
|
17
|
+
*
|
|
18
|
+
* INVARIANT (zorunlu):
|
|
19
|
+
* INV-1 — Cutover (Step 8) YALNIZ Step 7 verify YEŞİL ise. Pool-filtered
|
|
20
|
+
* row-count + checksum eşitliği; geçmezse cutover YOK.
|
|
21
|
+
* INV-2 — Promote başarılı olsa bile pool verisi SİLİNMEZ. Cleanup ayrı
|
|
22
|
+
* job (Faz 6.1, 14 gün retention). Geri-dönülebilir.
|
|
23
|
+
*
|
|
24
|
+
* Bu komut iki şapkada koşar:
|
|
25
|
+
* 1) Standalone: `ci db promote-silo --brand <slug> --host <alias>` —
|
|
26
|
+
* operator debug + apply dry-run + ilk tatbikat.
|
|
27
|
+
* 2) Agent dispatch: `ci agent run` `db_promote_silo` job tipini gördüğünde
|
|
28
|
+
* bu komut spawn edilir (mevcut spawn pattern; agent index.ts dispatch).
|
|
29
|
+
*
|
|
30
|
+
* Bu turun KAPSAM DIŞI: gerçek child-FK discovery + COPY pipe data-plane
|
|
31
|
+
* implementation. v1 skeleton: validate → discovery (brand_id kolonu olan
|
|
32
|
+
* tablolar + FK child genişletme) → silo CREATE + schema clone → manifest
|
|
33
|
+
* jsonb logging → verify gate iskeleti + INV-1/INV-2 guard. Gerçek data
|
|
34
|
+
* migrate adımları Faz 6 apply tatbikatı sırasında refinement görür (mkt
|
|
35
|
+
* sentinel brand test ile).
|
|
36
|
+
*/
|
|
37
|
+
import { log } from '../../lib/logger.js';
|
|
38
|
+
import { getHostConfig } from '../../lib/hosts.js';
|
|
39
|
+
import { dbSelectJson, dbExec } from '../../lib/ops/db.js';
|
|
40
|
+
import { runRemoteScript } from '../../lib/ops/ssh.js';
|
|
41
|
+
import { invalidateBrandStatus } from '../../lib/ops/redis.js';
|
|
42
|
+
import { isDryRun } from '../../lib/config.js';
|
|
43
|
+
const CONTROL_DB = 'vucore_control';
|
|
44
|
+
const SLUG_RE = /^[a-z][a-z0-9_-]{1,62}$/;
|
|
45
|
+
const STEP_COUNT = 9;
|
|
46
|
+
const RETENTION_DAYS_DEFAULT = 14;
|
|
47
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
48
|
+
// Public API — komut tanımı
|
|
49
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
50
|
+
export function registerPromoteSilo(db) {
|
|
51
|
+
db.command('promote-silo')
|
|
52
|
+
.description('Promote a pool-tier brand to a dedicated silo DB (9-step orchestrator; idempotent + resumable)')
|
|
53
|
+
.requiredOption('--brand <slug>', 'Brand slug to promote')
|
|
54
|
+
.option('-h, --host <host>', 'Host alias (cicore | mkt)')
|
|
55
|
+
.option('--job-id <uuid>', 'Existing cp_provisioning_jobs id (resume); else a new ad-hoc run')
|
|
56
|
+
.option('--confirm-manifest', 'Skip the human-review pause on Step 2 (CI/agent mode)', false)
|
|
57
|
+
.option('--dry-run', 'Plan only — Step 1+2 (validate + discovery), no DDL/DML', false)
|
|
58
|
+
.action(async (opts) => {
|
|
59
|
+
const cfg = resolveHost(opts.host);
|
|
60
|
+
if (!SLUG_RE.test(opts.brand)) {
|
|
61
|
+
log.error(`Invalid --brand slug '${opts.brand}'.`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
const ctx = {
|
|
65
|
+
cfg,
|
|
66
|
+
brand: opts.brand,
|
|
67
|
+
jobId: opts.jobId ?? null,
|
|
68
|
+
confirmManifest: opts.confirmManifest === true,
|
|
69
|
+
dryRun: opts.dryRun === true || isDryRun(), // collision-robust (Part B)
|
|
70
|
+
steps: blankSteps(STEP_COUNT),
|
|
71
|
+
};
|
|
72
|
+
const ok = await runOrchestrator(ctx);
|
|
73
|
+
process.exit(ok ? 0 : 1);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
export async function runOrchestrator(ctx) {
|
|
77
|
+
log.info(`[promote-silo] brand=${ctx.brand} host=${ctx.cfg.alias} dry-run=${ctx.dryRun}`);
|
|
78
|
+
try {
|
|
79
|
+
// STEP 1 — VALIDATE
|
|
80
|
+
const brand = await step(ctx, 0, 'validate', () => doValidate(ctx));
|
|
81
|
+
// STEP 2 — DISCOVERY (brand-scoped manifest + reference tables, INV-4)
|
|
82
|
+
const pool = await fetchPoolDb(ctx);
|
|
83
|
+
const discovery = await step(ctx, 1, 'discovery', () => doDiscovery(ctx, brand, pool));
|
|
84
|
+
if (ctx.dryRun) {
|
|
85
|
+
log.success('[promote-silo] dry-run complete: validate + discovery only');
|
|
86
|
+
log.info(` manifest: ${discovery.tables.length} brand-scoped table(s)`);
|
|
87
|
+
discovery.tables.forEach(t => log.info(` · ${t.schema}.${t.table} (via=${t.via})`));
|
|
88
|
+
log.info(` references: ${discovery.references.length} global table(s) (INV-4 full-copy)`);
|
|
89
|
+
discovery.references.forEach(r => log.info(` · ${r.schema}.${r.table}`));
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
// STEP 3 — PROVISION SILO DB
|
|
93
|
+
const silo = await step(ctx, 2, 'provision_silo', () => doProvisionSilo(ctx, brand));
|
|
94
|
+
// STEP 4 — SCHEMA MIGRATE
|
|
95
|
+
await step(ctx, 3, 'schema_migrate', () => doSchemaMigrate(ctx, pool, silo));
|
|
96
|
+
// STEP 5 — MARK MIGRATING
|
|
97
|
+
await step(ctx, 4, 'mark_migrating', () => doMarkMigrating(ctx, brand));
|
|
98
|
+
// STEP 6 — DATA MIGRATE (a) reference full-copy, (b) brand-scoped manifest
|
|
99
|
+
await step(ctx, 5, 'data_migrate', () => doDataMigrate(ctx, brand, discovery.tables, discovery.references, pool, silo));
|
|
100
|
+
// STEP 7 — VERIFY (INV-1 cutover + INV-4 FK orphan + anti-sızıntı)
|
|
101
|
+
await step(ctx, 6, 'verify', () => doVerify(ctx, brand, discovery.tables, discovery.references, pool, silo));
|
|
102
|
+
// STEP 8 — REGISTRY FLIP (atomic single-txn)
|
|
103
|
+
await step(ctx, 7, 'registry_flip', () => doRegistryFlip(ctx, brand, silo));
|
|
104
|
+
// STEP 9 — CACHE INVALIDATE + AUDIT END
|
|
105
|
+
await step(ctx, 8, 'cache_invalidate', () => doCacheInvalidate(ctx, brand));
|
|
106
|
+
log.success(`[promote-silo] ✅ success — brand '${ctx.brand}' promoted to silo (pool intact; INV-2)`);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
111
|
+
log.error(`[promote-silo] ❌ failed at step ${currentStepIndex(ctx) + 1}: ${msg}`);
|
|
112
|
+
await persistSteps(ctx, 'failed', msg);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Generic per-step wrapper: idempotent guard + state log + audit. */
|
|
117
|
+
export async function step(ctx, idx, name, body) {
|
|
118
|
+
if (ctx.steps[idx].status === 'completed') {
|
|
119
|
+
log.info(`[step ${idx + 1}/${STEP_COUNT}] ${name} — already completed, skip (resume)`);
|
|
120
|
+
return (ctx.steps[idx].detail ?? {});
|
|
121
|
+
}
|
|
122
|
+
ctx.steps[idx].status = 'running';
|
|
123
|
+
ctx.steps[idx].startedAt = new Date().toISOString();
|
|
124
|
+
await persistSteps(ctx, 'running', null);
|
|
125
|
+
log.info(`[step ${idx + 1}/${STEP_COUNT}] ${name} — start`);
|
|
126
|
+
try {
|
|
127
|
+
const out = await body();
|
|
128
|
+
ctx.steps[idx].status = 'completed';
|
|
129
|
+
ctx.steps[idx].finishedAt = new Date().toISOString();
|
|
130
|
+
ctx.steps[idx].detail = sanitizeForJson(out);
|
|
131
|
+
await persistSteps(ctx, 'running', null);
|
|
132
|
+
log.success(`[step ${idx + 1}/${STEP_COUNT}] ${name} — done`);
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
ctx.steps[idx].status = 'failed';
|
|
137
|
+
ctx.steps[idx].error = err instanceof Error ? err.message : String(err);
|
|
138
|
+
ctx.steps[idx].finishedAt = new Date().toISOString();
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
143
|
+
// Step bodies — minimal but invariant-correct
|
|
144
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
145
|
+
/** Step 1 — VALIDATE: brand var + tier=pool + sentinel değil + en az 1 satır. */
|
|
146
|
+
export async function doValidate(ctx) {
|
|
147
|
+
const rows = await dbSelectJson(ctx.cfg, CONTROL_DB, `SELECT id::text, slug, db_tier, is_default::text, status, placement_region
|
|
148
|
+
FROM cp_brands WHERE slug='${sqlLit(ctx.brand)}' LIMIT 1`);
|
|
149
|
+
if (rows.length === 0)
|
|
150
|
+
throw new Error(`brand '${ctx.brand}' not found in cp_brands`);
|
|
151
|
+
const b = rows[0];
|
|
152
|
+
if (b.db_tier === 'silo')
|
|
153
|
+
throw new Error(`brand '${ctx.brand}' is already silo (idempotent no-op)`);
|
|
154
|
+
if (b.db_tier !== 'pool')
|
|
155
|
+
throw new Error(`brand '${ctx.brand}' has unexpected tier '${b.db_tier}'`);
|
|
156
|
+
if (b.is_default === 't' || b.is_default === true || b.is_default === 'true') {
|
|
157
|
+
throw new Error(`brand '${ctx.brand}' is_default=true — sentinel brand cannot be promoted`);
|
|
158
|
+
}
|
|
159
|
+
return b;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Step 2 — DISCOVERY: tablo manifest üretimi (GERÇEK, K2 tamlık).
|
|
163
|
+
*
|
|
164
|
+
* Akış:
|
|
165
|
+
* 1. `direct[]` — public şemada `brand_id` kolonu olan tablolar.
|
|
166
|
+
* 2. `fkEdges[]` — pg_catalog'tan TÜM single-column FK ilişkileri
|
|
167
|
+
* (child.fk_col → parent.pk_col). Composite FK'lar (nadir; addon
|
|
168
|
+
* tarafından nadiren) — sadece ilk kolon kullanılır + uyarı logla.
|
|
169
|
+
* 3. **BFS closure** — direct tablolardan başlayıp FK arc'larıyla
|
|
170
|
+
* ulaşılabilen tüm child tabloları depth-tagged topla. brand-reachable
|
|
171
|
+
* hiçbir tablo sessizce atlanmaz (Mustafa K2 tamlık zorunluluğu).
|
|
172
|
+
* 4. **Predicate composition** — her tablo için WHERE fragment'i üret:
|
|
173
|
+
* direct → `brand_id = '<uuid>'`
|
|
174
|
+
* fk → `<fk_col> IN (SELECT <parent_pk> FROM <parent> WHERE <parent_predicate>)`
|
|
175
|
+
* Recursive: çok-seviyeli FK'lar nested SUBQUERY ile zincirlenir.
|
|
176
|
+
* 5. **Topological sort** — `depth` artan sırada (parents → children).
|
|
177
|
+
* İki yönlü cycle (FK cycle, postgres'te ON DELETE CASCADE arası nadir)
|
|
178
|
+
* → cycle hatası fırlat (operator manuel müdahalesi).
|
|
179
|
+
*
|
|
180
|
+
* `--confirm-manifest` flag yoksa manifest dökümü + DUR (insan-gözden-geçirme).
|
|
181
|
+
* CI/agent mode (panel enqueue) flag'i her zaman geçer.
|
|
182
|
+
*/
|
|
183
|
+
export async function doDiscovery(ctx, brand, pool) {
|
|
184
|
+
// 1. brand_id kolonu olan tablolar (direct entry'ler).
|
|
185
|
+
const directRows = await dbSelectJson(ctx.cfg, pool.db_name, `SELECT table_schema, table_name FROM information_schema.columns
|
|
186
|
+
WHERE column_name='brand_id' AND table_schema='public'
|
|
187
|
+
ORDER BY table_schema, table_name`);
|
|
188
|
+
if (directRows.length === 0) {
|
|
189
|
+
log.warn(`[discovery] no 'brand_id' columns in pool DB '${pool.db_name}' — manifest will be empty`);
|
|
190
|
+
}
|
|
191
|
+
// 2. FK ilişkileri (single-column; composite uyarı + ilk col kullan).
|
|
192
|
+
const fkRows = await dbSelectJson(ctx.cfg, pool.db_name, `SELECT
|
|
193
|
+
nc.nspname AS child_schema,
|
|
194
|
+
cc.relname AS child_table,
|
|
195
|
+
ca.attname AS child_column,
|
|
196
|
+
np.nspname AS parent_schema,
|
|
197
|
+
pc.relname AS parent_table,
|
|
198
|
+
pa.attname AS parent_column,
|
|
199
|
+
array_length(c.conkey, 1) AS col_count
|
|
200
|
+
FROM pg_constraint c
|
|
201
|
+
JOIN pg_class cc ON cc.oid = c.conrelid
|
|
202
|
+
JOIN pg_namespace nc ON nc.oid = cc.relnamespace
|
|
203
|
+
JOIN pg_class pc ON pc.oid = c.confrelid
|
|
204
|
+
JOIN pg_namespace np ON np.oid = pc.relnamespace
|
|
205
|
+
JOIN pg_attribute ca ON ca.attrelid = c.conrelid AND ca.attnum = c.conkey[1]
|
|
206
|
+
JOIN pg_attribute pa ON pa.attrelid = c.confrelid AND pa.attnum = c.confkey[1]
|
|
207
|
+
WHERE c.contype = 'f' AND nc.nspname = 'public' AND np.nspname = 'public'
|
|
208
|
+
ORDER BY child_schema, child_table, child_column`);
|
|
209
|
+
// Composite FK uyarısı (ilk col kullanılır; production'da composite path lazım).
|
|
210
|
+
for (const fk of fkRows) {
|
|
211
|
+
if (Number(fk.col_count) > 1) {
|
|
212
|
+
log.warn(`[discovery] composite FK ignored beyond first column: ${fk.child_schema}.${fk.child_table}(${fk.child_column}) → ${fk.parent_schema}.${fk.parent_table}(${fk.parent_column}) — col_count=${fk.col_count}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// 3. BFS closure — direct'ten başla, FK arc'larıyla yayıl.
|
|
216
|
+
const brandIdLit = sqlLit(brand.id);
|
|
217
|
+
const entries = new Map(); // tableKey → entry
|
|
218
|
+
for (const r of directRows) {
|
|
219
|
+
const key = `${r.table_schema}.${r.table_name}`;
|
|
220
|
+
entries.set(key, {
|
|
221
|
+
schema: r.table_schema,
|
|
222
|
+
table: r.table_name,
|
|
223
|
+
via: 'direct',
|
|
224
|
+
fkPath: [],
|
|
225
|
+
predicate: `brand_id = '${brandIdLit}'`,
|
|
226
|
+
depth: 0,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
// Adjacency: parent → list of (child + fk_col + parent_pk)
|
|
230
|
+
const childrenOf = new Map();
|
|
231
|
+
for (const fk of fkRows) {
|
|
232
|
+
const parent = `${fk.parent_schema}.${fk.parent_table}`;
|
|
233
|
+
const child = `${fk.child_schema}.${fk.child_table}`;
|
|
234
|
+
if (parent === child)
|
|
235
|
+
continue; // self-FK loop: ignore
|
|
236
|
+
if (!childrenOf.has(parent))
|
|
237
|
+
childrenOf.set(parent, []);
|
|
238
|
+
childrenOf.get(parent).push({ child, childCol: fk.child_column, parentCol: fk.parent_column });
|
|
239
|
+
}
|
|
240
|
+
// BFS queue: parent entries from direct + their newly-discovered children.
|
|
241
|
+
const queue = [...entries.keys()];
|
|
242
|
+
let safety = 0;
|
|
243
|
+
while (queue.length > 0) {
|
|
244
|
+
if (++safety > 2000)
|
|
245
|
+
throw new Error('[discovery] BFS safety break (>2000 iterations) — possible FK cycle');
|
|
246
|
+
const parentKey = queue.shift();
|
|
247
|
+
const parentEntry = entries.get(parentKey);
|
|
248
|
+
if (!parentEntry)
|
|
249
|
+
continue;
|
|
250
|
+
const children = childrenOf.get(parentKey) ?? [];
|
|
251
|
+
for (const c of children) {
|
|
252
|
+
if (entries.has(c.child))
|
|
253
|
+
continue; // zaten ekli (direct veya başka path)
|
|
254
|
+
// Predicate: child <fk_col> IN (SELECT <parent_pk> FROM <parent> WHERE <parent_predicate>)
|
|
255
|
+
const predicate = `${quoteIdent(c.childCol)} IN ` +
|
|
256
|
+
`(SELECT ${quoteIdent(c.parentCol)} FROM ${quoteQualified(parentKey)} WHERE ${parentEntry.predicate})`;
|
|
257
|
+
const [schema, table] = c.child.split('.');
|
|
258
|
+
entries.set(c.child, {
|
|
259
|
+
schema, table,
|
|
260
|
+
via: 'fk',
|
|
261
|
+
fkPath: [parentKey, ...parentEntry.fkPath],
|
|
262
|
+
predicate,
|
|
263
|
+
depth: parentEntry.depth + 1,
|
|
264
|
+
});
|
|
265
|
+
queue.push(c.child);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// 4. Topological sort: depth asc, sonra schema.table asc (deterministik).
|
|
269
|
+
const manifest = Array.from(entries.values()).sort((a, b) => {
|
|
270
|
+
if (a.depth !== b.depth)
|
|
271
|
+
return a.depth - b.depth;
|
|
272
|
+
const ka = `${a.schema}.${a.table}`;
|
|
273
|
+
const kb = `${b.schema}.${b.table}`;
|
|
274
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
275
|
+
});
|
|
276
|
+
const directCount = manifest.filter(t => t.via === 'direct').length;
|
|
277
|
+
const fkCount = manifest.filter(t => t.via === 'fk').length;
|
|
278
|
+
log.info(`[discovery] manifest: ${manifest.length} table(s) — ${directCount} direct + ${fkCount} fk (closure)`);
|
|
279
|
+
manifest.forEach(t => {
|
|
280
|
+
const tail = t.via === 'fk' ? ` parent_chain=${t.fkPath.join('→')}` : '';
|
|
281
|
+
log.info(` · [depth=${t.depth}] ${t.schema}.${t.table} (via=${t.via})${tail}`);
|
|
282
|
+
log.debug(` predicate: WHERE ${t.predicate}`);
|
|
283
|
+
});
|
|
284
|
+
// 5. INV-4 — Reference tabloları tespit (TRANSITİF fixpoint).
|
|
285
|
+
//
|
|
286
|
+
// Önceki tek-geçişli yaklaşım çok-seviyeli zincirde eksik kalıyordu:
|
|
287
|
+
// brand → cities → countries → continents
|
|
288
|
+
// 'cities' yakalanırdı (closure → cities), ama 'countries'/'continents'
|
|
289
|
+
// YAKALANMAZDI — 'cities' closure'a dahil değil, FK out-edge'i taranmıyordu.
|
|
290
|
+
// Sonuç: silo'da 'cities' satırları 'countries'a dangling FK; orphan-check
|
|
291
|
+
// yakalardı (fail-safe) ama meşru migration'ı bloklardı.
|
|
292
|
+
//
|
|
293
|
+
// Çözüm: 'kapsanan küme' = closure ∪ keşfedilmiş reference'lar. Yeni FK
|
|
294
|
+
// edge'in child'ı kapsanan kümede AMA parent kapsanan kümede DEĞİLse →
|
|
295
|
+
// yeni reference. Worklist boşalana dek tekrarla (fixpoint). Safety guard
|
|
296
|
+
// ile cycle break.
|
|
297
|
+
const closureKeys = new Set(manifest.map(t => `${t.schema}.${t.table}`));
|
|
298
|
+
const refMap = new Map();
|
|
299
|
+
const covered = new Set(closureKeys);
|
|
300
|
+
const worklist = [];
|
|
301
|
+
let safetyRef = 0;
|
|
302
|
+
const considerEdge = (childKey, parentSchema, parentTable) => {
|
|
303
|
+
const parentKey = `${parentSchema}.${parentTable}`;
|
|
304
|
+
if (childKey === parentKey)
|
|
305
|
+
return; // self-FK
|
|
306
|
+
if (covered.has(parentKey))
|
|
307
|
+
return; // zaten kapsanmış
|
|
308
|
+
if (!covered.has(childKey))
|
|
309
|
+
return; // child kapsanmamış → henüz alakalı değil
|
|
310
|
+
const existing = refMap.get(parentKey);
|
|
311
|
+
if (existing) {
|
|
312
|
+
if (!existing.referrers.includes(childKey))
|
|
313
|
+
existing.referrers.push(childKey);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
refMap.set(parentKey, {
|
|
317
|
+
schema: parentSchema,
|
|
318
|
+
table: parentTable,
|
|
319
|
+
referrers: [childKey],
|
|
320
|
+
});
|
|
321
|
+
covered.add(parentKey);
|
|
322
|
+
worklist.push(parentKey);
|
|
323
|
+
};
|
|
324
|
+
// Tur 1 — closure'un out-FK edge'lerini tara.
|
|
325
|
+
for (const fk of fkRows) {
|
|
326
|
+
considerEdge(`${fk.child_schema}.${fk.child_table}`, fk.parent_schema, fk.parent_table);
|
|
327
|
+
}
|
|
328
|
+
// Fixpoint — her yeni reference'ın kendi out-FK'larını da değerlendir.
|
|
329
|
+
while (worklist.length > 0) {
|
|
330
|
+
if (++safetyRef > 2000) {
|
|
331
|
+
throw new Error('[discovery] reference fixpoint safety break (>2000 iter) — possible FK cycle in references');
|
|
332
|
+
}
|
|
333
|
+
const newRefKey = worklist.shift();
|
|
334
|
+
for (const fk of fkRows) {
|
|
335
|
+
const childKey = `${fk.child_schema}.${fk.child_table}`;
|
|
336
|
+
if (childKey !== newRefKey)
|
|
337
|
+
continue;
|
|
338
|
+
considerEdge(childKey, fk.parent_schema, fk.parent_table);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// 6. Reference'lar arası FK varsa: parents-first topological sort
|
|
342
|
+
// (örn. continents → countries → cities sırası). Aksi takdirde
|
|
343
|
+
// session_replication_role=replica fallback'i tutar ama temiz sıra
|
|
344
|
+
// cycle-safe yükleme için tercih.
|
|
345
|
+
const references = topoSortReferences(Array.from(refMap.values()), fkRows);
|
|
346
|
+
if (references.length > 0) {
|
|
347
|
+
log.info(`[discovery] reference tables (INV-4 full-copy, transitive closure): ${references.length}`);
|
|
348
|
+
references.forEach(r => log.info(` · ${r.schema}.${r.table} (referred by: ${r.referrers.join(', ')})`));
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
log.info('[discovery] no out-of-closure FK references (no global reference tables)');
|
|
352
|
+
}
|
|
353
|
+
if (!ctx.confirmManifest) {
|
|
354
|
+
throw new Error('manifest review required — re-run with --confirm-manifest after operator review (K2 + INV-4)');
|
|
355
|
+
}
|
|
356
|
+
return { tables: manifest, references };
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Reference tabloları arası FK varsa: parents-first topological sort.
|
|
360
|
+
* Kahn's algoritması. Cycle → throw (operator manuel müdahalesi; nadir).
|
|
361
|
+
* Stable: in-degree 0 küme alfabetik sırayla pop'lanır.
|
|
362
|
+
*/
|
|
363
|
+
export function topoSortReferences(refs, fkRows) {
|
|
364
|
+
if (refs.length <= 1)
|
|
365
|
+
return refs.slice();
|
|
366
|
+
// Explicit `string` generic — template literal type ('${string}.${string}')
|
|
367
|
+
// inference'ı kapat; aşağıdaki Map<string, ...> kullanımları narrow tipte
|
|
368
|
+
// takılmasın.
|
|
369
|
+
const refKeys = new Set(refs.map(r => `${r.schema}.${r.table}`));
|
|
370
|
+
const byKey = new Map(refs.map(r => [`${r.schema}.${r.table}`, r]));
|
|
371
|
+
// child→parent edge: child reference, in-degree of child artar.
|
|
372
|
+
const inDeg = new Map();
|
|
373
|
+
const children = new Map(); // parentKey → [childKey...]
|
|
374
|
+
for (const k of refKeys)
|
|
375
|
+
inDeg.set(k, 0);
|
|
376
|
+
for (const fk of fkRows) {
|
|
377
|
+
const childKey = `${fk.child_schema}.${fk.child_table}`;
|
|
378
|
+
const parentKey = `${fk.parent_schema}.${fk.parent_table}`;
|
|
379
|
+
if (childKey === parentKey)
|
|
380
|
+
continue;
|
|
381
|
+
if (!refKeys.has(childKey) || !refKeys.has(parentKey))
|
|
382
|
+
continue;
|
|
383
|
+
inDeg.set(childKey, (inDeg.get(childKey) ?? 0) + 1);
|
|
384
|
+
if (!children.has(parentKey))
|
|
385
|
+
children.set(parentKey, []);
|
|
386
|
+
if (!children.get(parentKey).includes(childKey))
|
|
387
|
+
children.get(parentKey).push(childKey);
|
|
388
|
+
}
|
|
389
|
+
// Kahn's: in-degree 0 kümesini stable (alfabetik) tut.
|
|
390
|
+
const ready = [];
|
|
391
|
+
for (const [k, d] of inDeg)
|
|
392
|
+
if (d === 0)
|
|
393
|
+
ready.push(k);
|
|
394
|
+
ready.sort();
|
|
395
|
+
const sorted = [];
|
|
396
|
+
let safety = 0;
|
|
397
|
+
while (ready.length > 0) {
|
|
398
|
+
if (++safety > refs.length + 100) {
|
|
399
|
+
throw new Error('[discovery] reference topo sort safety break — possible FK cycle in references');
|
|
400
|
+
}
|
|
401
|
+
const k = ready.shift();
|
|
402
|
+
sorted.push(byKey.get(k));
|
|
403
|
+
for (const c of children.get(k) ?? []) {
|
|
404
|
+
const d = (inDeg.get(c) ?? 0) - 1;
|
|
405
|
+
inDeg.set(c, d);
|
|
406
|
+
if (d === 0) {
|
|
407
|
+
// Stable insert (sıralı kalsın).
|
|
408
|
+
const idx = ready.findIndex(x => x > c);
|
|
409
|
+
if (idx === -1)
|
|
410
|
+
ready.push(c);
|
|
411
|
+
else
|
|
412
|
+
ready.splice(idx, 0, c);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (sorted.length !== refs.length) {
|
|
417
|
+
throw new Error(`[discovery] reference topo sort incomplete (${sorted.length}/${refs.length}); FK cycle among references`);
|
|
418
|
+
}
|
|
419
|
+
return sorted;
|
|
420
|
+
}
|
|
421
|
+
/** Postgres identifier'ı çift tırnağa al (reserved keyword + case-sensitive korumalı). */
|
|
422
|
+
export function quoteIdent(name) {
|
|
423
|
+
return '"' + name.replace(/"/g, '""') + '"';
|
|
424
|
+
}
|
|
425
|
+
/** "schema.table" → "schema"."table" */
|
|
426
|
+
export function quoteQualified(qualified) {
|
|
427
|
+
const [schema, table] = qualified.split('.');
|
|
428
|
+
return `${quoteIdent(schema)}.${quoteIdent(table)}`;
|
|
429
|
+
}
|
|
430
|
+
/** Step 3 — PROVISION SILO DB (idempotent: var ise atla). */
|
|
431
|
+
export async function doProvisionSilo(ctx, brand) {
|
|
432
|
+
const hash = shortHash(brand.id);
|
|
433
|
+
const coreSlug = (ctx.cfg.brandName || 'core').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
434
|
+
const dbName = `db_${coreSlug}_${brand.slug.replace(/-/g, '_')}_${hash}`;
|
|
435
|
+
const dbUser = `${dbName}_user`;
|
|
436
|
+
// İdempotent: zaten varsa create skip.
|
|
437
|
+
const exists = await dbSelectJson(ctx.cfg, 'postgres', `SELECT '1' AS exists FROM pg_database WHERE datname='${sqlLit(dbName)}'`);
|
|
438
|
+
if (exists.length === 0) {
|
|
439
|
+
const pg = ctx.cfg.dbContainerName;
|
|
440
|
+
const admin = ctx.cfg.dbAdminUser;
|
|
441
|
+
const script = `set -euo pipefail\n` +
|
|
442
|
+
`docker exec ${pg} psql -U ${admin} -h 127.0.0.1 -d postgres -v ON_ERROR_STOP=1 <<SQL\n` +
|
|
443
|
+
`CREATE DATABASE ${dbName};\n` +
|
|
444
|
+
`DO \\$\\$ BEGIN\n` +
|
|
445
|
+
` IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='${dbUser}') THEN\n` +
|
|
446
|
+
` CREATE ROLE ${dbUser} LOGIN;\n` +
|
|
447
|
+
` END IF;\n` +
|
|
448
|
+
`END \\$\\$;\n` +
|
|
449
|
+
`GRANT ALL PRIVILEGES ON DATABASE ${dbName} TO ${dbUser};\n` +
|
|
450
|
+
`SQL\n`;
|
|
451
|
+
const res = await runRemoteScript(ctx.cfg, script, { silent: true });
|
|
452
|
+
if (!res.success)
|
|
453
|
+
throw new Error(`silo provision failed: ${res.stderr.trim() || res.stdout.trim()}`);
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
log.info(`[provision] silo DB '${dbName}' already exists — skip create (idempotent)`);
|
|
457
|
+
}
|
|
458
|
+
// Şu an pool DB host/port'unu paylaşıyor (cluster aynı); M2.2'de relocation.
|
|
459
|
+
const pool = await fetchPoolDb(ctx);
|
|
460
|
+
return { db_host: pool.db_host, db_port: pool.db_port, db_name: dbName, db_user: dbUser };
|
|
461
|
+
}
|
|
462
|
+
/** Step 4 — SCHEMA MIGRATE: pool schema-only dump → silo restore. İdempotent. */
|
|
463
|
+
export async function doSchemaMigrate(ctx, pool, silo) {
|
|
464
|
+
// İdempotent guard: silo'da herhangi bir tablo varsa atla.
|
|
465
|
+
const siloTables = await dbSelectJson(ctx.cfg, silo.db_name, `SELECT count(*)::text AS n FROM information_schema.tables WHERE table_schema='public'`);
|
|
466
|
+
if (siloTables.length > 0 && Number.parseInt(siloTables[0].n, 10) > 0) {
|
|
467
|
+
log.info(`[schema] silo '${silo.db_name}' already has ${siloTables[0].n} tables — skip (resume)`);
|
|
468
|
+
return { ok: true };
|
|
469
|
+
}
|
|
470
|
+
const pg = ctx.cfg.dbContainerName;
|
|
471
|
+
const admin = ctx.cfg.dbAdminUser;
|
|
472
|
+
const script = `set -euo pipefail\n` +
|
|
473
|
+
`docker exec ${pg} pg_dump -U ${admin} -h 127.0.0.1 -d ${pool.db_name} --schema-only --no-owner --no-acl \\\n` +
|
|
474
|
+
` | docker exec -i ${pg} psql -U ${admin} -h 127.0.0.1 -d ${silo.db_name} -v ON_ERROR_STOP=1\n`;
|
|
475
|
+
const res = await runRemoteScript(ctx.cfg, script, { silent: true });
|
|
476
|
+
if (!res.success)
|
|
477
|
+
throw new Error(`schema migrate failed: ${res.stderr.trim() || res.stdout.trim()}`);
|
|
478
|
+
return { ok: true };
|
|
479
|
+
}
|
|
480
|
+
/** Step 5 — MARK MIGRATING: cp_brands.status='migrating' (UI uyarısı). */
|
|
481
|
+
export async function doMarkMigrating(ctx, brand) {
|
|
482
|
+
await dbExec(ctx.cfg, CONTROL_DB, `UPDATE cp_brands SET status='migrating', updated_at=now()
|
|
483
|
+
WHERE id='${brand.id}' AND status != 'migrating'`);
|
|
484
|
+
await invalidateBrandStatus(ctx.cfg, brand.id); // F1: freeze anlık cross-worker
|
|
485
|
+
return { status: 'migrating' };
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Step 6 — DATA MIGRATE (GERÇEK COPY pipe).
|
|
489
|
+
*
|
|
490
|
+
* Mustafa kilitleri:
|
|
491
|
+
* K1 — FK koruması: per-table COPY pipe iki tarafta da
|
|
492
|
+
* `SET session_replication_role = replica` ile çalışır
|
|
493
|
+
* (trigger + FK constraint suspend; PostgreSQL replication
|
|
494
|
+
* pattern'i, "data restore için güvenli"). Topolojik manifest
|
|
495
|
+
* sırası (parents→children) zaten doğru sırayı sağlıyor; replica
|
|
496
|
+
* mode "belt + suspenders" — composite veya cycle gibi nadir
|
|
497
|
+
* senaryolarda sessiz FK hatasını imkansızlaştırır.
|
|
498
|
+
* K3 — Sızıntı koruması: her COPY pool tarafı SELECT'inde manifest'in
|
|
499
|
+
* ürettiği EXPLICIT predicate'i taşır (kapalı RLS'e güvenme;
|
|
500
|
+
* statik discovery garantisi).
|
|
501
|
+
*
|
|
502
|
+
* İdempotency / resumability:
|
|
503
|
+
* - silo_count == pool_predicate_count → atla (resume).
|
|
504
|
+
* - silo_count == 0 → TRUNCATE skip, doğrudan COPY.
|
|
505
|
+
* - 0 < silo_count != pool_predicate_count → partial copy: TRUNCATE
|
|
506
|
+
* RESTART IDENTITY (silo yeni DB; CASCADE değil, downstream entry'ler
|
|
507
|
+
* manifest sırasında zaten ele alınacak) + COPY.
|
|
508
|
+
*
|
|
509
|
+
* Subprocess seçimi (Mustafa "sen seç + gerekçesini doc'a yaz"):
|
|
510
|
+
* psql subprocess pipe seçildi. Gerekçe: (a) mevcut altyapı CLI→SSH→
|
|
511
|
+
* docker exec; Node↔postgres direct connection mevcut değil (VPN ardı,
|
|
512
|
+
* credentials .env'de host-side). (b) pg-copy-streams ek Node bağımlı;
|
|
513
|
+
* credential + connection lifecycle managemnt overhead. (c) psql streaming
|
|
514
|
+
* pipe production-tested pattern (pg_dump | psql; pgBackRest internal).
|
|
515
|
+
* (d) Tek bağımlılık: docker + psql (zaten kurulu). (e) Hata yönetimi:
|
|
516
|
+
* pipefail + ON_ERROR_STOP=1 + exit code; stderr parse.
|
|
517
|
+
*
|
|
518
|
+
* Format: TEXT (default). BINARY daha hızlı ama type compatibility +
|
|
519
|
+
* version sensitivity riski; sentinel tatbikatı sonrası BINARY'ye geçiş
|
|
520
|
+
* düşünülebilir.
|
|
521
|
+
*/
|
|
522
|
+
export async function doDataMigrate(ctx, brand, manifest, references, pool, silo) {
|
|
523
|
+
let migrated = 0;
|
|
524
|
+
let skipped = 0;
|
|
525
|
+
let rowsTotal = 0;
|
|
526
|
+
let refsCopied = 0;
|
|
527
|
+
let refsRows = 0;
|
|
528
|
+
// (a) INV-4 — Reference tabloları FİLTRESİZ tam-kopya (parents önce).
|
|
529
|
+
// Brand-scoped pipe'tan ÖNCE çalışır; dangling FK önler. v1 default
|
|
530
|
+
// full-copy (operator manifest review'da görür); fail-loud alternatifi
|
|
531
|
+
// M3'te per-table override.
|
|
532
|
+
for (const r of references) {
|
|
533
|
+
const label = `${r.schema}.${r.table}`;
|
|
534
|
+
const poolCount = await countWhere(ctx, pool.db_name, r.schema, r.table, '1=1');
|
|
535
|
+
const siloCount = await countWhere(ctx, silo.db_name, r.schema, r.table, '1=1');
|
|
536
|
+
if (poolCount === siloCount && siloCount > 0) {
|
|
537
|
+
log.info(` [ref:skip ] ${label} — silo_count=${siloCount} matches pool (idempotent)`);
|
|
538
|
+
refsCopied++; // already at desired state — sayıma dahil
|
|
539
|
+
refsRows += siloCount;
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (poolCount === 0 && siloCount === 0) {
|
|
543
|
+
log.info(` [ref:empty] ${label} — both sides 0, no-op`);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (siloCount > 0 && siloCount !== poolCount) {
|
|
547
|
+
log.warn(` [ref:reset] ${label} — partial copy (silo=${siloCount} pool=${poolCount}); TRUNCATE + full-copy`);
|
|
548
|
+
await truncateTable(ctx, silo.db_name, r.schema, r.table);
|
|
549
|
+
}
|
|
550
|
+
if (poolCount > 100_000) {
|
|
551
|
+
log.warn(` [ref:warn ] ${label} — large reference table (${poolCount} rows); full-copy will take time`);
|
|
552
|
+
}
|
|
553
|
+
log.info(` [ref:copy ] ${label} — ${poolCount} row(s) (referrers: ${r.referrers.join(', ')})`);
|
|
554
|
+
// Filtresiz tam-kopya: pseudo-entry yarat (predicate = 'true').
|
|
555
|
+
const refEntry = {
|
|
556
|
+
schema: r.schema, table: r.table, via: 'direct', fkPath: [],
|
|
557
|
+
predicate: 'true', depth: -1,
|
|
558
|
+
};
|
|
559
|
+
await copyTableWithPredicate(ctx, pool.db_name, silo.db_name, refEntry);
|
|
560
|
+
const after = await countWhere(ctx, silo.db_name, r.schema, r.table, '1=1');
|
|
561
|
+
if (after !== poolCount) {
|
|
562
|
+
log.warn(` [ref:warn ] ${label} post-copy silo=${after} != pool=${poolCount} (Step 7 verify will block cutover)`);
|
|
563
|
+
}
|
|
564
|
+
refsCopied++;
|
|
565
|
+
refsRows += after;
|
|
566
|
+
}
|
|
567
|
+
if (references.length > 0) {
|
|
568
|
+
log.info(`[data-migrate] references: copied=${refsCopied}/${references.length} rows=${refsRows}`);
|
|
569
|
+
}
|
|
570
|
+
// (b) Brand-scoped manifest — topological sırada COPY pipe.
|
|
571
|
+
if (manifest.length === 0) {
|
|
572
|
+
log.info('[data-migrate] brand-scoped manifest empty — nothing further to copy');
|
|
573
|
+
return { migrated, skipped, rows: rowsTotal, refsCopied, refsRows };
|
|
574
|
+
}
|
|
575
|
+
for (const t of manifest) {
|
|
576
|
+
const tableLabel = `${t.schema}.${t.table}`;
|
|
577
|
+
const poolCount = await countWithPredicate(ctx, pool.db_name, t);
|
|
578
|
+
const siloCount = await countWhere(ctx, silo.db_name, t.schema, t.table, '1=1');
|
|
579
|
+
if (poolCount === siloCount && siloCount > 0) {
|
|
580
|
+
log.info(` [skip ] ${tableLabel} — silo_count=${siloCount} matches pool_predicate_count (idempotent)`);
|
|
581
|
+
skipped++;
|
|
582
|
+
rowsTotal += siloCount;
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (poolCount === 0 && siloCount === 0) {
|
|
586
|
+
log.info(` [empty] ${tableLabel} — no rows match predicate, no-op`);
|
|
587
|
+
skipped++;
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
if (siloCount > 0 && siloCount !== poolCount) {
|
|
591
|
+
log.warn(` [reset] ${tableLabel} — partial copy detected (silo=${siloCount} pool=${poolCount}); TRUNCATE + COPY`);
|
|
592
|
+
await truncateTable(ctx, silo.db_name, t.schema, t.table);
|
|
593
|
+
}
|
|
594
|
+
log.info(` [copy ] ${tableLabel} — ${poolCount} row(s) via=${t.via} depth=${t.depth}`);
|
|
595
|
+
await copyTableWithPredicate(ctx, pool.db_name, silo.db_name, t);
|
|
596
|
+
const after = await countWhere(ctx, silo.db_name, t.schema, t.table, '1=1');
|
|
597
|
+
if (after !== poolCount) {
|
|
598
|
+
// INV-1 ön-uyarı; gerçek cutover gate Step 7. Burada throw etmiyoruz —
|
|
599
|
+
// Step 7 verify görsün diye veriyi sayım uyuşmazlığıyla bırak; Mustafa
|
|
600
|
+
// INV-1 sözleşmesi cutover gate'in TEK yer olduğunu söyler.
|
|
601
|
+
log.warn(` [warn ] ${tableLabel} post-copy silo=${after} != pool=${poolCount} (Step 7 verify will block cutover)`);
|
|
602
|
+
}
|
|
603
|
+
migrated++;
|
|
604
|
+
rowsTotal += after;
|
|
605
|
+
}
|
|
606
|
+
log.info(`[data-migrate] brand: migrated=${migrated} skipped=${skipped} rows=${rowsTotal}`);
|
|
607
|
+
return { migrated, skipped, rows: rowsTotal, refsCopied, refsRows };
|
|
608
|
+
}
|
|
609
|
+
/** Helper — pool tarafında `count(*) WHERE <entry.predicate>`. */
|
|
610
|
+
export async function countWithPredicate(ctx, dbName, t) {
|
|
611
|
+
const rows = await dbSelectJson(ctx.cfg, dbName, `SELECT count(*)::text AS n FROM ${quoteQualified(`${t.schema}.${t.table}`)} WHERE ${t.predicate}`);
|
|
612
|
+
return rows.length ? Number.parseInt(rows[0].n, 10) : 0;
|
|
613
|
+
}
|
|
614
|
+
/** Helper — `count(*) WHERE <where>` (silo tarafında `1=1` veya brand_id!=X). */
|
|
615
|
+
export async function countWhere(ctx, dbName, schema, table, where) {
|
|
616
|
+
const rows = await dbSelectJson(ctx.cfg, dbName, `SELECT count(*)::text AS n FROM ${quoteQualified(`${schema}.${table}`)} WHERE ${where}`);
|
|
617
|
+
return rows.length ? Number.parseInt(rows[0].n, 10) : 0;
|
|
618
|
+
}
|
|
619
|
+
/** Helper — silo'da partial copy artığı varsa temizle. */
|
|
620
|
+
export async function truncateTable(ctx, dbName, schema, table) {
|
|
621
|
+
// RESTART IDENTITY — sequence'i sıfırla. CASCADE değil; manifest'in
|
|
622
|
+
// sıralaması child'ları zaten ayrı entry olarak işliyor.
|
|
623
|
+
await dbExec(ctx.cfg, dbName, `TRUNCATE TABLE ${quoteQualified(`${schema}.${table}`)} RESTART IDENTITY`);
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Helper — pool'dan silo'ya bir tablonun WHERE-filtreli verisini COPY pipe ile
|
|
627
|
+
* taşı. psql subprocess pipe: pool TO STDOUT | silo FROM STDIN. session_
|
|
628
|
+
* replication_role=replica iki tarafta da (FK + trigger bypass).
|
|
629
|
+
*/
|
|
630
|
+
export async function copyTableWithPredicate(ctx, poolDb, siloDb, t) {
|
|
631
|
+
const pg = ctx.cfg.dbContainerName;
|
|
632
|
+
const admin = ctx.cfg.dbAdminUser;
|
|
633
|
+
const qTable = quoteQualified(`${t.schema}.${t.table}`);
|
|
634
|
+
// Pool SELECT (read source). Predicate fragment'i quoteIdent'lenmiş.
|
|
635
|
+
const selectSql = `SET session_replication_role = replica; ` +
|
|
636
|
+
`COPY (SELECT * FROM ${qTable} WHERE ${t.predicate}) TO STDOUT`;
|
|
637
|
+
// Silo INSERT (write target). Aynı session-level setting.
|
|
638
|
+
const insertSql = `SET session_replication_role = replica; ` +
|
|
639
|
+
`COPY ${qTable} FROM STDIN`;
|
|
640
|
+
// Bash pipe — set -o pipefail, ON_ERROR_STOP=1; psql çıkışları stderr'e
|
|
641
|
+
// yazarsa hata yakalanır. SSH üzerinden docker exec ile koşar.
|
|
642
|
+
const script = `set -euo pipefail\n` +
|
|
643
|
+
`docker exec -i ${pg} bash -lc ` +
|
|
644
|
+
`'set -o pipefail && ` +
|
|
645
|
+
` psql -U ${admin} -h 127.0.0.1 -d ${poolDb} -v ON_ERROR_STOP=1 --quiet --tuples-only -c ` +
|
|
646
|
+
sshSqlArg(selectSql) +
|
|
647
|
+
` | psql -U ${admin} -h 127.0.0.1 -d ${siloDb} -v ON_ERROR_STOP=1 --quiet --tuples-only -c ` +
|
|
648
|
+
sshSqlArg(insertSql) +
|
|
649
|
+
`'\n`;
|
|
650
|
+
const res = await runRemoteScript(ctx.cfg, script, { silent: true });
|
|
651
|
+
if (!res.success) {
|
|
652
|
+
throw new Error(`COPY ${t.schema}.${t.table} failed: ${(res.stderr || res.stdout).trim().slice(0, 500)}`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* SQL string'ini docker exec bash -lc içinde tek-tırnak literal olarak güvenli
|
|
657
|
+
* geç. Bash double-quote escape değil — outer single-quote içinde inner
|
|
658
|
+
* single-quote'lara `'\''` ile çıkış yapıyoruz; tüm shq() pattern'iyle aynı.
|
|
659
|
+
*/
|
|
660
|
+
export function sshSqlArg(sql) {
|
|
661
|
+
// Outer 'bash -lc' SCRIPT'i kendisi single quote içinde (script'i
|
|
662
|
+
// SSH'a heredoc-değil-arg geçirmemize bağlı değil — runRemoteScript heredoc).
|
|
663
|
+
// İçeride psql -c "<SQL>" double-quote'u kullanıyoruz; SQL içindeki "
|
|
664
|
+
// ler escape edilir.
|
|
665
|
+
const escaped = sql.replace(/"/g, '\\"');
|
|
666
|
+
return `"${escaped}"`;
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Step 7 — VERIFY (GERÇEK INV-1 cutover gate + K3 anti-sızıntı).
|
|
670
|
+
*
|
|
671
|
+
* Üç gate, her tablo için:
|
|
672
|
+
* 1. **COUNT eşitliği** (ZORUNLU): pool WHERE predicate == silo WHERE 1=1.
|
|
673
|
+
* 2. **CHECKSUM** (ölçeklenebilir): per-row md5 → string_agg ORDER BY pk →
|
|
674
|
+
* outer md5. PK yoksa checksum atlanır (uyarı + count-only fallback);
|
|
675
|
+
* sentinel tatbikatlı tablo şemalarında her tablonun PK'sı olmalı.
|
|
676
|
+
* Pragmatik MD5 seçimi: tek SELECT'le çalışır, postgres 9+; bit_xor
|
|
677
|
+
* modern (postgres 14+) ama version uyumluluğu için MD5 + string_agg
|
|
678
|
+
* seçildi. Büyük tablo (>~1M satır) → string_agg server-side bellek
|
|
679
|
+
* mertebesi MB; sentinel için sorun değil. Gerçek müşteri büyük
|
|
680
|
+
* tablolarda bit_xor opt M3.
|
|
681
|
+
* 3. **Anti-sızıntı** (K3, direct entry'lerde): silo WHERE brand_id != :b
|
|
682
|
+
* == 0. fk-only tablolarda brand_id yok → bu gate'i atla (parent
|
|
683
|
+
* predicate zaten sızıntıyı engelliyor).
|
|
684
|
+
*
|
|
685
|
+
* Herhangi biri FAIL → THROW (Step 8 atomic flip ÇALIŞMAZ; INV-1 koruması).
|
|
686
|
+
* Pool intakt (INV-2); silo forensic için kalır (operator inceleyip
|
|
687
|
+
* Faz 6.2 rollback ile temiz geri alabilir veya manuel fix).
|
|
688
|
+
*/
|
|
689
|
+
export async function doVerify(ctx, brand, manifest, references, pool, silo) {
|
|
690
|
+
let mismatches = 0;
|
|
691
|
+
let leakage = 0;
|
|
692
|
+
let checksumsSkipped = 0;
|
|
693
|
+
let orphans = 0;
|
|
694
|
+
for (const t of manifest) {
|
|
695
|
+
const label = `${t.schema}.${t.table}`;
|
|
696
|
+
// Gate 1: count eşitliği.
|
|
697
|
+
const poolCount = await countWithPredicate(ctx, pool.db_name, t);
|
|
698
|
+
const siloCount = await countWhere(ctx, silo.db_name, t.schema, t.table, '1=1');
|
|
699
|
+
if (poolCount !== siloCount) {
|
|
700
|
+
log.error(` [count] ${label} — pool_predicate=${poolCount} silo=${siloCount} MISMATCH`);
|
|
701
|
+
mismatches++;
|
|
702
|
+
continue; // checksum/leak fail-fast, atla
|
|
703
|
+
}
|
|
704
|
+
log.info(` [count] ${label} — ${poolCount} ✓`);
|
|
705
|
+
// Gate 2: checksum (PK varsa).
|
|
706
|
+
const pkCols = await fetchPrimaryKeyColumns(ctx, pool.db_name, t.schema, t.table);
|
|
707
|
+
if (pkCols.length === 0) {
|
|
708
|
+
log.warn(` [chk ] ${label} — no PK; checksum SKIPPED (count-only)`);
|
|
709
|
+
checksumsSkipped++;
|
|
710
|
+
}
|
|
711
|
+
else {
|
|
712
|
+
const orderBy = pkCols.map(quoteIdent).join(', ');
|
|
713
|
+
const poolHash = await tableChecksum(ctx, pool.db_name, t.schema, t.table, t.predicate, orderBy);
|
|
714
|
+
const siloHash = await tableChecksum(ctx, silo.db_name, t.schema, t.table, '1=1', orderBy);
|
|
715
|
+
if (poolHash !== siloHash) {
|
|
716
|
+
log.error(` [chk ] ${label} — pool_md5=${poolHash} silo_md5=${siloHash} MISMATCH`);
|
|
717
|
+
mismatches++;
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
log.info(` [chk ] ${label} — md5=${poolHash} ✓`);
|
|
721
|
+
}
|
|
722
|
+
// Gate 3: anti-sızıntı (sadece direct — brand_id kolonu olan tablolar).
|
|
723
|
+
if (t.via === 'direct') {
|
|
724
|
+
const leak = await countWhere(ctx, silo.db_name, t.schema, t.table, `brand_id != '${sqlLit(brand.id)}'`);
|
|
725
|
+
if (leak > 0) {
|
|
726
|
+
log.error(` [leak ] ${label} — silo has ${leak} row(s) with brand_id != ${brand.id} (K3 ihlali)`);
|
|
727
|
+
leakage += leak;
|
|
728
|
+
}
|
|
729
|
+
else {
|
|
730
|
+
log.info(` [leak ] ${label} — anti-leakage 0 ✓`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
// Gate 4 (INV-4): FK orphan check — referential integrity post-migration.
|
|
735
|
+
// Brand-scoped manifest'in tüm FK'leri ve reference tablolarını referans
|
|
736
|
+
// edenler için silo'da dangling = 0 olmalı. Dangling > 0 → cutover YOK.
|
|
737
|
+
if (manifest.length > 0) {
|
|
738
|
+
const orphanCount = await checkFkOrphans(ctx, silo.db_name, manifest);
|
|
739
|
+
orphans = orphanCount;
|
|
740
|
+
if (orphanCount > 0) {
|
|
741
|
+
log.error(`[verify] FK-orphan check: ${orphanCount} dangling row(s) in silo (INV-4 ihlali)`);
|
|
742
|
+
}
|
|
743
|
+
else {
|
|
744
|
+
log.info(`[verify] FK-orphan check: 0 dangling rows ✓`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
log.info(`[verify] gate result: brand_tables=${manifest.length} refs=${references.length} ` +
|
|
748
|
+
`mismatches=${mismatches} leakage=${leakage} ` +
|
|
749
|
+
`checksums_skipped=${checksumsSkipped} fk_orphans=${orphans}`);
|
|
750
|
+
if (mismatches > 0 || leakage > 0 || orphans > 0) {
|
|
751
|
+
throw new Error(`INV-1 cutover gate FAIL — mismatches=${mismatches} leakage=${leakage} orphans=${orphans}; ` +
|
|
752
|
+
`pool intact (INV-2); silo retained for forensic`);
|
|
753
|
+
}
|
|
754
|
+
return { tables: manifest.length, mismatches, leakage, checksumsSkipped, orphans };
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Silo'da FK dangling sayısı (INV-4).
|
|
758
|
+
*
|
|
759
|
+
* Akış: brand-scoped tabloların TÜM single-column FK'leri için
|
|
760
|
+
* SELECT count(*) FROM <child> WHERE <fk_col> IS NOT NULL
|
|
761
|
+
* AND NOT EXISTS (SELECT 1 FROM <parent> WHERE <pk> = <child>.<fk_col>)
|
|
762
|
+
* Reference tablolarını da kapsar (zaten silo'da olmaları gerek; eğer
|
|
763
|
+
* eksiksizse orphan = 0). Parent'ın silo'da varlığı şart değil — eğer
|
|
764
|
+
* yoksa NOT EXISTS true döner ve orphan artar.
|
|
765
|
+
*
|
|
766
|
+
* Composite FK'lar Step 2'de zaten ilk kolonla logged-warning + indirgendi;
|
|
767
|
+
* bu FK orphan check de ilk kolonu kontrol eder.
|
|
768
|
+
*/
|
|
769
|
+
export async function checkFkOrphans(ctx, dbName, manifest) {
|
|
770
|
+
// Manifest'teki tabloların FK out-edge'lerini topla.
|
|
771
|
+
const manifestKeys = new Set(manifest.map(t => `${t.schema}.${t.table}`));
|
|
772
|
+
const fkRows = await dbSelectJson(ctx.cfg, dbName, `SELECT
|
|
773
|
+
nc.nspname AS child_schema,
|
|
774
|
+
cc.relname AS child_table,
|
|
775
|
+
ca.attname AS child_column,
|
|
776
|
+
np.nspname AS parent_schema,
|
|
777
|
+
pc.relname AS parent_table,
|
|
778
|
+
pa.attname AS parent_column,
|
|
779
|
+
array_length(c.conkey, 1) AS col_count
|
|
780
|
+
FROM pg_constraint c
|
|
781
|
+
JOIN pg_class cc ON cc.oid = c.conrelid
|
|
782
|
+
JOIN pg_namespace nc ON nc.oid = cc.relnamespace
|
|
783
|
+
JOIN pg_class pc ON pc.oid = c.confrelid
|
|
784
|
+
JOIN pg_namespace np ON np.oid = pc.relnamespace
|
|
785
|
+
JOIN pg_attribute ca ON ca.attrelid = c.conrelid AND ca.attnum = c.conkey[1]
|
|
786
|
+
JOIN pg_attribute pa ON pa.attrelid = c.confrelid AND pa.attnum = c.confkey[1]
|
|
787
|
+
WHERE c.contype = 'f' AND nc.nspname = 'public'`);
|
|
788
|
+
let totalOrphans = 0;
|
|
789
|
+
for (const fk of fkRows) {
|
|
790
|
+
const childKey = `${fk.child_schema}.${fk.child_table}`;
|
|
791
|
+
if (!manifestKeys.has(childKey))
|
|
792
|
+
continue; // sadece brand-scoped tabloların FK'leri
|
|
793
|
+
const qChild = quoteQualified(childKey);
|
|
794
|
+
const qParent = quoteQualified(`${fk.parent_schema}.${fk.parent_table}`);
|
|
795
|
+
const qFk = quoteIdent(fk.child_column);
|
|
796
|
+
const qPk = quoteIdent(fk.parent_column);
|
|
797
|
+
const sql = `SELECT count(*)::text AS n FROM ${qChild} c ` +
|
|
798
|
+
`WHERE c.${qFk} IS NOT NULL ` +
|
|
799
|
+
`AND NOT EXISTS (SELECT 1 FROM ${qParent} p WHERE p.${qPk} = c.${qFk})`;
|
|
800
|
+
const rows = await dbSelectJson(ctx.cfg, dbName, sql);
|
|
801
|
+
const orphans = rows.length ? Number.parseInt(rows[0].n, 10) : 0;
|
|
802
|
+
if (orphans > 0) {
|
|
803
|
+
log.error(` [fk ] ${childKey}.${fk.child_column} → ${fk.parent_schema}.${fk.parent_table}.${fk.parent_column}: ` +
|
|
804
|
+
`${orphans} orphan(s) (INV-4)`);
|
|
805
|
+
totalOrphans += orphans;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return totalOrphans;
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Tabloda WHERE-filtreli md5 checksum hesapla.
|
|
812
|
+
* md5(coalesce(string_agg(md5(t::text), '' ORDER BY <pk>), ''))
|
|
813
|
+
* Sıralama: PK kolonları (deterministik). Empty result → empty-string md5
|
|
814
|
+
* (constant); pool == silo eşitliği boş tabloda da tutar.
|
|
815
|
+
*/
|
|
816
|
+
export async function tableChecksum(ctx, dbName, schema, table, where, orderBy) {
|
|
817
|
+
const q = quoteQualified(`${schema}.${table}`);
|
|
818
|
+
const sql = `SELECT md5(coalesce(string_agg(md5(t.*::text), '' ORDER BY ${orderBy}), '')) AS h ` +
|
|
819
|
+
`FROM ${q} t WHERE ${where}`;
|
|
820
|
+
const rows = await dbSelectJson(ctx.cfg, dbName, sql);
|
|
821
|
+
return rows.length ? rows[0].h : '';
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Tablonun PK kolonlarını sırayla döndür. PK yoksa boş array.
|
|
825
|
+
* pg_index.indisprimary + pg_attribute join.
|
|
826
|
+
*/
|
|
827
|
+
export async function fetchPrimaryKeyColumns(ctx, dbName, schema, table) {
|
|
828
|
+
const qSchema = sqlLit(schema);
|
|
829
|
+
const qTable = sqlLit(table);
|
|
830
|
+
const rows = await dbSelectJson(ctx.cfg, dbName, `SELECT a.attname
|
|
831
|
+
FROM pg_index i
|
|
832
|
+
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
|
833
|
+
JOIN pg_class c ON c.oid = i.indrelid
|
|
834
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
835
|
+
WHERE i.indisprimary AND n.nspname = '${qSchema}' AND c.relname = '${qTable}'
|
|
836
|
+
ORDER BY array_position(i.indkey, a.attnum)`);
|
|
837
|
+
return rows.map(r => r.attname);
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Step 8 — REGISTRY FLIP (atomic single-txn).
|
|
841
|
+
*
|
|
842
|
+
* Insert silo into cp_databases AND update cp_brands tier+data_db_name
|
|
843
|
+
* in ONE transaction. INV-2: pool data UNTOUCHED.
|
|
844
|
+
*/
|
|
845
|
+
export async function doRegistryFlip(ctx, brand, silo) {
|
|
846
|
+
const sql = `BEGIN;\n` +
|
|
847
|
+
`INSERT INTO cp_databases (owner_type, owner_id, db_kind, cluster_name, db_host, db_port, db_name, db_user, status, db_tier, placement_region)\n` +
|
|
848
|
+
`SELECT 'brand', '${brand.id}', 'dedicated', 'shared-1', '${sqlLit(silo.db_host)}', ${silo.db_port},\n` +
|
|
849
|
+
` '${sqlLit(silo.db_name)}', '${sqlLit(silo.db_user)}', 'active', 'silo', ${brand.placement_region ? `'${sqlLit(brand.placement_region)}'` : 'NULL'}\n` +
|
|
850
|
+
` WHERE NOT EXISTS (\n` +
|
|
851
|
+
` SELECT 1 FROM cp_databases WHERE db_name='${sqlLit(silo.db_name)}' AND owner_type='brand' AND owner_id='${brand.id}'\n` +
|
|
852
|
+
` );\n` +
|
|
853
|
+
`UPDATE cp_brands SET db_tier='silo', data_db_name='${sqlLit(silo.db_name)}', status='active', updated_at=now()\n` +
|
|
854
|
+
` WHERE id='${brand.id}' AND db_tier='pool';\n` +
|
|
855
|
+
`COMMIT;\n`;
|
|
856
|
+
const res = await dbExec(ctx.cfg, CONTROL_DB, sql);
|
|
857
|
+
if (!res.success)
|
|
858
|
+
throw new Error(`registry flip failed: ${res.stderr.trim()}`);
|
|
859
|
+
return { silo_id: silo.db_name, tier: 'silo' };
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Step 9 — CACHE INVALIDATE + AUDIT END.
|
|
863
|
+
*
|
|
864
|
+
* In-process cache invalidate (TenantDbResolver::invalidate) PHP-side; CLI
|
|
865
|
+
* burada yalnız cp_audit_log'a tamamlanma kaydı düşer + finished_at güncel.
|
|
866
|
+
* Multi-instance Redis pub/sub M3 perf milestone.
|
|
867
|
+
*/
|
|
868
|
+
export async function doCacheInvalidate(ctx, brand) {
|
|
869
|
+
await dbExec(ctx.cfg, CONTROL_DB, `INSERT INTO cp_audit_log (actor, action, target, after)
|
|
870
|
+
VALUES ('cli:promote-silo', 'promote_silo_completed', 'brand:${brand.id}',
|
|
871
|
+
'${sqlLit(JSON.stringify({ brand: ctx.brand, retention_days: RETENTION_DAYS_DEFAULT }))}'::jsonb)`);
|
|
872
|
+
return { logged: true };
|
|
873
|
+
}
|
|
874
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
875
|
+
// Yardımcılar
|
|
876
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
877
|
+
export async function fetchPoolDb(ctx) {
|
|
878
|
+
const rows = await dbSelectJson(ctx.cfg, CONTROL_DB, `SELECT db_host, db_port, db_name, db_user
|
|
879
|
+
FROM cp_databases
|
|
880
|
+
WHERE owner_type='core' AND db_tier='pool' AND status='active'
|
|
881
|
+
LIMIT 1`);
|
|
882
|
+
if (rows.length === 0)
|
|
883
|
+
throw new Error('no active pool DB in cp_databases (owner=core, tier=pool)');
|
|
884
|
+
return rows[0];
|
|
885
|
+
}
|
|
886
|
+
export async function persistSteps(ctx, status, err) {
|
|
887
|
+
if (!ctx.jobId)
|
|
888
|
+
return; // ad-hoc run: skip; CI mode jobId zorunlu
|
|
889
|
+
const errSql = err === null ? 'NULL' : `'${sqlLit(err)}'`;
|
|
890
|
+
const stepsJson = JSON.stringify(ctx.steps);
|
|
891
|
+
await dbExec(ctx.cfg, CONTROL_DB, `UPDATE cp_provisioning_jobs SET status='${status}', steps='${sqlLit(stepsJson)}'::jsonb, error=${errSql}
|
|
892
|
+
WHERE id='${ctx.jobId}'`);
|
|
893
|
+
}
|
|
894
|
+
export function currentStepIndex(ctx) {
|
|
895
|
+
for (let i = 0; i < ctx.steps.length; i++) {
|
|
896
|
+
if (ctx.steps[i].status === 'running' || ctx.steps[i].status === 'failed')
|
|
897
|
+
return i;
|
|
898
|
+
}
|
|
899
|
+
return ctx.steps.findIndex(s => s.status === 'pending');
|
|
900
|
+
}
|
|
901
|
+
export function blankSteps(n) {
|
|
902
|
+
return Array.from({ length: n }, () => ({ status: 'pending' }));
|
|
903
|
+
}
|
|
904
|
+
export function shortHash(s) {
|
|
905
|
+
// Basit deterministik 6-char ID — collision riski apply yüzeyinde küçük.
|
|
906
|
+
let h = 0;
|
|
907
|
+
for (const ch of s)
|
|
908
|
+
h = ((h << 5) - h + ch.charCodeAt(0)) | 0;
|
|
909
|
+
return Math.abs(h).toString(16).padStart(6, '0').slice(0, 6);
|
|
910
|
+
}
|
|
911
|
+
export function sanitizeForJson(value) {
|
|
912
|
+
if (value === null || typeof value !== 'object')
|
|
913
|
+
return { value };
|
|
914
|
+
return value;
|
|
915
|
+
}
|
|
916
|
+
export function resolveHost(alias) {
|
|
917
|
+
if (alias === undefined) {
|
|
918
|
+
log.error('--host is required (cicore | mkt).');
|
|
919
|
+
process.exit(1);
|
|
920
|
+
}
|
|
921
|
+
try {
|
|
922
|
+
return getHostConfig(alias);
|
|
923
|
+
}
|
|
924
|
+
catch (err) {
|
|
925
|
+
log.error(err instanceof Error ? err.message : String(err));
|
|
926
|
+
process.exit(1);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
export function sqlLit(s) { return s.replace(/'/g, "''"); }
|
|
930
|
+
//# sourceMappingURL=promote-silo.js.map
|