vibecarbon 0.4.0 → 0.5.1

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.
@@ -40,11 +40,17 @@ import * as p from '@clack/prompts';
40
40
  import { runCommandAsync } from '../../command.js';
41
41
  import { knownHostsPath } from '../../host-keys.js';
42
42
  import { loadCloudInit, renderScript } from '../../iac/cloud-init.js';
43
+ import { dbImageRef } from '../../images.js';
43
44
  import { perfAsync } from '../../perf.js';
44
45
  import { postAdminUser, waitForGotrueHealth } from '../admin-user.js';
45
46
 
46
47
  export const K3S_VERSION = 'v1.31.5+k3s1';
47
48
 
49
+ /** The db image is pre-published + multi-arch — pulled, never built/sideloaded. */
50
+ export function resolveDbImageTag() {
51
+ return dbImageRef();
52
+ }
53
+
48
54
  /**
49
55
  * Pattern of kube-apiserver transient signatures we've actually observed
50
56
  * mid-RPC during k3s control-plane warm-up. See runKubectlWithRetry —
@@ -53,7 +59,12 @@ export const K3S_VERSION = 'v1.31.5+k3s1';
53
59
  * obvious place to do it).
54
60
  */
55
61
  export const KUBECTL_TRANSIENT_PATTERN =
56
- /http2: client connection lost|connection reset by peer|context deadline exceeded|EOF|i\/o timeout|TLS handshake|unexpected error when reading response body/i;
62
+ // `connection timed out` covers cross-cluster API blips like
63
+ // `read tcp <ip>:<port>->...:6443: read: connection timed out` — seen when a
64
+ // kubectl exec (e.g. enable-WAL-archiving) hits a momentarily unreachable
65
+ // standby control-plane in k8s-ha. Without it the call failed without
66
+ // retrying, aborting the deploy. (RCA 2026-06-01: k8s-ha standby deploy.)
67
+ /http2: client connection lost|connection reset by peer|context deadline exceeded|connection timed out|EOF|i\/o timeout|TLS handshake|unexpected error when reading response body/i;
57
68
 
