vibecarbon 0.9.0 → 0.11.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.
Files changed (88) hide show
  1. package/LICENSE +110 -663
  2. package/README.md +4 -4
  3. package/carbon/.env.example +11 -0
  4. package/carbon/backup/compose-backup.sh +8 -0
  5. package/carbon/docker-compose.yml +11 -0
  6. package/carbon/ha/primary-init.sql +10 -2
  7. package/carbon/k8s/base/backup/cronjob.yaml +9 -0
  8. package/carbon/k8s/base/repl-gateway/kustomization.yaml +11 -0
  9. package/carbon/k8s/base/repl-gateway/repl-gateway.yaml +92 -0
  10. package/carbon/k8s/values/supabase.values.yaml +6 -1
  11. package/carbon/src/client/lib/supabase.ts +41 -0
  12. package/carbon/src/client/locales/de.json +27 -12
  13. package/carbon/src/client/locales/en.json +30 -15
  14. package/carbon/src/client/locales/es.json +27 -12
  15. package/carbon/src/client/locales/fr.json +27 -12
  16. package/carbon/src/client/locales/pt.json +27 -12
  17. package/carbon/src/client/pages/Checkout.tsx +1 -1
  18. package/carbon/src/server/billing/providers/paddle.ts +21 -2
  19. package/carbon/src/server/billing/providers/polar.ts +14 -0
  20. package/carbon/src/server/index.ts +12 -9
  21. package/carbon/src/server/lib/client-ip.ts +68 -0
  22. package/carbon/src/server/lib/env.ts +20 -6
  23. package/carbon/src/server/lib/rate-limiter.ts +11 -65
  24. package/carbon/src/server/lib/supabase.ts +10 -4
  25. package/carbon/src/server/middleware/requireOrgRole.ts +76 -0
  26. package/carbon/src/server/middleware/requirePlan.ts +64 -34
  27. package/carbon/src/server/middleware/requireSuperAdmin.ts +26 -0
  28. package/carbon/src/server/routes/v1/admin/contact.ts +5 -20
  29. package/carbon/src/server/routes/v1/admin/jobs.ts +6 -20
  30. package/carbon/src/server/routes/v1/admin/newsletter.ts +26 -23
  31. package/carbon/src/server/routes/v1/auth.ts +5 -2
  32. package/carbon/src/server/routes/v1/billing.ts +24 -64
  33. package/carbon/src/server/routes/v1/index.ts +8 -1
  34. package/carbon/src/server/routes/v1/newsletter.ts +18 -12
  35. package/carbon/src/server/routes/v1/theme.ts +36 -15
  36. package/carbon/supabase/migrations/00003_pg_cron.sql +2 -2
  37. package/carbon/supabase/migrations/00004_contact_newsletter.sql +24 -14
  38. package/carbon/tests/component/supabase-env-guard.test.ts +54 -0
  39. package/carbon/tests/integration/server/middleware/rate-limit-ip-spoof.test.ts +48 -0
  40. package/carbon/tests/integration/server/middleware/require-plan.test.ts +101 -0
  41. package/carbon/tests/integration/server/routes/admin-newsletter.test.ts +93 -0
  42. package/carbon/tests/integration/server/routes/billing-subscription-idor.test.ts +92 -0
  43. package/carbon/tests/integration/server/routes/newsletter-unsubscribe.test.ts +65 -0
  44. package/carbon/tests/integration/server/routes/theme-validation.test.ts +66 -0
  45. package/carbon/tests/unit/server/client-ip.test.ts +76 -0
  46. package/carbon/tests/unit/server/env-bool.test.ts +63 -0
  47. package/carbon/tests/unit/server/paddle-webhook.test.ts +57 -0
  48. package/carbon/tests/unit/server/polar-webhook.test.ts +63 -0
  49. package/carbon/tests/unit/server/supabase-client.test.ts +30 -0
  50. package/carbon/volumes/db/wal-archive.sh +36 -0
  51. package/package.json +2 -2
  52. package/src/backup.js +0 -2
  53. package/src/cli.js +16 -0
  54. package/src/deploy.js +16 -5
  55. package/src/destroy.js +968 -886
  56. package/src/failover.js +118 -237
  57. package/src/lib/command.js +19 -1
  58. package/src/lib/deploy/compose/ha.js +180 -102
  59. package/src/lib/deploy/compose/index.js +106 -57
  60. package/src/lib/deploy/compose/single.js +421 -0
  61. package/src/lib/deploy/image.js +38 -30
  62. package/src/lib/deploy/k8s/ha/index.js +397 -150
  63. package/src/lib/deploy/k8s/k3s.js +411 -279
  64. package/src/lib/deploy/orchestrator.js +274 -453
  65. package/src/lib/deploy/remote-build.js +8 -1
  66. package/src/lib/deploy/replication.js +540 -0
  67. package/src/lib/deploy/state.js +62 -5
  68. package/src/lib/deploy/tier-registry.js +30 -0
  69. package/src/lib/deploy/utils.js +30 -2
  70. package/src/lib/deploy/wireguard.js +146 -0
  71. package/src/lib/host-keys.js +120 -5
  72. package/src/lib/iac/index.js +57 -23
  73. package/src/lib/licensing/gate.js +59 -0
  74. package/src/lib/licensing/index.js +1 -1
  75. package/src/lib/licensing/tiers.js +6 -5
  76. package/src/lib/prod-confirm.js +65 -0
  77. package/src/lib/providers/hetzner-s3.js +85 -0
  78. package/src/lib/retry.js +59 -0
  79. package/src/lib/scale-plan.js +159 -0
  80. package/src/lib/ssh.js +35 -35
  81. package/src/restore.js +102 -14
  82. package/src/scale.js +105 -133
  83. package/src/status.js +122 -0
  84. package/src/upgrade.js +0 -4
  85. package/carbon/ha/activate-standby.sh +0 -52
  86. package/carbon/ha/standby-init.sh +0 -74
  87. package/carbon/src/server/lib/request.ts +0 -34
  88. package/src/lib/pod-backups.js +0 -53
@@ -4,7 +4,6 @@
4
4
  */
5
5
 
6
6
  import dns from 'node:dns';
7
- import { readFileSync } from 'node:fs';
8
7
  import { join } from 'node:path';
9
8
  import * as p from '@clack/prompts';
10
9
  import { c } from '../colors.js';
@@ -15,16 +14,13 @@ import {
15
14
  registerProject,
16
15
  saveProjectConfig,
17
16
  } from '../config.js';
18
- import { waitForDNSPropagation } from '../dns-propagation.js';
19
17
  import { ensureOperatorIpAccess } from '../operator-ip.js';
20
18
  import { perfAsync, perfTimer } from '../perf.js';
21
19
  import { createTracker } from '../tracker.js';
22
20
  import { useDnsChallenge } from './acme.js';
23
21
  import { renderBundle } from './bundle.js';
24
- import { sideloadCompose } from './image.js';
25
- import { buildRemote } from './remote-build.js';
26
22
  import { StateTracker } from './state.js';
