vibecarbon 0.4.0 → 0.5.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/carbon/backup/compose-backup.sh +45 -121
- package/carbon/docker-compose.metabase.prod.yml +1 -2
- package/carbon/docker-compose.n8n.prod.yml +1 -2
- package/carbon/docker-compose.prod.yml +9 -7
- package/carbon/k8s/base/traefik/certificate.yaml +13 -7
- package/carbon/k8s/values/supabase.values.yaml +8 -0
- package/carbon/scripts/docker-up.js +13 -1
- package/carbon/src/client/components/DeploymentScenariosSection.tsx +171 -0
- package/carbon/src/client/locales/de.json +29 -0
- package/carbon/src/client/locales/en.json +29 -0
- package/carbon/src/client/locales/es.json +29 -0
- package/carbon/src/client/locales/fr.json +29 -0
- package/carbon/src/client/locales/pt.json +29 -0
- package/carbon/src/client/pages/Home.tsx +5 -0
- package/package.json +1 -1
- package/src/backup.js +11 -30
- package/src/destroy.js +5 -43
- package/src/lib/deploy/bundle.js +120 -5
- package/src/lib/deploy/compose/ha.js +114 -1
- package/src/lib/deploy/compose/index.js +243 -177
- package/src/lib/deploy/k8s/k3s.js +119 -66
- package/src/lib/deploy/orchestrator.js +22 -0
- package/src/lib/iac/index.js +45 -7
- package/src/restore.js +135 -54
- package/src/scale.js +33 -57
|
@@ -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
|
-
|
|
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.
|
|
@@ -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
|
|
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}
|
|
1295
|
-
//
|
|
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.
|
|
1801
|
-
//
|
|
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
|
|
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.
|
|
2656
|
-
//
|
|
2657
|
-
//
|
|
2658
|
-
//
|
|
2659
|
-
|
|
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
|
|
2759
|
+
s.start(`Sideloading app to ${sshTargets.length} node(s)`);
|
|
2707
2760
|
await Promise.all(sideloadTasks);
|
|
2708
|
-
s.stop('Sideload complete (app
|
|
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
|
package/src/lib/iac/index.js
CHANGED
|
@@ -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
|
|
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
|
|
194
|
-
onOutput: options.onOutput,
|
|
195
|
-
|
|
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
|
|
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
|
|
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
|
|
20
|
-
import { basename
|
|
19
|
+
import { existsSync } from 'node:fs';
|
|
20
|
+
import { basename } from 'node:path';
|
|
21
21
|
import * as p from '@clack/prompts';
|
|
22
|
-
import {
|
|
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,
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
306
|
-
// k8s restore
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
// the
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
651
|
+
envConfig = {},
|
|
652
|
+
restoreCompose = realRestoreCompose,
|
|
653
|
+
configureStandbyReplication = realConfigureStandbyReplication,
|
|
590
654
|
}) {
|
|
591
|
-
const backupFile = chosenSource.name;
|
|
592
|
-
|
|
593
655
|
if (chosenSource.kind === 'local') {
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
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,
|