58
69
  /**
59
70
  * Run a short-lived kubectl command with retry-on-transient-error.
@@ -113,7 +124,7 @@ export async function runKubectlWithRetry(
113
124
  );
114
125
  await delay(backoffMs);
115
126
  }
116
- if (!result || result.status !== 0) {
127
+ if (result?.status !== 0) {
117
128
  throw new Error(
118
129
  `${desc} failed with exit ${result?.status ?? '?'}: ${lastStderr.trim().slice(-500)}`,
119
130
  );
@@ -209,6 +220,35 @@ export function pickIssuerName({ dnsProvider, acmeServer }) {
209
220
  return `letsencrypt-${stage}-${providerSuffix}`;
210
221
  }
211
222
 
223
+ /**
224
+ * Build the `dnsNames` list for the `Certificate/vibecarbon-tls` patch.
225
+ *
226
+ * DNS-01 issuers (cloudflare, hetzner) support wildcard SANs — cert-manager
227
+ * can obtain `*.${domain}` in a single ACME order, covering every subdomain
228
+ * (api, studio, dashboard, grafana, n8n …) without separate per-subdomain
229
+ * certificates. One wildcard cert = no per-router cert-resolver needed =
230
+ * no per-domain ACME rate-limit risk.
231
+ *
232
+ * Manual issuers use HTTP-01 which cannot issue wildcards, so fall back to
233
+ * the apex domain only (the IngressRoutes already work; only non-apex
234
+ * subdomains are uncovered, but those are admin-only tools the operator
235
+ * configures manually for manual-DNS deploys).
236
+ *
237
+ * @param {string} domain - The apex deploy domain, e.g. `e1.carbonstack.dev`
238
+ * @param {string} issuerName - The ClusterIssuer name, e.g.
239
+ * `letsencrypt-prod-hetzner`, produced by `pickIssuerName`
240
+ * @returns {string[]}
241
+ */
242
+ export function certificateDnsNames(domain, issuerName) {
243
+ // Only the known DNS-01 providers (cloudflare, hetzner) support wildcard SANs.
244
+ // `manual` and any future/unknown issuer name falls back to apex-only so an
245
+ // unrecognised issuer never attempts a wildcard via HTTP-01 (which LE rejects).
246
+ const isDns01 = /-(cloudflare|hetzner)$/.test(issuerName);
247
+ if (!isDns01) return [domain];
248
+ // DNS-01 issuers (cloudflare, hetzner — prod or staging): issue one wildcard.
249
+ return [domain, `*.${domain}`];
250
+ }
251
+
212
252
  /**
213
253
  * Render the per-DNS-provider Secret YAML that cert-manager's DNS-01
214
254
  * solvers read for API auth, or null when the provider doesn't need a
@@ -1287,12 +1327,14 @@ export async function installSupabase({
1287
1327
  }
1288
1328
  if (!dbImageTag) {
1289
1329
  throw new Error(
1290
- 'installSupabase: dbImageTag is required (the wal-g-equipped db image, built by k3s-db-build).',
1330
+ 'installSupabase: dbImageTag is required (the pre-published wal-g db image, pulled from ghcr).',
1291
1331
  );
1292
1332
  }
1293
1333
  // Split the full image ref into repository + tag for the chart's
1294
- // image.db.{repository,tag}. dbImageTag is `10.0.1.1:5000/<project>-db:<ver>`
1295
- // the registry host carries a port colon, so split on the LAST colon.
1334
+ // image.db.{repository,tag} (and the walg-restore init's `{{DB_IMAGE}}:{{DB_IMAGE_TAG}}`).
1335
+ // dbImageTag is now the pre-published `ghcr.io/<org>/postgres:<pg>-walg<ver>`
1336
+ // ref — split on the LAST colon so the registry host (and any future port
1337
+ // colon) stays in the repository.
1296
1338
  const lastColon = dbImageTag.lastIndexOf(':');
1297
1339
  const dbImageRepo = dbImageTag.slice(0, lastColon);
1298
1340
  const dbImageVer = dbImageTag.slice(lastColon + 1);
@@ -1797,42 +1839,73 @@ export async function applyK3sManifests({
1797
1839
  // Fan out the three deploys in parallel: kubectl wait blocks per-arg
1798
1840
  // sequentially, and the cainjector + webhook usually become Available
1799
1841
  // ~10-30s after the core deploy, so the serial form pays the longest
1800
- // arrival time three times in a row. Two-stage retry preserves the
1801
- // outer 180s+120s envelope of the previous --timeout=180s call.
1842
+ // arrival time three times in a row.
1843
+ //
1844
+ // Each deploy POLLS to Available within a generous total budget rather than
1845
+ // one or two fixed `kubectl wait` calls. On a fresh worker, cold image pulls
1846
+ // can push cainjector/webhook readiness past a tight envelope even though the
1847
+ // pod ultimately comes up healthy (observed 2026-06-01: k8s e2e failed
1848
+ // `kubectl wait deploy/cert-manager-cainjector exited 1` while the pod was
1849
+ // 1/1 Running moments later — the old 90s→120s=210s envelope just gave up
1850
+ // first). Re-attempting `kubectl wait` in chunks until the budget also
1851
+ // self-heals the fast-fail race where `wait` runs before the just-applied
1852
+ // Deployment object has propagated (NotFound → exit 1 immediately). A
1853
+ // genuinely stuck deploy still fails once the budget is exhausted.
1802
1854
  const certManagerDeploys = [
1803
1855
  'deploy/cert-manager',
1804
1856
  'deploy/cert-manager-webhook',
1805
1857
  'deploy/cert-manager-cainjector',
1806
1858
  ];
1859
+ // 8-min budget: on a freshly provisioned cluster under load, cert-manager
1860
+ // pods have been observed not even getting created until ~5 min after apply
1861
+ // (control-plane/scheduler lag, not image pull), overrunning a tighter
1862
+ // envelope even though the pods then come up healthy. This margin covers that
1863
+ // cold-start tail; the poll below re-attempts within it rather than giving up.
1864
+ const CERT_MANAGER_READY_BUDGET_MS = 480_000;
1865
+ const waitDeployAvailableOnce = (deploy, timeoutSec) =>
1866
+ new Promise((resolveFn, reject) => {
1867
+ const child = spawn(
1868
+ 'kubectl',
1869
+ [
1870
+ '-n',
1871
+ 'cert-manager',
1872
+ 'wait',
1873
+ '--for=condition=Available',
1874
+ `--timeout=${timeoutSec}s`,
1875
+ deploy,
1876
+ ],
1877
+ { stdio: 'inherit', env },
1878
+ );
1879
+ child.on('error', reject);
1880
+ child.on('exit', (code) =>
1881
+ code === 0 ? resolveFn() : reject(new Error(`kubectl wait ${deploy} exited ${code}`)),
1882
+ );
1883
+ });
1884
+ const waitDeployAvailable = async (deploy) => {
1885
+ const deadline = Date.now() + CERT_MANAGER_READY_BUDGET_MS;
1886
+ let lastErr;
1887
+ while (Date.now() < deadline) {
1888
+ const remainingSec = Math.max(5, Math.ceil((deadline - Date.now()) / 1000));
1889
+ try {
1890
+ await waitDeployAvailableOnce(deploy, Math.min(60, remainingSec));
1891
+ return;
1892
+ } catch (err) {
1893
+ lastErr = err;
1894
+ if (Date.now() >= deadline) break;
1895
+ // Brief backoff so a fast-failing wait (e.g. the Deployment object not
1896
+ // yet propagated → immediate NotFound) doesn't busy-spin the budget.
1897
+ await new Promise((r) => setTimeout(r, 3000));
1898
+ }
1899
+ }
1900
+ throw (
1901
+ lastErr ??
1902
+ new Error(
1903
+ `kubectl wait ${deploy} never became Available within ${CERT_MANAGER_READY_BUDGET_MS / 1000}s`,
1904
+ )
1905
+ );
1906
+ };
1807
1907
  await perfAsync(`deploy.${perfPrefix}.certManager.wait`, () =>
1808
- Promise.all(
1809
- certManagerDeploys.map(async (deploy) => {
1810
- const waitOnce = (timeoutSec) =>
1811
- new Promise((resolveFn, reject) => {
1812
- const child = spawn(
1813
- 'kubectl',
1814
- [
1815
- '-n',
1816
- 'cert-manager',
1817
- 'wait',
1818
- '--for=condition=Available',
1819
- `--timeout=${timeoutSec}s`,
1820
- deploy,
1821
- ],
1822
- { stdio: 'inherit', env },
1823
- );
1824
- child.on('error', reject);
1825
- child.on('exit', (code) =>
1826
- code === 0 ? resolveFn() : reject(new Error(`kubectl wait ${deploy} exited ${code}`)),
1827
- );
1828
- });
1829
- try {
1830
- await waitOnce(90);
1831
- } catch {
1832
- await waitOnce(120);
1833
- }
1834
- }),
1835
- ),
1908
+ Promise.all(certManagerDeploys.map((deploy) => waitDeployAvailable(deploy))),
1836
1909
  );
1837
1910
  // 2b. Land the per-DNS-provider Secret in the cert-manager namespace
1838
1911
  // BEFORE the kustomization that defines the issuers, so when the
@@ -2084,6 +2157,10 @@ export async function applyK3sManifests({
2084
2157
  // — DNS-01 vs HTTP-01 — cert-manager actually uses; see pickIssuerName.
2085
2158
  const acmeServer = envLocal?.ACME_CA_SERVER || process.env.ACME_CA_SERVER || '';
2086
2159
  const issuerName = pickIssuerName({ dnsProvider, acmeServer });
2160
+ // DNS-01 issuers (cloudflare, hetzner) support wildcard SANs — one cert
2161
+ // covers *.${domain} so every IngressRoute subdomain is included without
2162
+ // separate per-router ACME orders. Manual (HTTP-01) falls back to apex-only.
2163
+ const dnsNames = certificateDnsNames(domain, issuerName);
2087
2164
  await runKubectlWithRetry(
2088
2165
  [
2089
2166
  '-n',
@@ -2094,7 +2171,7 @@ export async function applyK3sManifests({
2094
2171
  '--type=merge',
2095
2172
  '-p',
2096
2173
  JSON.stringify({
2097
- spec: { dnsNames: [domain], issuerRef: { name: issuerName, kind: 'ClusterIssuer' } },
2174
+ spec: { dnsNames, issuerRef: { name: issuerName, kind: 'ClusterIssuer' } },
2098
2175
  }),
2099
2176
  ],
2100
2177
  { env, description: 'applyK3sManifests: kubectl patch certificate/vibecarbon-tls' },
@@ -2652,26 +2729,11 @@ export async function deployK3s(options) {
2652
2729
  }
2653
2730
  const { tag: imageTag } = state.getStepResult('k3s-build');
2654
2731
 
2655
- // 7. Build the wal-g-equipped db image (carbon/db/Dockerfile: FROM
2656
- // supabase/postgres + wal-g v3.0.5). This REPLACES the old separate
2657
- // ~500MB backup image: the db image already carries wal-g + psql, and
2658
- // co-locating wal-g with PGDATA is what enables physical base backups +
2659
- // PITR (the old backup pod ran off-node and was stuck on pg_dump). The
2660
- // supabase Helm chart's image.db is pointed at this build in
2661
- // installSupabase. Built up-front so its sideload fans out with the app.
2662
- const dbBuildInputs = { projectName: `${options.projectName}-db` };
2663
- if (!state.shouldSkip('k3s-db-build', dbBuildInputs)) {
2664
- state.startStep('k3s-db-build', dbBuildInputs);
2665
- s.start('Building db image (wal-g)');
2666
- const built = await buildAppImage(join(projectDir, 'db'), `${options.projectName}-db`);
2667
- state.completeStep('k3s-db-build', {
2668
- tag: built.tag,
2669
- gitSha: built.gitSha,
2670
- isDirty: built.isDirty,
2671
- });
2672
- s.stop(`DB image built: ${built.tag}`);
2673
- }
2674
- const { tag: dbImageTag } = state.getStepResult('k3s-db-build');
2732
+ // 7. The wal-g-equipped db image (supabase/postgres + wal-g) is now a
2733
+ // pre-published, multi-arch image pulled from ghcr no per-deploy build
2734
+ // or sideload. The supabase Helm chart's image.db + the walg-restore init
2735
+ // container both reference it (see installSupabase + supabase.values.yaml).
2736
+ const dbImageTag = resolveDbImageTag();
2675
2737
 
2676
2738
  // 7b. Sideload to every node (parallel SSH within sideloadK3s) AND fan out
2677
2739
  // the app+db sideloads against each other. Both writes go through
@@ -2684,7 +2746,6 @@ export async function deployK3s(options) {
2684
2746
  if (supabaseIp) sshTargets.push(`root@${supabaseIp}`);
2685
2747
  for (const wIp of workerIps ?? []) sshTargets.push(`root@${wIp}`);
2686
2748
  const sideloadInputs = { imageTag, targets: sshTargets.join(',') };
2687
- const dbSideloadInputs = { dbImageTag, targets: sshTargets.join(',') };
2688
2749
  const sideloadTasks = [];
2689
2750
  if (!state.shouldSkip('k3s-sideload', sideloadInputs)) {
2690
2751
  state.startStep('k3s-sideload', sideloadInputs);
@@ -2694,18 +2755,10 @@ export async function deployK3s(options) {
2694
2755
  ).then(() => state.completeStep('k3s-sideload')),
2695
2756
  );
2696
2757
  }
2697
- if (!state.shouldSkip('k3s-db-sideload', dbSideloadInputs)) {
2698
- state.startStep('k3s-db-sideload', dbSideloadInputs);
2699
- sideloadTasks.push(
2700
- perfAsync(`deploy.${perfPrefix}.sideload.db`, () =>
2701
- sideloadK3s({ tag: dbImageTag, sshTargets, sshKey: privateKeyPath, khPath }),
2702
- ).then(() => state.completeStep('k3s-db-sideload')),
2703
- );
2704
- }
2705
2758
  if (sideloadTasks.length > 0) {
2706
- s.start(`Sideloading app + db to ${sshTargets.length} node(s) in parallel`);
2759
+ s.start(`Sideloading app to ${sshTargets.length} node(s)`);
2707
2760
  await Promise.all(sideloadTasks);
2708
- s.stop('Sideload complete (app + db)');
2761
+ s.stop('Sideload complete (app)');
2709
2762
  }
2710
2763
 
2711
2764
  // 8. Apply manifests + supabase Helm release + roll out the app.
@@ -560,6 +560,7 @@ export async function executeDeployment(args, gatheredConfig) {
560
560
  startComposeStack,
561
561
  runMigrations,
562
562
  createAdminUser,
563
+ setupComposeBackupCron,
563
564
  } = await import('./compose/index.js');
564
565
  const { setupServerFiles, waitForSSH: waitForComposeSSH } = await import(
565
566
  './compose/index.js'
@@ -873,6 +874,27 @@ export async function executeDeployment(args, gatheredConfig) {
873
874
  }
874
875
  healthSpinner.stop(`App is serving requests (HTTP ${health.status})`);
875
876
 
877
+ // Install the scheduled wal-g backup cron on the server. Without this,
878
+ // a fresh compose deploy collects + persists backupConfig (envConfig.backup)
879
+ // but never schedules a backup — backups only ran after a `vibecarbon scale`
880
+ // event (scale.js was the lone caller). setupComposeBackupCron defaults the
881
+ // schedule + retain when backupConfig is absent, so it always installs a
882
+ // sensible cron. wal-g reads its S3 config from the db container env
883
+ // (rendered into .env at deploy) — the cron just runs compose-backup.sh,
884
+ // which self-skips (exit 0) when no S3 backup target is configured, so an
885
+ // always-installed cron never hard-fails on a no-S3 deploy.
886
+ //
887
+ // A cron-install failure must NOT fail an already-healthy deploy: the app
888
+ // is serving by this point. Downgrade to a warning so a transient ssh/cron
889
+ // hiccup doesn't roll back a successful deploy.
890
+ try {
891
+ await perfAsync('deploy.compose.backupCron', async () =>
892
+ setupComposeBackupCron(serverIp, sshKeyPath, projectConfig.projectName, backupConfig),
893
+ );
894
+ } catch (err) {
895
+ p.log.warn(`Scheduled backup cron install failed (deploy still succeeded): ${err.message}`);
896
+ }
897
+
876
898
  // No ACME self-heal needed here. Managed-DNS deploys (cloudflare/hetzner)
877
899
  // issue certs via DNS-01, which has no HTTP-01-vs-DNS-propagation race to
878
900
  // recover from; `manual` DNS was never self-healed (we don't manage its
@@ -135,6 +135,35 @@ export async function initBackend(options = {}) {
135
135
  }
136
136
  }
137
137
 
138
+ // S3 (Hetzner Object Storage) throttles the Pulumi state backend with
139
+ // `503 SlowDown` / `ServiceUnavailable` when many stack operations read/write
140
+ // state concurrently — the documented blocker for running the e2e matrix in
141
+ // parallel (multiple scenarios' `up`/`refresh`/`destroy` hit one S3 account at
142
+ // once). It is also a transient any single deploy can hit. These ops are all
143
+ // idempotent (Pulumi reconciles to desired state), so retrying on a throttle is
144
+ // safe. Non-throttle errors propagate immediately.
145
+ export const STATE_BACKEND_THROTTLE_PATTERN =
146
+ /SlowDown|ServiceUnavailable|RequestLimitExceeded|throttl|too many requests|\b503\b/i;
147
+
148
+ export async function withStateBackendRetry(fn, desc, options = {}) {
149
+ const attempts = options.attempts ?? 5;
150
+ const baseMs = options.baseMs ?? 2000;
151
+ const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
152
+ for (let attempt = 1; ; attempt++) {
153
+ try {
154
+ return await fn();
155
+ } catch (err) {
156
+ const msg = err?.message || String(err);
157
+ if (attempt >= attempts || !STATE_BACKEND_THROTTLE_PATTERN.test(msg)) throw err;
158
+ const backoff = Math.min(baseMs * 2 ** (attempt - 1), 30_000);
159
+ console.error(
160
+ `[pulumi] ${desc}: S3 state backend throttled (attempt ${attempt}/${attempts}), retrying in ${backoff}ms`,
161
+ );
162
+ await sleep(backoff);
163
+ }
164
+ }
165
+ }
166
+
138
167
  /**
139
168
  * Run `pulumi up` and return the raw outputs. Streams Pulumi logs to an
140
169
  * optional `onOutput` sink so callers can forward progress to a spinner.
@@ -181,7 +210,10 @@ export async function upStack(stackName, program, options = {}) {
181
210
  }
182
211
  if (needsRefresh) {
183
212
  try {
184
- await stack.refresh({ onOutput: options.onOutput, color: 'never' });
213
+ await withStateBackendRetry(
214
+ () => stack.refresh({ onOutput: options.onOutput, color: 'never' }),
215
+ 'pre-up refresh',
216
+ );
185
217
  } catch (err) {
186
218
  // refresh can fail if the state file is completely corrupt or a
187
219
  // resource is unreachable. Log and proceed — `up` may still succeed
@@ -190,10 +222,10 @@ export async function upStack(stackName, program, options = {}) {
190
222
  }
191
223
  }
192
224
 
193
- const result = await stack.up({
194
- onOutput: options.onOutput,
195
- color: 'never',
196
- });
225
+ const result = await withStateBackendRetry(
226
+ () => stack.up({ onOutput: options.onOutput, color: 'never' }),
227
+ 'up',
228
+ );
197
229
  // Flatten outputs: { foo: { value: 'bar', secret: false } } → { foo: 'bar' }
198
230
  const outputs = {};
199
231
  for (const [key, { value }] of Object.entries(result.outputs || {})) {
@@ -221,11 +253,17 @@ export async function destroyStack(stackName, program, options = {}) {
221
253
  // treats it as a hard error — surfaced in orphan-stack cleanup as
222
254
  // "server not found (not_found, ...)".
223
255
  try {
224
- await stack.refresh({ onOutput: options.onOutput, color: 'never' });
256
+ await withStateBackendRetry(
257
+ () => stack.refresh({ onOutput: options.onOutput, color: 'never' }),
258
+ 'pre-destroy refresh',
259
+ );
225
260
  } catch (err) {
226
261
  console.error(`[pulumi] pre-destroy refresh warning: ${err.message?.split('\n')[0] || err}`);
227
262
  }
228
- await stack.destroy({ onOutput: options.onOutput, color: 'never' });
263
+ await withStateBackendRetry(
264
+ () => stack.destroy({ onOutput: options.onOutput, color: 'never' }),
265
+ 'destroy',
266
+ );
229
267
  await stack.workspace.removeStack(stackName);
230
268
  return { destroyed: true };
231
269
  }
package/src/restore.js CHANGED
@@ -16,10 +16,10 @@
16
16
  * memory:feedback_cli_single_dash_flags.
17
17
  */
