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
|
@@ -40,10 +40,17 @@ import * as p from '@clack/prompts';
|
|
|
40
40
|
import { runCommandAsync } from '../../command.js';
|
|
41
41
|
import { knownHostsPath } from '../../host-keys.js';
|
|
42
42
|
import { loadCloudInit, renderScript } from '../../iac/cloud-init.js';
|
|
43
|
+
import { dbImageRef } from '../../images.js';
|
|
43
44
|
import { perfAsync } from '../../perf.js';
|
|
45
|
+
import { postAdminUser, waitForGotrueHealth } from '../admin-user.js';
|
|
44
46
|
|
|
45
47
|
export const K3S_VERSION = 'v1.31.5+k3s1';
|
|
46
48
|
|
|
49
|
+
/** The db image is pre-published + multi-arch — pulled, never built/sideloaded. */
|
|
50
|
+
export function resolveDbImageTag() {
|
|
51
|
+
return dbImageRef();
|
|
52
|
+
}
|
|
53
|
+
|
|
47
54
|
/**
|
|
48
55
|
* Pattern of kube-apiserver transient signatures we've actually observed
|
|
49
56
|
* mid-RPC during k3s control-plane warm-up. See runKubectlWithRetry —
|
|
@@ -52,7 +59,12 @@ export const K3S_VERSION = 'v1.31.5+k3s1';
|
|
|
52
59
|
* obvious place to do it).
|
|
53
60
|
*/
|
|
54
61
|
export const KUBECTL_TRANSIENT_PATTERN =
|
|
55
|
-
|
|
62
|
+
// `connection timed out` covers cross-cluster API blips like
|
|
63
|
+
// `read tcp <ip>:<port>->...:6443: read: connection timed out` — seen when a
|
|
64
|
+
// kubectl exec (e.g. enable-WAL-archiving) hits a momentarily unreachable
|
|
65
|
+
// standby control-plane in k8s-ha. Without it the call failed without
|
|
66
|
+
// retrying, aborting the deploy. (RCA 2026-06-01: k8s-ha standby deploy.)
|
|
67
|
+
/http2: client connection lost|connection reset by peer|context deadline exceeded|connection timed out|EOF|i\/o timeout|TLS handshake|unexpected error when reading response body/i;
|
|
56
68
|
|
|
57
69
|
/**
|
|
58
70
|
* Run a short-lived kubectl command with retry-on-transient-error.
|
|
@@ -208,6 +220,35 @@ export function pickIssuerName({ dnsProvider, acmeServer }) {
|
|
|
208
220
|
return `letsencrypt-${stage}-${providerSuffix}`;
|
|
209
221
|
}
|
|
210
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Build the `dnsNames` list for the `Certificate/vibecarbon-tls` patch.
|
|
225
|
+
*
|
|
226
|
+
* DNS-01 issuers (cloudflare, hetzner) support wildcard SANs — cert-manager
|
|
227
|
+
* can obtain `*.${domain}` in a single ACME order, covering every subdomain
|
|
228
|
+
* (api, studio, dashboard, grafana, n8n …) without separate per-subdomain
|
|
229
|
+
* certificates. One wildcard cert = no per-router cert-resolver needed =
|
|
230
|
+
* no per-domain ACME rate-limit risk.
|
|
231
|
+
*
|
|
232
|
+
* Manual issuers use HTTP-01 which cannot issue wildcards, so fall back to
|
|
233
|
+
* the apex domain only (the IngressRoutes already work; only non-apex
|
|
234
|
+
* subdomains are uncovered, but those are admin-only tools the operator
|
|
235
|
+
* configures manually for manual-DNS deploys).
|
|
236
|
+
*
|
|
237
|
+
* @param {string} domain - The apex deploy domain, e.g. `e1.carbonstack.dev`
|
|
238
|
+
* @param {string} issuerName - The ClusterIssuer name, e.g.
|
|
239
|
+
* `letsencrypt-prod-hetzner`, produced by `pickIssuerName`
|
|
240
|
+
* @returns {string[]}
|
|
241
|
+
*/
|
|
242
|
+
export function certificateDnsNames(domain, issuerName) {
|
|
243
|
+
// Only the known DNS-01 providers (cloudflare, hetzner) support wildcard SANs.
|
|
244
|
+
// `manual` and any future/unknown issuer name falls back to apex-only so an
|
|
245
|
+
// unrecognised issuer never attempts a wildcard via HTTP-01 (which LE rejects).
|
|
246
|
+
const isDns01 = /-(cloudflare|hetzner)$/.test(issuerName);
|
|
247
|
+
if (!isDns01) return [domain];
|
|
248
|
+
// DNS-01 issuers (cloudflare, hetzner — prod or staging): issue one wildcard.
|
|
249
|
+
return [domain, `*.${domain}`];
|
|
250
|
+
}
|
|
251
|
+
|
|
211
252
|
/**
|
|
212
253
|
* Render the per-DNS-provider Secret YAML that cert-manager's DNS-01
|
|
213
254
|
* solvers read for API auth, or null when the provider doesn't need a
|
|
@@ -1046,9 +1087,18 @@ export async function pushImageToLocalRegistry({
|
|
|
1046
1087
|
const openTunnel = openTunnelOrWalk;
|
|
1047
1088
|
|
|
1048
1089
|
/** Push the localhost-aliased tag through the local-forwarded port. */
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1090
|
+
// Async spawn (not execFileSync): a docker push of a multi-hundred-MB image
|
|
1091
|
+
// takes minutes, and a synchronous call blocks Node's event loop the whole
|
|
1092
|
+
// time — freezing the spinner/log output and leaking unreaped child
|
|
1093
|
+
// processes (SIGCHLD can't fire while blocked). spawn keeps the loop live.
|
|
1094
|
+
const push = () =>
|
|
1095
|
+
new Promise((resolve, reject) => {
|
|
1096
|
+
const child = spawn('docker', ['push', localTag], { stdio: 'inherit' });
|
|
1097
|
+
child.on('error', (err) => reject(new Error(`docker push spawn failed: ${err.message}`)));
|
|
1098
|
+
child.on('exit', (code) =>
|
|
1099
|
+
code === 0 ? resolve(undefined) : reject(new Error(`docker push exited with code ${code}`)),
|
|
1100
|
+
);
|
|
1101
|
+
});
|
|
1052
1102
|
|
|
1053
1103
|
// Three attempts with progressive backoff. Failure modes we're
|
|
1054
1104
|
// covering, in priority order:
|
|
@@ -1094,7 +1144,7 @@ export async function pushImageToLocalRegistry({
|
|
|
1094
1144
|
taggedAliases.add(localTag);
|
|
1095
1145
|
}
|
|
1096
1146
|
await delay(settleDelays[attempt]);
|
|
1097
|
-
push();
|
|
1147
|
+
await push();
|
|
1098
1148
|
pushed = true;
|
|
1099
1149
|
break;
|
|
1100
1150
|
} catch (err) {
|
|
@@ -1201,6 +1251,14 @@ export async function applyVibecarbonSecrets({ kubeconfig, envLocal, s3Config, b
|
|
|
1201
1251
|
stringData.S3_BUCKET = s3Config.bucket;
|
|
1202
1252
|
stringData.S3_ENDPOINT = s3Config.endpoint;
|
|
1203
1253
|
stringData.S3_REGION = s3Config.region;
|
|
1254
|
+
// AWS-SDK-format credentials file for wal-g in the supabase-db pod. The
|
|
1255
|
+
// supabase Helm chart can't inject secret env on the db container
|
|
1256
|
+
// (environment.db is string-only), so instead of a post-helm env patch
|
|
1257
|
+
// (which helm would strip every upgrade → a db restart every deploy) we
|
|
1258
|
+
// mount THIS key as a file and point AWS_SHARED_CREDENTIALS_FILE at it
|
|
1259
|
+
// (volume is helm-owned → never stripped → no per-deploy restart). See
|
|
1260
|
+
// k8s/values/supabase.values.yaml deployment.db.volumes.
|
|
1261
|
+
stringData.S3_CREDENTIALS_INI = `[default]\naws_access_key_id=${s3Config.accessKey}\naws_secret_access_key=${s3Config.secretKey}\n`;
|
|
1204
1262
|
}
|
|
1205
1263
|
if (backupBucketName) {
|
|
1206
1264
|
stringData.S3_BACKUP_BUCKET = backupBucketName;
|
|
@@ -1255,6 +1313,8 @@ export async function installSupabase({
|
|
|
1255
1313
|
domain,
|
|
1256
1314
|
s3Config,
|
|
1257
1315
|
envLocal,
|
|
1316
|
+
dbImageTag,
|
|
1317
|
+
backupBucketName,
|
|
1258
1318
|
}) {
|
|
1259
1319
|
const env = { ...process.env, KUBECONFIG: kubeconfig };
|
|
1260
1320
|
// Render the values template — substitute every {{TOKEN}} the template
|
|
@@ -1265,10 +1325,35 @@ export async function installSupabase({
|
|
|
1265
1325
|
`installSupabase: ${valuesTemplatePath} not found. Project must include the supabase values template.`,
|
|
1266
1326
|
);
|
|
1267
1327
|
}
|
|
1328
|
+
if (!dbImageTag) {
|
|
1329
|
+
throw new Error(
|
|
1330
|
+
'installSupabase: dbImageTag is required (the pre-published wal-g db image, pulled from ghcr).',
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
// Split the full image ref into repository + tag for the chart's
|
|
1334
|
+
// image.db.{repository,tag} (and the walg-restore init's `{{DB_IMAGE}}:{{DB_IMAGE_TAG}}`).
|
|
1335
|
+
// dbImageTag is now the pre-published `ghcr.io/<org>/postgres:<pg>-walg<ver>`
|
|
1336
|
+
// ref — split on the LAST colon so the registry host (and any future port
|
|
1337
|
+
// colon) stays in the repository.
|
|
1338
|
+
const lastColon = dbImageTag.lastIndexOf(':');
|
|
1339
|
+
const dbImageRepo = dbImageTag.slice(0, lastColon);
|
|
1340
|
+
const dbImageVer = dbImageTag.slice(lastColon + 1);
|
|
1341
|
+
// WAL-G S3 prefix — dedicated backup bucket if provided, else the storage
|
|
1342
|
+
// bucket (mirrors compose's S3_BACKUP_BUCKET:-S3_BUCKET fallback). Never
|
|
1343
|
+
// leave it as s3:/// (wal-g would error).
|
|
1344
|
+
const walgBucket = backupBucketName || s3Config?.bucket || '';
|
|
1345
|
+
const walgS3Prefix = walgBucket
|
|
1346
|
+
? `s3://${walgBucket}/backups/${projectName || 'vibecarbon'}/walg`
|
|
1347
|
+
: '';
|
|
1268
1348
|
const rendered = readFileSync(valuesTemplatePath, 'utf-8')
|
|
1269
1349
|
.replace(/\{\{DOMAIN\}\}/g, domain || 'localhost')
|
|
1270
1350
|
.replace(/\{\{PROJECT_NAME\}\}/g, projectName || 'vibecarbon')
|
|
1271
|
-
.replace(/\{\{S3_BACKUP_BUCKET\}\}/g,
|
|
1351
|
+
.replace(/\{\{S3_BACKUP_BUCKET\}\}/g, walgBucket)
|
|
1352
|
+
.replace(/\{\{WALG_S3_PREFIX\}\}/g, walgS3Prefix)
|
|
1353
|
+
.replace(/\{\{S3_ENDPOINT\}\}/g, s3Config?.endpoint ?? '')
|
|
1354
|
+
.replace(/\{\{S3_REGION\}\}/g, s3Config?.region ?? '')
|
|
1355
|
+
.replace(/\{\{DB_IMAGE\}\}/g, dbImageRepo)
|
|
1356
|
+
.replace(/\{\{DB_IMAGE_TAG\}\}/g, dbImageVer)
|
|
1272
1357
|
// Studio dashboard creds — sourced from .env.local at deploy time. Empty
|
|
1273
1358
|
// string fallback keeps yaml valid; chart inlines them into the
|
|
1274
1359
|
// chart-generated supabase-dashboard secret.
|
|
@@ -1587,6 +1672,99 @@ export async function reloadPostgrest({ kubeconfig }) {
|
|
|
1587
1672
|
}
|
|
1588
1673
|
}
|
|
1589
1674
|
|
|
1675
|
+
/**
|
|
1676
|
+
* Create the production app super-admin in `auth.users` via GoTrue's admin API.
|
|
1677
|
+
*
|
|
1678
|
+
* The k8s mirror of compose/index.js `createAdminUser`: identical GoTrue
|
|
1679
|
+
* admin-API contract (POST the super_admin payload with the service-role
|
|
1680
|
+
* bearer, idempotent on 422 via the shared admin-user helper), reached via
|
|
1681
|
+
* `kubectl port-forward svc/supabase-supabase-auth` instead of an `ssh -L`
|
|
1682
|
+
* tunnel. Goes direct to GoTrue (auth :9999, path `/admin/users`) — no Kong
|
|
1683
|
+
* `/auth/v1` prefix to strip.
|
|
1684
|
+
*
|
|
1685
|
+
* Without this, a k8s deploy shipped a prod app the operator couldn't log
|
|
1686
|
+
* into: ADMIN_EMAIL/ADMIN_PASSWORD only seeded the Supabase Studio dashboard
|
|
1687
|
+
* basic-auth secret, never an app `auth.users` row.
|
|
1688
|
+
*
|
|
1689
|
+
* @param {{kubeconfig: string, envLocal: Record<string,string>, localPort?: number}} args
|
|
1690
|
+
* @returns {Promise<{success: boolean, message: string}>}
|
|
1691
|
+
*/
|
|
1692
|
+
export async function provisionAdminUser({ kubeconfig, envLocal, localPort = 15000 }) {
|
|
1693
|
+
const adminEmail = envLocal?.ADMIN_EMAIL;
|
|
1694
|
+
const adminPassword = envLocal?.ADMIN_PASSWORD;
|
|
1695
|
+
const serviceRoleKey = envLocal?.SERVICE_ROLE_KEY ?? envLocal?.SUPABASE_SERVICE_ROLE_KEY;
|
|
1696
|
+
if (!adminEmail || !adminPassword || !serviceRoleKey) {
|
|
1697
|
+
return { success: false, message: 'Admin credentials not found in .env.local' };
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
const env = { ...process.env, KUBECONFIG: kubeconfig };
|
|
1701
|
+
// Background port-forward to GoTrue. Distinct localPort per cluster so the
|
|
1702
|
+
// parallel HA fan-out (primary + standby on one operator host) doesn't race
|
|
1703
|
+
// for the same bind — mirrors the 5000/5001 registry-tunnel split.
|
|
1704
|
+
const pf = spawn(
|
|
1705
|
+
'kubectl',
|
|
1706
|
+
['-n', 'vibecarbon', 'port-forward', 'svc/supabase-supabase-auth', `${localPort}:9999`],
|
|
1707
|
+
{ env, stdio: 'ignore' },
|
|
1708
|
+
);
|
|
1709
|
+
try {
|
|
1710
|
+
const connected = await waitForGotrueHealth(`http://localhost:${localPort}/health`, {
|
|
1711
|
+
attempts: 20,
|
|
1712
|
+
intervalMs: 500,
|
|
1713
|
+
});
|
|
1714
|
+
if (!connected) {
|
|
1715
|
+
return { success: false, message: 'Could not reach GoTrue via kubectl port-forward' };
|
|
1716
|
+
}
|
|
1717
|
+
return await postAdminUser({
|
|
1718
|
+
adminUsersUrl: `http://localhost:${localPort}/admin/users`,
|
|
1719
|
+
serviceRoleKey,
|
|
1720
|
+
adminEmail,
|
|
1721
|
+
adminPassword,
|
|
1722
|
+
});
|
|
1723
|
+
} finally {
|
|
1724
|
+
pf.kill();
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
/**
|
|
1729
|
+
* The four `ALTER SYSTEM` settings that turn on continuous WAL archiving for
|
|
1730
|
+
* wal-g on the chart-installed supabase-db.
|
|
1731
|
+
*/
|
|
1732
|
+
export const WAL_ARCHIVING_SETTINGS = [
|
|
1733
|
+
"ALTER SYSTEM SET archive_mode='on'",
|
|
1734
|
+
"ALTER SYSTEM SET archive_command='bash /etc/postgresql/wal-archive.sh %p'",
|
|
1735
|
+
"ALTER SYSTEM SET archive_timeout='900'",
|
|
1736
|
+
"ALTER SYSTEM SET wal_level='replica'",
|
|
1737
|
+
];
|
|
1738
|
+
|
|
1739
|
+
/**
|
|
1740
|
+
* Build the `kubectl exec … psql …` argument list that enables WAL archiving.
|
|
1741
|
+
*
|
|
1742
|
+
* Each `ALTER SYSTEM` MUST be its own `-c` option. psql sends a single `-c`
|
|
1743
|
+
* string as one simple-query request, and Postgres runs a multi-statement
|
|
1744
|
+
* simple query inside ONE implicit transaction — where `ALTER SYSTEM` is
|
|
1745
|
+
* rejected with "ALTER SYSTEM cannot run inside a transaction block". The
|
|
1746
|
+
* documented remedy (and the compose/ha.js pattern) is to pass each statement
|
|
1747
|
+
* as a separate `-c`, so every ALTER SYSTEM is its own implicit transaction.
|
|
1748
|
+
*
|
|
1749
|
+
* @param {string} dbPod - e.g. `supabase-supabase-db-0`
|
|
1750
|
+
* @returns {string[]}
|
|
1751
|
+
*/
|
|
1752
|
+
export function enableWalArchivingPsqlArgs(dbPod) {
|
|
1753
|
+
return [
|
|
1754
|
+
'-n',
|
|
1755
|
+
'vibecarbon',
|
|
1756
|
+
'exec',
|
|
1757
|
+
dbPod,
|
|
1758
|
+
'--',
|
|
1759
|
+
'psql',
|
|
1760
|
+
'-U',
|
|
1761
|
+
'supabase_admin',
|
|
1762
|
+
'-d',
|
|
1763
|
+
'postgres',
|
|
1764
|
+
...WAL_ARCHIVING_SETTINGS.flatMap((stmt) => ['-c', stmt]),
|
|
1765
|
+
];
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1590
1768
|
/**
|
|
1591
1769
|
* Apply cert-manager + vibecarbon-secrets + the project's k8s/infra +
|
|
1592
1770
|
* k8s/base kustomizations, install supabase via helm, patch the app
|
|
@@ -1595,18 +1773,21 @@ export async function reloadPostgrest({ kubeconfig }) {
|
|
|
1595
1773
|
* Local-first by default — no Flux on the deploy path; Flux is opt-in
|
|
1596
1774
|
* via `vibecarbon configure cicd`.
|
|
1597
1775
|
*
|
|
1598
|
-
* @param {{kubeconfig: string, projectDir: string, projectName: string, imageTag: string,
|
|
1776
|
+
* @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
|
|
1777
|
+
* `restore` (when set, e.g. 'latest' or an ISO timestamp) skips applyMigrations
|
|
1778
|
+
* — the wal-g-restored DB already carries schema_migrations.
|
|
1599
1779
|
*/
|
|
1600
1780
|
export async function applyK3sManifests({
|
|
1601
1781
|
kubeconfig,
|
|
1602
1782
|
projectDir,
|
|
1603
1783
|
projectName,
|
|
1604
1784
|
imageTag,
|
|
1605
|
-
|
|
1785
|
+
dbImageTag,
|
|
1606
1786
|
envLocal,
|
|
1607
1787
|
domain,
|
|
1608
1788
|
s3Config,
|
|
1609
1789
|
backupBucketName,
|
|
1790
|
+
restore,
|
|
1610
1791
|
dnsProvider,
|
|
1611
1792
|
cloudflareApiToken,
|
|
1612
1793
|
hetznerApiToken,
|
|
@@ -1658,42 +1839,73 @@ export async function applyK3sManifests({
|
|
|
1658
1839
|
// Fan out the three deploys in parallel: kubectl wait blocks per-arg
|
|
1659
1840
|
// sequentially, and the cainjector + webhook usually become Available
|
|
1660
1841
|
// ~10-30s after the core deploy, so the serial form pays the longest
|
|
1661
|
-
// arrival time three times in a row.
|
|
1662
|
-
//
|
|
1842
|
+
// arrival time three times in a row.
|
|
1843
|
+
//
|
|
1844
|
+
// Each deploy POLLS to Available within a generous total budget rather than
|
|
1845
|
+
// one or two fixed `kubectl wait` calls. On a fresh worker, cold image pulls
|
|
1846
|
+
// can push cainjector/webhook readiness past a tight envelope even though the
|
|
1847
|
+
// pod ultimately comes up healthy (observed 2026-06-01: k8s e2e failed
|
|
1848
|
+
// `kubectl wait deploy/cert-manager-cainjector exited 1` while the pod was
|
|
1849
|
+
// 1/1 Running moments later — the old 90s→120s=210s envelope just gave up
|
|
1850
|
+
// first). Re-attempting `kubectl wait` in chunks until the budget also
|
|
1851
|
+
// self-heals the fast-fail race where `wait` runs before the just-applied
|
|
1852
|
+
// Deployment object has propagated (NotFound → exit 1 immediately). A
|
|
1853
|
+
// genuinely stuck deploy still fails once the budget is exhausted.
|
|
1663
1854
|
const certManagerDeploys = [
|
|
1664
1855
|
'deploy/cert-manager',
|
|
1665
1856
|
'deploy/cert-manager-webhook',
|
|
1666
1857
|
'deploy/cert-manager-cainjector',
|
|
1667
1858
|
];
|
|
1859
|
+
// 8-min budget: on a freshly provisioned cluster under load, cert-manager
|
|
1860
|
+
// pods have been observed not even getting created until ~5 min after apply
|
|
1861
|
+
// (control-plane/scheduler lag, not image pull), overrunning a tighter
|
|
1862
|
+
// envelope even though the pods then come up healthy. This margin covers that
|
|
1863
|
+
// cold-start tail; the poll below re-attempts within it rather than giving up.
|
|
1864
|
+
const CERT_MANAGER_READY_BUDGET_MS = 480_000;
|
|
1865
|
+
const waitDeployAvailableOnce = (deploy, timeoutSec) =>
|
|
1866
|
+
new Promise((resolveFn, reject) => {
|
|
1867
|
+
const child = spawn(
|
|
1868
|
+
'kubectl',
|
|
1869
|
+
[
|
|
1870
|
+
'-n',
|
|
1871
|
+
'cert-manager',
|
|
1872
|
+
'wait',
|
|
1873
|
+
'--for=condition=Available',
|
|
1874
|
+
`--timeout=${timeoutSec}s`,
|
|
1875
|
+
deploy,
|
|
1876
|
+
],
|
|
1877
|
+
{ stdio: 'inherit', env },
|
|
1878
|
+
);
|
|
1879
|
+
child.on('error', reject);
|
|
1880
|
+
child.on('exit', (code) =>
|
|
1881
|
+
code === 0 ? resolveFn() : reject(new Error(`kubectl wait ${deploy} exited ${code}`)),
|
|
1882
|
+
);
|
|
1883
|
+
});
|
|
1884
|
+
const waitDeployAvailable = async (deploy) => {
|
|
1885
|
+
const deadline = Date.now() + CERT_MANAGER_READY_BUDGET_MS;
|
|
1886
|
+
let lastErr;
|
|
1887
|
+
while (Date.now() < deadline) {
|
|
1888
|
+
const remainingSec = Math.max(5, Math.ceil((deadline - Date.now()) / 1000));
|
|
1889
|
+
try {
|
|
1890
|
+
await waitDeployAvailableOnce(deploy, Math.min(60, remainingSec));
|
|
1891
|
+
return;
|
|
1892
|
+
} catch (err) {
|
|
1893
|
+
lastErr = err;
|
|
1894
|
+
if (Date.now() >= deadline) break;
|
|
1895
|
+
// Brief backoff so a fast-failing wait (e.g. the Deployment object not
|
|
1896
|
+
// yet propagated → immediate NotFound) doesn't busy-spin the budget.
|
|
1897
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
throw (
|
|
1901
|
+
lastErr ??
|
|
1902
|
+
new Error(
|
|
1903
|
+
`kubectl wait ${deploy} never became Available within ${CERT_MANAGER_READY_BUDGET_MS / 1000}s`,
|
|
1904
|
+
)
|
|
1905
|
+
);
|
|
1906
|
+
};
|
|
1668
1907
|
await perfAsync(`deploy.${perfPrefix}.certManager.wait`, () =>
|
|
1669
|
-
Promise.all(
|
|
1670
|
-
certManagerDeploys.map(async (deploy) => {
|
|
1671
|
-
const waitOnce = (timeoutSec) =>
|
|
1672
|
-
new Promise((resolveFn, reject) => {
|
|
1673
|
-
const child = spawn(
|
|
1674
|
-
'kubectl',
|
|
1675
|
-
[
|
|
1676
|
-
'-n',
|
|
1677
|
-
'cert-manager',
|
|
1678
|
-
'wait',
|
|
1679
|
-
'--for=condition=Available',
|
|
1680
|
-
`--timeout=${timeoutSec}s`,
|
|
1681
|
-
deploy,
|
|
1682
|
-
],
|
|
1683
|
-
{ stdio: 'inherit', env },
|
|
1684
|
-
);
|
|
1685
|
-
child.on('error', reject);
|
|
1686
|
-
child.on('exit', (code) =>
|
|
1687
|
-
code === 0 ? resolveFn() : reject(new Error(`kubectl wait ${deploy} exited ${code}`)),
|
|
1688
|
-
);
|
|
1689
|
-
});
|
|
1690
|
-
try {
|
|
1691
|
-
await waitOnce(90);
|
|
1692
|
-
} catch {
|
|
1693
|
-
await waitOnce(120);
|
|
1694
|
-
}
|
|
1695
|
-
}),
|
|
1696
|
-
),
|
|
1908
|
+
Promise.all(certManagerDeploys.map((deploy) => waitDeployAvailable(deploy))),
|
|
1697
1909
|
);
|
|
1698
1910
|
// 2b. Land the per-DNS-provider Secret in the cert-manager namespace
|
|
1699
1911
|
// BEFORE the kustomization that defines the issuers, so when the
|
|
@@ -1898,55 +2110,21 @@ export async function applyK3sManifests({
|
|
|
1898
2110
|
if (!khPath) {
|
|
1899
2111
|
throw new Error('applyK3sManifests: khPath required for registry push (Phase 6)');
|
|
1900
2112
|
}
|
|
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).
|
|
2113
|
+
// Push the app image to the in-cluster registry so CA-spawned workers
|
|
2114
|
+
// (which don't exist at sideload time) can pull it on first schedule.
|
|
2115
|
+
// The db image is NOT pushed here: it's pinned to the static supabase
|
|
2116
|
+
// node, reaches it via sideload, and the chart references it with
|
|
2117
|
+
// imagePullPolicy: IfNotPresent — CA workers never run postgres.
|
|
1910
2118
|
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);
|
|
2119
|
+
await perfAsync(`deploy.${perfPrefix}.registryPush.app`, () =>
|
|
2120
|
+
pushImageToLocalRegistry({
|
|
2121
|
+
tag: imageTag,
|
|
2122
|
+
masterIp,
|
|
2123
|
+
sshKey: sshKeyPath,
|
|
2124
|
+
khPath,
|
|
2125
|
+
localTunnelPort: appPushPort,
|
|
2126
|
+
}),
|
|
2127
|
+
);
|
|
1950
2128
|
// 5a'. Apply cluster-autoscaler manifests SEPARATELY. CA lives in
|
|
1951
2129
|
// kube-system but base/'s parent kustomization sets
|
|
1952
2130
|
// `namespace: vibecarbon`, whose namespace transformer would
|
|
@@ -1979,6 +2157,10 @@ export async function applyK3sManifests({
|
|
|
1979
2157
|
// — DNS-01 vs HTTP-01 — cert-manager actually uses; see pickIssuerName.
|
|
1980
2158
|
const acmeServer = envLocal?.ACME_CA_SERVER || process.env.ACME_CA_SERVER || '';
|
|
1981
2159
|
const issuerName = pickIssuerName({ dnsProvider, acmeServer });
|
|
2160
|
+
// DNS-01 issuers (cloudflare, hetzner) support wildcard SANs — one cert
|
|
2161
|
+
// covers *.${domain} so every IngressRoute subdomain is included without
|
|
2162
|
+
// separate per-router ACME orders. Manual (HTTP-01) falls back to apex-only.
|
|
2163
|
+
const dnsNames = certificateDnsNames(domain, issuerName);
|
|
1982
2164
|
await runKubectlWithRetry(
|
|
1983
2165
|
[
|
|
1984
2166
|
'-n',
|
|
@@ -1989,7 +2171,7 @@ export async function applyK3sManifests({
|
|
|
1989
2171
|
'--type=merge',
|
|
1990
2172
|
'-p',
|
|
1991
2173
|
JSON.stringify({
|
|
1992
|
-
spec: { dnsNames
|
|
2174
|
+
spec: { dnsNames, issuerRef: { name: issuerName, kind: 'ClusterIssuer' } },
|
|
1993
2175
|
}),
|
|
1994
2176
|
],
|
|
1995
2177
|
{ env, description: 'applyK3sManifests: kubectl patch certificate/vibecarbon-tls' },
|
|
@@ -2148,53 +2330,109 @@ export async function applyK3sManifests({
|
|
|
2148
2330
|
['-n', 'vibecarbon', 'set', 'image', 'deployment/app', `app=${imageTag}`],
|
|
2149
2331
|
{ env, description: 'applyK3sManifests: kubectl set image deployment/app' },
|
|
2150
2332
|
);
|
|
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
2333
|
// 7. Install Supabase via helm. Blocks until release converges (15m
|
|
2183
|
-
// timeout in chart).
|
|
2184
|
-
//
|
|
2185
|
-
//
|
|
2186
|
-
// the sideloaded image.
|
|
2334
|
+
// timeout in chart). image.db points at the wal-g-equipped db image
|
|
2335
|
+
// (sideloaded in step 7b), and the chart mounts wal-archive.sh + the
|
|
2336
|
+
// non-secret WALG_* env from supabase.values.yaml.
|
|
2187
2337
|
await perfAsync(`deploy.${perfPrefix}.installSupabase`, async () =>
|
|
2188
|
-
installSupabase({
|
|
2338
|
+
installSupabase({
|
|
2339
|
+
kubeconfig,
|
|
2340
|
+
projectDir,
|
|
2341
|
+
projectName,
|
|
2342
|
+
domain,
|
|
2343
|
+
s3Config,
|
|
2344
|
+
envLocal,
|
|
2345
|
+
dbImageTag,
|
|
2346
|
+
backupBucketName,
|
|
2347
|
+
}),
|
|
2189
2348
|
);
|
|
2349
|
+
// 7b. Enable continuous WAL archiving on the supabase-db. The chart can't
|
|
2350
|
+
// inject secret env (environment.db is string-only) or postgres archive
|
|
2351
|
+
// flags, so we (a) ALTER SYSTEM the archive params (persisted to
|
|
2352
|
+
// postgresql.auto.conf on the PVC), and (b) inject the SECRET S3 creds
|
|
2353
|
+
// via a strategic-merge env patch (merged by env name → idempotent;
|
|
2354
|
+
// re-applied every deploy because helm upgrade strips non-chart env).
|
|
2355
|
+
// The cred patch rolls the pod when it changes the template; on first
|
|
2356
|
+
// enable that rollout is what makes postgres pick up archive_mode.
|
|
2357
|
+
// Skipped without S3 (dev / no backups configured).
|
|
2358
|
+
if (s3Config?.accessKey) {
|
|
2359
|
+
await perfAsync(`deploy.${perfPrefix}.enableWalArchiving`, async () => {
|
|
2360
|
+
const dbSts = 'supabase-supabase-db';
|
|
2361
|
+
const dbPod = `${dbSts}-0`;
|
|
2362
|
+
// S3 creds reach the db container as a mounted file (helm-owned, see
|
|
2363
|
+
// supabase.values.yaml walg-s3-creds volume) — no env patch, so no
|
|
2364
|
+
// db restart on warm deploys. Only the archive params need ALTER
|
|
2365
|
+
// SYSTEM, and archive_mode flips only with a RESTART. So: check the
|
|
2366
|
+
// RUNNING archive_mode; if already 'on' (persisted in
|
|
2367
|
+
// postgresql.auto.conf from a prior enable), this is a warm deploy —
|
|
2368
|
+
// nothing to do. If 'off', set the params and restart ONCE.
|
|
2369
|
+
let archiveMode = '';
|
|
2370
|
+
try {
|
|
2371
|
+
archiveMode = (
|
|
2372
|
+
await runKubectlWithRetry(
|
|
2373
|
+
[
|
|
2374
|
+
'-n',
|
|
2375
|
+
'vibecarbon',
|
|
2376
|
+
'exec',
|
|
2377
|
+
dbPod,
|
|
2378
|
+
'--',
|
|
2379
|
+
'psql',
|
|
2380
|
+
'-U',
|
|
2381
|
+
'supabase_admin',
|
|
2382
|
+
'-d',
|
|
2383
|
+
'postgres',
|
|
2384
|
+
'-tAc',
|
|
2385
|
+
'SHOW archive_mode',
|
|
2386
|
+
],
|
|
2387
|
+
{
|
|
2388
|
+
env,
|
|
2389
|
+
captureStdout: true,
|
|
2390
|
+
description: 'applyK3sManifests: read archive_mode',
|
|
2391
|
+
},
|
|
2392
|
+
)
|
|
2393
|
+
)
|
|
2394
|
+
?.toString()
|
|
2395
|
+
.trim();
|
|
2396
|
+
} catch {
|
|
2397
|
+
// Treat an unreadable value as "not yet enabled" — the worst case is
|
|
2398
|
+
// one extra ALTER SYSTEM + restart, which is idempotent.
|
|
2399
|
+
}
|
|
2400
|
+
if (archiveMode === 'on') {
|
|
2401
|
+
console.error('[k3s] WAL archiving already enabled — skipping ALTER SYSTEM + restart.');
|
|
2402
|
+
return;
|
|
2403
|
+
}
|
|
2404
|
+
await runKubectlWithRetry(enableWalArchivingPsqlArgs(dbPod), {
|
|
2405
|
+
env,
|
|
2406
|
+
description: 'applyK3sManifests: enable WAL archiving (ALTER SYSTEM)',
|
|
2407
|
+
});
|
|
2408
|
+
// archive_mode requires a restart (reload is insufficient). Restart the
|
|
2409
|
+
// StatefulSet ONCE; persisted to postgresql.auto.conf on the PVC, so
|
|
2410
|
+
// subsequent deploys hit the archiveMode==='on' fast path above.
|
|
2411
|
+
await runKubectlWithRetry(
|
|
2412
|
+
['-n', 'vibecarbon', 'rollout', 'restart', `statefulset/${dbSts}`],
|
|
2413
|
+
{ env, description: 'applyK3sManifests: restart supabase-db to enable WAL archiving' },
|
|
2414
|
+
);
|
|
2415
|
+
await runKubectlWithRetry(
|
|
2416
|
+
['-n', 'vibecarbon', 'rollout', 'status', `statefulset/${dbSts}`, '--timeout=300s'],
|
|
2417
|
+
{ env, description: 'applyK3sManifests: wait supabase-db rollout (WAL archiving)' },
|
|
2418
|
+
);
|
|
2419
|
+
});
|
|
2420
|
+
}
|
|
2190
2421
|
// 8. Apply project SQL migrations against the chart-installed postgres.
|
|
2191
|
-
//
|
|
2192
|
-
//
|
|
2193
|
-
//
|
|
2194
|
-
//
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2422
|
+
// SKIPPED when restoring: a wal-g restore brings the full DB including
|
|
2423
|
+
// supabase_migrations.schema_migrations, so re-running migrations would
|
|
2424
|
+
// hit duplicate-key errors (applyMigrations deliberately does not
|
|
2425
|
+
// swallow them). On a normal deploy, helm's `--wait` guarantees the db
|
|
2426
|
+
// pod is up; without migrations the app's /api/health/ready 503s.
|
|
2427
|
+
if (restore) {
|
|
2428
|
+
console.error(
|
|
2429
|
+
`[k3s] restore=${restore} requested — skipping applyMigrations (restored DB carries schema_migrations).`,
|
|
2430
|
+
);
|
|
2431
|
+
} else {
|
|
2432
|
+
await perfAsync(`deploy.${perfPrefix}.applyMigrations`, async () =>
|
|
2433
|
+
applyMigrations({ kubeconfig, projectDir }),
|
|
2434
|
+
);
|
|
2435
|
+
}
|
|
2198
2436
|
// 8b. Reload PostgREST's schema cache so it sees the tables applyMigrations
|
|
2199
2437
|
// just created. rest came up during helm --wait (before migrations) and
|
|
2200
2438
|
// no DDL-watch trigger exists to auto-NOTIFY — without this, /rest/v1/*
|
|
@@ -2202,6 +2440,28 @@ export async function applyK3sManifests({
|
|
|
2202
2440
|
await perfAsync(`deploy.${perfPrefix}.reloadPostgrest`, async () =>
|
|
2203
2441
|
reloadPostgrest({ kubeconfig }),
|
|
2204
2442
|
);
|
|
2443
|
+
// 8c. Create the production app super-admin via GoTrue's admin API. Mirrors
|
|
2444
|
+
// the compose path — without it the operator can't log into their own
|
|
2445
|
+
// deployed app (ADMIN_* only seeds the Studio dashboard secret, never an
|
|
2446
|
+
// auth.users row). Runs unconditionally (incl. restore: idempotent on
|
|
2447
|
+
// 422, and a backup predating admin-creation still gets seeded). In HA
|
|
2448
|
+
// both clusters run this against their independently-writable DB at
|
|
2449
|
+
// deploy time (same as applyMigrations); the localPort is derived from
|
|
2450
|
+
// localTunnelPort so the parallel primary/standby port-forwards don't
|
|
2451
|
+
// race for the same bind. Non-fatal: a GoTrue blip warns + retries on
|
|
2452
|
+
// the next deploy rather than failing an otherwise-healthy one.
|
|
2453
|
+
await perfAsync(`deploy.${perfPrefix}.createAdminUser`, async () => {
|
|
2454
|
+
const adminResult = await provisionAdminUser({
|
|
2455
|
+
kubeconfig,
|
|
2456
|
+
envLocal,
|
|
2457
|
+
localPort: 15000 + (localTunnelPort - 5000),
|
|
2458
|
+
});
|
|
2459
|
+
if (adminResult.success) {
|
|
2460
|
+
console.log(`[k3s] ${adminResult.message}`);
|
|
2461
|
+
} else {
|
|
2462
|
+
console.warn(`[k3s] ${adminResult.message} — re-run \`vibecarbon deploy\` to retry.`);
|
|
2463
|
+
}
|
|
2464
|
+
});
|
|
2205
2465
|
// 9. Wait for app rollout. The deployment template already has
|
|
2206
2466
|
// imagePullPolicy: IfNotPresent, so kubelet uses the sideloaded image
|
|
2207
2467
|
// without pulling. 300s wasn't enough on a cold deploy where the app
|
|
@@ -2469,37 +2729,23 @@ export async function deployK3s(options) {
|
|
|
2469
2729
|
}
|
|
2470
2730
|
const { tag: imageTag } = state.getStepResult('k3s-build');
|
|
2471
2731
|
|
|
2472
|
-
// 7.
|
|
2473
|
-
//
|
|
2474
|
-
//
|
|
2475
|
-
//
|
|
2476
|
-
|
|
2477
|
-
const backupBuildInputs = { projectName: `${options.projectName}-backup` };
|
|
2478
|
-
if (!state.shouldSkip('k3s-backup-build', backupBuildInputs)) {
|
|
2479
|
-
state.startStep('k3s-backup-build', backupBuildInputs);
|
|
2480
|
-
s.start('Building backup image');
|
|
2481
|
-
const built = await buildAppImage(join(projectDir, 'backup'), `${options.projectName}-backup`);
|
|
2482
|
-
state.completeStep('k3s-backup-build', {
|
|
2483
|
-
tag: built.tag,
|
|
2484
|
-
gitSha: built.gitSha,
|
|
2485
|
-
isDirty: built.isDirty,
|
|
2486
|
-
});
|
|
2487
|
-
s.stop(`Backup image built: ${built.tag}`);
|
|
2488
|
-
}
|
|
2489
|
-
const { tag: backupImageTag } = state.getStepResult('k3s-backup-build');
|
|
2732
|
+
// 7. The wal-g-equipped db image (supabase/postgres + wal-g) is now a
|
|
2733
|
+
// pre-published, multi-arch image pulled from ghcr — no per-deploy build
|
|
2734
|
+
// or sideload. The supabase Helm chart's image.db + the walg-restore init
|
|
2735
|
+
// container both reference it (see installSupabase + supabase.values.yaml).
|
|
2736
|
+
const dbImageTag = resolveDbImageTag();
|
|
2490
2737
|
|
|
2491
2738
|
// 7b. Sideload to every node (parallel SSH within sideloadK3s) AND fan out
|
|
2492
|
-
// the app+
|
|
2493
|
-
//
|
|
2494
|
-
//
|
|
2495
|
-
//
|
|
2496
|
-
//
|
|
2497
|
-
//
|
|
2739
|
+
// the app+db sideloads against each other. Both writes go through
|
|
2740
|
+
// `docker save | ssh ... gunzip | k3s ctr import` against disjoint
|
|
2741
|
+
// image refs on the same target nodes — kernel disk buffer + sshd
|
|
2742
|
+
// multiplex two parallel streams. Critical path = max(app, db).
|
|
2743
|
+
// The db image only NEEDS the supabase node (db is pinned there), but
|
|
2744
|
+
// sideloading to all nodes is harmless and keeps the target list shared.
|
|
2498
2745
|
const sshTargets = [`root@${masterIp}`];
|
|
2499
2746
|
if (supabaseIp) sshTargets.push(`root@${supabaseIp}`);
|
|
2500
2747
|
for (const wIp of workerIps ?? []) sshTargets.push(`root@${wIp}`);
|
|
2501
2748
|
const sideloadInputs = { imageTag, targets: sshTargets.join(',') };
|
|
2502
|
-
const backupSideloadInputs = { backupImageTag, targets: sshTargets.join(',') };
|
|
2503
2749
|
const sideloadTasks = [];
|
|
2504
2750
|
if (!state.shouldSkip('k3s-sideload', sideloadInputs)) {
|
|
2505
2751
|
state.startStep('k3s-sideload', sideloadInputs);
|
|
@@ -2509,23 +2755,15 @@ export async function deployK3s(options) {
|
|
|
2509
2755
|
).then(() => state.completeStep('k3s-sideload')),
|
|
2510
2756
|
);
|
|
2511
2757
|
}
|
|
2512
|
-
if (!state.shouldSkip('k3s-backup-sideload', backupSideloadInputs)) {
|
|
2513
|
-
state.startStep('k3s-backup-sideload', backupSideloadInputs);
|
|
2514
|
-
sideloadTasks.push(
|
|
2515
|
-
perfAsync(`deploy.${perfPrefix}.sideload.backup`, () =>
|
|
2516
|
-
sideloadK3s({ tag: backupImageTag, sshTargets, sshKey: privateKeyPath, khPath }),
|
|
2517
|
-
).then(() => state.completeStep('k3s-backup-sideload')),
|
|
2518
|
-
);
|
|
2519
|
-
}
|
|
2520
2758
|
if (sideloadTasks.length > 0) {
|
|
2521
|
-
s.start(`Sideloading app
|
|
2759
|
+
s.start(`Sideloading app to ${sshTargets.length} node(s)`);
|
|
2522
2760
|
await Promise.all(sideloadTasks);
|
|
2523
|
-
s.stop('Sideload complete (app
|
|
2761
|
+
s.stop('Sideload complete (app)');
|
|
2524
2762
|
}
|
|
2525
2763
|
|
|
2526
2764
|
// 8. Apply manifests + supabase Helm release + roll out the app.
|
|
2527
|
-
if (!state.shouldSkip('k3s-apply', { imageTag,
|
|
2528
|
-
state.startStep('k3s-apply', { imageTag,
|
|
2765
|
+
if (!state.shouldSkip('k3s-apply', { imageTag, dbImageTag, restore: options.restore ?? '' })) {
|
|
2766
|
+
state.startStep('k3s-apply', { imageTag, dbImageTag, restore: options.restore ?? '' });
|
|
2529
2767
|
s.start('Applying k8s manifests + installing Supabase + rolling out app');
|
|
2530
2768
|
const envLocal = loadEnvLocal(join(projectDir, '.env.local'));
|
|
2531
2769
|
await perfAsync(`deploy.${perfPrefix}.applyManifests`, () =>
|
|
@@ -2535,11 +2773,12 @@ export async function deployK3s(options) {
|
|
|
2535
2773
|
projectDir,
|
|
2536
2774
|
projectName: options.projectName,
|
|
2537
2775
|
imageTag,
|
|
2538
|
-
|
|
2776
|
+
dbImageTag,
|
|
2539
2777
|
envLocal,
|
|
2540
2778
|
domain: options.domain || 'localhost',
|
|
2541
2779
|
s3Config: options.s3Config,
|
|
2542
2780
|
backupBucketName: options.backupBucketName,
|
|
2781
|
+
restore: options.restore,
|
|
2543
2782
|
dnsProvider: options.dnsProvider,
|
|
2544
2783
|
cloudflareApiToken: options.cloudflareApiToken,
|
|
2545
2784
|
// Hetzner DNS-01 uses the SAME Cloud API token (api.hetzner.cloud)
|