vibecarbon 0.3.1 → 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/db/Dockerfile +14 -3
- package/carbon/docker-compose.dns01.prod.yml +42 -0
- 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/backup/configmap-walg.yaml +47 -0
- package/carbon/k8s/base/backup/cronjob.yaml +67 -92
- package/carbon/k8s/base/backup/kustomization.yaml +1 -0
- package/carbon/k8s/base/backup/network-policy.yaml +9 -14
- package/carbon/k8s/base/backup/rbac.yaml +40 -0
- package/carbon/k8s/base/network-policies.yaml +33 -0
- package/carbon/k8s/base/traefik/certificate.yaml +13 -7
- package/carbon/k8s/values/supabase.values.yaml +152 -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 -2
- package/src/backup.js +13 -66
- package/src/create.js +9 -4
- package/src/deploy.js +12 -0
- package/src/destroy.js +5 -43
- package/src/lib/backup-s3.js +0 -31
- package/src/lib/cloudflare.js +0 -59
- package/src/lib/config.js +1 -42
- package/src/lib/deploy/acme.js +57 -0
- package/src/lib/deploy/admin-user.js +105 -0
- package/src/lib/deploy/bundle.js +137 -5
- package/src/lib/deploy/compose/ha.js +208 -92
- package/src/lib/deploy/compose/index.js +292 -424
- package/src/lib/deploy/k8s/ha/index.js +12 -148
- package/src/lib/deploy/k8s/index.js +1 -21
- package/src/lib/deploy/k8s/k3s.js +407 -168
- package/src/lib/deploy/orchestrator.js +90 -33
- package/src/lib/deploy/utils.js +20 -0
- package/src/lib/hetzner-guided-setup.js +0 -7
- package/src/lib/iac/index.js +45 -16
- package/src/lib/images.js +17 -0
- package/src/lib/licensing/index.js +1 -59
- package/src/lib/licensing/tiers.js +0 -8
- package/src/lib/pod-backups.js +53 -0
- package/src/lib/providers/index.js +0 -47
- package/src/lib/ssh.js +12 -0
- package/src/restore.js +216 -302
- package/src/scale.js +76 -77
- package/carbon/backup/Dockerfile +0 -75
- package/carbon/backup/backup.sh +0 -95
- package/carbon/runtime.Dockerfile +0 -17
- package/src/lib/deploy/compose/acme-verify.js +0 -121
- package/src/lib/deploy/index.js +0 -119
- package/src/lib/kubectl.js +0 -81
|
@@ -19,6 +19,7 @@ import { waitForDNSPropagation } from '../dns-propagation.js';
|
|
|
19
19
|
import { ensureOperatorIpAccess } from '../operator-ip.js';
|
|
20
20
|
import { perfAsync, perfTimer } from '../perf.js';
|
|
21
21
|
import { createTracker } from '../tracker.js';
|
|
22
|
+
import { useDnsChallenge } from './acme.js';
|
|
22
23
|
import { renderBundle } from './bundle.js';
|
|
23
24
|
import { sideloadCompose } from './image.js';
|
|
24
25
|
import { buildRemote } from './remote-build.js';
|
|
@@ -458,6 +459,13 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
458
459
|
// gutter and print flush-left, breaking the visual flow. Bundle content
|
|
459
460
|
// is reproducible from the project tree; debug it by re-running with
|
|
460
461
|
// VIBECARBON_PERF=1 + VIBECARBON_BUNDLE_VERBOSE=1 if needed.
|
|
462
|
+
// Managed-DNS compose deploys (cloudflare/hetzner) solve ACME via DNS-01 —
|
|
463
|
+
// Traefik writes a TXT record through the provider API, so there is no
|
|
464
|
+
// HTTP-01 race against A-record propagation and no need for the
|
|
465
|
+
// DNS-propagation poll or the Traefik cert self-heal below. `manual` keeps
|
|
466
|
+
// HTTP-01. Scoped to compose: k8s issues certs via cert-manager and ignores
|
|
467
|
+
// this Traefik override entirely.
|
|
468
|
+
const dnsChallenge = isComposeDeploy && useDnsChallenge(dnsProvider);
|
|
461
469
|
const bundlePath = renderBundle(projectConfig.projectName, {
|
|
462
470
|
domain,
|
|
463
471
|
image: imageRef,
|
|
@@ -467,6 +475,10 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
467
475
|
redis: services.redis,
|
|
468
476
|
s3: s3Config,
|
|
469
477
|
backupS3: backupS3Config,
|
|
478
|
+
dnsChallenge,
|
|
479
|
+
dnsProvider,
|
|
480
|
+
cloudflareApiToken,
|
|
481
|
+
hetznerApiToken: apiToken,
|
|
470
482
|
verbose: process.env.VIBECARBON_BUNDLE_VERBOSE === '1',
|
|
471
483
|
});
|
|
472
484
|
bundleRenderTimer.end();
|
|
@@ -542,9 +554,14 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
542
554
|
);
|
|
543
555
|
s.stop('Compose HA deployment complete');
|
|
544
556
|
} else {
|
|
545
|
-
const {
|
|
546
|
-
|
|
547
|
-
|
|
557
|
+
const {
|
|
558
|
+
setupServer,
|
|
559
|
+
dockerLoginOnServer,
|
|
560
|
+
startComposeStack,
|
|
561
|
+
runMigrations,
|
|
562
|
+
createAdminUser,
|
|
563
|
+
setupComposeBackupCron,
|
|
564
|
+
} = await import('./compose/index.js');
|
|
548
565
|
const { setupServerFiles, waitForSSH: waitForComposeSSH } = await import(
|
|
549
566
|
'./compose/index.js'
|
|
550
567
|
);
|
|
@@ -750,27 +767,35 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
750
767
|
await perfAsync('deploy.dns.create', () => updateDnsIp(token, zoneId, domain, serverIp));
|
|
751
768
|
state.completeStep('compose-dns-update', { serverIp });
|
|
752
769
|
}
|
|
753
|
-
//
|
|
754
|
-
// budget covers Cloudflare's typical edge propagation (under
|
|
755
|
-
// with margin. Returns false on timeout; we proceed anyway —
|
|
756
|
-
// Traefik can still acquire certs on its own retries, the
|
|
757
|
-
//
|
|
758
|
-
//
|
|
770
|
+
// HTTP-01 only: block compose-up until public resolvers see the real
|
|
771
|
+
// IP. 120s budget covers Cloudflare's typical edge propagation (under
|
|
772
|
+
// 60s) with margin. Returns false on timeout; we proceed anyway —
|
|
773
|
+
// Traefik can still acquire certs on its own retries, the customer
|
|
774
|
+
// just sees a brief self-signed-cert window. Hard-failing here would
|
|
775
|
+
// mask deploys that succeed eventually.
|
|
776
|
+
//
|
|
777
|
+
// DNS-01 (managed providers) skips this entirely: lego validates via a
|
|
778
|
+
// TXT record it writes through the provider API, so cert issuance does
|
|
779
|
+
// not wait on the A record propagating. The A record we just wrote
|
|
780
|
+
// propagates in the background; the post-deploy health probe uses
|
|
781
|
+
// localhost + Host header and never depends on it.
|
|
759
782
|
//
|
|
760
783
|
// Visible spinner so the operator isn't staring at a frozen
|
|
761
784
|
// "Yes" screen for up to 120s on a cold deploy where the DNS
|
|
762
785
|
// edge is slow. `timer` indicator forces the rendered string to
|
|
763
786
|
// change every second so progress remains visibly alive.
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
787
|
+
if (!dnsChallenge) {
|
|
788
|
+
const dnsSpinner = p.spinner({ indicator: 'timer' });
|
|
789
|
+
dnsSpinner.start(`Waiting for ${domain} to resolve to ${serverIp}`);
|
|
790
|
+
const dnsPropagated = await perfAsync('deploy.dns.waitForPropagation', () =>
|
|
791
|
+
waitForDNSPropagation(domain, serverIp, 120_000),
|
|
792
|
+
);
|
|
793
|
+
dnsSpinner.stop(
|
|
794
|
+
dnsPropagated
|
|
795
|
+
? `DNS resolves: ${domain} → ${serverIp}`
|
|
796
|
+
: `DNS propagation timed out — proceeding anyway`,
|
|
797
|
+
);
|
|
798
|
+
}
|
|
774
799
|
}
|
|
775
800
|
|
|
776
801
|
// `docker compose up -d` is idempotent and cheap — always run so a
|
|
@@ -794,6 +819,24 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
794
819
|
runMigrations(serverIp, sshKeyPath, projectConfig.projectName),
|
|
795
820
|
);
|
|
796
821
|
|
|
822
|
+
// Create the production app super-admin in auth.users via GoTrue's admin
|
|
823
|
+
// API. Same class of trap as runMigrations above: this inline single-
|
|
824
|
+
// compose path previously skipped it (only deployComposeHA called
|
|
825
|
+
// createAdminUser), so a single-server deploy shipped a prod app the
|
|
826
|
+
// operator couldn't actually log into — the only admin user lived in
|
|
827
|
+
// their local Docker. Idempotent (422 = already exists); a failure here
|
|
828
|
+
// is a warning, not a hard abort, so a transient GoTrue blip doesn't
|
|
829
|
+
// fail an otherwise-healthy deploy — re-running `vibecarbon deploy`
|
|
830
|
+
// retries it.
|
|
831
|
+
const adminResult = await perfAsync('deploy.compose.createAdminUser', () =>
|
|
832
|
+
createAdminUser(serverIp, sshKeyPath, projectConfig.projectName),
|
|
833
|
+
);
|
|
834
|
+
if (adminResult.success) {
|
|
835
|
+
p.log.success(adminResult.message);
|
|
836
|
+
} else {
|
|
837
|
+
p.log.warn(`${adminResult.message}. Run \`vibecarbon deploy\` again to retry.`);
|
|
838
|
+
}
|
|
839
|
+
|
|
797
840
|
// --- Verifiable success gate ---
|
|
798
841
|
// Probe the app's own /api/health endpoint on the server itself (via
|
|
799
842
|
// localhost + Host header), bypassing DNS/TLS. A successful deploy
|
|
@@ -831,23 +874,33 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
831
874
|
}
|
|
832
875
|
healthSpinner.stop(`App is serving requests (HTTP ${health.status})`);
|
|
833
876
|
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
//
|
|
843
|
-
//
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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),
|
|
848
893
|
);
|
|
894
|
+
} catch (err) {
|
|
895
|
+
p.log.warn(`Scheduled backup cron install failed (deploy still succeeded): ${err.message}`);
|
|
849
896
|
}
|
|
850
897
|
|
|
898
|
+
// No ACME self-heal needed here. Managed-DNS deploys (cloudflare/hetzner)
|
|
899
|
+
// issue certs via DNS-01, which has no HTTP-01-vs-DNS-propagation race to
|
|
900
|
+
// recover from; `manual` DNS was never self-healed (we don't manage its
|
|
901
|
+
// records). The old acme.json poll + Traefik restart (acme-verify.js)
|
|
902
|
+
// existed only for the HTTP-01 race the DNS-01 switch eliminates.
|
|
903
|
+
|
|
851
904
|
deployResult = {
|
|
852
905
|
masterIp: serverIp,
|
|
853
906
|
serverId: serverHetznerId || 'manual',
|
|
@@ -883,6 +936,10 @@ export async function executeDeployment(args, gatheredConfig) {
|
|
|
883
936
|
s3Config,
|
|
884
937
|
backupConfig,
|
|
885
938
|
backupBucketName: backupS3Config?.bucket || null,
|
|
939
|
+
// DR: when set (`deploy -restore latest|<ts>`), the db pod's init
|
|
940
|
+
// container seeds PGDATA from S3 via wal-g and applyMigrations is
|
|
941
|
+
// skipped (the restored DB is authoritative).
|
|
942
|
+
restore: args.restore || null,
|
|
886
943
|
operatorCidrs: projectConfig.operatorCidrs ?? [],
|
|
887
944
|
imageReadyPromise,
|
|
888
945
|
tracker,
|
package/src/lib/deploy/utils.js
CHANGED
|
@@ -139,6 +139,26 @@ export function generateSSHKeyPair(keyPath) {
|
|
|
139
139
|
return readFileSync(`${keyPath}.pub`, 'utf-8').trim();
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Read REPL_PASSWORD — process.env first (CI may export it), then .env.local
|
|
144
|
+
* (where `vibecarbon create` writes it at project-init time). Shared by the
|
|
145
|
+
* compose-HA and k8s-HA replication paths so the parsing lives in one place.
|
|
146
|
+
* Accepts both double-quoted (machine secrets) and single-quoted
|
|
147
|
+
* (escapeDotenv'd user secrets) forms, matching create.js output.
|
|
148
|
+
*
|
|
149
|
+
* @param {string} [cwd] - directory to look for .env.local in
|
|
150
|
+
* @returns {string|null} the password, or null if absent everywhere
|
|
151
|
+
*/
|
|
152
|
+
export function readReplPassword(cwd = process.cwd()) {
|
|
153
|
+
if (process.env.REPL_PASSWORD) return process.env.REPL_PASSWORD;
|
|
154
|
+
const envLocalPath = join(cwd, '.env.local');
|
|
155
|
+
if (!existsSync(envLocalPath)) return null;
|
|
156
|
+
const content = readFileSync(envLocalPath, 'utf-8');
|
|
157
|
+
const m =
|
|
158
|
+
content.match(/^REPL_PASSWORD="([^"]+)"/m) || content.match(/^REPL_PASSWORD='([^']+)'/m);
|
|
159
|
+
return m ? m[1] : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
142
162
|
/**
|
|
143
163
|
* Wait for SSH to become available on a host
|
|
144
164
|
* @param {string} host
|
|
@@ -113,13 +113,6 @@ export async function saveIfWanted(credentials) {
|
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
/**
|
|
117
|
-
* Reset the session-level save preference (for testing)
|
|
118
|
-
*/
|
|
119
|
-
export function resetSavePreference() {
|
|
120
|
-
_savePreference = null;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
116
|
/**
|
|
124
117
|
* Get API token with improved visual guidance
|
|
125
118
|
* Lookup order: env var → credentials file → interactive prompt
|
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
|
}
|
|
@@ -290,15 +328,6 @@ export function classifyK3sTokenProbe({ outputs, error } = {}) {
|
|
|
290
328
|
};
|
|
291
329
|
}
|
|
292
330
|
|
|
293
|
-
/**
|
|
294
|
-
* Refresh the stack's view of reality without applying program changes.
|
|
295
|
-
* Useful before a partial scale operation.
|
|
296
|
-
*/
|
|
297
|
-
export async function refreshStack(stackName, program, options = {}) {
|
|
298
|
-
const stack = await getOrCreateStack(stackName, program, options);
|
|
299
|
-
await stack.refresh({ onOutput: options.onOutput, color: 'never' });
|
|
300
|
-
}
|
|
301
|
-
|
|
302
331
|
/**
|
|
303
332
|
* List stack names that exist in the configured backend for this project.
|
|
304
333
|
* Used by `destroy` to surface orphans (stacks without a matching envConfig).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the pre-published, multi-arch db image
|
|
3
|
+
* (supabase/postgres + wal-g). Built once per version by
|
|
4
|
+
* .github/workflows/publish-db-image.yml; pulled (never built) by deploys.
|
|
5
|
+
* Bumping the tag here + Dockerfile is the whole version-bump surface.
|
|
6
|
+
*/
|
|
7
|
+
// Publish to ghcr (GitHub Container Registry) under the hyperformant org
|
|
8
|
+
// (this repo is hyperformant/vibecarbon). The package must be set to PUBLIC
|
|
9
|
+
// so k3s/compose nodes pull without a secret. Single source of truth for the
|
|
10
|
+
// tag — bump here + the Dockerfile to cut a new version.
|
|
11
|
+
export const DB_IMAGE = 'ghcr.io/hyperformant/postgres';
|
|
12
|
+
export const DB_IMAGE_TAG = '15.8.1.085-walg3.0.5';
|
|
13
|
+
|
|
14
|
+
/** @returns {string} e.g. "ghcr.io/hyperformant/postgres:15.8.1.085-walg3.0.5" */
|
|
15
|
+
export function dbImageRef() {
|
|
16
|
+
return `${DB_IMAGE}:${DB_IMAGE_TAG}`;
|
|
17
|
+
}
|
|
@@ -9,7 +9,7 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from '
|
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { c } from '../colors.js';
|
|
12
|
-
import {
|
|
12
|
+
import { getTier, TIERS } from './tiers.js';
|
|
13
13
|
import { validateLicenseKey } from './validator.js';
|
|
14
14
|
|
|
15
15
|
// License storage location
|
|
@@ -165,32 +165,6 @@ export function deactivateLicense() {
|
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
/**
|
|
169
|
-
* Check if the current license allows a specific deployment mode
|
|
170
|
-
* @param {string} deploymentFlag - The deployment flag (e.g., '--k8s', '--ha')
|
|
171
|
-
* @returns {object} Access check result
|
|
172
|
-
*/
|
|
173
|
-
export function checkDeploymentAccess(deploymentFlag) {
|
|
174
|
-
const license = getLicense();
|
|
175
|
-
|
|
176
|
-
// If licensing is disabled in the validator, allow everything
|
|
177
|
-
if (license.licensingDisabled) {
|
|
178
|
-
return {
|
|
179
|
-
allowed: true,
|
|
180
|
-
tier: license.tier,
|
|
181
|
-
licensingDisabled: true,
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const allowed = _allowsFlag(license.tier, deploymentFlag);
|
|
186
|
-
|
|
187
|
-
return {
|
|
188
|
-
allowed,
|
|
189
|
-
tier: license.tier,
|
|
190
|
-
requiredTier: allowed ? license.tier : 'diamond',
|
|
191
|
-
};
|
|
192
|
-
}
|
|
193
|
-
|
|
194
168
|
/**
|
|
195
169
|
* Guard function: require any paid license or exit with upgrade message
|
|
196
170
|
* @param {string} commandName - The command that requires a license
|
|
@@ -222,38 +196,6 @@ export function requireLicense(commandName) {
|
|
|
222
196
|
process.exit(0);
|
|
223
197
|
}
|
|
224
198
|
|
|
225
|
-
/**
|
|
226
|
-
* Get a formatted status message for the current license
|
|
227
|
-
* @returns {string} Status message
|
|
228
|
-
*/
|
|
229
|
-
export function getLicenseStatus() {
|
|
230
|
-
const license = getLicense();
|
|
231
|
-
|
|
232
|
-
if (!license.active) {
|
|
233
|
-
return `License: Graphite tier (free, no license activated)
|
|
234
|
-
Features: ${TIERS.graphite.features.join(', ')}
|
|
235
|
-
|
|
236
|
-
Upgrade to deploy:
|
|
237
|
-
Diamond ($149 lifetime) — deploy to production, indie & startup use
|
|
238
|
-
Fullerene ($499 lifetime) — deploy for clients & agencies, commercial use rights
|
|
239
|
-
|
|
240
|
-
Purchase at https://vibecarbon.dev/pricing`;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
let status = `License: ${license.displayName}
|
|
244
|
-
Customer ID: ${license.customerId}
|
|
245
|
-
Activated: ${license.activatedAt}
|
|
246
|
-
Features: ${license.features.join(', ')}
|
|
247
|
-
Max servers: ${license.maxServers === Infinity ? 'Unlimited' : license.maxServers}
|
|
248
|
-
Expires: Never (lifetime license)`;
|
|
249
|
-
|
|
250
|
-
if (license.devMode) {
|
|
251
|
-
status += '\n\n[Development Mode - Signature not verified]';
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
return status;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
199
|
// Re-export tier utilities
|
|
258
200
|
export { allowsFlag, compareTiers, getRequiredTier, getTier, TIERS } from './tiers.js';
|
|
259
201
|
export { generateDevLicenseKey, validateLicenseKey } from './validator.js';
|
|
@@ -157,14 +157,6 @@ export function getRequiredTier(flag) {
|
|
|
157
157
|
return 'diamond';
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
/**
|
|
161
|
-
* Get all tier names
|
|
162
|
-
* @returns {string[]} Array of tier names
|
|
163
|
-
*/
|
|
164
|
-
export function getTierNames() {
|
|
165
|
-
return Object.keys(TIERS);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
160
|
/**
|
|
169
161
|
* Compare two tiers and return which one is higher
|
|
170
162
|
* @param {string} tierA - First tier name
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List Supabase database backups stored inside the Postgres pod's /backups dir
|
|
3
|
+
* (k8s mode, used when an env has no S3 configured). Shared by `backup` and
|
|
4
|
+
* `restore` so the parsing of `ls` output lives in exactly one place.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getPostgresPod, sshKubectl } from './ssh.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} ip - master node IP
|
|
11
|
+
* @param {string} sshKeyPath - path to the deploy SSH key
|
|
12
|
+
* @param {{ sort?: 'recent' | 'largest' }} [options]
|
|
13
|
+
* - 'recent' (default) → newest first (`ls -lt`); restore lists by recency.
|
|
14
|
+
* - 'largest' → biggest first with human-readable sizes (`ls -lhS`);
|
|
15
|
+
* backup lists by size.
|
|
16
|
+
* @returns {Array<{ size: string, date: string, name: string }>} parsed entries
|
|
17
|
+
* (empty array if the pod is unreachable or holds no backups)
|
|
18
|
+
*/
|
|
19
|
+
export function listPodBackups(ip, sshKeyPath, { sort = 'recent' } = {}) {
|
|
20
|
+
const pod = getPostgresPod(ip, sshKeyPath);
|
|
21
|
+
const lsFlags = sort === 'largest' ? '-lhS' : '-lt';
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const output = sshKubectl(ip, sshKeyPath, [
|
|
25
|
+
'exec',
|
|
26
|
+
'-n',
|
|
27
|
+
'vibecarbon',
|
|
28
|
+
pod,
|
|
29
|
+
'--',
|
|
30
|
+
'sh',
|
|
31
|
+
'-c',
|
|
32
|
+
`ls ${lsFlags} /backups/*_full.tar.gz /backups/*.sql.gz 2>/dev/null || echo NO_BACKUPS`,
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
if (output === 'NO_BACKUPS' || !output) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return output
|
|
40
|
+
.split('\n')
|
|
41
|
+
.filter((line) => line.includes('.tar.gz') || line.includes('.sql.gz'))
|
|
42
|
+
.map((line) => {
|
|
43
|
+
const parts = line.split(/\s+/);
|
|
44
|
+
return {
|
|
45
|
+
size: parts[4],
|
|
46
|
+
date: `${parts[5]} ${parts[6]} ${parts[7]}`,
|
|
47
|
+
name: parts[parts.length - 1].replace('/backups/', ''),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -74,14 +74,6 @@ export function listProviders() {
|
|
|
74
74
|
}));
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/**
|
|
78
|
-
* Get list of provider IDs
|
|
79
|
-
* @returns {string[]}
|
|
80
|
-
*/
|
|
81
|
-
export function getProviderIds() {
|
|
82
|
-
return Object.keys(PROVIDERS);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
77
|
/**
|
|
86
78
|
* Check if a provider exists
|
|
87
79
|
* @param {string} providerName - Provider name to check
|
|
@@ -91,14 +83,6 @@ export function hasProvider(providerName) {
|
|
|
91
83
|
return providerName.toLowerCase() in PROVIDERS;
|
|
92
84
|
}
|
|
93
85
|
|
|
94
|
-
/**
|
|
95
|
-
* Get the default provider ID
|
|
96
|
-
* @returns {string}
|
|
97
|
-
*/
|
|
98
|
-
export function getDefaultProvider() {
|
|
99
|
-
return 'hetzner';
|
|
100
|
-
}
|
|
101
|
-
|
|
102
86
|
/**
|
|
103
87
|
* Validate provider configuration
|
|
104
88
|
* @param {string} providerName - Provider name
|
|
@@ -128,37 +112,6 @@ export function validateProviderConfig(providerName, config) {
|
|
|
128
112
|
return { valid: errors.length === 0, errors };
|
|
129
113
|
}
|
|
130
114
|
|
|
131
|
-
/**
|
|
132
|
-
* Format provider regions for display (e.g., in CLI prompts)
|
|
133
|
-
* @param {string} providerName - Provider name
|
|
134
|
-
* @returns {Array<{value: string, label: string}>}
|
|
135
|
-
*/
|
|
136
|
-
export function formatRegionsForSelect(providerName) {
|
|
137
|
-
const Provider = PROVIDERS[providerName.toLowerCase()];
|
|
138
|
-
if (!Provider) return [];
|
|
139
|
-
|
|
140
|
-
return Object.entries(Provider.REGIONS).map(([id, name]) => ({
|
|
141
|
-
value: id,
|
|
142
|
-
label: `${id} - ${name}`,
|
|
143
|
-
}));
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Format provider server types for display
|
|
148
|
-
* @param {string} providerName - Provider name
|
|
149
|
-
* @returns {Array<{value: string, label: string, hint: string}>}
|
|
150
|
-
*/
|
|
151
|
-
export function formatServerTypesForSelect(providerName) {
|
|
152
|
-
const Provider = PROVIDERS[providerName.toLowerCase()];
|
|
153
|
-
if (!Provider) return [];
|
|
154
|
-
|
|
155
|
-
return Object.entries(Provider.SERVER_TYPES).map(([id, spec]) => ({
|
|
156
|
-
value: id,
|
|
157
|
-
label: id,
|
|
158
|
-
hint: `${spec.vcpu} vCPU, ${spec.ram}GB RAM, ${spec.disk}GB SSD - ${spec.price}`,
|
|
159
|
-
}));
|
|
160
|
-
}
|
|
161
|
-
|
|
162
115
|
// Re-export base class and providers for direct access
|
|
163
116
|
export { BaseProvider } from './base.js';
|
|
164
117
|
export { HetznerProvider } from './hetzner.js';
|
package/src/lib/ssh.js
CHANGED
|
@@ -94,6 +94,18 @@ function sshHostKeyOpts(env, { firstConnect = false } = {}) {
|
|
|
94
94
|
return opts;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The no-host-key-pinning SSH option set as a single space-joined string,
|
|
99
|
+
* for the compose deploy path's string-command runners (sshRun/sshRunAsync
|
|
100
|
+
* in compose/index.js) and raw `ssh ${SSH_OPTS}` interpolations.
|
|
101
|
+
*
|
|
102
|
+
* Derived from sshHostKeyOpts() so the string form can never drift from the
|
|
103
|
+
* argv form — the historical footgun was compose/ha.js hand-copying this
|
|
104
|
+
* string and silently dropping the ServerAlive keepalives, letting a
|
|
105
|
+
* banner-exchange hang stall an HA deploy for ~600s.
|
|
106
|
+
*/
|
|
107
|
+
export const NO_PIN_SSH_OPTS = sshHostKeyOpts().join(' ');
|
|
108
|
+
|
|
97
109
|
/**
|
|
98
110
|
* Execute a command on a remote server via SSH using argv form.
|
|
99
111
|
*
|