18
18
 
19
- import { existsSync, unlinkSync } from 'node:fs';
20
- import { basename, join } from 'node:path';
19
+ import { existsSync } from 'node:fs';
20
+ import { basename } from 'node:path';
21
21
  import * as p from '@clack/prompts';
22
- import { downloadS3Backup, listS3Backups } from './lib/backup-s3.js';
22
+ import { listS3Backups } from './lib/backup-s3.js';
23
23
  import { renderHelp } from './lib/cli/help.js';
24
24
  import { parseFlags } from './lib/cli/parse-flags.js';
25
25
  import { selectEnvironment } from './lib/cli/select-environment.js';
@@ -31,13 +31,18 @@ import {
31
31
  loadProjectConfig,
32
32
  loadS3Config,
33
33
  } from './lib/config.js';
34
+ // Static imports (no cycle: neither compose/index.js nor compose/ha.js imports
35
+ // restore.js). Aliased so runComposeRestore can default to the real functions
36
+ // while still accepting injected mocks for unit tests.
37
+ import { configureStandbyReplication as realConfigureStandbyReplication } from './lib/deploy/compose/ha.js';
38
+ import { restoreCompose as realRestoreCompose } from './lib/deploy/compose/index.js';
34
39
  import { getS3Credentials } from './lib/hetzner-guided-setup.js';
