vibecarbon 0.3.0 → 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 +3 -3
- 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
|
@@ -41,6 +41,7 @@ import { runCommandAsync } from '../../command.js';
|
|
|
41
41
|
import { knownHostsPath } from '../../host-keys.js';
|
|
42
42
|
import { loadCloudInit, renderScript } from '../../iac/cloud-init.js';
|
|
43
43
|
import { perfAsync } from '../../perf.js';
|
|
44
|
+
import { postAdminUser, waitForGotrueHealth } from '../admin-user.js';
|
|
44
45
|
|
|
45
46
|
export const K3S_VERSION = 'v1.31.5+k3s1';
|
|
46
47
|
|
|
@@ -1046,9 +1047,18 @@ export async function pushImageToLocalRegistry({
|
|
|
1046
1047
|
const openTunnel = openTunnelOrWalk;
|
|
1047
1048
|
|
|
1048
1049
|
/** Push the localhost-aliased tag through the local-forwarded port. */
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1050
|
+
// Async spawn (not execFileSync): a docker push of a multi-hundred-MB image
|
|
1051
|
+
// takes minutes, and a synchronous call blocks Node's event loop the whole
|
|
1052
|
+
// time — freezing the spinner/log output and leaking unreaped child
|
|
1053
|
+
// processes (SIGCHLD can't fire while blocked). spawn keeps the loop live.
|
|
1054
|
+
const push = () =>
|
|
1055
|
+
new Promise((resolve, reject) => {
|
|
1056
|
+
const child = spawn('docker', ['push', localTag], { stdio: 'inherit' });
|
|
1057
|
+
child.on('error', (err) => reject(new Error(`docker push spawn failed: ${err.message}`)));
|
|
1058
|
+
child.on('exit', (code) =>
|
|
1059
|
+
code === 0 ? resolve(undefined) : reject(new Error(`docker push exited with code ${code}`)),
|
|
1060
|
+
);
|
|
1061
|
+
});
|
|
1052
1062
|
|
|
1053
1063
|
// Three attempts with progressive backoff. Failure modes we're
|
|
1054
1064
|
// covering, in priority order:
|
|
@@ -1094,7 +1104,7 @@ export async function pushImageToLocalRegistry({
|
|
|
1094
1104
|
taggedAliases.add(localTag);
|
|
1095
1105
|
}
|
|
1096
1106
|
await delay(settleDelays[attempt]);
|
|
1097
|
-
push();
|
|
1107
|
+
await push();
|
|
1098
1108
|
pushed = true;
|
|
1099
1109
|
break;
|
|
1100
1110
|
} catch (err) {
|
|
@@ -1201,6 +1211,14 @@ export async function applyVibecarbonSecrets({ kubeconfig, envLocal, s3Config, b
|
|
|
1201
1211
|
stringData.S3_BUCKET = s3Config.bucket;
|
|
1202
1212
|
stringData.S3_ENDPOINT = s3Config.endpoint;
|
|
1203
1213
|
stringData.S3_REGION = s3Config.region;
|
|
1214
|
+
// AWS-SDK-format credentials file for wal-g in the supabase-db pod. The
|
|
1215
|
+
// supabase Helm chart can't inject secret env on the db container
|
|
1216
|
+
// (environment.db is string-only), so instead of a post-helm env patch
|
|
1217
|
+
// (which helm would strip every upgrade → a db restart every deploy) we
|
|
1218
|
+
// mount THIS key as a file and point AWS_SHARED_CREDENTIALS_FILE at it
|
|
1219
|
+
// (volume is helm-owned → never stripped → no per-deploy restart). See
|
|
1220
|
+
// k8s/values/supabase.values.yaml deployment.db.volumes.
|
|
1221
|
+
stringData.S3_CREDENTIALS_INI = `[default]\naws_access_key_id=${s3Config.accessKey}\naws_secret_access_key=${s3Config.secretKey}\n`;
|
|
1204
1222
|
}
|
|
1205
1223
|
if (backupBucketName) {
|
|
1206
1224
|
stringData.S3_BACKUP_BUCKET = backupBucketName;
|
|
@@ -1255,6 +1273,8 @@ export async function installSupabase({
|
|
|
1255
1273
|
domain,
|
|
1256
1274
|
s3Config,
|
|
1257
1275
|
envLocal,
|
|
1276
|
+
dbImageTag,
|
|
1277
|
+
backupBucketName,
|
|
1258
1278
|
}) {
|
|
1259
1279
|
const env = { ...process.env, KUBECONFIG: kubeconfig };
|
|
1260
1280
|
// Render the values template — substitute every {{TOKEN}} the template
|
|
@@ -1265,10 +1285,33 @@ export async function installSupabase({
|
|
|
1265
1285
|
`installSupabase: ${valuesTemplatePath} not found. Project must include the supabase values template.`,
|
|
1266
1286
|
);
|
|
1267
1287
|
}
|
|
1288
|
+
if (!dbImageTag) {
|
|
1289
|
+
throw new Error(
|
|
1290
|
+
'installSupabase: dbImageTag is required (the wal-g-equipped db image, built by k3s-db-build).',
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
// Split the full image ref into repository + tag for the chart's
|
|
1294
|
+
// image.db.{repository,tag}. dbImageTag is `10.0.1.1:5000/<project>-db:<ver>`
|
|
1295
|
+
// — the registry host carries a port colon, so split on the LAST colon.
|
|
1296
|
+
const lastColon = dbImageTag.lastIndexOf(':');
|
|
1297
|
+
const dbImageRepo = dbImageTag.slice(0, lastColon);
|
|
1298
|
+
const dbImageVer = dbImageTag.slice(lastColon + 1);
|
|
1299
|
+
// WAL-G S3 prefix — dedicated backup bucket if provided, else the storage
|
|
1300
|
+
// bucket (mirrors compose's S3_BACKUP_BUCKET:-S3_BUCKET fallback). Never
|
|
1301
|
+
// leave it as s3:/// (wal-g would error).
|
|
1302
|
+
const walgBucket = backupBucketName || s3Config?.bucket || '';
|
|
1303
|
+
const walgS3Prefix = walgBucket
|
|
1304
|
+
? `s3://${walgBucket}/backups/${projectName || 'vibecarbon'}/walg`
|
|
1305
|
+
: '';
|
|
1268
1306
|
const rendered = readFileSync(valuesTemplatePath, 'utf-8')
|
|
1269
1307
|
.replace(/\{\{DOMAIN\}\}/g, domain || 'localhost')
|
|
1270
1308
|
.replace(/\{\{PROJECT_NAME\}\}/g, projectName || 'vibecarbon')
|
|
1271
|
-
.replace(/\{\{S3_BACKUP_BUCKET\}\}/g,
|
|
1309
|
+
.replace(/\{\{S3_BACKUP_BUCKET\}\}/g, walgBucket)
|
|
1310
|
+
.replace(/\{\{WALG_S3_PREFIX\}\}/g, walgS3Prefix)
|
|
1311
|
+
.replace(/\{\{S3_ENDPOINT\}\}/g, s3Config?.endpoint ?? '')
|
|
1312
|
+
.replace(/\{\{S3_REGION\}\}/g, s3Config?.region ?? '')
|
|
1313
|
+
.replace(/\{\{DB_IMAGE\}\}/g, dbImageRepo)
|
|
1314
|
+
.replace(/\{\{DB_IMAGE_TAG\}\}/g, dbImageVer)
|
|
1272
1315
|
// Studio dashboard creds — sourced from .env.local at deploy time. Empty
|
|
1273
1316
|
// string fallback keeps yaml valid; chart inlines them into the
|
|
1274
1317
|
// chart-generated supabase-dashboard secret.
|
|
@@ -1587,6 +1630,99 @@ export async function reloadPostgrest({ kubeconfig }) {
|
|
|
1587
1630
|
}
|
|
1588
1631
|
}
|
|
1589
1632
|
|
|
1633
|
+
/**
|
|
1634
|
+
* Create the production app super-admin in `auth.users` via GoTrue's admin API.
|
|
1635
|
+
*
|
|
1636
|
+
* The k8s mirror of compose/index.js `createAdminUser`: identical GoTrue
|
|
1637
|
+
* admin-API contract (POST the super_admin payload with the service-role
|
|
1638
|
+
* bearer, idempotent on 422 via the shared admin-user helper), reached via
|
|
1639
|
+
* `kubectl port-forward svc/supabase-supabase-auth` instead of an `ssh -L`
|
|
1640
|
+
* tunnel. Goes direct to GoTrue (auth :9999, path `/admin/users`) — no Kong
|
|
1641
|
+
* `/auth/v1` prefix to strip.
|
|
1642
|
+
*
|
|
1643
|
+
* Without this, a k8s deploy shipped a prod app the operator couldn't log
|
|
1644
|
+
* into: ADMIN_EMAIL/ADMIN_PASSWORD only seeded the Supabase Studio dashboard
|
|
1645
|
+
* basic-auth secret, never an app `auth.users` row.
|
|
1646
|
+
*
|
|
1647
|
+
* @param {{kubeconfig: string, envLocal: Record<string,string>, localPort?: number}} args
|
|
1648
|
+
* @returns {Promise<{success: boolean, message: string}>}
|
|
1649
|
+
*/
|
|
1650
|
+
export async function provisionAdminUser({ kubeconfig, envLocal, localPort = 15000 }) {
|
|
1651
|
+
const adminEmail = envLocal?.ADMIN_EMAIL;
|
|
1652
|
+
const adminPassword = envLocal?.ADMIN_PASSWORD;
|
|
1653
|
+
const serviceRoleKey = envLocal?.SERVICE_ROLE_KEY ?? envLocal?.SUPABASE_SERVICE_ROLE_KEY;
|
|
1654
|
+
if (!adminEmail || !adminPassword || !serviceRoleKey) {
|
|
1655
|
+
return { success: false, message: 'Admin credentials not found in .env.local' };
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
const env = { ...process.env, KUBECONFIG: kubeconfig };
|
|
1659
|
+
// Background port-forward to GoTrue. Distinct localPort per cluster so the
|
|
1660
|
+
// parallel HA fan-out (primary + standby on one operator host) doesn't race
|
|
1661
|
+
// for the same bind — mirrors the 5000/5001 registry-tunnel split.
|
|
1662
|
+
const pf = spawn(
|
|
1663
|
+
'kubectl',
|
|
1664
|
+
['-n', 'vibecarbon', 'port-forward', 'svc/supabase-supabase-auth', `${localPort}:9999`],
|
|
1665
|
+
{ env, stdio: 'ignore' },
|
|
1666
|
+
);
|
|
1667
|
+
try {
|
|
1668
|
+
const connected = await waitForGotrueHealth(`http://localhost:${localPort}/health`, {
|
|
1669
|
+
attempts: 20,
|
|
1670
|
+
intervalMs: 500,
|
|
1671
|
+
});
|
|
1672
|
+
if (!connected) {
|
|
1673
|
+
return { success: false, message: 'Could not reach GoTrue via kubectl port-forward' };
|
|
1674
|
+
}
|
|
1675
|
+
return await postAdminUser({
|
|
1676
|
+
adminUsersUrl: `http://localhost:${localPort}/admin/users`,
|
|
1677
|
+
serviceRoleKey,
|
|
1678
|
+
adminEmail,
|
|
1679
|
+
adminPassword,
|
|
1680
|
+
});
|
|
1681
|
+
} finally {
|
|
1682
|
+
pf.kill();
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
/**
|
|
1687
|
+
* The four `ALTER SYSTEM` settings that turn on continuous WAL archiving for
|
|
1688
|
+
* wal-g on the chart-installed supabase-db.
|
|
1689
|
+
*/
|
|
1690
|
+
export const WAL_ARCHIVING_SETTINGS = [
|
|
1691
|
+
"ALTER SYSTEM SET archive_mode='on'",
|
|
1692
|
+
"ALTER SYSTEM SET archive_command='bash /etc/postgresql/wal-archive.sh %p'",
|
|
1693
|
+
"ALTER SYSTEM SET archive_timeout='900'",
|
|
1694
|
+
"ALTER SYSTEM SET wal_level='replica'",
|
|
1695
|
+
];
|
|
1696
|
+
|
|
1697
|
+
/**
|
|
1698
|
+
* Build the `kubectl exec … psql …` argument list that enables WAL archiving.
|
|
1699
|
+
*
|
|
1700
|
+
* Each `ALTER SYSTEM` MUST be its own `-c` option. psql sends a single `-c`
|
|
1701
|
+
* string as one simple-query request, and Postgres runs a multi-statement
|
|
1702
|
+
* simple query inside ONE implicit transaction — where `ALTER SYSTEM` is
|
|
1703
|
+
* rejected with "ALTER SYSTEM cannot run inside a transaction block". The
|
|
1704
|
+
* documented remedy (and the compose/ha.js pattern) is to pass each statement
|
|
1705
|
+
* as a separate `-c`, so every ALTER SYSTEM is its own implicit transaction.
|
|
1706
|
+
*
|
|
1707
|
+
* @param {string} dbPod - e.g. `supabase-supabase-db-0`
|
|
1708
|
+
* @returns {string[]}
|
|
1709
|
+
*/
|
|
1710
|
+
export function enableWalArchivingPsqlArgs(dbPod) {
|
|
1711
|
+
return [
|
|
1712
|
+
'-n',
|
|
1713
|
+
'vibecarbon',
|
|
1714
|
+
'exec',
|
|
1715
|
+
dbPod,
|
|
1716
|
+
'--',
|
|
1717
|
+
'psql',
|
|
1718
|
+
'-U',
|
|
1719
|
+
'supabase_admin',
|
|
1720
|
+
'-d',
|
|
1721
|
+
'postgres',
|
|
1722
|
+
...WAL_ARCHIVING_SETTINGS.flatMap((stmt) => ['-c', stmt]),
|
|
1723
|
+
];
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1590
1726
|
/**
|
|
1591
1727
|
* Apply cert-manager + vibecarbon-secrets + the project's k8s/infra +
|
|
1592
1728
|
* k8s/base kustomizations, install supabase via helm, patch the app
|
|
@@ -1595,18 +1731,21 @@ export async function reloadPostgrest({ kubeconfig }) {
|
|
|
1595
1731
|
* Local-first by default — no Flux on the deploy path; Flux is opt-in
|
|
1596
1732
|
* via `vibecarbon configure cicd`.
|
|
1597
1733
|
*
|
|
1598
|
-
* @param {{kubeconfig: string, projectDir: string, projectName: string, imageTag: string,
|
|
1734
|
+
* @param {{kubeconfig: string, projectDir: string, projectName: string, imageTag: string, dbImageTag: string, envLocal: Record<string,string>, domain: string, s3Config?: {accessKey: string, secretKey: string, bucket: string, endpoint: string, region: string}, backupBucketName?: string|null, restore?: string|null, dnsProvider?: string|null, cloudflareApiToken?: string|null, hetznerApiToken?: string|null, apiToken?: string, region?: string, environment?: string, minWorkers?: number, maxWorkers?: number, workerServerType?: string, k3sToken?: string, masterIp?: string, sshKeyPath?: string, khPath?: string}} args
|
|
1735
|
+
* `restore` (when set, e.g. 'latest' or an ISO timestamp) skips applyMigrations
|
|
1736
|
+
* — the wal-g-restored DB already carries schema_migrations.
|
|
1599
1737
|
*/
|
|
1600
1738
|
export async function applyK3sManifests({
|
|
1601
1739
|
kubeconfig,
|
|
1602
1740
|
projectDir,
|
|
1603
1741
|
projectName,
|
|
1604
1742
|
imageTag,
|
|
1605
|
-
|
|
1743
|
+
dbImageTag,
|
|
1606
1744
|
envLocal,
|
|
1607
1745
|
domain,
|
|
1608
1746
|
s3Config,
|
|
1609
1747
|
backupBucketName,
|
|
1748
|
+
restore,
|
|
1610
1749
|
dnsProvider,
|
|
1611
1750
|
cloudflareApiToken,
|
|
1612
1751
|
hetznerApiToken,
|
|
@@ -1898,55 +2037,21 @@ export async function applyK3sManifests({
|
|
|
1898
2037
|
if (!khPath) {
|
|
1899
2038
|
throw new Error('applyK3sManifests: khPath required for registry push (Phase 6)');
|
|
1900
2039
|
}
|
|
1901
|
-
// Push app
|
|
1902
|
-
//
|
|
1903
|
-
//
|
|
1904
|
-
//
|
|
1905
|
-
//
|
|
1906
|
-
// fallback has 80 ports of headroom before colliding (plus HA's 5001
|
|
1907
|
-
// standby cluster lands on +100=5101, well clear of primary's 5000+19).
|
|
1908
|
-
// RCA: parallelizing without an offset would silently corrupt pushes
|
|
1909
|
-
// (registry returns "blob unknown" on a teardown'd partial upload).
|
|
2040
|
+
// Push the app image to the in-cluster registry so CA-spawned workers
|
|
2041
|
+
// (which don't exist at sideload time) can pull it on first schedule.
|
|
2042
|
+
// The db image is NOT pushed here: it's pinned to the static supabase
|
|
2043
|
+
// node, reaches it via sideload, and the chart references it with
|
|
2044
|
+
// imagePullPolicy: IfNotPresent — CA workers never run postgres.
|
|
1910
2045
|
const appPushPort = localTunnelPort;
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
}
|
|
1921
|
-
const pushTasks = [
|
|
1922
|
-
perfAsync(`deploy.${perfPrefix}.registryPush.app`, () =>
|
|
1923
|
-
pushImageToLocalRegistry({
|
|
1924
|
-
tag: imageTag,
|
|
1925
|
-
masterIp,
|
|
1926
|
-
sshKey: sshKeyPath,
|
|
1927
|
-
khPath,
|
|
1928
|
-
localTunnelPort: appPushPort,
|
|
1929
|
-
}),
|
|
1930
|
-
),
|
|
1931
|
-
];
|
|
1932
|
-
// 5b'''. Push the backup image too. The backup CronJob's pods may
|
|
1933
|
-
// eventually schedule onto a CA-spawned worker (no node
|
|
1934
|
-
// affinity pinning them to the static pool), so the same
|
|
1935
|
-
// registry-fallback story applies.
|
|
1936
|
-
if (backupImageTag) {
|
|
1937
|
-
pushTasks.push(
|
|
1938
|
-
perfAsync(`deploy.${perfPrefix}.registryPush.backup`, () =>
|
|
1939
|
-
pushImageToLocalRegistry({
|
|
1940
|
-
tag: backupImageTag,
|
|
1941
|
-
masterIp,
|
|
1942
|
-
sshKey: sshKeyPath,
|
|
1943
|
-
khPath,
|
|
1944
|
-
localTunnelPort: backupPushPort,
|
|
1945
|
-
}),
|
|
1946
|
-
),
|
|
1947
|
-
);
|
|
1948
|
-
}
|
|
1949
|
-
await Promise.all(pushTasks);
|
|
2046
|
+
await perfAsync(`deploy.${perfPrefix}.registryPush.app`, () =>
|
|
2047
|
+
pushImageToLocalRegistry({
|
|
2048
|
+
tag: imageTag,
|
|
2049
|
+
masterIp,
|
|
2050
|
+
sshKey: sshKeyPath,
|
|
2051
|
+
khPath,
|
|
2052
|
+
localTunnelPort: appPushPort,
|
|
2053
|
+
}),
|
|
2054
|
+
);
|
|
1950
2055
|
// 5a'. Apply cluster-autoscaler manifests SEPARATELY. CA lives in
|
|
1951
2056
|
// kube-system but base/'s parent kustomization sets
|
|
1952
2057
|
// `namespace: vibecarbon`, whose namespace transformer would
|
|
@@ -2148,53 +2253,109 @@ export async function applyK3sManifests({
|
|
|
2148
2253
|
['-n', 'vibecarbon', 'set', 'image', 'deployment/app', `app=${imageTag}`],
|
|
2149
2254
|
{ env, description: 'applyK3sManifests: kubectl set image deployment/app' },
|
|
2150
2255
|
);
|
|
2151
|
-
// 6b. Patch the backup CronJob image. base/backup/cronjob.yaml ships
|
|
2152
|
-
// image=ghcr.io/<owner>/<project>-backup:latest — never pushed in
|
|
2153
|
-
// direct/local mode, so the scheduled job pod fails ImagePullBackOff.
|
|
2154
|
-
// deployK3s sideloaded the backup image to every node (step 7b);
|
|
2155
|
-
// point the CronJob at it. imagePullPolicy: Always in the template
|
|
2156
|
-
// would defeat the sideload — switch to IfNotPresent at the same
|
|
2157
|
-
// time so kubelet uses the local copy.
|
|
2158
|
-
if (backupImageTag) {
|
|
2159
|
-
await runKubectlWithRetry(
|
|
2160
|
-
['-n', 'vibecarbon', 'set', 'image', 'cronjob/backup', `backup=${backupImageTag}`],
|
|
2161
|
-
{ env, description: 'applyK3sManifests: kubectl set image cronjob/backup' },
|
|
2162
|
-
);
|
|
2163
|
-
await runKubectlWithRetry(
|
|
2164
|
-
[
|
|
2165
|
-
'-n',
|
|
2166
|
-
'vibecarbon',
|
|
2167
|
-
'patch',
|
|
2168
|
-
'cronjob/backup',
|
|
2169
|
-
'--type=json',
|
|
2170
|
-
'-p',
|
|
2171
|
-
JSON.stringify([
|
|
2172
|
-
{
|
|
2173
|
-
op: 'replace',
|
|
2174
|
-
path: '/spec/jobTemplate/spec/template/spec/containers/0/imagePullPolicy',
|
|
2175
|
-
value: 'IfNotPresent',
|
|
2176
|
-
},
|
|
2177
|
-
]),
|
|
2178
|
-
],
|
|
2179
|
-
{ env, description: 'applyK3sManifests: kubectl patch cronjob/backup imagePullPolicy' },
|
|
2180
|
-
);
|
|
2181
|
-
}
|
|
2182
2256
|
// 7. Install Supabase via helm. Blocks until release converges (15m
|
|
2183
|
-
// timeout in chart).
|
|
2184
|
-
//
|
|
2185
|
-
//
|
|
2186
|
-
// the sideloaded image.
|
|
2257
|
+
// timeout in chart). image.db points at the wal-g-equipped db image
|
|
2258
|
+
// (sideloaded in step 7b), and the chart mounts wal-archive.sh + the
|
|
2259
|
+
// non-secret WALG_* env from supabase.values.yaml.
|
|
2187
2260
|
await perfAsync(`deploy.${perfPrefix}.installSupabase`, async () =>
|
|
2188
|
-
installSupabase({
|
|
2261
|
+
installSupabase({
|
|
2262
|
+
kubeconfig,
|
|
2263
|
+
projectDir,
|
|
2264
|
+
projectName,
|
|
2265
|
+
domain,
|
|
2266
|
+
s3Config,
|
|
2267
|
+
envLocal,
|
|
2268
|
+
dbImageTag,
|
|
2269
|
+
backupBucketName,
|
|
2270
|
+
}),
|
|
2189
2271
|
);
|
|
2272
|
+
// 7b. Enable continuous WAL archiving on the supabase-db. The chart can't
|
|
2273
|
+
// inject secret env (environment.db is string-only) or postgres archive
|
|
2274
|
+
// flags, so we (a) ALTER SYSTEM the archive params (persisted to
|
|
2275
|
+
// postgresql.auto.conf on the PVC), and (b) inject the SECRET S3 creds
|
|
2276
|
+
// via a strategic-merge env patch (merged by env name → idempotent;
|
|
2277
|
+
// re-applied every deploy because helm upgrade strips non-chart env).
|
|
2278
|
+
// The cred patch rolls the pod when it changes the template; on first
|
|
2279
|
+
// enable that rollout is what makes postgres pick up archive_mode.
|
|
2280
|
+
// Skipped without S3 (dev / no backups configured).
|
|
2281
|
+
if (s3Config?.accessKey) {
|
|
2282
|
+
await perfAsync(`deploy.${perfPrefix}.enableWalArchiving`, async () => {
|
|
2283
|
+
const dbSts = 'supabase-supabase-db';
|
|
2284
|
+
const dbPod = `${dbSts}-0`;
|
|
2285
|
+
// S3 creds reach the db container as a mounted file (helm-owned, see
|
|
2286
|
+
// supabase.values.yaml walg-s3-creds volume) — no env patch, so no
|
|
2287
|
+
// db restart on warm deploys. Only the archive params need ALTER
|
|
2288
|
+
// SYSTEM, and archive_mode flips only with a RESTART. So: check the
|
|
2289
|
+
// RUNNING archive_mode; if already 'on' (persisted in
|
|
2290
|
+
// postgresql.auto.conf from a prior enable), this is a warm deploy —
|
|
2291
|
+
// nothing to do. If 'off', set the params and restart ONCE.
|
|
2292
|
+
let archiveMode = '';
|
|
2293
|
+
try {
|
|
2294
|
+
archiveMode = (
|
|
2295
|
+
await runKubectlWithRetry(
|
|
2296
|
+
[
|
|
2297
|
+
'-n',
|
|
2298
|
+
'vibecarbon',
|
|
2299
|
+
'exec',
|
|
2300
|
+
dbPod,
|
|
2301
|
+
'--',
|
|
2302
|
+
'psql',
|
|
2303
|
+
'-U',
|
|
2304
|
+
'supabase_admin',
|
|
2305
|
+
'-d',
|
|
2306
|
+
'postgres',
|
|
2307
|
+
'-tAc',
|
|
2308
|
+
'SHOW archive_mode',
|
|
2309
|
+
],
|
|
2310
|
+
{
|
|
2311
|
+
env,
|
|
2312
|
+
captureStdout: true,
|
|
2313
|
+
description: 'applyK3sManifests: read archive_mode',
|
|
2314
|
+
},
|
|
2315
|
+
)
|
|
2316
|
+
)
|
|
2317
|
+
?.toString()
|
|
2318
|
+
.trim();
|
|
2319
|
+
} catch {
|
|
2320
|
+
// Treat an unreadable value as "not yet enabled" — the worst case is
|
|
2321
|
+
// one extra ALTER SYSTEM + restart, which is idempotent.
|
|
2322
|
+
}
|
|
2323
|
+
if (archiveMode === 'on') {
|
|
2324
|
+
console.error('[k3s] WAL archiving already enabled — skipping ALTER SYSTEM + restart.');
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
2327
|
+
await runKubectlWithRetry(enableWalArchivingPsqlArgs(dbPod), {
|
|
2328
|
+
env,
|
|
2329
|
+
description: 'applyK3sManifests: enable WAL archiving (ALTER SYSTEM)',
|
|
2330
|
+
});
|
|
2331
|
+
// archive_mode requires a restart (reload is insufficient). Restart the
|
|
2332
|
+
// StatefulSet ONCE; persisted to postgresql.auto.conf on the PVC, so
|
|
2333
|
+
// subsequent deploys hit the archiveMode==='on' fast path above.
|
|
2334
|
+
await runKubectlWithRetry(
|
|
2335
|
+
['-n', 'vibecarbon', 'rollout', 'restart', `statefulset/${dbSts}`],
|
|
2336
|
+
{ env, description: 'applyK3sManifests: restart supabase-db to enable WAL archiving' },
|
|
2337
|
+
);
|
|
2338
|
+
await runKubectlWithRetry(
|
|
2339
|
+
['-n', 'vibecarbon', 'rollout', 'status', `statefulset/${dbSts}`, '--timeout=300s'],
|
|
2340
|
+
{ env, description: 'applyK3sManifests: wait supabase-db rollout (WAL archiving)' },
|
|
2341
|
+
);
|
|
2342
|
+
});
|
|
2343
|
+
}
|
|
2190
2344
|
// 8. Apply project SQL migrations against the chart-installed postgres.
|
|
2191
|
-
//
|
|
2192
|
-
//
|
|
2193
|
-
//
|
|
2194
|
-
//
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2345
|
+
// SKIPPED when restoring: a wal-g restore brings the full DB including
|
|
2346
|
+
// supabase_migrations.schema_migrations, so re-running migrations would
|
|
2347
|
+
// hit duplicate-key errors (applyMigrations deliberately does not
|
|
2348
|
+
// swallow them). On a normal deploy, helm's `--wait` guarantees the db
|
|
2349
|
+
// pod is up; without migrations the app's /api/health/ready 503s.
|
|
2350
|
+
if (restore) {
|
|
2351
|
+
console.error(
|
|
2352
|
+
`[k3s] restore=${restore} requested — skipping applyMigrations (restored DB carries schema_migrations).`,
|
|
2353
|
+
);
|
|
2354
|
+
} else {
|
|
2355
|
+
await perfAsync(`deploy.${perfPrefix}.applyMigrations`, async () =>
|
|
2356
|
+
applyMigrations({ kubeconfig, projectDir }),
|
|
2357
|
+
);
|
|
2358
|
+
}
|
|
2198
2359
|
// 8b. Reload PostgREST's schema cache so it sees the tables applyMigrations
|
|
2199
2360
|
// just created. rest came up during helm --wait (before migrations) and
|
|
2200
2361
|
// no DDL-watch trigger exists to auto-NOTIFY — without this, /rest/v1/*
|
|
@@ -2202,6 +2363,28 @@ export async function applyK3sManifests({
|
|
|
2202
2363
|
await perfAsync(`deploy.${perfPrefix}.reloadPostgrest`, async () =>
|
|
2203
2364
|
reloadPostgrest({ kubeconfig }),
|
|
2204
2365
|
);
|
|
2366
|
+
// 8c. Create the production app super-admin via GoTrue's admin API. Mirrors
|
|
2367
|
+
// the compose path — without it the operator can't log into their own
|
|
2368
|
+
// deployed app (ADMIN_* only seeds the Studio dashboard secret, never an
|
|
2369
|
+
// auth.users row). Runs unconditionally (incl. restore: idempotent on
|
|
2370
|
+
// 422, and a backup predating admin-creation still gets seeded). In HA
|
|
2371
|
+
// both clusters run this against their independently-writable DB at
|
|
2372
|
+
// deploy time (same as applyMigrations); the localPort is derived from
|
|
2373
|
+
// localTunnelPort so the parallel primary/standby port-forwards don't
|
|
2374
|
+
// race for the same bind. Non-fatal: a GoTrue blip warns + retries on
|
|
2375
|
+
// the next deploy rather than failing an otherwise-healthy one.
|
|
2376
|
+
await perfAsync(`deploy.${perfPrefix}.createAdminUser`, async () => {
|
|
2377
|
+
const adminResult = await provisionAdminUser({
|
|
2378
|
+
kubeconfig,
|
|
2379
|
+
envLocal,
|
|
2380
|
+
localPort: 15000 + (localTunnelPort - 5000),
|
|
2381
|
+
});
|
|
2382
|
+
if (adminResult.success) {
|
|
2383
|
+
console.log(`[k3s] ${adminResult.message}`);
|
|
2384
|
+
} else {
|
|
2385
|
+
console.warn(`[k3s] ${adminResult.message} — re-run \`vibecarbon deploy\` to retry.`);
|
|
2386
|
+
}
|
|
2387
|
+
});
|
|
2205
2388
|
// 9. Wait for app rollout. The deployment template already has
|
|
2206
2389
|
// imagePullPolicy: IfNotPresent, so kubelet uses the sideloaded image
|
|
2207
2390
|
// without pulling. 300s wasn't enough on a cold deploy where the app
|
|
@@ -2469,37 +2652,39 @@ export async function deployK3s(options) {
|
|
|
2469
2652
|
}
|
|
2470
2653
|
const { tag: imageTag } = state.getStepResult('k3s-build');
|
|
2471
2654
|
|
|
2472
|
-
// 7. Build the
|
|
2473
|
-
//
|
|
2474
|
-
//
|
|
2475
|
-
//
|
|
2476
|
-
//
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2655
|
+
// 7. Build the wal-g-equipped db image (carbon/db/Dockerfile: FROM
|
|
2656
|
+
// supabase/postgres + wal-g v3.0.5). This REPLACES the old separate
|
|
2657
|
+
// ~500MB backup image: the db image already carries wal-g + psql, and
|
|
2658
|
+
// co-locating wal-g with PGDATA is what enables physical base backups +
|
|
2659
|
+
// PITR (the old backup pod ran off-node and was stuck on pg_dump). The
|
|
2660
|
+
// supabase Helm chart's image.db is pointed at this build in
|
|
2661
|
+
// installSupabase. Built up-front so its sideload fans out with the app.
|
|
2662
|
+
const dbBuildInputs = { projectName: `${options.projectName}-db` };
|
|
2663
|
+
if (!state.shouldSkip('k3s-db-build', dbBuildInputs)) {
|
|
2664
|
+
state.startStep('k3s-db-build', dbBuildInputs);
|
|
2665
|
+
s.start('Building db image (wal-g)');
|
|
2666
|
+
const built = await buildAppImage(join(projectDir, 'db'), `${options.projectName}-db`);
|
|
2667
|
+
state.completeStep('k3s-db-build', {
|
|
2483
2668
|
tag: built.tag,
|
|
2484
2669
|
gitSha: built.gitSha,
|
|
2485
2670
|
isDirty: built.isDirty,
|
|
2486
2671
|
});
|
|
2487
|
-
s.stop(`
|
|
2672
|
+
s.stop(`DB image built: ${built.tag}`);
|
|
2488
2673
|
}
|
|
2489
|
-
const { tag:
|
|
2674
|
+
const { tag: dbImageTag } = state.getStepResult('k3s-db-build');
|
|
2490
2675
|
|
|
2491
2676
|
// 7b. Sideload to every node (parallel SSH within sideloadK3s) AND fan out
|
|
2492
|
-
// the app+
|
|
2493
|
-
//
|
|
2494
|
-
//
|
|
2495
|
-
//
|
|
2496
|
-
//
|
|
2497
|
-
//
|
|
2677
|
+
// the app+db sideloads against each other. Both writes go through
|
|
2678
|
+
// `docker save | ssh ... gunzip | k3s ctr import` against disjoint
|
|
2679
|
+
// image refs on the same target nodes — kernel disk buffer + sshd
|
|
2680
|
+
// multiplex two parallel streams. Critical path = max(app, db).
|
|
2681
|
+
// The db image only NEEDS the supabase node (db is pinned there), but
|
|
2682
|
+
// sideloading to all nodes is harmless and keeps the target list shared.
|
|
2498
2683
|
const sshTargets = [`root@${masterIp}`];
|
|
2499
2684
|
if (supabaseIp) sshTargets.push(`root@${supabaseIp}`);
|
|
2500
2685
|
for (const wIp of workerIps ?? []) sshTargets.push(`root@${wIp}`);
|
|
2501
2686
|
const sideloadInputs = { imageTag, targets: sshTargets.join(',') };
|
|
2502
|
-
const
|
|
2687
|
+
const dbSideloadInputs = { dbImageTag, targets: sshTargets.join(',') };
|
|
2503
2688
|
const sideloadTasks = [];
|
|
2504
2689
|
if (!state.shouldSkip('k3s-sideload', sideloadInputs)) {
|
|
2505
2690
|
state.startStep('k3s-sideload', sideloadInputs);
|
|
@@ -2509,23 +2694,23 @@ export async function deployK3s(options) {
|
|
|
2509
2694
|
).then(() => state.completeStep('k3s-sideload')),
|
|
2510
2695
|
);
|
|
2511
2696
|
}
|
|
2512
|
-
if (!state.shouldSkip('k3s-
|
|
2513
|
-
state.startStep('k3s-
|
|
2697
|
+
if (!state.shouldSkip('k3s-db-sideload', dbSideloadInputs)) {
|
|
2698
|
+
state.startStep('k3s-db-sideload', dbSideloadInputs);
|
|
2514
2699
|
sideloadTasks.push(
|
|
2515
|
-
perfAsync(`deploy.${perfPrefix}.sideload.
|
|
2516
|
-
sideloadK3s({ tag:
|
|
2517
|
-
).then(() => state.completeStep('k3s-
|
|
2700
|
+
perfAsync(`deploy.${perfPrefix}.sideload.db`, () =>
|
|
2701
|
+
sideloadK3s({ tag: dbImageTag, sshTargets, sshKey: privateKeyPath, khPath }),
|
|
2702
|
+
).then(() => state.completeStep('k3s-db-sideload')),
|
|
2518
2703
|
);
|
|
2519
2704
|
}
|
|
2520
2705
|
if (sideloadTasks.length > 0) {
|
|
2521
|
-
s.start(`Sideloading app +
|
|
2706
|
+
s.start(`Sideloading app + db to ${sshTargets.length} node(s) in parallel`);
|
|
2522
2707
|
await Promise.all(sideloadTasks);
|
|
2523
|
-
s.stop('Sideload complete (app +
|
|
2708
|
+
s.stop('Sideload complete (app + db)');
|
|
2524
2709
|
}
|
|
2525
2710
|
|
|
2526
2711
|
// 8. Apply manifests + supabase Helm release + roll out the app.
|
|
2527
|
-
if (!state.shouldSkip('k3s-apply', { imageTag,
|
|
2528
|
-
state.startStep('k3s-apply', { imageTag,
|
|
2712
|
+
if (!state.shouldSkip('k3s-apply', { imageTag, dbImageTag, restore: options.restore ?? '' })) {
|
|
2713
|
+
state.startStep('k3s-apply', { imageTag, dbImageTag, restore: options.restore ?? '' });
|
|
2529
2714
|
s.start('Applying k8s manifests + installing Supabase + rolling out app');
|
|
2530
2715
|
const envLocal = loadEnvLocal(join(projectDir, '.env.local'));
|
|
2531
2716
|
await perfAsync(`deploy.${perfPrefix}.applyManifests`, () =>
|
|
@@ -2535,11 +2720,12 @@ export async function deployK3s(options) {
|
|
|
2535
2720
|
projectDir,
|
|
2536
2721
|
projectName: options.projectName,
|
|
2537
2722
|
imageTag,
|
|
2538
|
-
|
|
2723
|
+
dbImageTag,
|
|
2539
2724
|
envLocal,
|
|
2540
2725
|
domain: options.domain || 'localhost',
|
|
2541
2726
|
s3Config: options.s3Config,
|
|
2542
2727
|
backupBucketName: options.backupBucketName,
|
|
2728
|
+
restore: options.restore,
|
|
2543
2729
|
dnsProvider: options.dnsProvider,
|
|
2544
2730
|
cloudflareApiToken: options.cloudflareApiToken,
|
|
2545
2731
|
// Hetzner DNS-01 uses the SAME Cloud API token (api.hetzner.cloud)
|