vibecarbon 0.9.0 → 0.10.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/LICENSE +110 -663
- package/README.md +4 -4
- package/carbon/.env.example +11 -0
- package/carbon/backup/compose-backup.sh +8 -0
- package/carbon/docker-compose.yml +11 -0
- package/carbon/ha/primary-init.sql +10 -2
- package/carbon/k8s/base/backup/cronjob.yaml +9 -0
- package/carbon/k8s/base/repl-gateway/kustomization.yaml +11 -0
- package/carbon/k8s/base/repl-gateway/repl-gateway.yaml +92 -0
- package/carbon/k8s/values/supabase.values.yaml +6 -1
- package/carbon/src/client/lib/supabase.ts +41 -0
- package/carbon/src/client/locales/de.json +27 -12
- package/carbon/src/client/locales/en.json +30 -15
- package/carbon/src/client/locales/es.json +27 -12
- package/carbon/src/client/locales/fr.json +27 -12
- package/carbon/src/client/locales/pt.json +27 -12
- package/carbon/src/client/pages/Checkout.tsx +1 -1
- package/carbon/src/server/billing/providers/paddle.ts +21 -2
- package/carbon/src/server/billing/providers/polar.ts +14 -0
- package/carbon/src/server/index.ts +12 -9
- package/carbon/src/server/lib/client-ip.ts +68 -0
- package/carbon/src/server/lib/env.ts +20 -6
- package/carbon/src/server/lib/rate-limiter.ts +11 -65
- package/carbon/src/server/lib/supabase.ts +10 -4
- package/carbon/src/server/middleware/requireOrgRole.ts +76 -0
- package/carbon/src/server/middleware/requirePlan.ts +64 -34
- package/carbon/src/server/middleware/requireSuperAdmin.ts +26 -0
- package/carbon/src/server/routes/v1/admin/contact.ts +5 -20
- package/carbon/src/server/routes/v1/admin/jobs.ts +6 -20
- package/carbon/src/server/routes/v1/admin/newsletter.ts +26 -23
- package/carbon/src/server/routes/v1/auth.ts +5 -2
- package/carbon/src/server/routes/v1/billing.ts +24 -64
- package/carbon/src/server/routes/v1/index.ts +8 -1
- package/carbon/src/server/routes/v1/newsletter.ts +18 -12
- package/carbon/src/server/routes/v1/theme.ts +36 -15
- package/carbon/supabase/migrations/00003_pg_cron.sql +2 -2
- package/carbon/supabase/migrations/00004_contact_newsletter.sql +24 -14
- package/carbon/tests/component/supabase-env-guard.test.ts +54 -0
- package/carbon/tests/integration/server/middleware/rate-limit-ip-spoof.test.ts +48 -0
- package/carbon/tests/integration/server/middleware/require-plan.test.ts +101 -0
- package/carbon/tests/integration/server/routes/admin-newsletter.test.ts +93 -0
- package/carbon/tests/integration/server/routes/billing-subscription-idor.test.ts +92 -0
- package/carbon/tests/integration/server/routes/newsletter-unsubscribe.test.ts +65 -0
- package/carbon/tests/integration/server/routes/theme-validation.test.ts +66 -0
- package/carbon/tests/unit/server/client-ip.test.ts +76 -0
- package/carbon/tests/unit/server/env-bool.test.ts +63 -0
- package/carbon/tests/unit/server/paddle-webhook.test.ts +57 -0
- package/carbon/tests/unit/server/polar-webhook.test.ts +63 -0
- package/carbon/tests/unit/server/supabase-client.test.ts +30 -0
- package/carbon/volumes/db/wal-archive.sh +36 -0
- package/package.json +2 -2
- package/src/backup.js +0 -2
- package/src/cli.js +16 -0
- package/src/deploy.js +16 -5
- package/src/destroy.js +78 -0
- package/src/failover.js +118 -237
- package/src/lib/command.js +19 -1
- package/src/lib/deploy/compose/ha.js +180 -102
- package/src/lib/deploy/compose/index.js +63 -20
- package/src/lib/deploy/image.js +19 -23
- package/src/lib/deploy/k8s/ha/index.js +397 -150
- package/src/lib/deploy/k8s/k3s.js +65 -14
- package/src/lib/deploy/orchestrator.js +176 -6
- package/src/lib/deploy/remote-build.js +8 -1
- package/src/lib/deploy/replication.js +540 -0
- package/src/lib/deploy/state.js +62 -5
- package/src/lib/deploy/utils.js +30 -2
- package/src/lib/deploy/wireguard.js +146 -0
- package/src/lib/host-keys.js +120 -5
- package/src/lib/iac/index.js +57 -23
- package/src/lib/licensing/gate.js +59 -0
- package/src/lib/licensing/index.js +1 -1
- package/src/lib/licensing/tiers.js +6 -5
- package/src/lib/prod-confirm.js +65 -0
- package/src/lib/providers/hetzner-s3.js +85 -0
- package/src/lib/ssh.js +35 -35
- package/src/restore.js +102 -14
- package/src/scale.js +4 -2
- package/src/status.js +122 -0
- package/src/upgrade.js +0 -4
- package/carbon/ha/activate-standby.sh +0 -52
- package/carbon/ha/standby-init.sh +0 -74
- package/carbon/src/server/lib/request.ts +0 -34
- package/src/lib/pod-backups.js +0 -53
|
@@ -37,9 +37,9 @@ import { tmpdir } from 'node:os';
|
|
|
37
37
|
import { join } from 'node:path';
|
|
38
38
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
39
39
|
import * as p from '@clack/prompts';
|
|
40
|
-
import { runCommandAsync } from '../../command.js';
|
|
40
|
+
import { runCommandAsync, writeSecretFile } from '../../command.js';
|
|
41
41
|
import { featureConfigKeys, featureSecretKeys } from '../../config-registry.js';
|
|
42
|
-
import { knownHostsPath } from '../../host-keys.js';
|
|
42
|
+
import { knownHostsPath, seedKnownHosts } from '../../host-keys.js';
|
|
43
43
|
import { loadCloudInit, renderScript } from '../../iac/cloud-init.js';
|
|
44
44
|
import { dbImageRef } from '../../images.js';
|
|
45
45
|
import { perfAsync } from '../../perf.js';
|
|
@@ -1101,21 +1101,28 @@ export async function pushImageToLocalRegistry({
|
|
|
1101
1101
|
);
|
|
1102
1102
|
});
|
|
1103
1103
|
|
|
1104
|
-
//
|
|
1105
|
-
//
|
|
1104
|
+
// Progressive backoff across attempts. Failure modes we're covering, in
|
|
1105
|
+
// priority order:
|
|
1106
1106
|
// 1. Stale tunnel from a parallel run holds :5000 → reopen tunnel.
|
|
1107
1107
|
// Recovers in seconds (handled by the per-attempt teardown).
|
|
1108
1108
|
// 2. Registry pod restarted between back-to-back pushes (e.g.
|
|
1109
1109
|
// probe-killed mid-prior-push) → manifest PUT returns EOF or
|
|
1110
1110
|
// `manifest blob unknown to registry`. Pod recovery is ~10–20s,
|
|
1111
|
-
// so the
|
|
1111
|
+
// so the later attempts have the longer backoff to let the
|
|
1112
1112
|
// replacement pod become Ready before re-pushing.
|
|
1113
|
-
// 3. S3
|
|
1114
|
-
// → registry
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
//
|
|
1118
|
-
|
|
1113
|
+
// 3. S3 throttle (Hetzner Object Storage 503 SlowDown during blob
|
|
1114
|
+
// upload) → the registry propagates a `503 Service Unavailable` to
|
|
1115
|
+
// the docker client. This is LOAD-induced: k8s-HA deploys the primary
|
|
1116
|
+
// and standby clusters in parallel, and BOTH registries write layers
|
|
1117
|
+
// to the same Hetzner S3 account at once, so the account throttles.
|
|
1118
|
+
// 3 short attempts (~46s of settle) couldn't ride it out. The longer
|
|
1119
|
+
// tail (60s/120s) extends the window past the throttle window AND past
|
|
1120
|
+
// the sibling cluster's push draining, which halves the S3 write load.
|
|
1121
|
+
// The 1s/15s head keeps the happy path fast (single attempt ~6s end-to-end)
|
|
1122
|
+
// while the 30s/60s/120s tail rides out genuine restarts + S3 throttling.
|
|
1123
|
+
// Note docker push is content-addressable: layers whose S3 write already
|
|
1124
|
+
// succeeded are skipped on retry, so later attempts get progressively cheaper.
|
|
1125
|
+
const settleDelays = settleDelaysMs ?? [1_000, 15_000, 30_000, 60_000, 120_000];
|
|
1119
1126
|
const maxAttempts = settleDelays.length;
|
|
1120
1127
|
const attemptErrors = [];
|
|
1121
1128
|
const taggedAliases = new Set();
|
|
@@ -1325,6 +1332,7 @@ export async function installSupabase({
|
|
|
1325
1332
|
envLocal,
|
|
1326
1333
|
dbImageTag,
|
|
1327
1334
|
backupBucketName,
|
|
1335
|
+
walgRole = 'primary',
|
|
1328
1336
|
}) {
|
|
1329
1337
|
const env = { ...process.env, KUBECONFIG: kubeconfig };
|
|
1330
1338
|
// Render the values template — substitute every {{TOKEN}} the template
|
|
@@ -1351,6 +1359,15 @@ export async function installSupabase({
|
|
|
1351
1359
|
// WAL-G S3 prefix — dedicated backup bucket if provided, else the storage
|
|
1352
1360
|
// bucket (mirrors compose's S3_BACKUP_BUCKET:-S3_BUCKET fallback). Never
|
|
1353
1361
|
// leave it as s3:/// (wal-g would error).
|
|
1362
|
+
//
|
|
1363
|
+
// SINGLE canonical prefix (NO role segment). Reads (backup-fetch/restore/
|
|
1364
|
+
// reseed) and writes (backup-push/wal-push) must all agree on ONE prefix — a
|
|
1365
|
+
// role-segmented prefix made the standby read an empty `…/walg/standby` and
|
|
1366
|
+
// fail restore/reseed with "No backups found" (caught by compose-ha scale
|
|
1367
|
+
// e2e). Anti-collision (finding #3: a standby / bring-up-phase independent
|
|
1368
|
+
// primary must never WRITE here) is enforced by the WALG_ROLE WRITE-GUARD:
|
|
1369
|
+
// wal-archive.sh and the backup CronJob both no-op when WALG_ROLE=standby.
|
|
1370
|
+
// walgRole is rendered into the db env below so those write guards can read it.
|
|
1354
1371
|
const walgBucket = backupBucketName || s3Config?.bucket || '';
|
|
1355
1372
|
const walgS3Prefix = walgBucket
|
|
1356
1373
|
? `s3://${walgBucket}/backups/${projectName || 'vibecarbon'}/walg`
|
|
@@ -1360,6 +1377,7 @@ export async function installSupabase({
|
|
|
1360
1377
|
.replace(/\{\{PROJECT_NAME\}\}/g, projectName || 'vibecarbon')
|
|
1361
1378
|
.replace(/\{\{S3_BACKUP_BUCKET\}\}/g, walgBucket)
|
|
1362
1379
|
.replace(/\{\{WALG_S3_PREFIX\}\}/g, walgS3Prefix)
|
|
1380
|
+
.replace(/\{\{WALG_ROLE\}\}/g, walgRole)
|
|
1363
1381
|
.replace(/\{\{S3_ENDPOINT\}\}/g, s3Config?.endpoint ?? '')
|
|
1364
1382
|
.replace(/\{\{S3_REGION\}\}/g, s3Config?.region ?? '')
|
|
1365
1383
|
.replace(/\{\{DB_IMAGE\}\}/g, dbImageRepo)
|
|
@@ -1370,7 +1388,9 @@ export async function installSupabase({
|
|
|
1370
1388
|
.replace(/\{\{ADMIN_EMAIL\}\}/g, envLocal?.ADMIN_EMAIL ?? '')
|
|
1371
1389
|
.replace(/\{\{ADMIN_PASSWORD\}\}/g, envLocal?.ADMIN_PASSWORD ?? '');
|
|
1372
1390
|
const tmpValues = join(tmpdir(), `vibecarbon-supabase-values-${process.pid}-${Date.now()}.yaml`);
|
|
1373
|
-
|
|
1391
|
+
// SECURITY: the rendered values contain ADMIN_EMAIL/ADMIN_PASSWORD and live
|
|
1392
|
+
// for the whole `helm upgrade --wait` window — write 0o600, never 0o644.
|
|
1393
|
+
writeSecretFile(tmpValues, rendered);
|
|
1374
1394
|
try {
|
|
1375
1395
|
// Add the supabase-community helm repo (idempotent — non-zero if exists).
|
|
1376
1396
|
try {
|
|
@@ -2344,6 +2364,12 @@ export async function applyK3sManifests({
|
|
|
2344
2364
|
// timeout in chart). image.db points at the wal-g-equipped db image
|
|
2345
2365
|
// (sideloaded in step 7b), and the chart mounts wal-archive.sh + the
|
|
2346
2366
|
// non-secret WALG_* env from supabase.values.yaml.
|
|
2367
|
+
// Derive the wal-g WRITE-GUARD role (NOT a path segment). deployK8sHA calls
|
|
2368
|
+
// deployK3s with environment `${env}-primary` / `${env}-standby`; single-
|
|
2369
|
+
// cluster deploys pass the plain env name → 'primary'. Rendered into the db
|
|
2370
|
+
// container env as WALG_ROLE so wal-archive.sh + the backup CronJob no-op on
|
|
2371
|
+
// the standby. The S3 prefix itself stays canonical (no role segment).
|
|
2372
|
+
const walgRole = environment?.endsWith('-standby') ? 'standby' : 'primary';
|
|
2347
2373
|
await perfAsync(`deploy.${perfPrefix}.installSupabase`, async () =>
|
|
2348
2374
|
installSupabase({
|
|
2349
2375
|
kubeconfig,
|
|
@@ -2354,6 +2380,7 @@ export async function applyK3sManifests({
|
|
|
2354
2380
|
envLocal,
|
|
2355
2381
|
dbImageTag,
|
|
2356
2382
|
backupBucketName,
|
|
2383
|
+
walgRole,
|
|
2357
2384
|
}),
|
|
2358
2385
|
);
|
|
2359
2386
|
// 7b. Enable continuous WAL archiving on the supabase-db. The chart can't
|
|
@@ -2593,7 +2620,7 @@ export async function applyK3sManifests({
|
|
|
2593
2620
|
* App install + sideload land in a follow-up commit (Task 7).
|
|
2594
2621
|
*
|
|
2595
2622
|
* @param {K3sDeployOptions} options
|
|
2596
|
-
* @returns {Promise<{masterIp: string, floatingIp: string, supabaseIp: string, workerIps: string[], networkId: string|number, kubeconfig: string, sshKeyPath: string}>}
|
|
2623
|
+
* @returns {Promise<{masterIp: string, floatingIp: string, supabaseIp: string, supabasePrivateIp: string, workerIps: string[], networkId: string|number, kubeconfig: string, sshKeyPath: string}>}
|
|
2597
2624
|
*/
|
|
2598
2625
|
export async function deployK3s(options) {
|
|
2599
2626
|
const { state } = options;
|
|
@@ -2689,7 +2716,14 @@ export async function deployK3s(options) {
|
|
|
2689
2716
|
s.stop(`Cluster provisioned — master=${outputs.masterIp} floating=${outputs.floatingIp}`);
|
|
2690
2717
|
}
|
|
2691
2718
|
const infraOutputs = state.getStepResult('k3s-infra');
|
|
2692
|
-
const {
|
|
2719
|
+
const {
|
|
2720
|
+
masterIp,
|
|
2721
|
+
floatingIp,
|
|
2722
|
+
supabaseIp,
|
|
2723
|
+
supabasePrivateIp = '10.0.1.2',
|
|
2724
|
+
workerIps,
|
|
2725
|
+
networkId,
|
|
2726
|
+
} = infraOutputs;
|
|
2693
2727
|
|
|
2694
2728
|
// 4. Wait for cloud-init to finish + k3s to be Ready
|
|
2695
2729
|
if (!state.shouldSkip('k3s-ready', { masterIp })) {
|
|
@@ -2709,6 +2743,18 @@ export async function deployK3s(options) {
|
|
|
2709
2743
|
s.stop('k3s reachable');
|
|
2710
2744
|
}
|
|
2711
2745
|
|
|
2746
|
+
// Trusted host-key seed. The SSH opts above pin per-env with accept-new
|
|
2747
|
+
// (TOFU on first connect); seeding the known_hosts from a real ssh-keyscan
|
|
2748
|
+
// now — on (re)provision, once SSH is reachable — turns accept-new into a
|
|
2749
|
+
// strict pin for the rest of this deploy AND every later command, and
|
|
2750
|
+
// re-pins a Hetzner-recycled IP cleanly (seedKnownHosts drops the stale
|
|
2751
|
+
// per-IP line). Best-effort: if ssh-keyscan can't reach a node we fall back
|
|
2752
|
+
// to accept-new TOFU. See host-keys.js seedKnownHosts for the full security
|
|
2753
|
+
// rationale (why only provisioning re-seeds).
|
|
2754
|
+
for (const seedIp of [masterIp, supabaseIp, ...(workerIps ?? [])].filter(Boolean)) {
|
|
2755
|
+
await seedKnownHosts(khPath, seedIp);
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2712
2758
|
// 5. Fetch + patch kubeconfig
|
|
2713
2759
|
let kubeconfig;
|
|
2714
2760
|
if (!state.shouldSkip('k3s-kubeconfig', { masterIp })) {
|
|
@@ -2827,6 +2873,11 @@ export async function deployK3s(options) {
|
|
|
2827
2873
|
masterIp,
|
|
2828
2874
|
floatingIp,
|
|
2829
2875
|
supabaseIp,
|
|
2876
|
+
// The supabase node's Hetzner private-network IP (deterministic in the IaC
|
|
2877
|
+
// program, but threaded through the output rather than hardcoded at the use
|
|
2878
|
+
// site). setupReplication renders it into the repl-gateway egress
|
|
2879
|
+
// NetworkPolicy + socat bind for this cluster.
|
|
2880
|
+
supabasePrivateIp,
|
|
2830
2881
|
workerIps,
|
|
2831
2882
|
networkId,
|
|
2832
2883
|
kubeconfig,
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
saveProjectConfig,
|
|
17
17
|
} from '../config.js';
|
|
18
18
|
import { waitForDNSPropagation } from '../dns-propagation.js';
|
|
19
|
+
import { knownHostsPathForKey, seedKnownHosts } from '../host-keys.js';
|
|
19
20
|
import { ensureOperatorIpAccess } from '../operator-ip.js';
|
|
20
21
|
import { perfAsync, perfTimer } from '../perf.js';
|
|
21
22
|
import { createTracker } from '../tracker.js';
|
|
@@ -223,16 +224,45 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
223
224
|
}
|
|
224
225
|
|
|
225
226
|
const s3Spinner = p.spinner();
|
|
226
|
-
const { HetznerS3Provider } = await import('../providers/hetzner-s3.js');
|
|
227
|
+
const { HetznerS3Provider, deriveStateBucketName } = await import('../providers/hetzner-s3.js');
|
|
227
228
|
const s3Provider = new HetznerS3Provider(s3Config.accessKey, s3Config.secretKey, s3Config.region);
|
|
228
229
|
|
|
230
|
+
// Dedicated Pulumi-state bucket, derived once from the app storage bucket.
|
|
231
|
+
// Pulumi state used to live in the app storage bucket, which meant `destroy`
|
|
232
|
+
// deleting that bucket could yank the state backend mid-destroy. State now
|
|
233
|
+
// lives here instead. Both HA stacks share this ONE bucket.
|
|
234
|
+
const stateBucket = deriveStateBucketName(s3Config.bucket);
|
|
235
|
+
|
|
229
236
|
// --- STEP 1: S3 Setup (Idempotent) ---
|
|
237
|
+
// `stateBucket` is part of the inputs hash so an upgraded CLI (whose prior
|
|
238
|
+
// deploy-state predates the dedicated state bucket) re-runs this step once to
|
|
239
|
+
// create + migrate the state bucket. Combined with the StateTracker version
|
|
240
|
+
// bump, no stale hash can skip the migration.
|
|
230
241
|
const s3Inputs = {
|
|
231
242
|
bucket: s3Config.bucket,
|
|
232
243
|
backupBucket: backupS3Config.bucket,
|
|
244
|
+
stateBucket,
|
|
233
245
|
region: s3Config.region,
|
|
234
246
|
};
|
|
235
|
-
|
|
247
|
+
// Verify hook: even on a matching hash, re-run s3-setup if any of the buckets
|
|
248
|
+
// were deleted out-of-band (e.g. by a prior `destroy`). Without this, a
|
|
249
|
+
// resumed deploy skips bucket creation then fails downstream on NoSuchBucket
|
|
250
|
+
// (the exact resume hazard called out in Finding 2). Other steps that would
|
|
251
|
+
// benefit from a similar probe — `compose-setup-server` (server may be gone)
|
|
252
|
+
// and `dns-setup` — are left on the plain hash check to keep this change
|
|
253
|
+
// focused; s3-setup is the highest-value one because the state backend lives
|
|
254
|
+
// here.
|
|
255
|
+
const verifyBucketsExist = async () => {
|
|
256
|
+
const [app, backup, stateB] = await Promise.all([
|
|
257
|
+
s3Provider.bucketExists(s3Config.bucket),
|
|
258
|
+
backupS3Config?.bucket
|
|
259
|
+
? s3Provider.bucketExists(backupS3Config.bucket)
|
|
260
|
+
: Promise.resolve(true),
|
|
261
|
+
s3Provider.bucketExists(stateBucket),
|
|
262
|
+
]);
|
|
263
|
+
return app && backup && stateB;
|
|
264
|
+
};
|
|
265
|
+
if (!(await state.shouldSkipWithVerify('s3-setup', s3Inputs, verifyBucketsExist))) {
|
|
236
266
|
state.startStep('s3-setup', s3Inputs);
|
|
237
267
|
try {
|
|
238
268
|
s3Spinner.start(`Creating S3 bucket: ${s3Config.bucket}`);
|
|
@@ -245,7 +275,22 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
245
275
|
|
|
246
276
|
try {
|
|
247
277
|
s3Spinner.start('Configuring bucket CORS');
|
|
248
|
-
|
|
278
|
+
// SECURITY: never fall back to a `*` wildcard — that would let any
|
|
279
|
+
// origin issue credentialed browser requests against the storage
|
|
280
|
+
// bucket. When a domain is configured we scope to its https origins
|
|
281
|
+
// (apex + api/app subdomains the app actually serves from); a
|
|
282
|
+
// domainless deploy has no browser origin to trust yet, so we scope
|
|
283
|
+
// to localhost dev origins and warn. The operator re-runs deploy once
|
|
284
|
+
// a domain is set to widen CORS to the real origin.
|
|
285
|
+
let corsOrigins;
|
|
286
|
+
if (domain) {
|
|
287
|
+
corsOrigins = [`https://${domain}`, `https://api.${domain}`, `https://app.${domain}`];
|
|
288
|
+
} else {
|
|
289
|
+
corsOrigins = ['http://localhost:3000', 'http://localhost:5173'];
|
|
290
|
+
p.log.warn(
|
|
291
|
+
'No domain configured — scoping bucket CORS to localhost dev origins instead of `*`. Re-run deploy with a domain to allow your production origin.',
|
|
292
|
+
);
|
|
293
|
+
}
|
|
249
294
|
await s3Provider.configureCORS(s3Config.bucket, corsOrigins);
|
|
250
295
|
s3Spinner.stop('CORS configured');
|
|
251
296
|
} catch {
|
|
@@ -264,7 +309,98 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
264
309
|
s3Spinner.stop(`Backup bucket creation failed: ${backupBucketError.message}`);
|
|
265
310
|
}
|
|
266
311
|
|
|
267
|
-
|
|
312
|
+
// Dedicated Pulumi-state bucket. Created imperatively (like the app +
|
|
313
|
+
// backup buckets) BEFORE any Pulumi stack op / initBackend. NOT
|
|
314
|
+
// web-facing: no CORS, not public.
|
|
315
|
+
s3Spinner.start(`Creating Pulumi state bucket: ${stateBucket}`);
|
|
316
|
+
const stateBucketResult = await s3Provider.createBucket(stateBucket);
|
|
317
|
+
s3Spinner.stop(
|
|
318
|
+
stateBucketResult.created
|
|
319
|
+
? `Pulumi state bucket created: ${stateBucket}`
|
|
320
|
+
: `Pulumi state bucket exists: ${stateBucket}`,
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
// SECURITY/SAFETY: migrate legacy Pulumi state out of the app storage
|
|
324
|
+
// bucket. If the state bucket has no `.pulumi/` objects but the OLD app
|
|
325
|
+
// bucket does, this is an env deployed before the dedicated state bucket
|
|
326
|
+
// existed — copy the state prefix over so we resolve to real state, not
|
|
327
|
+
// empty state. Re-initializing an EMPTY state backend against live infra
|
|
328
|
+
// would make Pulumi believe nothing exists and either duplicate or orphan
|
|
329
|
+
// the running servers. So if the copy fails, we DO NOT proceed silently:
|
|
330
|
+
// abort loudly and tell the operator to migrate manually or
|
|
331
|
+
// destroy+redeploy. Fresh deploys hit neither branch (both buckets empty).
|
|
332
|
+
const PULUMI_STATE_PREFIX = '.pulumi/';
|
|
333
|
+
// Determine whether there is GENUINE legacy state to migrate. The check
|
|
334
|
+
// lists the just-created state bucket, which can race S3 list-after-create
|
|
335
|
+
// consistency and throw — that is NOT the orphan-risk case (a fresh deploy
|
|
336
|
+
// has no legacy state), so a check failure must NOT hard-abort. Only a
|
|
337
|
+
// CONFIRMED legacy-state copy failure aborts (below). Retry the checks a
|
|
338
|
+
// few times so a transient blip doesn't misfire either way.
|
|
339
|
+
let legacyState = false;
|
|
340
|
+
let checkOk = false;
|
|
341
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
342
|
+
try {
|
|
343
|
+
const stateSeeded = await s3Provider.hasObjectsWithPrefix(
|
|
344
|
+
stateBucket,
|
|
345
|
+
PULUMI_STATE_PREFIX,
|
|
346
|
+
);
|
|
347
|
+
legacyState = stateSeeded
|
|
348
|
+
? false
|
|
349
|
+
: await s3Provider.hasObjectsWithPrefix(s3Config.bucket, PULUMI_STATE_PREFIX);
|
|
350
|
+
checkOk = true;
|
|
351
|
+
break;
|
|
352
|
+
} catch (checkError) {
|
|
353
|
+
if (attempt === 3) {
|
|
354
|
+
// Couldn't determine migration need after retries (e.g. list-after-
|
|
355
|
+
// create consistency on the fresh state bucket). A fresh deploy — the
|
|
356
|
+
// overwhelming common case — has nothing to migrate, so proceed with
|
|
357
|
+
// a loud warning rather than blocking every deploy on a transient S3
|
|
358
|
+
// blip. (Pulumi then uses the dedicated state bucket normally.)
|
|
359
|
+
p.log.warn(
|
|
360
|
+
`Could not verify legacy Pulumi state after ${attempt} attempts — proceeding ` +
|
|
361
|
+
`(a fresh deploy has none to migrate): ${checkError.message}`,
|
|
362
|
+
);
|
|
363
|
+
} else {
|
|
364
|
+
await new Promise((r) => setTimeout(r, 1500 * attempt));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (checkOk && legacyState) {
|
|
369
|
+
// CONFIRMED legacy state in the app bucket — migrate it. If THIS copy
|
|
370
|
+
// fails we DO NOT proceed silently: re-initializing an EMPTY state
|
|
371
|
+
// backend against live infra would orphan the running servers.
|
|
372
|
+
try {
|
|
373
|
+
s3Spinner.start(`Migrating Pulumi state ${s3Config.bucket} → ${stateBucket}`);
|
|
374
|
+
const { copied } = await s3Provider.copyPrefix(
|
|
375
|
+
s3Config.bucket,
|
|
376
|
+
stateBucket,
|
|
377
|
+
PULUMI_STATE_PREFIX,
|
|
378
|
+
);
|
|
379
|
+
s3Spinner.stop(`Migrated ${copied} Pulumi state object(s) to ${stateBucket}`);
|
|
380
|
+
} catch (copyError) {
|
|
381
|
+
s3Spinner.stop(`Pulumi state migration FAILED: ${copyError.message}`);
|
|
382
|
+
p.log.error(
|
|
383
|
+
[
|
|
384
|
+
'Could not migrate existing Pulumi state from the app storage bucket to the',
|
|
385
|
+
`dedicated state bucket (${stateBucket}). Proceeding would re-initialize an`,
|
|
386
|
+
'EMPTY state backend against your live infrastructure, which would orphan the',
|
|
387
|
+
'running servers (Pulumi would no longer track them).',
|
|
388
|
+
'',
|
|
389
|
+
'Aborting to protect your infrastructure. To recover, either:',
|
|
390
|
+
` • Copy the ".pulumi/" prefix from ${s3Config.bucket} to ${stateBucket} manually`,
|
|
391
|
+
' (e.g. via `aws s3 sync ... --endpoint-url <endpoint>`), then re-run deploy; or',
|
|
392
|
+
' • Destroy this environment and redeploy from scratch.',
|
|
393
|
+
].join('\n'),
|
|
394
|
+
);
|
|
395
|
+
process.exit(1);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const result = {
|
|
400
|
+
region: s3Provider.region,
|
|
401
|
+
endpoint: s3Provider.getEndpoint(),
|
|
402
|
+
stateBucket,
|
|
403
|
+
};
|
|
268
404
|
state.completeStep('s3-setup', result);
|
|
269
405
|
} catch (error) {
|
|
270
406
|
const errMsg =
|
|
@@ -285,6 +421,11 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
285
421
|
backupS3Config.region = s3Config.region;
|
|
286
422
|
backupS3Config.endpoint = s3Config.endpoint;
|
|
287
423
|
}
|
|
424
|
+
// Always resolve stateBucket (deterministic derivation) so the Pulumi
|
|
425
|
+
// backend targets the dedicated bucket, whether s3-setup ran or was skipped.
|
|
426
|
+
// Every downstream iac call reads it via s3Config, and it's persisted into
|
|
427
|
+
// .vibecarbon.json so scale / failover / destroy resolve the same backend.
|
|
428
|
+
s3Config.stateBucket = s3Result?.stateBucket || stateBucket;
|
|
288
429
|
|
|
289
430
|
// SKELETON SAVE: persist the minimum env entry that `vibecarbon destroy`
|
|
290
431
|
// needs to recover, BEFORE any `pulumi up` runs. Without this, a crash
|
|
@@ -315,7 +456,12 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
315
456
|
...(secondaryRegion ? { secondaryRegion } : {}),
|
|
316
457
|
...(domain ? { domain } : {}),
|
|
317
458
|
...(dnsProvider ? { dnsProvider } : {}),
|
|
318
|
-
s3: {
|
|
459
|
+
s3: {
|
|
460
|
+
bucket: s3Config.bucket,
|
|
461
|
+
region: s3Config.region,
|
|
462
|
+
endpoint: s3Config.endpoint,
|
|
463
|
+
stateBucket: s3Config.stateBucket,
|
|
464
|
+
},
|
|
319
465
|
backupS3: backupS3Config?.bucket
|
|
320
466
|
? {
|
|
321
467
|
bucket: backupS3Config.bucket,
|
|
@@ -558,6 +704,9 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
558
704
|
backupS3Config,
|
|
559
705
|
backupConfig,
|
|
560
706
|
bundlePath,
|
|
707
|
+
// Finding #1: hard-gate replication unless the operator opts into a
|
|
708
|
+
// warm/degraded standby via `deploy -allow-degraded`.
|
|
709
|
+
allowDegraded: !!args.allowDegraded,
|
|
561
710
|
onProgress: (msg) => s.message(msg),
|
|
562
711
|
}),
|
|
563
712
|
);
|
|
@@ -606,6 +755,14 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
606
755
|
serverHetznerId = result.outputs.serverId || null;
|
|
607
756
|
serverHetznerName = `${projectConfig.projectName}-${environment}`;
|
|
608
757
|
await perfAsync('deploy.iac.waitForSSH', () => waitForComposeSSH(serverIp, sshKeyPath, 30));
|
|
758
|
+
// Trusted host-key seed on fresh provision — captures the new VPS's
|
|
759
|
+
// host key via ssh-keyscan into the per-env known_hosts so the pinned
|
|
760
|
+
// SSH opts (accept-new) become a strict pin for this deploy and every
|
|
761
|
+
// later command. Re-seeding here also cleanly re-pins a Hetzner-
|
|
762
|
+
// recycled IP (destroy → redeploy). Best-effort: falls back to
|
|
763
|
+
// accept-new TOFU if the scan can't reach the server yet. See
|
|
764
|
+
// host-keys.js seedKnownHosts for the full security rationale.
|
|
765
|
+
await seedKnownHosts(knownHostsPathForKey(sshKeyPath), serverIp);
|
|
609
766
|
}
|
|
610
767
|
|
|
611
768
|
// --- Warm path (StateTracker) ---
|
|
@@ -961,6 +1118,9 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
961
1118
|
// container seeds PGDATA from S3 via wal-g and applyMigrations is
|
|
962
1119
|
// skipped (the restored DB is authoritative).
|
|
963
1120
|
restore: args.restore || null,
|
|
1121
|
+
// Finding #1: hard-gate k8s-HA replication unless the operator opts into a
|
|
1122
|
+
// warm/degraded standby via `deploy -allow-degraded`.
|
|
1123
|
+
allowDegraded: !!args.allowDegraded,
|
|
964
1124
|
operatorCidrs: projectConfig.operatorCidrs ?? [],
|
|
965
1125
|
imageReadyPromise,
|
|
966
1126
|
tracker,
|
|
@@ -1405,6 +1565,11 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
1405
1565
|
region,
|
|
1406
1566
|
...(isHADeploy && {
|
|
1407
1567
|
secondaryRegion,
|
|
1568
|
+
// Finding #1: persist DR posture. A default (gated) HA deploy that
|
|
1569
|
+
// reaches this point is streaming; only `-allow-degraded` finalizes a
|
|
1570
|
+
// warm/degraded standby, which the inner deploy flags on deployResult.
|
|
1571
|
+
replication: deployResult?.degraded ? 'degraded' : 'streaming',
|
|
1572
|
+
degraded: !!deployResult?.degraded,
|
|
1408
1573
|
// Persist per-cluster floatingIp + supabaseIp under ha.primary /
|
|
1409
1574
|
// ha.standby so failover.identifyServers takes the structured
|
|
1410
1575
|
// path that surfaces floatingIp. Without these, identifyServers
|
|
@@ -1475,7 +1640,12 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
1475
1640
|
}),
|
|
1476
1641
|
deployedAt: new Date().toISOString(),
|
|
1477
1642
|
deployedCommit,
|
|
1478
|
-
s3: {
|
|
1643
|
+
s3: {
|
|
1644
|
+
bucket: s3Config.bucket,
|
|
1645
|
+
region: s3Config.region,
|
|
1646
|
+
endpoint: s3Config.endpoint,
|
|
1647
|
+
stateBucket: s3Config.stateBucket,
|
|
1648
|
+
},
|
|
1479
1649
|
...(backupS3Persist && { backupS3: backupS3Persist }),
|
|
1480
1650
|
backup: backupConfig,
|
|
1481
1651
|
services,
|
|
@@ -9,6 +9,7 @@ import { tmpdir } from 'node:os';
|
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import * as p from '@clack/prompts';
|
|
11
11
|
import { runCommand } from '../command.js';
|
|
12
|
+
import { knownHostsPathForKey } from '../host-keys.js';
|
|
12
13
|
import { perfTimer } from '../perf.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -41,8 +42,13 @@ export async function buildRemote(ip, sshKeyPath, imageTag, cwd, buildArgs = {})
|
|
|
41
42
|
// session helper drops with "error reading preface ... file already closed".
|
|
42
43
|
// Keepalives let ssh detect a dead peer (~60s) and fail the attempt cleanly
|
|
43
44
|
// so the retry below can re-establish, instead of hanging to the build timeout.
|
|
45
|
+
// Host-key pinned against the per-env known_hosts (passed via env var so no
|
|
46
|
+
// shell-quoting concern), NOT /dev/null + no. accept-new TOFU's an
|
|
47
|
+
// ephemeral/recycled Hetzner IP but rejects a changed key for an already-
|
|
48
|
+
// pinned host (MITM on an established env fails); GlobalKnownHostsFile=
|
|
49
|
+
// /dev/null ignores the system file and never touches ~/.ssh/known_hosts.
|
|
44
50
|
const sshWrapper = `#!/bin/bash
|
|
45
|
-
exec /usr/bin/ssh -i "$VIBECARBON_SSH_KEY" -o StrictHostKeyChecking=
|
|
51
|
+
exec /usr/bin/ssh -i "$VIBECARBON_SSH_KEY" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$VIBECARBON_KNOWN_HOSTS" -o GlobalKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 "$@"
|
|
46
52
|
`;
|
|
47
53
|
writeFileSync(sshWrapperPath, sshWrapper, { mode: 0o755 });
|
|
48
54
|
|
|
@@ -52,6 +58,7 @@ exec /usr/bin/ssh -i "$VIBECARBON_SSH_KEY" -o StrictHostKeyChecking=no -o UserKn
|
|
|
52
58
|
DOCKER_BUILDKIT: '1',
|
|
53
59
|
PATH: `${tempBinDir}:${process.env.PATH}`, // Prepend our wrapper
|
|
54
60
|
VIBECARBON_SSH_KEY: sshKeyPath,
|
|
61
|
+
VIBECARBON_KNOWN_HOSTS: knownHostsPathForKey(sshKeyPath),
|
|
55
62
|
};
|
|
56
63
|
|
|
57
64
|
try {
|