35
40
  import { requireLicense } from './lib/licensing/index.js';
36
41
  import { ensureOperatorIpAccess } from './lib/operator-ip.js';
37
42
  import { perfAsync } from './lib/perf.js';
38
43
  import { listPodBackups } from './lib/pod-backups.js';
39
44
  import { assertInProjectDir } from './lib/project-guard.js';
40
- import { getPostgresPod, getSSHKeyPath, scpUpload, sshKubectl, sshRunScript } from './lib/ssh.js';
45
+ import { getPostgresPod, getSSHKeyPath, sshKubectl, sshRunScript } from './lib/ssh.js';
41
46
  import { createTracker } from './lib/tracker.js';
42
47
  import { validateBackupFilename } from './lib/validators.js';
43
48
  import { VERSION } from './lib/version.js';
@@ -116,18 +121,50 @@ function scaleUpApp(ip, sshKeyPath) {
116
121
  sshKubectl(ip, sshKeyPath, ['scale', 'deployment', 'app', '-n', 'vibecarbon', '--replicas=2']);
117
122
  }
118
123
 
119
- function verifyPostgres(ip, sshKeyPath) {
120
- const pod = getPostgresPod(ip, sshKeyPath);
121
- sshKubectl(ip, sshKeyPath, [
122
- 'exec',
123
- '-n',
124
- 'vibecarbon',
125
- pod,
126
- '--',
127
- 'pg_isready',
128
- '-U',
129
- 'postgres',
130
- ]);
124
+ /**
125
+ * Wait until postgres on the db pod is actually accepting connections.
126
+ *
127
+ * After a wal-g restore, postgres replays archived WAL (restore_command) before
128
+ * promoting — minutes on a busy DB (observed 112s). The pod can be Ready while
129
+ * postgres is still "not accepting connections", so a single pg_isready races
130
+ * slow replay and reports a false restore failure. Poll until it accepts (or
131
+ * give up after `timeoutMs`).
132
+ *
133
+ * @param {string} ip
134
+ * @param {string} sshKeyPath
135
+ * @param {object} [opts]
136
+ * @param {number} [opts.timeoutMs=300000]
137
+ * @param {number} [opts.intervalMs=5000]
138
+ * @param {(ip: string, key: string, args: string[]) => unknown} [opts.exec=sshKubectl]
139
+ * @param {(ip: string, key: string) => string} [opts.getPod=getPostgresPod]
140
+ * @param {(ms: number) => Promise<void>} [opts.sleep]
141
+ */
142
+ export async function verifyPostgres(ip, sshKeyPath, opts = {}) {
143
+ const {
144
+ timeoutMs = 300_000,
145
+ intervalMs = 5_000,
146
+ exec = sshKubectl,
147
+ getPod = getPostgresPod,
148
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
149
+ } = opts;
150
+ const pod = getPod(ip, sshKeyPath);
151
+ const deadline = Date.now() + timeoutMs;
152
+ let lastErr = '';
153
+ for (;;) {
154
+ try {
155
+ exec(ip, sshKeyPath, ['exec', '-n', 'vibecarbon', pod, '--', 'pg_isready', '-U', 'postgres']);
156
+ return; // exit 0 = accepting connections
157
+ } catch (err) {
158
+ lastErr = err instanceof Error ? err.message : String(err);
159
+ }
160
+ if (Date.now() >= deadline) {
161
+ throw new Error(
162
+ `verifyPostgres: postgres did not accept connections within ${Math.round(timeoutMs / 1000)}s ` +
163
+ `(still replaying WAL after a wal-g restore?). Last pg_isready error: ${lastErr}`,
164
+ );
165
+ }
166
+ await sleep(intervalMs);
167
+ }
131
168
  }
132
169
 
133
170
  // (k8s pg_dump restore helpers removed — k8s restore is now wal-g-native via
@@ -222,7 +259,13 @@ export async function run(args) {
222
259
  seed: envSeed,
223
260
  });
