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
|
@@ -11,16 +11,16 @@
|
|
|
11
11
|
|
|
12
12
|
import { spawn } from 'node:child_process';
|
|
13
13
|
import { randomBytes } from 'node:crypto';
|
|
14
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync
|
|
14
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
15
15
|
import { tmpdir } from 'node:os';
|
|
16
16
|
import { dirname, join } from 'node:path';
|
|
17
17
|
import { fileURLToPath } from 'node:url';
|
|
18
18
|
import * as p from '@clack/prompts';
|
|
19
|
-
import { uploadS3Backup } from '../../backup-s3.js';
|
|
20
19
|
import { runCommand, runCommandAsync } from '../../command.js';
|
|
21
20
|
import { perfAsync, perfTimer } from '../../perf.js';
|
|
22
|
-
import { shEscape } from '../../shell.js';
|
|
23
|
-
import {
|
|
21
|
+
import { parseDotenv, shEscape } from '../../shell.js';
|
|
22
|
+
import { NO_PIN_SSH_OPTS, sshRunScript } from '../../ssh.js';
|
|
23
|
+
import { postAdminUser, waitForGotrueHealth } from '../admin-user.js';
|
|
24
24
|
|
|
25
25
|
const __filename = fileURLToPath(import.meta.url);
|
|
26
26
|
const __dirname = dirname(__filename);
|
|
@@ -41,18 +41,20 @@ export function loadCloudInitScript() {
|
|
|
41
41
|
return readFileSync(CLOUD_INIT_PATH, 'utf-8');
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
const SSH_OPTS =
|
|
50
|
-
'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=4';
|
|
44
|
+
// Single-sourced from lib/ssh.js (sshHostKeyOpts) so the string form used by
|
|
45
|
+
// the runners below can't drift from the argv form — notably keeping the
|
|
46
|
+
// ServerAlive keepalives that fail a banner-exchange hang fast instead of
|
|
47
|
+
// stalling for ~600s. Re-exported as the canonical compose deploy SSH opts.
|
|
48
|
+
// shell-safety-ignore: NO_PIN_SSH_OPTS is derived from lib/ssh.js sshHostKeyOpts(), which bakes in BatchMode=yes (validated there)
|
|
49
|
+
const SSH_OPTS = NO_PIN_SSH_OPTS;
|
|
51
50
|
|
|
52
51
|
/**
|
|
53
|
-
* Run a command on a remote server via SSH
|
|
52
|
+
* Run a command on a remote server via SSH.
|
|
53
|
+
*
|
|
54
|
+
* Shared with compose/ha.js (imported, not re-copied) so both halves of the
|
|
55
|
+
* compose path use identical, single-sourced SSH options.
|
|
54
56
|
*/
|
|
55
|
-
function sshRun(ip, sshKeyPath, command, options = {}) {
|
|
57
|
+
export function sshRun(ip, sshKeyPath, command, options = {}) {
|
|
56
58
|
const { timeout = 120_000 } = options;
|
|
57
59
|
return runCommand(['ssh', ...SSH_OPTS.split(' '), '-i', sshKeyPath, `root@${ip}`, command], {
|
|
58
60
|
stdio: 'pipe',
|
|
@@ -700,16 +702,6 @@ export async function verifyAppHealth(ip, sshKeyPath, projectName, options = {})
|
|
|
700
702
|
return { healthy: false, status: lastStatus, details };
|
|
701
703
|
}
|
|
702
704
|
|
|
703
|
-
/**
|
|
704
|
-
* Stop the Docker Compose stack on the remote server
|
|
705
|
-
*/
|
|
706
|
-
export async function stopComposeStack(ip, sshKeyPath, projectName, options = {}) {
|
|
707
|
-
const remoteDir = `/opt/${projectName}`;
|
|
708
|
-
const flags = composeFileFlags(options);
|
|
709
|
-
const composeCmd = `cd ${remoteDir} && docker compose ${flags} down --timeout 30`;
|
|
710
|
-
await sshRunAsync(ip, sshKeyPath, composeCmd, { timeout: 120_000 });
|
|
711
|
-
}
|
|
712
|
-
|
|
713
705
|
/**
|
|
714
706
|
* Pre-pull container images on the remote server.
|
|
715
707
|
* Run this in parallel with the local Docker build to overlap I/O.
|
|
@@ -803,9 +795,8 @@ export async function runMigrations(ip, sshKeyPath, projectName) {
|
|
|
803
795
|
// 2026-05-26. Use the canonical SQL NOTIFY on the pgrst channel instead
|
|
804
796
|
// (db-channel-enabled defaults on); fall back to restarting rest if NOTIFY
|
|
805
797
|
// doesn't land. Best-effort: a missed reload self-heals on the next deploy.
|
|
806
|
-
// Lives here
|
|
807
|
-
//
|
|
808
|
-
// a queryable schema instead of PGRST205.
|
|
798
|
+
// Lives here so every caller — deployComposeHA and the orchestrator's inline
|
|
799
|
+
// single-compose path — gets a queryable schema instead of PGRST205.
|
|
809
800
|
await sshRunAsync(
|
|
810
801
|
ip,
|
|
811
802
|
sshKeyPath,
|
|
@@ -815,153 +806,6 @@ export async function runMigrations(ip, sshKeyPath, projectName) {
|
|
|
815
806
|
);
|
|
816
807
|
}
|
|
817
808
|
|
|
818
|
-
/**
|
|
819
|
-
* Main Compose deployment orchestrator
|
|
820
|
-
*
|
|
821
|
-
* @param {object} options
|
|
822
|
-
* @param {string} options.projectName - Project name
|
|
823
|
-
* @param {string} options.environment - Environment name (prod, staging, etc.)
|
|
824
|
-
* @param {string} options.ip - Server IP address
|
|
825
|
-
* @param {string} options.sshKeyPath - Path to SSH private key
|
|
826
|
-
* @param {string} [options.domain] - Custom domain
|
|
827
|
-
* @param {boolean} [options.isNewServer] - Whether server was just provisioned
|
|
828
|
-
* @param {object} [options.services] - Enabled services (observability, n8n, etc.)
|
|
829
|
-
* @param {Function} [options.onProgress] - Progress callback
|
|
830
|
-
* @returns {object} Deployment result
|
|
831
|
-
*/
|
|
832
|
-
export async function deployCompose(options) {
|
|
833
|
-
const {
|
|
834
|
-
projectName,
|
|
835
|
-
ip,
|
|
836
|
-
sshKeyPath,
|
|
837
|
-
domain,
|
|
838
|
-
isNewServer = false,
|
|
839
|
-
services = {},
|
|
840
|
-
imageRef,
|
|
841
|
-
onProgress = () => {},
|
|
842
|
-
} = options;
|
|
843
|
-
|
|
844
|
-
if (!imageRef) {
|
|
845
|
-
throw new Error('deployCompose requires options.imageRef (ghcr.io/<owner>/<repo>:<tag>)');
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
// Step 1: Install Docker on new servers
|
|
849
|
-
if (isNewServer) {
|
|
850
|
-
onProgress('Configuring server...');
|
|
851
|
-
await setupServer(ip, sshKeyPath);
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
const serviceOpts = {
|
|
855
|
-
observability: services.observability,
|
|
856
|
-
n8n: services.n8n,
|
|
857
|
-
metabase: services.metabase,
|
|
858
|
-
redis: services.redis,
|
|
859
|
-
};
|
|
860
|
-
|
|
861
|
-
// Step 2: Copy project files + point APP_IMAGE at the CI-built ghcr.io tag
|
|
862
|
-
onProgress('Copying project files to server...');
|
|
863
|
-
await setupServerFiles(ip, sshKeyPath, projectName, {
|
|
864
|
-
domain,
|
|
865
|
-
image: imageRef,
|
|
866
|
-
...serviceOpts,
|
|
867
|
-
});
|
|
868
|
-
|
|
869
|
-
// Step 3: Pull all images (app + Supabase) — docker compose pull handles it.
|
|
870
|
-
onProgress('Pulling images...');
|
|
871
|
-
await pullComposeImages(ip, sshKeyPath, projectName, serviceOpts);
|
|
872
|
-
|
|
873
|
-
// Step 4: Start Docker Compose stack
|
|
874
|
-
onProgress('Starting services...');
|
|
875
|
-
await startComposeStack(ip, sshKeyPath, projectName, serviceOpts);
|
|
876
|
-
|
|
877
|
-
// Step 5: Run migrations. A failure here means an empty/partial schema, which
|
|
878
|
-
// makes every DB-backed feature 500 — so let it abort the deploy rather than
|
|
879
|
-
// swallowing it (RCA prod-1 2026-05-26: the previous blanket try/catch hid a
|
|
880
|
-
// fully-failed migration and shipped a schema-less prod that reported success).
|
|
881
|
-
onProgress('Running database migrations...');
|
|
882
|
-
// runMigrations applies supabase/migrations/* AND reloads PostgREST's schema
|
|
883
|
-
// cache, so the new tables are immediately queryable (no PGRST205).
|
|
884
|
-
await runMigrations(ip, sshKeyPath, projectName);
|
|
885
|
-
|
|
886
|
-
return {
|
|
887
|
-
success: true,
|
|
888
|
-
ip,
|
|
889
|
-
domain,
|
|
890
|
-
deployMode: 'compose',
|
|
891
|
-
};
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
/**
|
|
895
|
-
* Fast re-deploy: sync new config and reconcile.
|
|
896
|
-
*/
|
|
897
|
-
export async function redeployCompose(options) {
|
|
898
|
-
const { projectName, ip, sshKeyPath, services = {}, imageRef, onProgress = () => {} } = options;
|
|
899
|
-
|
|
900
|
-
if (!imageRef) {
|
|
901
|
-
throw new Error('redeployCompose requires options.imageRef');
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
// Step 1: Sync new files (including .env with new image tag)
|
|
905
|
-
onProgress('Syncing configuration...');
|
|
906
|
-
await setupServerFiles(ip, sshKeyPath, projectName, {
|
|
907
|
-
image: imageRef,
|
|
908
|
-
observability: services.observability,
|
|
909
|
-
n8n: services.n8n,
|
|
910
|
-
metabase: services.metabase,
|
|
911
|
-
redis: services.redis,
|
|
912
|
-
domain: options.domain,
|
|
913
|
-
});
|
|
914
|
-
|
|
915
|
-
// Step 2: Reconcile (pulls images if needed and restarts)
|
|
916
|
-
onProgress('Reconciling services...');
|
|
917
|
-
await startComposeStack(ip, sshKeyPath, projectName, {
|
|
918
|
-
observability: services.observability,
|
|
919
|
-
n8n: services.n8n,
|
|
920
|
-
metabase: services.metabase,
|
|
921
|
-
redis: services.redis,
|
|
922
|
-
});
|
|
923
|
-
|
|
924
|
-
return { success: true };
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
/**
|
|
928
|
-
* Get status of Docker Compose services on the remote server
|
|
929
|
-
*/
|
|
930
|
-
export function getComposeStatus(ip, sshKeyPath, projectName) {
|
|
931
|
-
const remoteDir = `/opt/${projectName}`;
|
|
932
|
-
|
|
933
|
-
try {
|
|
934
|
-
const output = sshRun(ip, sshKeyPath, `cd ${remoteDir} && docker compose ps --format json`, {
|
|
935
|
-
timeout: 15_000,
|
|
936
|
-
});
|
|
937
|
-
|
|
938
|
-
if (!output?.trim()) {
|
|
939
|
-
return { running: false, services: [] };
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
// docker compose ps --format json outputs one JSON object per line
|
|
943
|
-
const services = output
|
|
944
|
-
.trim()
|
|
945
|
-
.split('\n')
|
|
946
|
-
.filter(Boolean)
|
|
947
|
-
.map((line) => {
|
|
948
|
-
try {
|
|
949
|
-
return JSON.parse(line);
|
|
950
|
-
} catch {
|
|
951
|
-
return null;
|
|
952
|
-
}
|
|
953
|
-
})
|
|
954
|
-
.filter(Boolean);
|
|
955
|
-
|
|
956
|
-
return {
|
|
957
|
-
running: services.some((s) => s.State === 'running'),
|
|
958
|
-
services,
|
|
959
|
-
};
|
|
960
|
-
} catch {
|
|
961
|
-
return { running: false, services: [] };
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
|
|
965
809
|
/**
|
|
966
810
|
* Destroy a Docker Compose deployment
|
|
967
811
|
*/
|
|
@@ -980,248 +824,284 @@ export function destroyCompose(ip, sshKeyPath, projectName) {
|
|
|
980
824
|
}
|
|
981
825
|
|
|
982
826
|
/**
|
|
983
|
-
*
|
|
984
|
-
*
|
|
985
|
-
*
|
|
986
|
-
*
|
|
987
|
-
*
|
|
988
|
-
*
|
|
827
|
+
* Build the shell command that runs the wal-g base backup on a Compose VPS.
|
|
828
|
+
*
|
|
829
|
+
* `carbon/backup/compose-backup.sh` is the SINGLE source of truth for the
|
|
830
|
+
* backup logic (guard + PGUSER=supabase_admin + wal-g backup-push + delete
|
|
831
|
+
* retain, plus a flock and set -e). This builder is a thin, quote-safe
|
|
832
|
+
* wrapper so the on-demand path and the cron path run byte-identical commands.
|
|
833
|
+
*
|
|
834
|
+
* The `RETAIN=<n>` prefix binds to `bash` (a real command, so the assignment
|
|
835
|
+
* is exported into the script's environment) — NOT to `cd` (a builtin, where
|
|
836
|
+
* it would be discarded). The script reads it via `${RETAIN:-7}`.
|
|
837
|
+
*
|
|
838
|
+
* Invoking via `bash backup/compose-backup.sh` (relative to remoteDir, which
|
|
839
|
+
* we cd into) means the script's exec bit is irrelevant — same lesson as
|
|
840
|
+
* wal-archive.sh. The bundle ships it to `${remoteDir}/backup/`.
|
|
841
|
+
*
|
|
842
|
+
* @param {string} remoteDir Absolute path to the project directory on the VPS
|
|
843
|
+
* (e.g. /opt/myapp)
|
|
844
|
+
* @param {number} [retain=7] Number of full base backups to keep. Validated
|
|
845
|
+
* to a positive integer (injection-safe + sane).
|
|
846
|
+
* @returns {string}
|
|
989
847
|
*/
|
|
990
|
-
export function
|
|
991
|
-
const
|
|
992
|
-
|
|
993
|
-
const timestamp = new Date().toISOString().replace(/T/, '_').replace(/[:.]/g, '').slice(0, 15);
|
|
994
|
-
const archiveName = `${projectName}_${timestamp}_full.tar.gz`;
|
|
995
|
-
const cleanFlags = clean ? '--clean --if-exists' : '';
|
|
996
|
-
|
|
997
|
-
// Step 1: Dump postgres database
|
|
998
|
-
const t1 = perfTimer('backup.pgDump.main');
|
|
999
|
-
sshRun(
|
|
1000
|
-
ip,
|
|
1001
|
-
sshKeyPath,
|
|
1002
|
-
`cd ${remoteDir} && docker compose exec -T db pg_dump -U postgres ${cleanFlags} postgres | gzip > backups/_postgres.sql.gz`,
|
|
1003
|
-
{ timeout: 300_000 },
|
|
1004
|
-
);
|
|
1005
|
-
t1.end();
|
|
1006
|
-
|
|
1007
|
-
// Step 2: Dump optional service databases (n8n, metabase) if they exist
|
|
1008
|
-
const t2 = perfTimer('backup.pgDump.features');
|
|
1009
|
-
sshRun(
|
|
1010
|
-
ip,
|
|
1011
|
-
sshKeyPath,
|
|
1012
|
-
`cd ${remoteDir} && for db in n8n metabase; do docker compose exec -T db psql -U postgres -tAc "SELECT 1 FROM pg_database WHERE datname='\${db}'" 2>/dev/null | grep -q 1 && docker compose exec -T db pg_dump -U postgres "\${db}" | gzip > "backups/_\${db}.sql.gz" || true; done`,
|
|
1013
|
-
{ timeout: 300_000 },
|
|
1014
|
-
);
|
|
1015
|
-
t2.end();
|
|
1016
|
-
|
|
1017
|
-
// Step 3: Backup storage files if using file backend
|
|
1018
|
-
const t3 = perfTimer('backup.storageFiles');
|
|
1019
|
-
sshRun(
|
|
1020
|
-
ip,
|
|
1021
|
-
sshKeyPath,
|
|
1022
|
-
`cd ${remoteDir} && FCOUNT=$(docker compose exec -T storage sh -c 'find /var/lib/storage -type f 2>/dev/null | wc -l' 2>/dev/null || echo 0) && [ "$FCOUNT" -gt 0 ] 2>/dev/null && docker compose exec -T storage tar czf - -C / var/lib/storage > backups/_storage.tar.gz || true`,
|
|
1023
|
-
{ timeout: 300_000 },
|
|
1024
|
-
);
|
|
1025
|
-
t3.end();
|
|
1026
|
-
|
|
1027
|
-
// Step 4: Create combined archive from work files (prefixed with _)
|
|
1028
|
-
const t4 = perfTimer('backup.combineArchive');
|
|
1029
|
-
sshRun(
|
|
1030
|
-
ip,
|
|
1031
|
-
sshKeyPath,
|
|
1032
|
-
`cd ${remoteDir}/backups && tar czf ${archiveName} $(ls _postgres.sql.gz _n8n.sql.gz _metabase.sql.gz _storage.tar.gz 2>/dev/null) && rm -f _postgres.sql.gz _n8n.sql.gz _metabase.sql.gz _storage.tar.gz`,
|
|
1033
|
-
{ timeout: 120_000 },
|
|
1034
|
-
);
|
|
1035
|
-
t4.end();
|
|
1036
|
-
|
|
1037
|
-
return archiveName;
|
|
848
|
+
export function composeBackupCmd(remoteDir, retain = 7) {
|
|
849
|
+
const r = Number.isInteger(retain) && retain > 0 ? retain : 7;
|
|
850
|
+
return `cd ${remoteDir} && RETAIN=${r} bash backup/compose-backup.sh`;
|
|
1038
851
|
}
|
|
1039
852
|
|
|
1040
853
|
/**
|
|
1041
|
-
*
|
|
854
|
+
* Trigger a wal-g base backup on a Compose VPS via compose-backup.sh.
|
|
855
|
+
*
|
|
856
|
+
* wal-g pushes the backup directly to S3 from inside the db container —
|
|
857
|
+
* there is no local archive to scp or upload. Success = exit 0.
|
|
1042
858
|
*
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
1045
|
-
*
|
|
859
|
+
* @param {string} ip
|
|
860
|
+
* @param {string} sshKeyPath
|
|
861
|
+
* @param {string} projectName
|
|
862
|
+
* @param {object} [options]
|
|
863
|
+
* @param {number} [options.retain] Full base backups to keep (default 7).
|
|
1046
864
|
*/
|
|
1047
|
-
export
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
backupS3Config,
|
|
1053
|
-
) {
|
|
1054
|
-
const localTmpPath = join(tmpdir(), `vc-backup-${archiveName}`);
|
|
1055
|
-
try {
|
|
1056
|
-
await perfAsync('backup.scpFromServer', async () =>
|
|
1057
|
-
scpDownload(ip, sshKeyPath, `/opt/${projectName}/backups/${archiveName}`, localTmpPath),
|
|
1058
|
-
);
|
|
1059
|
-
await perfAsync('backup.uploadS3', async () =>
|
|
1060
|
-
uploadS3Backup(backupS3Config, localTmpPath, `backups/${archiveName}`),
|
|
1061
|
-
);
|
|
1062
|
-
} finally {
|
|
1063
|
-
try {
|
|
1064
|
-
unlinkSync(localTmpPath);
|
|
1065
|
-
} catch {
|
|
1066
|
-
// Ignore cleanup errors
|
|
1067
|
-
}
|
|
1068
|
-
}
|
|
865
|
+
export function backupCompose(ip, sshKeyPath, projectName, options = {}) {
|
|
866
|
+
const remoteDir = `/opt/${projectName}`;
|
|
867
|
+
const t = perfTimer('backup.walgPush');
|
|
868
|
+
sshRun(ip, sshKeyPath, composeBackupCmd(remoteDir, options.retain ?? 7), { timeout: 900_000 });
|
|
869
|
+
t.end();
|
|
1069
870
|
}
|
|
1070
871
|
|
|
1071
872
|
/**
|
|
1072
|
-
* Set up automated backup cron on the VPS.
|
|
873
|
+
* Set up the automated wal-g backup cron on the VPS.
|
|
874
|
+
*
|
|
875
|
+
* Installs a crontab entry that runs compose-backup.sh on the configured
|
|
876
|
+
* schedule — the SAME builder the on-demand path uses, so there is exactly
|
|
877
|
+
* one quoting/invocation path. No awscli required — wal-g already has S3
|
|
878
|
+
* credentials via env vars in docker-compose.yml.
|
|
879
|
+
*
|
|
880
|
+
* The cron line contains no single quotes (the builder is plain
|
|
881
|
+
* `cd … && RETAIN=N bash …`), so it is installed quote-safely by piping the
|
|
882
|
+
* new crontab through stdin (heredoc) rather than wrapping it in `echo '…'`.
|
|
883
|
+
*
|
|
884
|
+
* @param {string} ip
|
|
885
|
+
* @param {string} sshKeyPath
|
|
886
|
+
* @param {string} projectName
|
|
887
|
+
* @param {object} [backupConfig]
|
|
888
|
+
* @param {string} [backupConfig.schedule]
|
|
889
|
+
* @param {number} [backupConfig.retentionDays]
|
|
890
|
+
* @param {object} [opts]
|
|
891
|
+
* @param {(ip: string, key: string, script: string, o: object) => unknown} [opts.runScript=sshRunScript]
|
|
892
|
+
* Injectable script runner — defaults to the real SSH runner; overridden in unit tests.
|
|
1073
893
|
*/
|
|
1074
|
-
export function setupComposeBackupCron(ip, sshKeyPath, projectName,
|
|
894
|
+
export function setupComposeBackupCron(ip, sshKeyPath, projectName, backupConfig, opts = {}) {
|
|
895
|
+
const { runScript = sshRunScript } = opts;
|
|
1075
896
|
const remoteDir = `/opt/${projectName}`;
|
|
1076
|
-
const schedule = backupConfig?.schedule || '0
|
|
1077
|
-
const
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
897
|
+
const schedule = backupConfig?.schedule || '0 2 * * *';
|
|
898
|
+
const retain =
|
|
899
|
+
Number.isInteger(backupConfig?.retentionDays) && backupConfig.retentionDays > 0
|
|
900
|
+
? backupConfig.retentionDays
|
|
901
|
+
: 7;
|
|
902
|
+
|
|
903
|
+
const cronLine = `${schedule} ${composeBackupCmd(remoteDir, retain)} >> ${remoteDir}/backups/backup.log 2>&1`;
|
|
904
|
+
|
|
905
|
+
// Install quote-safely: build the next crontab on the server (existing minus
|
|
906
|
+
// any prior compose-backup.sh line, plus our line) and pipe via stdin. The
|
|
907
|
+
// cron line is fed to `cat` through a heredoc so no shell quoting can mangle
|
|
908
|
+
// it — there are no single quotes in it to collide with anyway.
|
|
909
|
+
const installScript = [
|
|
910
|
+
'set -e',
|
|
911
|
+
`mkdir -p ${remoteDir}/backups`,
|
|
912
|
+
'TMP_CRON=$(mktemp)',
|
|
913
|
+
'crontab -l 2>/dev/null | grep -v \'compose-backup.sh\' > "$TMP_CRON" || true',
|
|
914
|
+
'cat >> "$TMP_CRON" <<\'VC_CRON_EOF\'',
|
|
915
|
+
cronLine,
|
|
916
|
+
'VC_CRON_EOF',
|
|
917
|
+
'crontab "$TMP_CRON"',
|
|
918
|
+
'rm -f "$TMP_CRON"',
|
|
919
|
+
].join('\n');
|
|
920
|
+
|
|
921
|
+
runScript(ip, sshKeyPath, installScript, { timeout: 60_000 });
|
|
922
|
+
}
|
|
1090
923
|
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
924
|
+
// ISO-8601 datetime: YYYY-MM-DDTHH:MM:SS followed by Z or ±HH:MM
|
|
925
|
+
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/;
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Build the shell script that fetches a wal-g base backup and writes the
|
|
929
|
+
* archive-recovery configuration so postgres replays WAL from S3 and
|
|
930
|
+
* promotes to read-write on completion (or at a PITR target).
|
|
931
|
+
*
|
|
932
|
+
* This is a pure function (no I/O) so it can be unit-tested without SSH.
|
|
933
|
+
* The script is SCP'd to the server and bind-mounted into the db container
|
|
934
|
+
* via `docker compose run --rm -v …:/restore.sh:ro db bash /restore.sh`,
|
|
935
|
+
* which avoids the quoting collision that would arise from embedding
|
|
936
|
+
* single-quoted postgres config values in a deeply-nested ssh command.
|
|
937
|
+
*
|
|
938
|
+
* Mirrors the k8s walg-restore init container in carbon/k8s/values/supabase.values.yaml.
|
|
939
|
+
*
|
|
940
|
+
* @param {'latest' | string} target 'latest' for the most-recent base backup,
|
|
941
|
+
* or an ISO-8601 datetime string for point-in-time recovery.
|
|
942
|
+
* @returns {string} The bash script body.
|
|
943
|
+
*/
|
|
944
|
+
export function composeRestoreScript(target) {
|
|
945
|
+
if (target !== 'latest' && !ISO_DATETIME_RE.test(target)) {
|
|
946
|
+
throw new Error(
|
|
947
|
+
`Invalid target ${JSON.stringify(target)}: must be "latest" or an ISO-8601 datetime (e.g. 2026-05-31T12:00:00Z)`,
|
|
1098
948
|
);
|
|
1099
949
|
}
|
|
1100
950
|
|
|
1101
|
-
//
|
|
1102
|
-
const
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
951
|
+
// pitrLine goes inside the { ... } block — no separate redirect needed
|
|
952
|
+
const pitrLine = target !== 'latest' ? ` echo "recovery_target_time = '${target}'"` : null;
|
|
953
|
+
|
|
954
|
+
return [
|
|
955
|
+
'#!/bin/bash',
|
|
956
|
+
'set -euo pipefail',
|
|
957
|
+
'PGDATA=/var/lib/postgresql/data',
|
|
958
|
+
'',
|
|
959
|
+
'# Empty PGDATA — wal-g backup-fetch requires an empty target dir. We are',
|
|
960
|
+
'# inside the db container, which mounts the real <project>_db_data volume',
|
|
961
|
+
'# at $PGDATA, so this clears the correct data (unlike a `docker compose run',
|
|
962
|
+
"# -v db_data:/data` flag, which bypasses Compose's project-volume naming).",
|
|
963
|
+
'if [ -f "$PGDATA/PG_VERSION" ]; then',
|
|
964
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: ${PGDATA:?} is a deliberate bash "fail if unset" guard, not a JS template placeholder
|
|
965
|
+
' rm -rf "${PGDATA:?}/"* "${PGDATA:?}/".* 2>/dev/null || true',
|
|
966
|
+
'fi',
|
|
967
|
+
'',
|
|
968
|
+
'# Fetch the base backup from S3 (wal-g reads S3 credentials from container env)',
|
|
969
|
+
`wal-g backup-fetch "$PGDATA" LATEST`,
|
|
970
|
+
'',
|
|
971
|
+
'# Remove any recovery settings left in postgresql.auto.conf by a previous',
|
|
972
|
+
"# restore so they don't accumulate across cycles (postgres takes the last",
|
|
973
|
+
'# value, but stale duplicates are confusing).',
|
|
974
|
+
'if [ -f "$PGDATA/postgresql.auto.conf" ]; then',
|
|
975
|
+
' sed -i "/^restore_command =/d; /^recovery_target_action =/d; /^recovery_target_time =/d; /^recovery_target_timeline =/d" "$PGDATA/postgresql.auto.conf"',
|
|
976
|
+
'fi',
|
|
977
|
+
'',
|
|
978
|
+
'# Write archive-recovery config so postgres replays WAL segments from S3',
|
|
979
|
+
'# and promotes to read-write on end-of-WAL (or at the PITR target).',
|
|
980
|
+
'# Uses >> so any existing postgresql.auto.conf settings are preserved.',
|
|
981
|
+
'{',
|
|
982
|
+
` echo "restore_command = 'wal-g wal-fetch \\"%f\\" \\"%p\\"'"`,
|
|
983
|
+
` echo "recovery_target_action = 'promote'"`,
|
|
984
|
+
// Pin recovery to the FETCHED base backup's own timeline. Default is
|
|
985
|
+
// 'latest', which makes postgres chase the newest timeline it can find a
|
|
986
|
+
// .history file for. In HA, repeated restore→promote cycles accumulate
|
|
987
|
+
// DIVERGENT timelines in the shared wal-g S3 prefix, and 'latest' picks one
|
|
988
|
+
// that forked off BEFORE this base backup's checkpoint — postgres then
|
|
989
|
+
// crash-loops "requested timeline N is not a child of this server's history"
|
|
990
|
+
// / wal-g "Archive '0000000N.history' does not exist". 'current' recovers
|
|
991
|
+
// along the base backup's timeline to end-of-WAL, then promotes fresh.
|
|
992
|
+
// (RCA 2026-06-01: compose-ha kept-rig restore.)
|
|
993
|
+
` echo "recovery_target_timeline = 'current'"`,
|
|
994
|
+
pitrLine,
|
|
995
|
+
'} >> "$PGDATA/postgresql.auto.conf"',
|
|
996
|
+
'',
|
|
997
|
+
'# Signal file that tells postgres to enter archive recovery mode (PG 12+)',
|
|
998
|
+
'touch "$PGDATA/recovery.signal"',
|
|
999
|
+
]
|
|
1000
|
+
.filter((line) => line !== null)
|
|
1001
|
+
.join('\n');
|
|
1114
1002
|
}
|
|
1115
1003
|
|
|
1116
1004
|
/**
|
|
1117
|
-
* Restore from a backup
|
|
1118
|
-
*
|
|
1005
|
+
* Restore from a wal-g base backup via Docker Compose with full archive
|
|
1006
|
+
* recovery. Postgres replays WAL segments from S3 via restore_command and
|
|
1007
|
+
* promotes to read-write on reaching end-of-WAL or the PITR target.
|
|
1008
|
+
*
|
|
1009
|
+
* S3 credentials are read from the db container's environment (sourced from
|
|
1010
|
+
* the project .env file) — DO NOT pass them as -e overrides; the container
|
|
1011
|
+
* already has WALG_S3_PREFIX + AWS_* set by carbon/docker-compose.yml.
|
|
1012
|
+
*
|
|
1013
|
+
* @param {string} ip
|
|
1014
|
+
* @param {string} sshKeyPath
|
|
1015
|
+
* @param {string} projectName
|
|
1016
|
+
* @param {'latest' | string} [target='latest'] 'latest' or ISO-8601 PITR timestamp.
|
|
1119
1017
|
*/
|
|
1120
|
-
export async function restoreCompose(ip, sshKeyPath, projectName,
|
|
1018
|
+
export async function restoreCompose(ip, sshKeyPath, projectName, target = 'latest') {
|
|
1121
1019
|
const remoteDir = `/opt/${projectName}`;
|
|
1122
|
-
const isWalg = backupFile === 'latest' || backupFile === 'walg';
|
|
1123
1020
|
|
|
1124
|
-
//
|
|
1021
|
+
// Build and validate the restore script early — fail before touching the
|
|
1022
|
+
// running server if the target is malformed.
|
|
1023
|
+
const scriptBody = composeRestoreScript(target);
|
|
1024
|
+
|
|
1025
|
+
// 1. Stop app to prevent connections during restore
|
|
1125
1026
|
await sshRunAsync(
|
|
1126
1027
|
ip,
|
|
1127
1028
|
sshKeyPath,
|
|
1128
1029
|
`cd ${remoteDir} && docker compose stop app 2>/dev/null || true`,
|
|
1129
1030
|
);
|
|
1130
1031
|
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
)
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
-e
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
await sshRunAsync(
|
|
1156
|
-
ip,
|
|
1157
|
-
sshKeyPath,
|
|
1158
|
-
`mkdir -p /tmp/vb-restore && tar --no-xattrs --no-same-owner -xzf ${remoteDir}/backups/${backupFile} -C /tmp/vb-restore`,
|
|
1159
|
-
{ timeout: 120_000 },
|
|
1160
|
-
);
|
|
1032
|
+
// 2. Stop the DB. The restore container (step 4) clears PGDATA *itself*
|
|
1033
|
+
// from inside the db container — where the real ${project}_db_data volume
|
|
1034
|
+
// is mounted at $PGDATA — so there is no separate volume-clearing step.
|
|
1035
|
+
// (A `docker compose run --rm -v db_data:/data alpine` would clear a
|
|
1036
|
+
// literal `db_data` volume, NOT the project-prefixed one Compose uses.)
|
|
1037
|
+
await sshRunAsync(ip, sshKeyPath, `cd ${remoteDir} && docker compose stop db`);
|
|
1038
|
+
|
|
1039
|
+
// 3. Write the restore script to the server (heredoc — no quoting risk) and
|
|
1040
|
+
// bind-mount it into the db container. The script body contains single
|
|
1041
|
+
// quotes (restore_command = 'wal-g wal-fetch ...') so it MUST go via a
|
|
1042
|
+
// file rather than a nested bash -c '...' invocation. The script clears
|
|
1043
|
+
// PGDATA, runs wal-g backup-fetch, and writes the archive-recovery config.
|
|
1044
|
+
sshRunScript(
|
|
1045
|
+
ip,
|
|
1046
|
+
sshKeyPath,
|
|
1047
|
+
[
|
|
1048
|
+
'set -e',
|
|
1049
|
+
`cat > ${remoteDir}/restore-walg.sh <<'VC_RESTORE_EOF'`,
|
|
1050
|
+
scriptBody,
|
|
1051
|
+
'VC_RESTORE_EOF',
|
|
1052
|
+
`chmod +x ${remoteDir}/restore-walg.sh`,
|
|
1053
|
+
].join('\n'),
|
|
1054
|
+
{ timeout: 30_000 },
|
|
1055
|
+
);
|
|
1161
1056
|
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
);
|
|
1057
|
+
await sshRunAsync(
|
|
1058
|
+
ip,
|
|
1059
|
+
sshKeyPath,
|
|
1060
|
+
`cd ${remoteDir} && docker compose run --rm -v ${remoteDir}/restore-walg.sh:/restore.sh:ro db bash /restore.sh`,
|
|
1061
|
+
{ timeout: 900_000 },
|
|
1062
|
+
);
|
|
1169
1063
|
|
|
1170
|
-
|
|
1171
|
-
|
|
1064
|
+
// 4. Start the DB — postgres enters archive recovery, replays WAL, promotes
|
|
1065
|
+
await sshRunAsync(ip, sshKeyPath, `cd ${remoteDir} && docker compose start db`);
|
|
1066
|
+
|
|
1067
|
+
// 5. Poll until postgres has promoted (pg_isready + pg_is_in_recovery() = f).
|
|
1068
|
+
// WAL replay can take 1-2 minutes; deadline is 300s.
|
|
1069
|
+
const deadline = Date.now() + 300_000;
|
|
1070
|
+
let lastErr = '';
|
|
1071
|
+
for (;;) {
|
|
1072
|
+
try {
|
|
1073
|
+
// First check: postgres accepting connections
|
|
1172
1074
|
await sshRunAsync(
|
|
1173
1075
|
ip,
|
|
1174
1076
|
sshKeyPath,
|
|
1175
|
-
`
|
|
1176
|
-
{ timeout:
|
|
1077
|
+
`cd ${remoteDir} && docker compose exec -T db pg_isready -U postgres`,
|
|
1078
|
+
{ timeout: 10_000 },
|
|
1079
|
+
);
|
|
1080
|
+
// Second check: promoted (no longer in recovery)
|
|
1081
|
+
const recovering = await sshRunAsync(
|
|
1082
|
+
ip,
|
|
1083
|
+
sshKeyPath,
|
|
1084
|
+
`cd ${remoteDir} && docker compose exec -T db psql -U postgres -At -c 'SELECT pg_is_in_recovery()'`,
|
|
1085
|
+
{ timeout: 10_000 },
|
|
1177
1086
|
);
|
|
1087
|
+
if (String(recovering).trim() === 'f') {
|
|
1088
|
+
break; // promoted — restore complete
|
|
1089
|
+
}
|
|
1090
|
+
lastErr = 'pg_is_in_recovery() still true';
|
|
1091
|
+
} catch (err) {
|
|
1092
|
+
lastErr = err instanceof Error ? err.message : String(err);
|
|
1178
1093
|
}
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
sshKeyPath,
|
|
1184
|
-
`test -f /tmp/vb-restore/storage.tar.gz && cd ${remoteDir} && docker compose exec -T storage sh -c 'tar --no-xattrs --no-same-owner -xzf - -C /' < /tmp/vb-restore/storage.tar.gz || true`,
|
|
1185
|
-
{ timeout: 300_000 },
|
|
1186
|
-
);
|
|
1187
|
-
|
|
1188
|
-
// Cleanup
|
|
1189
|
-
await sshRunAsync(ip, sshKeyPath, 'rm -rf /tmp/vb-restore');
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
// Restart app
|
|
1193
|
-
await sshRunAsync(ip, sshKeyPath, `cd ${remoteDir} && docker compose start app`);
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
/**
|
|
1197
|
-
* Wait for an HTTPS endpoint to respond with a valid certificate.
|
|
1198
|
-
* Polls until a successful response (2xx/3xx) or timeout.
|
|
1199
|
-
*
|
|
1200
|
-
* @param {string} url - Full HTTPS URL to check
|
|
1201
|
-
* @param {object} [options]
|
|
1202
|
-
* @param {number} [options.timeout=90000] - Max wait time in ms
|
|
1203
|
-
* @param {number} [options.interval=5000] - Poll interval in ms
|
|
1204
|
-
* @returns {Promise<boolean>} true if SSL is ready, false if timed out
|
|
1205
|
-
*/
|
|
1206
|
-
export async function waitForSSL(url, { timeout = 90_000, interval = 5_000 } = {}) {
|
|
1207
|
-
const deadline = Date.now() + timeout;
|
|
1208
|
-
while (Date.now() < deadline) {
|
|
1209
|
-
try {
|
|
1210
|
-
const result = runCommand(
|
|
1211
|
-
['curl', '-sf', '--max-time', '5', '-o', '/dev/null', '-w', '%{http_code}', url],
|
|
1212
|
-
{
|
|
1213
|
-
stdio: 'pipe',
|
|
1214
|
-
encoding: 'utf-8',
|
|
1215
|
-
timeout: 10_000,
|
|
1216
|
-
},
|
|
1094
|
+
if (Date.now() >= deadline) {
|
|
1095
|
+
throw new Error(
|
|
1096
|
+
`restoreCompose: postgres did not promote within 300s after wal-g restore. ` +
|
|
1097
|
+
`Last error: ${lastErr}`,
|
|
1217
1098
|
);
|
|
1218
|
-
if (result?.trim().match(/^[23]\d\d$/)) return true;
|
|
1219
|
-
} catch {
|
|
1220
|
-
// Not ready yet
|
|
1221
1099
|
}
|
|
1222
|
-
await new Promise((r) => setTimeout(r,
|
|
1100
|
+
await new Promise((r) => setTimeout(r, 5_000));
|
|
1223
1101
|
}
|
|
1224
|
-
|
|
1102
|
+
|
|
1103
|
+
// 6. Bring the app back up
|
|
1104
|
+
await sshRunAsync(ip, sshKeyPath, `cd ${remoteDir} && docker compose start app`);
|
|
1225
1105
|
}
|
|
1226
1106
|
|
|
1227
1107
|
/**
|
|
@@ -1236,6 +1116,28 @@ export async function waitForSSL(url, { timeout = 90_000, interval = 5_000 } = {
|
|
|
1236
1116
|
* @param {string} projectName - Project name (for remote dir)
|
|
1237
1117
|
* @returns {Promise<{success: boolean, message: string}>}
|
|
1238
1118
|
*/
|
|
1119
|
+
/**
|
|
1120
|
+
* Extract the admin credentials a deploy needs to provision the super-admin
|
|
1121
|
+
* out of a project `.env` file's text.
|
|
1122
|
+
*
|
|
1123
|
+
* `create` writes these via `escapeDotenv` (ADMIN_PASSWORD is POSIX
|
|
1124
|
+
* single-quoted) and double-quotes for the rest, so the on-disk shapes are a
|
|
1125
|
+
* mix of `'…'` and `"…"`. Decoding MUST go through `parseDotenv`/`unescapeDotenv`
|
|
1126
|
+
* — the inverse of `escapeDotenv` — or single-quote wrappers leak into the
|
|
1127
|
+
* value and GoTrue provisions a password the operator can never type.
|
|
1128
|
+
*
|
|
1129
|
+
* @param {string} envContent
|
|
1130
|
+
* @returns {{adminEmail?: string, adminPassword?: string, serviceRoleKey?: string}}
|
|
1131
|
+
*/
|
|
1132
|
+
export function readAdminCredentials(envContent) {
|
|
1133
|
+
const env = parseDotenv(envContent);
|
|
1134
|
+
return {
|
|
1135
|
+
adminEmail: env.ADMIN_EMAIL,
|
|
1136
|
+
adminPassword: env.ADMIN_PASSWORD,
|
|
1137
|
+
serviceRoleKey: env.SUPABASE_SERVICE_ROLE_KEY,
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1239
1141
|
export async function createAdminUser(serverIp, sshKeyPath, projectName) {
|
|
1240
1142
|
const envPath = join(process.cwd(), '.env');
|
|
1241
1143
|
if (!existsSync(envPath)) {
|
|
@@ -1243,9 +1145,7 @@ export async function createAdminUser(serverIp, sshKeyPath, projectName) {
|
|
|
1243
1145
|
}
|
|
1244
1146
|
|
|
1245
1147
|
const envContent = readFileSync(envPath, 'utf-8');
|
|
1246
|
-
const adminEmail = envContent
|
|
1247
|
-
const adminPassword = envContent.match(/^ADMIN_PASSWORD="?([^"\n]+)"?/m)?.[1];
|
|
1248
|
-
const serviceRoleKey = envContent.match(/^SUPABASE_SERVICE_ROLE_KEY="?([^"\n]+)"?/m)?.[1];
|
|
1148
|
+
const { adminEmail, adminPassword, serviceRoleKey } = readAdminCredentials(envContent);
|
|
1249
1149
|
|
|
1250
1150
|
if (!adminEmail || !adminPassword || !serviceRoleKey) {
|
|
1251
1151
|
return { success: false, message: 'Admin credentials not found in .env' };
|
|
@@ -1299,54 +1199,22 @@ export async function createAdminUser(serverIp, sshKeyPath, projectName) {
|
|
|
1299
1199
|
);
|
|
1300
1200
|
|
|
1301
1201
|
try {
|
|
1302
|
-
// Wait for
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
try {
|
|
1308
|
-
await fetch(`http://localhost:${localPort}/auth/v1/health`, {
|
|
1309
|
-
signal: AbortSignal.timeout(2000),
|
|
1310
|
-
});
|
|
1311
|
-
connected = true;
|
|
1312
|
-
break;
|
|
1313
|
-
} catch {
|
|
1314
|
-
const interval = i < 5 ? 200 : 1000;
|
|
1315
|
-
await new Promise((r) => setTimeout(r, interval));
|
|
1316
|
-
}
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1202
|
+
// Wait for the ssh -L tunnel to start forwarding (usually <500ms).
|
|
1203
|
+
const connected = await waitForGotrueHealth(`http://localhost:${localPort}/auth/v1/health`, {
|
|
1204
|
+
attempts: 15,
|
|
1205
|
+
intervalMs: 500,
|
|
1206
|
+
});
|
|
1319
1207
|
if (!connected) {
|
|
1320
1208
|
return { success: false, message: 'Could not reach auth service via SSH tunnel' };
|
|
1321
1209
|
}
|
|
1322
1210
|
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
},
|
|
1330
|
-
body: JSON.stringify({
|
|
1331
|
-
email: adminEmail,
|
|
1332
|
-
password: adminPassword,
|
|
1333
|
-
email_confirm: true,
|
|
1334
|
-
app_metadata: { role: 'super_admin' },
|
|
1335
|
-
}),
|
|
1336
|
-
signal: AbortSignal.timeout(15000),
|
|
1211
|
+
// POST through Kong, which rewrites /auth/v1 → GoTrue's /.
|
|
1212
|
+
return await postAdminUser({
|
|
1213
|
+
adminUsersUrl: `http://localhost:${localPort}/auth/v1/admin/users`,
|
|
1214
|
+
serviceRoleKey,
|
|
1215
|
+
adminEmail,
|
|
1216
|
+
adminPassword,
|
|
1337
1217
|
});
|
|
1338
|
-
|
|
1339
|
-
if (response.ok) {
|
|
1340
|
-
return { success: true, message: `Admin user created: ${adminEmail}` };
|
|
1341
|
-
}
|
|
1342
|
-
if (response.status === 422) {
|
|
1343
|
-
return { success: true, message: `Admin user already exists: ${adminEmail}` };
|
|
1344
|
-
}
|
|
1345
|
-
const body = await response.text().catch(() => '');
|
|
1346
|
-
return {
|
|
1347
|
-
success: false,
|
|
1348
|
-
message: `Auth returned ${response.status}${body ? `: ${body}` : ''}`,
|
|
1349
|
-
};
|
|
1350
1218
|
} finally {
|
|
1351
1219
|
pf.kill();
|
|
1352
1220
|
}
|