vibecarbon 0.3.1 → 0.4.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/db/Dockerfile +14 -3
- package/carbon/docker-compose.dns01.prod.yml +42 -0
- 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/values/supabase.values.yaml +144 -0
- package/package.json +1 -2
- package/src/backup.js +2 -36
- package/src/create.js +9 -4
- package/src/deploy.js +12 -0
- 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 +17 -0
- package/src/lib/deploy/compose/ha.js +94 -91
- package/src/lib/deploy/compose/index.js +50 -248
- 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 +314 -128
- package/src/lib/deploy/orchestrator.js +70 -35
- package/src/lib/deploy/utils.js +20 -0
- package/src/lib/hetzner-guided-setup.js +0 -7
- package/src/lib/iac/index.js +0 -9
- 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 +98 -265
- package/src/scale.js +43 -20
- 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
|
@@ -14,7 +14,6 @@ import { join } from 'node:path';
|
|
|
14
14
|
import * as p from '@clack/prompts';
|
|
15
15
|
import { setupHA as setupCloudflareHA } from '../../cloudflare.js';
|
|
16
16
|
import { c } from '../../colors.js';
|
|
17
|
-
import { runCommand } from '../../command.js';
|
|
18
17
|
import {
|
|
19
18
|
loadCredentials,
|
|
20
19
|
loadProjectConfig,
|
|
@@ -24,6 +23,8 @@ import {
|
|
|
24
23
|
import { fetchWithRetry } from '../../fetch-retry.js';
|
|
25
24
|
import { perfAsync } from '../../perf.js';
|
|
26
25
|
import { getProvider } from '../../providers/index.js';
|
|
26
|
+
import { useDnsChallenge } from '../acme.js';
|
|
27
|
+
import { generateSSHKeyPair, readReplPassword } from '../utils.js';
|
|
27
28
|
import {
|
|
28
29
|
createAdminUser,
|
|
29
30
|
dockerLoginOnServer,
|
|
@@ -32,26 +33,12 @@ import {
|
|
|
32
33
|
runMigrations,
|
|
33
34
|
setupServer,
|
|
34
35
|
setupServerFiles,
|
|
36
|
+
sshRun,
|
|
35
37
|
sshRunAsync,
|
|
36
38
|
startComposeStack,
|
|
37
39
|
waitForSSH,
|
|
38
40
|
} from './index.js';
|
|
39
41
|
|
|
40
|
-
const SSH_OPTS = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes';
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Run a command on a remote server via SSH
|
|
44
|
-
*/
|
|
45
|
-
function sshRun(ip, sshKeyPath, command, options = {}) {
|
|
46
|
-
const { timeout = 120_000 } = options;
|
|
47
|
-
return runCommand(['ssh', ...SSH_OPTS.split(' '), '-i', sshKeyPath, `root@${ip}`, command], {
|
|
48
|
-
stdio: 'pipe',
|
|
49
|
-
timeout,
|
|
50
|
-
encoding: 'utf-8',
|
|
51
|
-
...options,
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
42
|
/**
|
|
56
43
|
* Look up a Hetzner firewall by exact name and DELETE it. Polls afterwards
|
|
57
44
|
* until the GET returns nothing (firewall actually gone) or the deadline
|
|
@@ -170,37 +157,6 @@ async function deleteFirewallByName(name, hetznerHeaders, deadlineMs = 30_000) {
|
|
|
170
157
|
return false;
|
|
171
158
|
}
|
|
172
159
|
|
|
173
|
-
/**
|
|
174
|
-
* Read REPL_PASSWORD — process.env first (CI may export it), then .env.local
|
|
175
|
-
* (where `vibecarbon create` writes it at project-init time). Every HA code
|
|
176
|
-
* path funnels through this so one fix handles all three call sites that
|
|
177
|
-
* previously threw `REPL_PASSWORD is not set` when run without the env var.
|
|
178
|
-
* Returns null if the value is not present anywhere.
|
|
179
|
-
*/
|
|
180
|
-
function readReplPassword(cwd = process.cwd()) {
|
|
181
|
-
if (process.env.REPL_PASSWORD) return process.env.REPL_PASSWORD;
|
|
182
|
-
const envLocalPath = join(cwd, '.env.local');
|
|
183
|
-
if (!existsSync(envLocalPath)) return null;
|
|
184
|
-
const content = readFileSync(envLocalPath, 'utf-8');
|
|
185
|
-
// Accept both double-quoted (machine secrets) and single-quoted
|
|
186
|
-
// (escapeDotenv'd user secrets) forms, matching create.js output.
|
|
187
|
-
const m =
|
|
188
|
-
content.match(/^REPL_PASSWORD="([^"]+)"/m) || content.match(/^REPL_PASSWORD='([^']+)'/m);
|
|
189
|
-
return m ? m[1] : null;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Generate SSH key pair for deployment
|
|
194
|
-
*/
|
|
195
|
-
function generateSSHKeyPair(sshKeyPath) {
|
|
196
|
-
if (!existsSync(sshKeyPath)) {
|
|
197
|
-
runCommand(
|
|
198
|
-
['ssh-keygen', '-t', 'ed25519', '-f', sshKeyPath, '-N', '', '-C', 'vibecarbon-deploy'],
|
|
199
|
-
{ stdio: 'pipe' },
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
160
|
// ============================================================================
|
|
205
161
|
// POSTGRESQL STREAMING REPLICATION SETUP
|
|
206
162
|
// ============================================================================
|
|
@@ -1022,7 +978,11 @@ export async function deployComposeHA(options) {
|
|
|
1022
978
|
// future deploys that shrink that gap. Same fix lives in orchestrator.js
|
|
1023
979
|
// for single-region compose where the race was actively biting (RCA from
|
|
1024
980
|
// vibecarbon.com cold-deploy 2026-05-19).
|
|
1025
|
-
|
|
981
|
+
// HTTP-01 only: managed-DNS HA deploys issue certs via DNS-01 (the shared
|
|
982
|
+
// bundle ships docker-compose.dns01.prod.yml to both nodes), which doesn't
|
|
983
|
+
// gate on the A record propagating — so skip the wait there. `manual` keeps
|
|
984
|
+
// HTTP-01 but isn't reached here (the guard already excludes it).
|
|
985
|
+
if (domain && dnsProvider && dnsProvider !== 'manual' && !useDnsChallenge(dnsProvider)) {
|
|
1026
986
|
// Surface this in the enclosing tracker spinner so the operator sees
|
|
1027
987
|
// why we're waiting (up to 120s on a cold edge). Without the message
|
|
1028
988
|
// the HA spinner sits on the prior "Configuring servers..." text for
|
|
@@ -1471,54 +1431,80 @@ export async function destroyComposeHA(options) {
|
|
|
1471
1431
|
}
|
|
1472
1432
|
|
|
1473
1433
|
// ============================================================================
|
|
1474
|
-
//
|
|
1434
|
+
// FAILOVER
|
|
1475
1435
|
// ============================================================================
|
|
1476
1436
|
|
|
1477
1437
|
/**
|
|
1478
|
-
*
|
|
1438
|
+
* Build the curl used to probe the new primary's real API endpoint locally.
|
|
1439
|
+
*
|
|
1440
|
+
* `--resolve <domain>:443:127.0.0.1` pins the hostname to loopback, so the
|
|
1441
|
+
* probe hits Traefik on the new-primary host itself and is independent of DNS
|
|
1442
|
+
* propagation (the DNS flip happens later in failover, and the operator host's
|
|
1443
|
+
* resolver may still be cached). `-k` skips the LE-staging cert. The path is
|
|
1444
|
+
* the *exact* endpoint verify-failover checks, so a 200 here means the whole
|
|
1445
|
+
* app → Kong → PostgREST → DB path is serving — including PostgREST's schema
|
|
1446
|
+
* cache, the thing that's still cold right after `docker compose restart`.
|
|
1447
|
+
*
|
|
1448
|
+
* @param {string} domain
|
|
1449
|
+
* @returns {string}
|
|
1479
1450
|
*/
|
|
1480
|
-
export
|
|
1481
|
-
const
|
|
1482
|
-
|
|
1483
|
-
try {
|
|
1484
|
-
const remoteDir = `/opt/${projectName}`;
|
|
1485
|
-
const output = await sshRunAsync(
|
|
1486
|
-
server.ip,
|
|
1487
|
-
sshKeyPath,
|
|
1488
|
-
`cd ${remoteDir} && docker compose ps --format json`,
|
|
1489
|
-
{ timeout: 15_000 },
|
|
1490
|
-
);
|
|
1491
|
-
if (!output?.trim()) {
|
|
1492
|
-
return { ...server, running: false, services: [] };
|
|
1493
|
-
}
|
|
1494
|
-
const services = output
|
|
1495
|
-
.trim()
|
|
1496
|
-
.split('\n')
|
|
1497
|
-
.filter(Boolean)
|
|
1498
|
-
.map((line) => {
|
|
1499
|
-
try {
|
|
1500
|
-
return JSON.parse(line);
|
|
1501
|
-
} catch {
|
|
1502
|
-
return null;
|
|
1503
|
-
}
|
|
1504
|
-
})
|
|
1505
|
-
.filter(Boolean);
|
|
1506
|
-
return {
|
|
1507
|
-
...server,
|
|
1508
|
-
running: services.some((s) => s.State === 'running'),
|
|
1509
|
-
services,
|
|
1510
|
-
};
|
|
1511
|
-
} catch {
|
|
1512
|
-
return { ...server, running: false, error: 'Unreachable' };
|
|
1513
|
-
}
|
|
1514
|
-
}),
|
|
1515
|
-
);
|
|
1516
|
-
return results;
|
|
1451
|
+
export function newPrimaryApiProbeCmd(domain) {
|
|
1452
|
+
const url = `https://${domain}/api/v1/notifications`;
|
|
1453
|
+
return `curl -sk -o /dev/null -w '%{http_code}' --max-time 5 --resolve ${domain}:443:127.0.0.1 ${url}`;
|
|
1517
1454
|
}
|
|
1518
1455
|
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1456
|
+
/**
|
|
1457
|
+
* Poll the new primary until it actually SERVES a DB-backed API request.
|
|
1458
|
+
*
|
|
1459
|
+
* Root-cause fix for the compose-ha failover-readiness race: Step 1b runs
|
|
1460
|
+
* `docker compose restart … rest app …` to force a clean reconnect against the
|
|
1461
|
+
* now-read-write DB, but `restart` returns when containers *start*, not when
|
|
1462
|
+
* they're *ready*. PostgREST then spends ~10-30s reconnecting and rebuilding
|
|
1463
|
+
* its schema cache; until it does, /api/v1/notifications 500s with a
|
|
1464
|
+
* PGRST schema-cache miss. failover used to return straight into that window,
|
|
1465
|
+
* and verify-failover (which does NOT retry on 500) caught it. Gating on the
|
|
1466
|
+
* real endpoint closes the race for the e2e *and* real operators (a failover
|
|
1467
|
+
* that reports success before the app serves traffic is itself a bug).
|
|
1468
|
+
*
|
|
1469
|
+
* Best-effort: returns false (and logs the last status) on timeout rather than
|
|
1470
|
+
* throwing — a not-yet-confirmed new primary is still better than aborting a
|
|
1471
|
+
* promotion that already happened.
|
|
1472
|
+
*
|
|
1473
|
+
* @param {string} ip - new primary IP
|
|
1474
|
+
* @param {string} sshKeyPath
|
|
1475
|
+
* @param {string} domain
|
|
1476
|
+
* @param {object} [opts]
|
|
1477
|
+
* @param {number} [opts.attempts=40]
|
|
1478
|
+
* @param {number} [opts.intervalMs=3000]
|
|
1479
|
+
* @param {(ip: string, key: string, cmd: string, o: object) => string|false} [opts.runner=sshRun]
|
|
1480
|
+
* @param {(ms: number) => Promise<void>} [opts.sleep]
|
|
1481
|
+
* @returns {Promise<boolean>}
|
|
1482
|
+
*/
|
|
1483
|
+
export async function waitForNewPrimaryApi(ip, sshKeyPath, domain, opts = {}) {
|
|
1484
|
+
const {
|
|
1485
|
+
attempts = 40,
|
|
1486
|
+
intervalMs = 3000,
|
|
1487
|
+
runner = sshRun,
|
|
1488
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
1489
|
+
} = opts;
|
|
1490
|
+
const cmd = newPrimaryApiProbeCmd(domain);
|
|
1491
|
+
let lastCode = '';
|
|
1492
|
+
for (let i = 0; i < attempts; i++) {
|
|
1493
|
+
try {
|
|
1494
|
+
const code = runner(ip, sshKeyPath, cmd, { silent: true, timeout: 12_000 });
|
|
1495
|
+
lastCode = typeof code === 'string' ? code.trim() : '';
|
|
1496
|
+
if (lastCode === '200') return true;
|
|
1497
|
+
} catch {
|
|
1498
|
+
lastCode = 'error';
|
|
1499
|
+
}
|
|
1500
|
+
if (i < attempts - 1) await sleep(intervalMs);
|
|
1501
|
+
}
|
|
1502
|
+
console.error(
|
|
1503
|
+
`[ha] new primary did not serve /api/v1/notifications (last HTTP ${lastCode || 'none'}) ` +
|
|
1504
|
+
`after ${attempts} probes — proceeding, but verify-failover may race PostgREST readiness`,
|
|
1505
|
+
);
|
|
1506
|
+
return false;
|
|
1507
|
+
}
|
|
1522
1508
|
|
|
1523
1509
|
/**
|
|
1524
1510
|
* Perform failover for a Compose HA environment.
|
|
@@ -1632,7 +1618,7 @@ export async function failoverComposeHA(envName, envConfig, projectConfig, parse
|
|
|
1632
1618
|
// and returns the result. We retry a handful of times because the very
|
|
1633
1619
|
// first call can race the postmaster startup ("connection refused" /
|
|
1634
1620
|
// "the database system is starting up").
|
|
1635
|
-
// `silent: true` is load-bearing:
|
|
1621
|
+
// `silent: true` is load-bearing: the shared compose sshRun wraps runCommand,
|
|
1636
1622
|
// which on non-zero exit returns `false` (not throws) unless silent — so
|
|
1637
1623
|
// without it, a still-booting postmaster's exit-1 manifests downstream as
|
|
1638
1624
|
// `false?.trim is not a function`, the catch records it, and all 5 attempts
|
|
@@ -1749,6 +1735,23 @@ export async function failoverComposeHA(envName, envConfig, projectConfig, parse
|
|
|
1749
1735
|
}
|
|
1750
1736
|
s.stop('New-primary app-tier restarted');
|
|
1751
1737
|
|
|
1738
|
+
// Step 1c: Gate on the new primary actually SERVING a DB-backed request
|
|
1739
|
+
// before we stop the old primary and flip DNS. The Step 1b restart only
|
|
1740
|
+
// *starts* the app tier; PostgREST's schema cache is cold for ~10-30s after,
|
|
1741
|
+
// during which /api/v1/notifications 500s. Without this wait, failover
|
|
1742
|
+
// returned into that window and verify-failover (no retry on 500) failed.
|
|
1743
|
+
// Probed locally via curl --resolve so readiness is independent of the DNS
|
|
1744
|
+
// flip below. Best-effort — see waitForNewPrimaryApi.
|
|
1745
|
+
if (envConfig.domain) {
|
|
1746
|
+
s.start('Waiting for new primary to serve API traffic');
|
|
1747
|
+
const apiReady = await waitForNewPrimaryApi(standbyServer.ip, sshKeyPath, envConfig.domain);
|
|
1748
|
+
s.stop(
|
|
1749
|
+
apiReady
|
|
1750
|
+
? 'New primary serving API traffic'
|
|
1751
|
+
: 'New primary API not confirmed — proceeding (verify may race readiness)',
|
|
1752
|
+
);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1752
1755
|
// Step 2: Stop old primary services to prevent split-brain
|
|
1753
1756
|
s.start('Stopping old primary services');
|
|
1754
1757
|
try {
|
|
@@ -19,8 +19,9 @@ import * as p from '@clack/prompts';
|
|
|
19
19
|
import { uploadS3Backup } from '../../backup-s3.js';
|
|
20
20
|
import { runCommand, runCommandAsync } from '../../command.js';
|
|
21
21
|
import { perfAsync, perfTimer } from '../../perf.js';
|
|
22
|
-
import { shEscape } from '../../shell.js';
|
|
23
|
-
import { scpDownload } from '../../ssh.js';
|
|
22
|
+
import { parseDotenv, shEscape } from '../../shell.js';
|
|
23
|
+
import { NO_PIN_SSH_OPTS, scpDownload } from '../../ssh.js';
|
|
24
|
+
import { postAdminUser, waitForGotrueHealth } from '../admin-user.js';
|
|
24
25
|
|
|
25
26
|
const __filename = fileURLToPath(import.meta.url);
|
|
26
27
|
const __dirname = dirname(__filename);
|
|
@@ -41,18 +42,20 @@ export function loadCloudInitScript() {
|
|
|
41
42
|
return readFileSync(CLOUD_INIT_PATH, 'utf-8');
|
|
42
43
|
}
|
|
43
44
|
|
|
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';
|
|
45
|
+
// Single-sourced from lib/ssh.js (sshHostKeyOpts) so the string form used by
|
|
46
|
+
// the runners below can't drift from the argv form — notably keeping the
|
|
47
|
+
// ServerAlive keepalives that fail a banner-exchange hang fast instead of
|
|
48
|
+
// stalling for ~600s. Re-exported as the canonical compose deploy SSH opts.
|
|
49
|
+
// shell-safety-ignore: NO_PIN_SSH_OPTS is derived from lib/ssh.js sshHostKeyOpts(), which bakes in BatchMode=yes (validated there)
|
|
50
|
+
const SSH_OPTS = NO_PIN_SSH_OPTS;
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
|
-
* Run a command on a remote server via SSH
|
|
53
|
+
* Run a command on a remote server via SSH.
|
|
54
|
+
*
|
|
55
|
+
* Shared with compose/ha.js (imported, not re-copied) so both halves of the
|
|
56
|
+
* compose path use identical, single-sourced SSH options.
|
|
54
57
|
*/
|
|
55
|
-
function sshRun(ip, sshKeyPath, command, options = {}) {
|
|
58
|
+
export function sshRun(ip, sshKeyPath, command, options = {}) {
|
|
56
59
|
const { timeout = 120_000 } = options;
|
|
57
60
|
return runCommand(['ssh', ...SSH_OPTS.split(' '), '-i', sshKeyPath, `root@${ip}`, command], {
|
|
58
61
|
stdio: 'pipe',
|
|
@@ -700,16 +703,6 @@ export async function verifyAppHealth(ip, sshKeyPath, projectName, options = {})
|
|
|
700
703
|
return { healthy: false, status: lastStatus, details };
|
|
701
704
|
}
|
|
702
705
|
|
|
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
706
|
/**
|
|
714
707
|
* Pre-pull container images on the remote server.
|
|
715
708
|
* Run this in parallel with the local Docker build to overlap I/O.
|
|
@@ -803,9 +796,8 @@ export async function runMigrations(ip, sshKeyPath, projectName) {
|
|
|
803
796
|
// 2026-05-26. Use the canonical SQL NOTIFY on the pgrst channel instead
|
|
804
797
|
// (db-channel-enabled defaults on); fall back to restarting rest if NOTIFY
|
|
805
798
|
// 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.
|
|
799
|
+
// Lives here so every caller — deployComposeHA and the orchestrator's inline
|
|
800
|
+
// single-compose path — gets a queryable schema instead of PGRST205.
|
|
809
801
|
await sshRunAsync(
|
|
810
802
|
ip,
|
|
811
803
|
sshKeyPath,
|
|
@@ -815,153 +807,6 @@ export async function runMigrations(ip, sshKeyPath, projectName) {
|
|
|
815
807
|
);
|
|
816
808
|
}
|
|
817
809
|
|
|
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
810
|
/**
|
|
966
811
|
* Destroy a Docker Compose deployment
|
|
967
812
|
*/
|
|
@@ -1193,37 +1038,6 @@ export async function restoreCompose(ip, sshKeyPath, projectName, backupFile, op
|
|
|
1193
1038
|
await sshRunAsync(ip, sshKeyPath, `cd ${remoteDir} && docker compose start app`);
|
|
1194
1039
|
}
|
|
1195
1040
|
|
|
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
|
-
},
|
|
1217
|
-
);
|
|
1218
|
-
if (result?.trim().match(/^[23]\d\d$/)) return true;
|
|
1219
|
-
} catch {
|
|
1220
|
-
// Not ready yet
|
|
1221
|
-
}
|
|
1222
|
-
await new Promise((r) => setTimeout(r, interval));
|
|
1223
|
-
}
|
|
1224
|
-
return false;
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
1041
|
/**
|
|
1228
1042
|
* Create the admin user in production Supabase via the GoTrue admin API.
|
|
1229
1043
|
*
|
|
@@ -1236,6 +1050,28 @@ export async function waitForSSL(url, { timeout = 90_000, interval = 5_000 } = {
|
|
|
1236
1050
|
* @param {string} projectName - Project name (for remote dir)
|
|
1237
1051
|
* @returns {Promise<{success: boolean, message: string}>}
|
|
1238
1052
|
*/
|
|
1053
|
+
/**
|
|
1054
|
+
* Extract the admin credentials a deploy needs to provision the super-admin
|
|
1055
|
+
* out of a project `.env` file's text.
|
|
1056
|
+
*
|
|
1057
|
+
* `create` writes these via `escapeDotenv` (ADMIN_PASSWORD is POSIX
|
|
1058
|
+
* single-quoted) and double-quotes for the rest, so the on-disk shapes are a
|
|
1059
|
+
* mix of `'…'` and `"…"`. Decoding MUST go through `parseDotenv`/`unescapeDotenv`
|
|
1060
|
+
* — the inverse of `escapeDotenv` — or single-quote wrappers leak into the
|
|
1061
|
+
* value and GoTrue provisions a password the operator can never type.
|
|
1062
|
+
*
|
|
1063
|
+
* @param {string} envContent
|
|
1064
|
+
* @returns {{adminEmail?: string, adminPassword?: string, serviceRoleKey?: string}}
|
|
1065
|
+
*/
|
|
1066
|
+
export function readAdminCredentials(envContent) {
|
|
1067
|
+
const env = parseDotenv(envContent);
|
|
1068
|
+
return {
|
|
1069
|
+
adminEmail: env.ADMIN_EMAIL,
|
|
1070
|
+
adminPassword: env.ADMIN_PASSWORD,
|
|
1071
|
+
serviceRoleKey: env.SUPABASE_SERVICE_ROLE_KEY,
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1239
1075
|
export async function createAdminUser(serverIp, sshKeyPath, projectName) {
|
|
1240
1076
|
const envPath = join(process.cwd(), '.env');
|
|
1241
1077
|
if (!existsSync(envPath)) {
|
|
@@ -1243,9 +1079,7 @@ export async function createAdminUser(serverIp, sshKeyPath, projectName) {
|
|
|
1243
1079
|
}
|
|
1244
1080
|
|
|
1245
1081
|
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];
|
|
1082
|
+
const { adminEmail, adminPassword, serviceRoleKey } = readAdminCredentials(envContent);
|
|
1249
1083
|
|
|
1250
1084
|
if (!adminEmail || !adminPassword || !serviceRoleKey) {
|
|
1251
1085
|
return { success: false, message: 'Admin credentials not found in .env' };
|
|
@@ -1299,54 +1133,22 @@ export async function createAdminUser(serverIp, sshKeyPath, projectName) {
|
|
|
1299
1133
|
);
|
|
1300
1134
|
|
|
1301
1135
|
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
|
-
|
|
1136
|
+
// Wait for the ssh -L tunnel to start forwarding (usually <500ms).
|
|
1137
|
+
const connected = await waitForGotrueHealth(`http://localhost:${localPort}/auth/v1/health`, {
|
|
1138
|
+
attempts: 15,
|
|
1139
|
+
intervalMs: 500,
|
|
1140
|
+
});
|
|
1319
1141
|
if (!connected) {
|
|
1320
1142
|
return { success: false, message: 'Could not reach auth service via SSH tunnel' };
|
|
1321
1143
|
}
|
|
1322
1144
|
|
|
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),
|
|
1145
|
+
// POST through Kong, which rewrites /auth/v1 → GoTrue's /.
|
|
1146
|
+
return await postAdminUser({
|
|
1147
|
+
adminUsersUrl: `http://localhost:${localPort}/auth/v1/admin/users`,
|
|
1148
|
+
serviceRoleKey,
|
|
1149
|
+
adminEmail,
|
|
1150
|
+
adminPassword,
|
|
1337
1151
|
});
|
|
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
1152
|
} finally {
|
|
1351
1153
|
pf.kill();
|
|
1352
1154
|
}
|