224
261
 
225
- const serverIp = envConfig.servers?.[0]?.ip;
262
+ // compose-ha: pick the CURRENT primary by role (a prior failover may have
263
+ // swapped roles while preserving array order, so servers[0] is not reliably
264
+ // the primary). Falls back to servers[0] for non-HA modes that have no role.
265
+ // This same serverIp feeds both the restore target and (in runComposeRestore)
266
+ // the re-seed source, so they stay consistent with the standby selection.
267
+ const primaryServer = envConfig.servers?.find((sv) => sv.role === 'primary');
268
+ const serverIp = primaryServer?.ip || envConfig.servers?.[0]?.ip;
226
269
  if (!serverIp) {
227
270
  p.log.error(`No server IP found for environment '${envName}'.`);
228
271
  process.exit(1);
@@ -302,16 +345,17 @@ export async function run(args) {
302
345
  let chosenSource;
303
346
  if (!wantsLatest && values.source) {
304
347
  chosenSource = classifySource(/** @type {string} */ (values.source));
305
- } else if (wantsLatest && !isCompose) {
306
- // k8s restore is wal-g-native: `latest` resolves to the LATEST base backup
307
- // INSIDE the cluster — the db pod's walg-restore init container runs
308
- // `wal-g backup-fetch LATEST` (see runK8sRestore). pickInteractiveSource
309
- // lists pg_dump `*_full.tar.gz` backups via listS3Backups, which cannot see
310
- // wal-g `base_*` objects, so routing k8s `-source latest` through it wrongly
311
- // exits "No backups found in S3" even though a wal-g base backup exists.
312
- // Hand runK8sRestore the `latest` sentinel directly and let wal-g resolve
313
- // it. (RCA 2026-05-31: e2e k8s/k8s-ha restore step never reached before
314
- // the backup chain (#4/#5/#6) was fixed.)
348
+ } else if (wantsLatest) {
349
+ // BOTH compose and k8s restore are now wal-g-native: `latest` resolves to
350
+ // the LATEST base backup that wal-g pushed to S3 k8s via the db pod's
351
+ // walg-restore init container, compose via `restoreCompose(..., 'latest')`
352
+ // `wal-g backup-fetch LATEST` (see runK8sRestore / runComposeRestore).
353
+ // pickInteractiveSource lists legacy pg_dump `*_full.tar.gz` backups via
354
+ // listS3Backups, which CANNOT see wal-g `basebackups_005/base_*` objects, so
355
+ // routing `-source latest` through it wrongly exits "No backups found in S3"
356
+ // even though a wal-g base backup exists. Hand the `latest` sentinel
357
+ // straight to the restore runner and let wal-g resolve it. (RCA 2026-05-31:
358
+ // e2e k8s/k8s-ha restore step; compose found via kept-rig manual restore.)
315
359
  chosenSource = { kind: 's3', name: 'latest' };
316
360
  } else {
317
361
  chosenSource = await pickInteractiveSource({
@@ -371,7 +415,7 @@ export async function run(args) {
371
415
  projectName,
372
416
  serverIp,
373
417
  sshKeyPath,
374
- s3WithCreds: s3,
418
+ envConfig,
375
419
  });
376
420
  } else {
377
421
  await runK8sRestore({
@@ -579,46 +623,83 @@ async function pickInteractiveSource({
579
623
  return { kind: 's3', name: /** @type {string} */ (selected) };
580
624
  }
581
625
 
582
- async function runComposeRestore({
626
+ // compose restore is wal-g-based (S3 pull, no file transfer). Mirrors
627
+ // runK8sRestore's structure: reject local-file sources, resolve target,
628
+ // call restoreCompose, verify postgres.
629
+ //
630
+ // COMPOSE-HA re-seed (Invariant 3): the wal-g restore rewinds the PRIMARY
631
+ // (servers[0]) to an earlier LSN. For a compose-ha deployment the standby then
632
+ // has WAL *ahead* of the restored primary (streamed from the old primary) and
633
+ // cannot resume streaming — PostgreSQL would reject it with "requested timeline
634
+ // does not contain minimum recovery point" (timeline divergence). So after the
635
+ // primary promotes we re-seed the standby with a fresh pg_basebackup from the
636
+ // restored primary via configureStandbyReplication, which is destructive-by-
637
+ // design (it wipes the standby's PGDATA and re-basebackups — exactly what we
638
+ // want here). Single-region compose has no standby, so the re-seed is skipped.
639
+ //
640
+ // restoreCompose + configureStandbyReplication are injectable so the dispatch
641
+ // logic is unit-testable without SSH; production passes neither and they resolve
642
+ // to the real module functions (static imports — no cycle: ha.js/compose-index
643
+ // do not import restore.js).
644
+ export async function runComposeRestore({
583
645
  s,
584
646
  chosenSource,
585
647
  envName,
586
648
  projectName,
587
649
  serverIp,
588
650
  sshKeyPath,
589
- s3WithCreds,
651
+ envConfig = {},
652
+ restoreCompose = realRestoreCompose,
653
+ configureStandbyReplication = realConfigureStandbyReplication,
590
654
  }) {
591
- const backupFile = chosenSource.name;
592
-
593
655
  if (chosenSource.kind === 'local') {
594
- p.log.info(`Uploading local file: ${c.bold(chosenSource.path)}`);
595
- scpUpload(serverIp, sshKeyPath, chosenSource.path, `/opt/${projectName}/backups/${backupFile}`);
596
- } else if (s3WithCreds?.secretKey) {
597
- s.start(`Downloading ${backupFile} from S3 to server`);
598
- const tmpLocal = join(process.cwd(), `.restore-tmp-${backupFile}`);
599
- try {
600
- await downloadS3Backup(s3WithCreds, `backups/${backupFile}`, tmpLocal);
601
- scpUpload(serverIp, sshKeyPath, tmpLocal, `/opt/${projectName}/backups/${backupFile}`);
656
+ throw new Error(
657
+ 'compose restore is wal-g-based and pulls from S3 — local backup files are not supported. ' +
658
+ 'Use `-source latest` or an ISO-8601 timestamp for PITR.',
659
+ );
660
+ }
661
+
662
+ // wal-g target: 'latest' → LATEST base backup; otherwise treat the source
663
+ // name as an ISO-8601 timestamp for point-in-time recovery.
664
+ const target =
665
+ chosenSource.name === 'latest' || !chosenSource.name ? 'latest' : chosenSource.name;
666
+
667
+ s.start('Restoring database via wal-g (app will be temporarily unavailable)');
668
+ await restoreCompose(serverIp, sshKeyPath, projectName, target);
669
+ s.stop('Database restored');
670
+
671
+ // compose-ha: the wal-g restore rewound the primary to an earlier LSN, so the
672
+ // standby now has WAL AHEAD of the primary and cannot resume streaming. Re-seed
673
+ // it with a fresh pg_basebackup from the restored primary so it rejoins the new
674
+ // timeline. (configureStandbyReplication is destructive-by-design: it wipes the
675
+ // standby's PGDATA and re-basebackups — exactly what we want here.)
676
+ //
677
+ // A re-seed failure is NOT a restore failure: the primary is restored and
678
+ // serving — only the standby is degraded. Warn (don't rethrow) so the restore
679
+ // still reports success; the operator can resync the standby via `deploy`.
680
+ if (envConfig.deployMode === 'compose-ha') {
681
+ const standby = envConfig.servers?.find((sv) => sv.role === 'standby');
682
+ if (standby?.ip) {
602
683
  try {
603
- unlinkSync(tmpLocal);
604
- } catch {
605
- // ignore cleanup errors
684
+ s.start('Re-seeding standby from restored primary (pg_basebackup)');
685
+ await configureStandbyReplication(standby.ip, serverIp, sshKeyPath, projectName);
686
+ s.stop('Standby re-seeded from restored primary');
687
+ } catch (err) {
688
+ s.stop('Standby re-seed failed');
689
+ p.log.warn(
690
+ `Primary restored and serving, but standby re-seed failed: ${err.message}. ` +
691
+ `Run \`vibecarbon deploy ${envName}\` to resync the standby.`,
692
+ );
606
693
  }
607
- s.stop('Backup transferred to server');
608
- } catch (error) {
609
- s.stop(`S3 download failed: ${error.message}`);
610
- throw error;
694
+ } else {
695
+ p.log.warn(
696
+ 'compose-ha restore: no standby server found to re-seed — skipping (verify replication manually).',
697
+ );
611
698
  }
612
699
  }
613
700
 
614
- s.start('Restoring database...');
615
- const { restoreCompose } = await import('./lib/deploy/compose/index.js');
616
- restoreCompose(serverIp, sshKeyPath, projectName, backupFile);
617
- s.stop('Database restored');
618
701
  p.log.success('Restore completed successfully');
619
- // env name is unused by restoreCompose but kept in the closure for
620
- // diagnostic logging consistency.
621
- void envName;
702
+ p.log.info(`Verify with: ${c.info(`vibecarbon status ${envName}`)}`);
622
703
  }
623
704
 
624
705
  // k8s restore is wal-g-native: postgres + wal-g live in the supabase-db pod,