27
- import { generateSSHKeyPair } from './utils.js';
23
+ import { isComposeTier, isHATier, isK8sTier, resolveTier } from './tier-registry.js';
28
24
 
29
25
  /**
30
26
  * Probe the public /api/health endpoint to verify the app is reachable
@@ -194,8 +190,7 @@ export async function executeDeployment(args, gatheredConfig) {
194
190
  } = gatheredConfig;
195
191
 
196
192
  const { deployMode } = config;
197
- const isComposeDeploy = deployMode === 'compose' || deployMode === 'compose-ha';
198
- const isHA = deployMode === 'compose-ha' || !!config.ha;
193
+ const tier = resolveTier({ deployMode, ha: config.ha });
199
194
 
200
195
  // Operator-IP access: detect + persist + (when env already deployed) patch
201
196
  // the live Hetzner firewall. On a fresh first deploy this just appends to
@@ -205,7 +200,7 @@ export async function executeDeployment(args, gatheredConfig) {
205
200
  const accessResult = await ensureOperatorIpAccess({
206
201
  projectConfig,
207
202
  environment,
208
- isHA,
203
+ isHA: isHATier(tier),
209
204
  apiToken,
210
205
  yes: !!args.yes,
211
206
  onMessage: (msg) => p.log.info(msg),
@@ -223,16 +218,45 @@ export async function executeDeployment(args, gatheredConfig) {
223
218
  }
224
219
 
225
220
  const s3Spinner = p.spinner();
226
- const { HetznerS3Provider } = await import('../providers/hetzner-s3.js');
221
+ const { HetznerS3Provider, deriveStateBucketName } = await import('../providers/hetzner-s3.js');
227
222
  const s3Provider = new HetznerS3Provider(s3Config.accessKey, s3Config.secretKey, s3Config.region);
228
223
 
224
+ // Dedicated Pulumi-state bucket, derived once from the app storage bucket.
225
+ // Pulumi state used to live in the app storage bucket, which meant `destroy`
226
+ // deleting that bucket could yank the state backend mid-destroy. State now
227
+ // lives here instead. Both HA stacks share this ONE bucket.
228
+ const stateBucket = deriveStateBucketName(s3Config.bucket);
229
+
229
230
  // --- STEP 1: S3 Setup (Idempotent) ---
231
+ // `stateBucket` is part of the inputs hash so an upgraded CLI (whose prior
232
+ // deploy-state predates the dedicated state bucket) re-runs this step once to
233
+ // create + migrate the state bucket. Combined with the StateTracker version
234
+ // bump, no stale hash can skip the migration.
230
235
  const s3Inputs = {
231
236
  bucket: s3Config.bucket,
232
237
  backupBucket: backupS3Config.bucket,
238
+ stateBucket,
233
239
  region: s3Config.region,
234
240
  };
235
- if (!state.shouldSkip('s3-setup', s3Inputs)) {
241
+ // Verify hook: even on a matching hash, re-run s3-setup if any of the buckets
242
+ // were deleted out-of-band (e.g. by a prior `destroy`). Without this, a
243
+ // resumed deploy skips bucket creation then fails downstream on NoSuchBucket
244
+ // (the exact resume hazard called out in Finding 2). Other steps that would
245
+ // benefit from a similar probe — `compose-setup-server` (server may be gone)
246
+ // and `dns-setup` — are left on the plain hash check to keep this change
247
+ // focused; s3-setup is the highest-value one because the state backend lives
248
+ // here.
249
+ const verifyBucketsExist = async () => {
250
+ const [app, backup, stateB] = await Promise.all([
251
+ s3Provider.bucketExists(s3Config.bucket),
252
+ backupS3Config?.bucket
253
+ ? s3Provider.bucketExists(backupS3Config.bucket)
254
+ : Promise.resolve(true),
255
+ s3Provider.bucketExists(stateBucket),
256
+ ]);
257
+ return app && backup && stateB;
258
+ };
259
+ if (!(await state.shouldSkipWithVerify('s3-setup', s3Inputs, verifyBucketsExist))) {
236
260
  state.startStep('s3-setup', s3Inputs);
237
261
  try {
238
262
  s3Spinner.start(`Creating S3 bucket: ${s3Config.bucket}`);
@@ -245,7 +269,22 @@ export async function executeDeployment(args, gatheredConfig) {
245
269
 
246
270
  try {
247
271
  s3Spinner.start('Configuring bucket CORS');
248
- const corsOrigins = domain ? [`https://${domain}`] : ['*'];
272
+ // SECURITY: never fall back to a `*` wildcard — that would let any
273
+ // origin issue credentialed browser requests against the storage
274
+ // bucket. When a domain is configured we scope to its https origins
275
+ // (apex + api/app subdomains the app actually serves from); a
276
+ // domainless deploy has no browser origin to trust yet, so we scope
277
+ // to localhost dev origins and warn. The operator re-runs deploy once
278
+ // a domain is set to widen CORS to the real origin.
279
+ let corsOrigins;
280
+ if (domain) {
281
+ corsOrigins = [`https://${domain}`, `https://api.${domain}`, `https://app.${domain}`];
282
+ } else {
283
+ corsOrigins = ['http://localhost:3000', 'http://localhost:5173'];
284
+ p.log.warn(
285
+ 'No domain configured — scoping bucket CORS to localhost dev origins instead of `*`. Re-run deploy with a domain to allow your production origin.',
286
+ );
287
+ }
249
288
  await s3Provider.configureCORS(s3Config.bucket, corsOrigins);
250
289
  s3Spinner.stop('CORS configured');
251
290
  } catch {
@@ -264,7 +303,98 @@ export async function executeDeployment(args, gatheredConfig) {
264
303
  s3Spinner.stop(`Backup bucket creation failed: ${backupBucketError.message}`);
265
304
  }
266
305
 
267
- const result = { region: s3Provider.region, endpoint: s3Provider.getEndpoint() };
306
+ // Dedicated Pulumi-state bucket. Created imperatively (like the app +
307
+ // backup buckets) BEFORE any Pulumi stack op / initBackend. NOT
308
+ // web-facing: no CORS, not public.
309
+ s3Spinner.start(`Creating Pulumi state bucket: ${stateBucket}`);
310
+ const stateBucketResult = await s3Provider.createBucket(stateBucket);
311
+ s3Spinner.stop(
312
+ stateBucketResult.created
313
+ ? `Pulumi state bucket created: ${stateBucket}`
314
+ : `Pulumi state bucket exists: ${stateBucket}`,
315
+ );
316
+
317
+ // SECURITY/SAFETY: migrate legacy Pulumi state out of the app storage
318
+ // bucket. If the state bucket has no `.pulumi/` objects but the OLD app
319
+ // bucket does, this is an env deployed before the dedicated state bucket
320
+ // existed — copy the state prefix over so we resolve to real state, not
321
+ // empty state. Re-initializing an EMPTY state backend against live infra
322
+ // would make Pulumi believe nothing exists and either duplicate or orphan
323
+ // the running servers. So if the copy fails, we DO NOT proceed silently:
324
+ // abort loudly and tell the operator to migrate manually or
325
+ // destroy+redeploy. Fresh deploys hit neither branch (both buckets empty).
326
+ const PULUMI_STATE_PREFIX = '.pulumi/';
327
+ // Determine whether there is GENUINE legacy state to migrate. The check
328
+ // lists the just-created state bucket, which can race S3 list-after-create
329
+ // consistency and throw — that is NOT the orphan-risk case (a fresh deploy
330
+ // has no legacy state), so a check failure must NOT hard-abort. Only a
331
+ // CONFIRMED legacy-state copy failure aborts (below). Retry the checks a
332
+ // few times so a transient blip doesn't misfire either way.
333
+ let legacyState = false;
334
+ let checkOk = false;
335
+ for (let attempt = 1; attempt <= 3; attempt++) {
336
+ try {
337
+ const stateSeeded = await s3Provider.hasObjectsWithPrefix(
338
+ stateBucket,
339
+ PULUMI_STATE_PREFIX,
340
+ );
341
+ legacyState = stateSeeded
342
+ ? false
343
+ : await s3Provider.hasObjectsWithPrefix(s3Config.bucket, PULUMI_STATE_PREFIX);
344
+ checkOk = true;
345
+ break;
346
+ } catch (checkError) {
347
+ if (attempt === 3) {
348
+ // Couldn't determine migration need after retries (e.g. list-after-
349
+ // create consistency on the fresh state bucket). A fresh deploy — the
350
+ // overwhelming common case — has nothing to migrate, so proceed with
351
+ // a loud warning rather than blocking every deploy on a transient S3
352
+ // blip. (Pulumi then uses the dedicated state bucket normally.)
353
+ p.log.warn(
354
+ `Could not verify legacy Pulumi state after ${attempt} attempts — proceeding ` +
355
+ `(a fresh deploy has none to migrate): ${checkError.message}`,
356
+ );
357
+ } else {
358
+ await new Promise((r) => setTimeout(r, 1500 * attempt));
359
+ }
360
+ }
361
+ }
362
+ if (checkOk && legacyState) {
363
+ // CONFIRMED legacy state in the app bucket — migrate it. If THIS copy
364
+ // fails we DO NOT proceed silently: re-initializing an EMPTY state
365
+ // backend against live infra would orphan the running servers.
366
+ try {
367
+ s3Spinner.start(`Migrating Pulumi state ${s3Config.bucket} → ${stateBucket}`);
368
+ const { copied } = await s3Provider.copyPrefix(
369
+ s3Config.bucket,
370
+ stateBucket,
371
+ PULUMI_STATE_PREFIX,
372
+ );
373
+ s3Spinner.stop(`Migrated ${copied} Pulumi state object(s) to ${stateBucket}`);
374
+ } catch (copyError) {
375
+ s3Spinner.stop(`Pulumi state migration FAILED: ${copyError.message}`);
376
+ p.log.error(
377
+ [
378
+ 'Could not migrate existing Pulumi state from the app storage bucket to the',
379
+ `dedicated state bucket (${stateBucket}). Proceeding would re-initialize an`,
380
+ 'EMPTY state backend against your live infrastructure, which would orphan the',
381
+ 'running servers (Pulumi would no longer track them).',
382
+ '',
383
+ 'Aborting to protect your infrastructure. To recover, either:',
384
+ ` • Copy the ".pulumi/" prefix from ${s3Config.bucket} to ${stateBucket} manually`,
385
+ ' (e.g. via `aws s3 sync ... --endpoint-url <endpoint>`), then re-run deploy; or',
386
+ ' • Destroy this environment and redeploy from scratch.',
387
+ ].join('\n'),
388
+ );
389
+ process.exit(1);
390
+ }
391
+ }
392
+
393
+ const result = {
394
+ region: s3Provider.region,
395
+ endpoint: s3Provider.getEndpoint(),
396
+ stateBucket,
397
+ };
268
398
  state.completeStep('s3-setup', result);
269
399
  } catch (error) {
270
400
  const errMsg =
@@ -285,6 +415,11 @@ export async function executeDeployment(args, gatheredConfig) {
285
415
  backupS3Config.region = s3Config.region;
286
416
  backupS3Config.endpoint = s3Config.endpoint;
287
417
  }
418
+ // Always resolve stateBucket (deterministic derivation) so the Pulumi
419
+ // backend targets the dedicated bucket, whether s3-setup ran or was skipped.
420
+ // Every downstream iac call reads it via s3Config, and it's persisted into
421
+ // .vibecarbon.json so scale / failover / destroy resolve the same backend.
422
+ s3Config.stateBucket = s3Result?.stateBucket || stateBucket;
288
423
 
289
424
  // SKELETON SAVE: persist the minimum env entry that `vibecarbon destroy`
290
425
  // needs to recover, BEFORE any `pulumi up` runs. Without this, a crash
@@ -315,7 +450,12 @@ export async function executeDeployment(args, gatheredConfig) {
315
450
  ...(secondaryRegion ? { secondaryRegion } : {}),
316
451
  ...(domain ? { domain } : {}),
317
452
  ...(dnsProvider ? { dnsProvider } : {}),
318
- s3: { bucket: s3Config.bucket, region: s3Config.region, endpoint: s3Config.endpoint },
453
+ s3: {
454
+ bucket: s3Config.bucket,
455
+ region: s3Config.region,
456
+ endpoint: s3Config.endpoint,
457
+ stateBucket: s3Config.stateBucket,
458
+ },
319
459
  backupS3: backupS3Config?.bucket
320
460
  ? {
321
461
  bucket: backupS3Config.bucket,
@@ -359,8 +499,7 @@ export async function executeDeployment(args, gatheredConfig) {
359
499
  const imageResolveTimer = perfTimer('deploy.image.resolve');
360
500
  const buildMode = resolveBuildMode(args, process.cwd(), deployMode);
361
501
  const isDirectDeploy = buildMode === 'direct';
362
- const isComposeMode = deployMode === 'compose' || deployMode === 'compose-ha';
363
- const isComposeLocal = buildMode === 'local' && isComposeMode;
502
+ const isComposeLocal = buildMode === 'local' && isComposeTier(tier);
364
503
  let imageReadyPromise;
365
504
  // For compose-local: kicked off here in parallel with iac.upStack below.
366
505
  // The build is awaited later (just before sideload). Other modes leave
@@ -474,7 +613,7 @@ export async function executeDeployment(args, gatheredConfig) {
474
613
  // DNS-propagation poll or the Traefik cert self-heal below. `manual` keeps
475
614
  // HTTP-01. Scoped to compose: k8s issues certs via cert-manager and ignores
476
615
  // this Traefik override entirely.
477
- const dnsChallenge = isComposeDeploy && useDnsChallenge(dnsProvider);
616
+ const dnsChallenge = isComposeTier(tier) && useDnsChallenge(dnsProvider);
478
617
  const bundlePath = renderBundle(projectConfig.projectName, {
479
618
  domain,
480
619
  image: imageRef,
@@ -524,9 +663,79 @@ export async function executeDeployment(args, gatheredConfig) {
524
663
  : Promise.resolve(null);
525
664
 
526
665
  // --- STEP 5: Architecture Dispatch ---
527
- let deployResult;
528
- if (isComposeDeploy) {
529
- if (deployMode === 'compose-ha') {
666
+ // One entry per tier, keyed by the registry. deploymentConfig is a cheap,
667
+ // pure object literal built unconditionally above the table; its k8s-only
668
+ // fields are simply unused on the compose paths.
669
+ const deploymentConfig = {
670
+ projectName: projectConfig.projectName,
671
+ environment: config.environment,
672
+ provider: config.provider,
673
+ region,
674
+ secondaryRegion,
675
+ masterServerType,
676
+ supabaseServerType,
677
+ workerServerType,
678
+ serverType,
679
+ minWorkers,
680
+ maxWorkers,
681
+ domain,
682
+ ha: config.ha,
683
+ observability: config.observability,
684
+ services,
685
+ apiToken,
686
+ cloudflareApiToken,
687
+ cloudflareZoneId,
688
+ hetznerDnsZoneId,
689
+ dnsProvider,
690
+ s3Config,
691
+ backupConfig,
692
+ backupBucketName: backupS3Config?.bucket || null,
693
+ // DR: when set (`deploy -restore latest|<ts>`), the db pod's init
694
+ // container seeds PGDATA from S3 via wal-g and applyMigrations is
695
+ // skipped (the restored DB is authoritative).
696
+ restore: args.restore || null,
697
+ // Finding #1: hard-gate k8s-HA replication unless the operator opts into a
698
+ // warm/degraded standby via `deploy -allow-degraded`.
699
+ allowDegraded: !!args.allowDegraded,
700
+ operatorCidrs: projectConfig.operatorCidrs ?? [],
701
+ imageReadyPromise,
702
+ tracker,
703
+ state,
704
+ };
705
+
706
+ const deployCtx = {
707
+ projectConfig,
708
+ environment,
709
+ envConfig,
710
+ region,
711
+ serverType,
712
+ services,
713
+ domain,
714
+ dnsProvider,
715
+ apiToken,
716
+ cloudflareApiToken,
717
+ cloudflareZoneId,
718
+ hetznerDnsZoneId,
719
+ backupConfig,
720
+ imageRef,
721
+ bundlePath,
722
+ ciReady,
723
+ s3Config,
724
+ state,
725
+ dnsChallenge,
726
+ dnsWarmupPromise,
727
+ isDirectDeploy,
728
+ isComposeLocal,
729
+ localImageTag,
730
+ composeLocalBuildPromise,
731
+ };
732
+
733
+ const TIER_DEPLOYERS = {
734
+ compose: async () => {
735
+ const { deployComposeSingle } = await import('./compose/single.js');
736
+ return deployComposeSingle(deployCtx);
737
+ },
738
+ 'compose-ha': async () => {
530
739
  const { deployComposeHA } = await import('./compose/ha.js');
531
740
  const sshKeyPath = join(process.cwd(), '.vibecarbon', `deploy_key_${environment}`);
532
741
  const s = tracker.spinner();
@@ -534,7 +743,7 @@ export async function executeDeployment(args, gatheredConfig) {
534
743
  // Wrap the entire compose-HA pipeline so the perf trace shows the
535
744
  // headline cost (which contains compose-ha.deploy.* sub-stages from
536
745
  // compose/ha.js).
537
- deployResult = await perfAsync('deploy.ha.compose.full', () =>
746
+ const result = await perfAsync('deploy.ha.compose.full', () =>
538
747
  deployComposeHA({
539
748
  projectName: projectConfig.projectName,
540
749
  environment,
@@ -558,426 +767,29 @@ export async function executeDeployment(args, gatheredConfig) {
558
767
  backupS3Config,
559
768
  backupConfig,
560
769
  bundlePath,
770
+ // Finding #1: hard-gate replication unless the operator opts into a
771
+ // warm/degraded standby via `deploy -allow-degraded`.
772
+ allowDegraded: !!args.allowDegraded,
561
773
  onProgress: (msg) => s.message(msg),
562
774
  }),
563
775
  );
564
776
  s.stop('Compose HA deployment complete');
565
- } else {
566
- const {
567
- setupServer,
568
- dockerLoginOnServer,
569
- startComposeStack,
570
- runMigrations,
571
- createAdminUser,
572
- setupComposeBackupCron,
573
- } = await import('./compose/index.js');
574
- const { setupServerFiles, waitForSSH: waitForComposeSSH } = await import(
575
- './compose/index.js'
576
- );
577
- const sshKeyPath = join(process.cwd(), '.vibecarbon', `deploy_key_${environment}`);
578
-
579
- let serverIp = envConfig.servers?.[0]?.ip || null;
580
- // Track the Hetzner server's API id + literal name so destroy can find
581
- // the VPS without falling back to the literal string "master" (which
582
- // never matches the Pulumi-assigned name `${projectName}-${env}`).
583
- // Without this, destroy → re-deploy hits 'server name is already used
584
- // (uniqueness_error)' because the previous VPS was never deleted
585
- // (caught in compose restore matrix runs).
586
- let serverHetznerId = envConfig.servers?.[0]?.id || null;
587
- let serverHetznerName = envConfig.servers?.[0]?.hetznerName || null;
588
- if (!serverIp) {
589
- generateSSHKeyPair(sshKeyPath);
590
- const { upStack } = await import('../iac/index.js');
591
- const { buildHetznerComposeProgram } = await import('../iac/programs/hetzner-compose.js');
592
- const program = buildHetznerComposeProgram({
593
- projectName: projectConfig.projectName,
594
- environment,
595
- sshPublicKey: readFileSync(`${sshKeyPath}.pub`, 'utf-8').trim(),
596
- location: region,
597
- serverType: serverType || 'cx23',
598
- labels: { 'managed-by': 'vibecarbon' },
599
- allowedSshIps: (projectConfig.operatorCidrs ?? []).map((e) => e.cidr),
600
- });
601
- // VM provisioning via Pulumi — typical 30-90s on Hetzner cx23.
602
- const result = await perfAsync('deploy.iac.upStack', () =>
603
- upStack(environment, program, { hcloudToken: apiToken, s3Config }),
604
- );
605
- serverIp = result.outputs.serverIp;
606
- serverHetznerId = result.outputs.serverId || null;
607
- serverHetznerName = `${projectConfig.projectName}-${environment}`;
608
- await perfAsync('deploy.iac.waitForSSH', () => waitForComposeSSH(serverIp, sshKeyPath, 30));
609
- }
610
-
611
- // --- Warm path (StateTracker) ---
612
- // On a second deploy against an already-provisioned server, skip the
613
- // idempotent-but-slow steps (cloud-init probe, GHCR login, bundle rsync)
614
- // when their inputs haven't changed. `docker compose up -d` is cheap
615
- // and always runs — it applies diffs to the live stack.
616
- const setupInputs = { serverIp };
617
- if (!state.shouldSkip('compose-setup-server', setupInputs)) {
618
- state.startStep('compose-setup-server', setupInputs);
619
- // Cloud-init readiness probe — first-deploy spends 20-40s here while
620
- // ufw + unattended-upgrades land. Warm deploys hit the ready marker
621
- // immediately, so this is mostly cold-deploy noise.
622
- await perfAsync('deploy.compose.cloudInitReady', async () => {
623
- if (isDirectDeploy || isComposeLocal) {
624
- // Both direct (build over DOCKER_HOST=ssh://) and local
625
- // (sideload via docker save | ssh docker load) need dockerd
626
- // up on the server before they can proceed. Push mode doesn't
627
- // because the server pulls from GHCR after compose-up starts.
628
- const { waitForDockerReady } = await import('./compose/index.js');
629
- await setupServer(serverIp, sshKeyPath);
630
- await waitForDockerReady(serverIp, sshKeyPath);
631
- } else {
632
- await setupServer(serverIp, sshKeyPath);
633
- }
634
- });
635
- state.completeStep('compose-setup-server', { serverIp });
636
- }
637
-
638
- // --- Image transfer to server (mode-dependent) ---
639
- if (isComposeLocal) {
640
- // Local-build path: the build was kicked off in parallel with
641
- // iac.upStack at the top of this function. By the time we reach
642
- // here (post-cloudInitReady), the build has almost always finished.
643
- // Await the promise (cheap if already settled) then sideload the
644
- // image tarball over SSH. Sideload uses docker save | gzip | ssh
645
- // | docker load — gzip drops ~600MB → ~150-200MB on the wire.
646
- //
647
- // Both run silently (build is silent:true; sideload streams no stdout),
648
- // so without a spinner this is a 15-30s dead pause right after "Proceed"
649
- // on warm redeploys (where the upStack spinner that normally overlaps
650
- // the build was skipped). Drive a spinner so the operator sees progress.
651
- const imageSpinner = p.spinner();
652
- imageSpinner.start('Building app image');
653
- await composeLocalBuildPromise;
654
- imageSpinner.message('Sideloading app image to the server');
655
- await perfAsync('deploy.image.sideload', async () =>
656
- sideloadCompose({
657
- tag: localImageTag,
658
- sshTarget: `root@${serverIp}`,
659
- sshKey: sshKeyPath,
660
- }),
661
- );
662
- imageSpinner.stop('App image built and sideloaded');
663
- } else if (isDirectDeploy) {
664
- // Direct path: build the image on the server via DOCKER_HOST=ssh://.
665
- // BuildKit caches aggressively, so unchanged source rebuilds in 1-3s.
666
- // Slower than local+sideload (no upStack overlap) but works when the
667
- // operator has no local Docker — selected by --direct or by
668
- // resolveBuildMode's docker-not-found fallback. VITE_* args plumbed
669
- // through here for the same reason as the local-build path above.
670
- const { collectComposeBuildArgs } = await import('./compose/build-args.js');
671
- const directBuildArgs = collectComposeBuildArgs(process.cwd(), {
672
- projectName: projectConfig.projectName,
673
- domain,
674
- });
675
- const success = await perfAsync('deploy.image.directBuild', () =>
676
- buildRemote(serverIp, sshKeyPath, localImageTag, process.cwd(), directBuildArgs),
677
- );
678
- if (!success) process.exit(1);
679
- }
680
-
681
- // --- Docker Hub login (skip if creds unchanged) ---
682
- // Without this, the reconcile script's `docker compose pull` for
683
- // Supabase service images (rest, auth, storage, kong, imgproxy, etc.)
684
- // hits Docker Hub anonymously. A fresh Hetzner VPS shares a NAT range
685
- // with many other deploys, so the per-IP unauthenticated pull quota
686
- // is routinely exhausted — observed 2026-04-26 matrix run #3
687
- // ("toomanyrequests: You have reached your unauthenticated pull rate
688
- // limit"). Compose-HA + scale already log in; this brings the
689
- // single-compose deploy path in line.
690
- const dockerHubCreds = loadCredentials().dockerHub || null;
691
- if (dockerHubCreds) {
692
- const dhLoginInputs = {
693
- serverIp,
694
- registry: 'docker.io',
695
- user: dockerHubCreds.username,
696
- tokenFp: dockerHubCreds.token ? dockerHubCreds.token.slice(0, 8) : '',
697
- };
698
- if (!state.shouldSkip('compose-dockerhub-login', dhLoginInputs)) {
699
- state.startStep('compose-dockerhub-login', dhLoginInputs);
700
- await dockerLoginOnServer(serverIp, sshKeyPath, dockerHubCreds);
701
- state.completeStep('compose-dockerhub-login', { serverIp });
702
- }
703
- }
704
-
705
- // --- GHCR login (skip if creds unchanged) ---
706
- if (ciReady.ghcrPullCreds) {
707
- const loginInputs = {
708
- serverIp,
709
- registry: 'ghcr.io',
710
- user: ciReady.ghcrPullCreds.owner,
711
- // Hash the token's fingerprint, not the token itself — we don't
712
- // want the token written to .vibecarbon/deploy-state-*.json.
713
- tokenFp: ciReady.ghcrPullCreds.token ? ciReady.ghcrPullCreds.token.slice(0, 8) : '',
714
- };
715
- if (!state.shouldSkip('compose-ghcr-login', loginInputs)) {
716
- state.startStep('compose-ghcr-login', loginInputs);
717
- await dockerLoginOnServer(serverIp, sshKeyPath, {
718
- username: ciReady.ghcrPullCreds.owner,
719
- token: ciReady.ghcrPullCreds.token,
720
- registry: 'ghcr.io',
721
- });
722
- state.completeStep('compose-ghcr-login', { serverIp });
723
- }
724
- }
725
-
726
- // --- Bundle rsync (skip when bundle inputs unchanged) ---
727
- const bundleInputs = {
728
- serverIp,
729
- projectName: projectConfig.projectName,
730
- imageRef,
731
- domain,
732
- services,
733
- };
734
- if (!state.shouldSkip('compose-setup-files', bundleInputs)) {
735
- state.startStep('compose-setup-files', bundleInputs);
736
- // setupServerFiles tars the bundle locally + streams over ssh +
737
- // extracts on the server in one pipeline. Bucket the whole upload
738
- // here; the inner tar/upload split is captured in compose/index.js.
739
- await perfAsync('deploy.bundle.upload', () =>
740
- setupServerFiles(serverIp, sshKeyPath, projectConfig.projectName, {
741
- ...services,
742
- domain,
743
- image: imageRef,
744
- bundlePath,
745
- }),
746
- );
747
- state.completeStep('compose-setup-files', { serverIp });
748
- }
749
-
750
- // --- DNS update + propagation wait BEFORE compose-up ---
751
- //
752
- // Traefik attempts ACME HTTP-01 challenges immediately on container
753
- // start and bursts ~7 attempts in ~30s before giving up. If LE's
754
- // DNS resolver sees stale or absent records on those attempts,
755
- // every challenge fails with "no valid A records found" and
756
- // acme.json ends the deploy with zero certs issued — browser
757
- // sees TRAEFIK DEFAULT CERT and NET::ERR_CERT_AUTHORITY_INVALID.
758
- //
759
- // The warm-up that fires in Step 4 writes a 0.0.0.0 placeholder;
760
- // LE specifically rejects 0.0.0.0 as not-a-valid-A-record. So even
761
- // when warm-up "succeeds," it doesn't help ACME — it actively
762
- // poisons the resolver state until the real IP overwrites it.
763
- //
764
- // Fix (RCA from vibecarbon.com cold-deploy 2026-05-19): swap the
765
- // ordering. Write the real IP now, wait for it to propagate to
766
- // public resolvers, then start compose. Traefik's first ACME
767
- // attempt sees real DNS, succeeds on the first try.
768
- //
769
- // The compose-HA path already writes DNS before compose-up at
770
- // src/lib/deploy/compose/ha.js — single-region compose was the
771
- // outlier that ran startComposeStack first then updated DNS.
772
- if (domain && dnsProvider && dnsProvider !== 'manual') {
773
- await perfAsync('deploy.dns.warm', async () => {
774
- await dnsWarmupPromise;
775
- });
776
- const dnsUpdateInputs = { domain, dnsProvider, serverIp };
777
- if (!state.shouldSkip('compose-dns-update', dnsUpdateInputs)) {
778
- state.startStep('compose-dns-update', dnsUpdateInputs);
779
- const { setupSimple: updateDnsIp } =
780
- dnsProvider === 'cloudflare'
781
- ? await import('../cloudflare.js')
782
- : await import('../hetzner-dns.js');
783
- const token = dnsProvider === 'cloudflare' ? cloudflareApiToken : apiToken;
784
- const zoneId = dnsProvider === 'cloudflare' ? cloudflareZoneId : hetznerDnsZoneId;
785
- const dnsSpinner = p.spinner();
786
- dnsSpinner.start(`Pointing ${domain} → ${serverIp}...`);
787
- await perfAsync('deploy.dns.create', () => updateDnsIp(token, zoneId, domain, serverIp));
788
- dnsSpinner.stop(`DNS A record set: ${domain} → ${serverIp}`);
789
- state.completeStep('compose-dns-update', { serverIp });
790
- }
791
- // HTTP-01 only: block compose-up until public resolvers see the real
792
- // IP. 120s budget covers Cloudflare's typical edge propagation (under
793
- // 60s) with margin. Returns false on timeout; we proceed anyway —
794
- // Traefik can still acquire certs on its own retries, the customer
795
- // just sees a brief self-signed-cert window. Hard-failing here would
796
- // mask deploys that succeed eventually.
797
- //
798
- // DNS-01 (managed providers) skips this entirely: lego validates via a
799
- // TXT record it writes through the provider API, so cert issuance does
800
- // not wait on the A record propagating. The A record we just wrote
801
- // propagates in the background; the post-deploy health probe uses
802
- // localhost + Host header and never depends on it.
803
- //
804
- // Visible spinner so the operator isn't staring at a frozen
805
- // "Yes" screen for up to 120s on a cold deploy where the DNS
806
- // edge is slow. `timer` indicator forces the rendered string to
807
- // change every second so progress remains visibly alive.
808
- if (!dnsChallenge) {
809
- const dnsSpinner = p.spinner({ indicator: 'timer' });
810
- dnsSpinner.start(`Waiting for ${domain} to resolve to ${serverIp}`);
811
- const dnsPropagated = await perfAsync('deploy.dns.waitForPropagation', () =>
812
- waitForDNSPropagation(domain, serverIp, 120_000),
813
- );
814
- dnsSpinner.stop(
815
- dnsPropagated
816
- ? `DNS resolves: ${domain} → ${serverIp}`
817
- : `DNS propagation timed out — proceeding anyway`,
818
- );
819
- }
820
- }
821
-
822
- // `docker compose up -d` is idempotent and cheap — always run so a
823
- // config tweak that didn't change the bundle hash (e.g. env var)
824
- // still takes effect. Reconcile.sh on the server runs `docker compose
825
- // pull` (when not local image) + `docker compose up -d` — see
826
- // compose/index.js for the inner perf instrumentation.
827
- await perfAsync('deploy.reconcile.run', () =>
828
- startComposeStack(serverIp, sshKeyPath, projectConfig.projectName, services),
829
- );
830
-
831
- // Apply app migrations + reload PostgREST. The orchestrator's inline
832
- // single-compose path previously skipped this (only deployComposeHA ran
833
- // runMigrations), so compose-single shipped an EMPTY app schema — 0 public
834
- // tables, every DB-backed feature 500'd, and db_schema verify failed with
835
- // PGRST205. runMigrations waits for supabase_admin, applies each
836
- // supabase/migrations/* with ON_ERROR_STOP=1 (a real failure aborts the
837
- // deploy rather than silently shipping a schema-less prod), then reloads
838
- // PostgREST's schema cache. Mirrors the HA path.
839
- await perfAsync('deploy.compose.migrations', () =>
840
- runMigrations(serverIp, sshKeyPath, projectConfig.projectName),
841
- );
842
-
843
- // Create the production app super-admin in auth.users via GoTrue's admin
844
- // API. Same class of trap as runMigrations above: this inline single-
845
- // compose path previously skipped it (only deployComposeHA called
846
- // createAdminUser), so a single-server deploy shipped a prod app the
847
- // operator couldn't actually log into — the only admin user lived in
848
- // their local Docker. Idempotent (422 = already exists); a failure here
849
- // is a warning, not a hard abort, so a transient GoTrue blip doesn't
850
- // fail an otherwise-healthy deploy — re-running `vibecarbon deploy`
851
- // retries it.
852
- const adminResult = await perfAsync('deploy.compose.createAdminUser', () =>
853
- createAdminUser(serverIp, sshKeyPath, projectConfig.projectName),
854
- );
855
- if (adminResult.success) {
856
- p.log.success(adminResult.message);
857
- } else {
858
- p.log.warn(`${adminResult.message}. Run \`vibecarbon deploy\` again to retry.`);
859
- }
860
-
861
- // --- Verifiable success gate ---
862
- // Probe the app's own /api/health endpoint on the server itself (via
863
- // localhost + Host header), bypassing DNS/TLS. A successful deploy
864
- // must yield an HTTP 2xx here; otherwise fail the deploy loudly with
865
- // container state + log tail.
866
- //
867
- // verifyAppHealth polls every 10s for up to 3 minutes while the app
868
- // container restarts and binds port 3000. Without a visible spinner
869
- // the operator just sees a frozen "Stack reconciled" line for that
870
- // entire window — observed 2026-05-19 vibecarbon.com re-deploy
871
- // where compose-up finished in ~5s but the app took ~3min to come
872
- // back, leaving the screen looking hung. `timer` indicator so the
873
- // elapsed-seconds counter ticks visibly even on terminals where
874
- // the spinner frame-character animation gets throttled under load.
875
- const { verifyAppHealth } = await import('./compose/index.js');
876
- const healthSpinner = p.spinner({ indicator: 'timer' });
877
- healthSpinner.start('Waiting for app to start serving requests');
878
- let health;
879
- try {
880
- health = await perfAsync('deploy.health.probe', () =>
881
- verifyAppHealth(serverIp, sshKeyPath, projectConfig.projectName, {
882
- domain,
883
- }),
884
- );
885
- } catch (err) {
886
- healthSpinner.stop('App health probe errored', 1);
887
- throw err;
888
- }
889
- if (!health.healthy) {
890
- healthSpinner.stop(`App not serving requests (status: ${health.status})`, 1);
891
- p.log.error(
892
- `Deploy produced an unhealthy app (probe status: ${health.status}):\n${health.details}`,
893
- );
894
- process.exit(1);
895
- }
896
- healthSpinner.stop(`App is serving requests (HTTP ${health.status})`);
897
-
898
- // Install the scheduled wal-g backup cron on the server. Without this,
899
- // a fresh compose deploy collects + persists backupConfig (envConfig.backup)
900
- // but never schedules a backup — backups only ran after a `vibecarbon scale`
901
- // event (scale.js was the lone caller). setupComposeBackupCron defaults the
902
- // schedule + retain when backupConfig is absent, so it always installs a
903
- // sensible cron. wal-g reads its S3 config from the db container env
904
- // (rendered into .env at deploy) — the cron just runs compose-backup.sh,
905
- // which self-skips (exit 0) when no S3 backup target is configured, so an
906
- // always-installed cron never hard-fails on a no-S3 deploy.
907
- //
908
- // A cron-install failure must NOT fail an already-healthy deploy: the app
909
- // is serving by this point. Downgrade to a warning so a transient ssh/cron
910
- // hiccup doesn't roll back a successful deploy.
911
- try {
912
- await perfAsync('deploy.compose.backupCron', async () =>
913
- setupComposeBackupCron(serverIp, sshKeyPath, projectConfig.projectName, backupConfig),
914
- );
915
- } catch (err) {
916
- p.log.warn(`Scheduled backup cron install failed (deploy still succeeded): ${err.message}`);
917
- }
918
-
919
- // No ACME self-heal needed here. Managed-DNS deploys (cloudflare/hetzner)
920
- // issue certs via DNS-01, which has no HTTP-01-vs-DNS-propagation race to
921
- // recover from; `manual` DNS was never self-healed (we don't manage its
922
- // records). The old acme.json poll + Traefik restart (acme-verify.js)
923
- // existed only for the HTTP-01 race the DNS-01 switch eliminates.
924
-
925
- deployResult = {
926
- masterIp: serverIp,
927
- serverId: serverHetznerId || 'manual',
928
- serverName: serverHetznerName,
929
- };
930
- }
931
- } else {
932
- // Kubernetes (k3s on Ubuntu)
933
- const { deployK3s } = await import('./k8s/k3s.js');
934
- const { deployK8sHA } = await import('./k8s/ha/index.js');
935
-
936
- const deploymentConfig = {
937
- projectName: projectConfig.projectName,
938
- environment: config.environment,
939
- provider: config.provider,
940
- region,
941
- secondaryRegion,
942
- masterServerType,
943
- supabaseServerType,
944
- workerServerType,
945
- serverType,
946
- minWorkers,
947
- maxWorkers,
948
- domain,
949
- ha: config.ha,
950
- observability: config.observability,
951
- services,
952
- apiToken,
953
- cloudflareApiToken,
954
- cloudflareZoneId,
955
- hetznerDnsZoneId,
956
- dnsProvider,
957
- s3Config,
958
- backupConfig,
959
- backupBucketName: backupS3Config?.bucket || null,
960
- // DR: when set (`deploy -restore latest|<ts>`), the db pod's init
961
- // container seeds PGDATA from S3 via wal-g and applyMigrations is
962
- // skipped (the restored DB is authoritative).
963
- restore: args.restore || null,
964
- operatorCidrs: projectConfig.operatorCidrs ?? [],
965
- imageReadyPromise,
966
- tracker,
967
- state,
968
- };
969
-
970
- if (config.ha) {
777
+ return result;
778
+ },
779
+ k8s: async () => {
780
+ const { deployK3s } = await import('./k8s/k3s.js');
781
+ return perfAsync('deploy.k3s.full', () => deployK3s(deploymentConfig));
782
+ },
783
+ 'k8s-ha': async () => {
971
784
  // K8s HA = 2 clusters (primary + standby) provisioned in parallel,
972
785
  // each running k3s + Helm + sideload. Cold HA observed ~25-40 min.
973
- deployResult = await perfAsync('deploy.ha.k8s.full', () => deployK8sHA(deploymentConfig));
974
- } else {
975
- deployResult = await perfAsync('deploy.k3s.full', () => deployK3s(deploymentConfig));
976
- }
786
+ const { deployK8sHA } = await import('./k8s/ha/index.js');
787
+ return perfAsync('deploy.ha.k8s.full', () => deployK8sHA(deploymentConfig));
788
+ },
789
+ };
790
+ const deployResult = await TIER_DEPLOYERS[tier]();
977
791
 
978
- // K3s is local-first: deployK3s already built the image, sideloaded it
979
- // to every node, and applied k8s manifests inline. Layer in Flux + GHA
980
- // via `vibecarbon configure cicd <env>` (PR 7) after the cluster is up.
792
+ if (isK8sTier(tier)) {
981
793
  {
982
794
  const kubeconfigPath = join(process.cwd(), '.vibecarbon', `kubeconfig-${environment}`);
983
795
  const masterIp = deployResult.primary?.masterIp || deployResult.masterIp;
@@ -1086,15 +898,14 @@ export async function executeDeployment(args, gatheredConfig) {
1086
898
  // root cause without requiring a follow-up `vibecarbon diagnose`.
1087
899
  // The cluster is about to be destroyed by the test runner; once
1088
900
  // it's gone, kubectl probes return nothing. We spawn each tool via
1089
- // execFileSync (array argv, no shell) — domain and environment come
901
+ // runCommandAsync (array argv, no shell) — domain and environment come
1090
902
  // from validated config, but we still avoid shell-string interp.
1091
903
  try {
1092
- const { execFileSync } = await import('node:child_process');
1093
904
  // HA deploys split into kubeconfig-<env>-primary / -standby; standalone
1094
905
  // uses just kubeconfig-<env>. Prefer primary (the user-facing cluster
1095
906
  // — Traefik/cert-manager state we want to inspect lives there) and
1096
907
  // fall back to the standalone path. fs.existsSync check would race
1097
- // against the FS, so prefer the file-not-found error in execFileSync.
908
+ // against the FS, so prefer the file-not-found error in runCommandAsync.
1098
909
  const { existsSync } = await import('node:fs');
1099
910
  const kubeconfigCandidates = [
1100
911
  join(process.cwd(), '.vibecarbon', `kubeconfig-${environment}-primary`),
@@ -1102,18 +913,18 @@ export async function executeDeployment(args, gatheredConfig) {
1102
913
  ];
1103
914
  const kubeconfig =
1104
915
  kubeconfigCandidates.find((c) => existsSync(c)) ?? kubeconfigCandidates[1];
1105
- // Run each tool via execFileSync. Capture stderr alongside stdout
916
+ // Run each tool via runCommandAsync. Capture stderr alongside stdout
1106
917
  // (kubectl prints errors to stderr; without merging we get the
1107
918
  // useless first line of err.message — observed 2026-04-26 run #5
1108
919
  // where every kubectl probe surfaced as "(kubectl failed: Command
1109
920
  // failed: kubectl ...)" with no actual diagnostic).
1110
- const safeRun = (cmd, argv) => {
921
+ const safeRun = async (cmd, argv) => {
1111
922
  try {
1112
- return execFileSync(cmd, argv, {
1113
- encoding: 'utf8',
923
+ const output = await runCommandAsync([cmd, ...argv], {
924
+ silent: true,
1114
925
  timeout: 30_000,
1115
- stdio: ['ignore', 'pipe', 'pipe'],
1116
- }).trim();
926
+ });
927
+ return output.trim();
1117
928
  } catch (err) {
1118
929
  const stdout = err?.stdout?.toString?.()?.trim() ?? '';
1119
930
  const stderr = err?.stderr?.toString?.()?.trim() ?? '';
@@ -1121,10 +932,10 @@ export async function executeDeployment(args, gatheredConfig) {
1121
932
  return `(${cmd} failed exit=${err?.status ?? '?'}):\n${tail || (err instanceof Error ? err.message : String(err))}`;
1122
933
  }
1123
934
  };
1124
- const dig = safeRun('dig', ['+short', domain, '@1.1.1.1']);
935
+ const dig = await safeRun('dig', ['+short', domain, '@1.1.1.1']);
1125
936
  const kcExists = existsSync(kubeconfig);
1126
937
  const certs = kcExists
1127
- ? safeRun('kubectl', [
938
+ ? await safeRun('kubectl', [
1128
939
  '--kubeconfig',
1129
940
  kubeconfig,
1130
941
  'get',
@@ -1136,7 +947,7 @@ export async function executeDeployment(args, gatheredConfig) {
1136
947
  ])
1137
948
  : `(kubeconfig not present at ${kubeconfig} — checked ${kubeconfigCandidates.join(', ')})`;
1138
949
  const events = kcExists
1139
- ? safeRun('kubectl', [
950
+ ? await safeRun('kubectl', [
1140
951
  '--kubeconfig',
1141
952
  kubeconfig,
1142
953
  'get',
@@ -1153,13 +964,13 @@ export async function executeDeployment(args, gatheredConfig) {
1153
964
  // kong 503, etc.). curl exits non-zero on connect/TLS errors so we
1154
965
  // route through safeRun. -k skips cert validation — we want the
1155
966
  // body even if origin's cert is staging or self-signed.
1156
- const curlVerbose = safeRun('curl', [
967
+ const curlVerbose = await safeRun('curl', [
1157
968
  '-skvI',
1158
969
  '--max-time',
1159
970
  '10',
1160
971
  `https://${domain}/api/health`,
1161
972
  ]);
1162
- const curlPodIp = safeRun('curl', [
973
+ const curlPodIp = await safeRun('curl', [
1163
974
  '-skv',
1164
975
  '--max-time',
1165
976
  '10',
@@ -1175,7 +986,7 @@ export async function executeDeployment(args, gatheredConfig) {
1175
986
  // turns "deploy probe got 404" from a riddle into a directly
1176
987
  // actionable error.
1177
988
  const pods = kcExists
1178
- ? safeRun('kubectl', [
989
+ ? await safeRun('kubectl', [
1179
990
  '--kubeconfig',
1180
991
  kubeconfig,
1181
992
  'get',
@@ -1187,7 +998,7 @@ export async function executeDeployment(args, gatheredConfig) {
1187
998
  ])
1188
999
  : '(skipped — no kubeconfig)';
1189
1000
  const endpoints = kcExists
1190
- ? safeRun('kubectl', [
1001
+ ? await safeRun('kubectl', [
1191
1002
  '--kubeconfig',
1192
1003
  kubeconfig,
1193
1004
  'get',
@@ -1199,7 +1010,7 @@ export async function executeDeployment(args, gatheredConfig) {
1199
1010
  // App pod logs (last 100 lines). If multiple replicas, this picks
1200
1011
  // by deployment selector — same as `kubectl logs deploy/app`.
1201
1012
  const appLogs = kcExists
1202
- ? safeRun('kubectl', [
1013
+ ? await safeRun('kubectl', [
1203
1014
  '--kubeconfig',
1204
1015
  kubeconfig,
1205
1016
  'logs',
@@ -1321,11 +1132,11 @@ export async function executeDeployment(args, gatheredConfig) {
1321
1132
  supabaseIp: deployResult.supabaseIp,
1322
1133
  region,
1323
1134
  serverType,
1324
- ...(isComposeDeploy ? {} : { role: 'master' }),
1135
+ ...(isComposeTier(tier) ? {} : { role: 'master' }),
1325
1136
  });
1326
1137
  // K8s only — compose has neither a separate supabase node nor worker
1327
1138
  // pool. Compose deploys leave servers as a single-element array.
1328
- if (!isComposeDeploy) {
1139
+ if (isK8sTier(tier)) {
1329
1140
  if (deployResult.supabaseIp) {
1330
1141
  servers.push({
1331
1142
  name: 'supabase',
@@ -1405,6 +1216,11 @@ export async function executeDeployment(args, gatheredConfig) {
1405
1216
  region,
1406
1217
  ...(isHADeploy && {
1407
1218
  secondaryRegion,
1219
+ // Finding #1: persist DR posture. A default (gated) HA deploy that
1220
+ // reaches this point is streaming; only `-allow-degraded` finalizes a
1221
+ // warm/degraded standby, which the inner deploy flags on deployResult.
1222
+ replication: deployResult?.degraded ? 'degraded' : 'streaming',
1223
+ degraded: !!deployResult?.degraded,
1408
1224
  // Persist per-cluster floatingIp + supabaseIp under ha.primary /
1409
1225
  // ha.standby so failover.identifyServers takes the structured
1410
1226
  // path that surfaces floatingIp. Without these, identifyServers
@@ -1475,7 +1291,12 @@ export async function executeDeployment(args, gatheredConfig) {
1475
1291
  }),
1476
1292
  deployedAt: new Date().toISOString(),
1477
1293
  deployedCommit,
1478
- s3: { bucket: s3Config.bucket, region: s3Config.region, endpoint: s3Config.endpoint },
1294
+ s3: {
1295
+ bucket: s3Config.bucket,
1296
+ region: s3Config.region,
1297
+ endpoint: s3Config.endpoint,
1298
+ stateBucket: s3Config.stateBucket,
1299
+ },
1479
1300
  ...(backupS3Persist && { backupS3: backupS3Persist }),
1480
1301
  backup: backupConfig,
1481
1302
  services,