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
package/src/backup.js
CHANGED
|
@@ -30,6 +30,7 @@ import { getS3Credentials } from './lib/hetzner-guided-setup.js';
|
|
|
30
30
|
import { requireLicense } from './lib/licensing/index.js';
|
|
31
31
|
import { ensureOperatorIpAccess } from './lib/operator-ip.js';
|
|
32
32
|
import { perfAsync } from './lib/perf.js';
|
|
33
|
+
import { listPodBackups } from './lib/pod-backups.js';
|
|
33
34
|
import { assertInProjectDir } from './lib/project-guard.js';
|
|
34
35
|
import {
|
|
35
36
|
getPostgresPod,
|
|
@@ -101,41 +102,6 @@ const ACTION_CHOICES = [
|
|
|
101
102
|
// LEGACY POD HELPERS (used when a k8s env has no S3 configured)
|
|
102
103
|
// ============================================================================
|
|
103
104
|
|
|
104
|
-
async function listPodBackups(ip, sshKeyPath) {
|
|
105
|
-
const pod = getPostgresPod(ip, sshKeyPath);
|
|
106
|
-
|
|
107
|
-
try {
|
|
108
|
-
const output = sshKubectl(ip, sshKeyPath, [
|
|
109
|
-
'exec',
|
|
110
|
-
'-n',
|
|
111
|
-
'vibecarbon',
|
|
112
|
-
pod,
|
|
113
|
-
'--',
|
|
114
|
-
'sh',
|
|
115
|
-
'-c',
|
|
116
|
-
'ls -lhS /backups/*_full.tar.gz /backups/*.sql.gz 2>/dev/null || echo NO_BACKUPS',
|
|
117
|
-
]);
|
|
118
|
-
|
|
119
|
-
if (output === 'NO_BACKUPS' || !output) {
|
|
120
|
-
return [];
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return output
|
|
124
|
-
.split('\n')
|
|
125
|
-
.filter((line) => line.includes('.tar.gz') || line.includes('.sql.gz'))
|
|
126
|
-
.map((line) => {
|
|
127
|
-
const parts = line.split(/\s+/);
|
|
128
|
-
return {
|
|
129
|
-
size: parts[4],
|
|
130
|
-
date: `${parts[5]} ${parts[6]} ${parts[7]}`,
|
|
131
|
-
name: parts[parts.length - 1].replace('/backups/', ''),
|
|
132
|
-
};
|
|
133
|
-
});
|
|
134
|
-
} catch {
|
|
135
|
-
return [];
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
105
|
async function downloadPodBackup(ip, sshKeyPath, filename) {
|
|
140
106
|
const pod = getPostgresPod(ip, sshKeyPath);
|
|
141
107
|
|
|
@@ -482,6 +448,7 @@ export async function run(args) {
|
|
|
482
448
|
sshKeyPath,
|
|
483
449
|
projectName,
|
|
484
450
|
envName,
|
|
451
|
+
backupRetain: envConfig.backup?.retentionDays,
|
|
485
452
|
yes: !!values.y,
|
|
486
453
|
});
|
|
487
454
|
tracker.finish();
|
|
@@ -613,7 +580,7 @@ async function runList({
|
|
|
613
580
|
return;
|
|
614
581
|
}
|
|
615
582
|
|
|
616
|
-
const backups =
|
|
583
|
+
const backups = listPodBackups(serverIp, sshKeyPath, { sort: 'largest' });
|
|
617
584
|
s.stop('Backups retrieved');
|
|
618
585
|
printBackupList(backups, envName);
|
|
619
586
|
}
|
|
@@ -681,6 +648,7 @@ async function runCreate({
|
|
|
681
648
|
sshKeyPath,
|
|
682
649
|
projectName,
|
|
683
650
|
envName,
|
|
651
|
+
backupRetain,
|
|
684
652
|
yes,
|
|
685
653
|
}) {
|
|
686
654
|
if (!yes) {
|
|
@@ -694,37 +662,16 @@ async function runCreate({
|
|
|
694
662
|
}
|
|
695
663
|
|
|
696
664
|
if (isCompose) {
|
|
697
|
-
s.start('Creating
|
|
665
|
+
s.start('Creating wal-g base backup...');
|
|
698
666
|
try {
|
|
699
|
-
const { backupCompose
|
|
700
|
-
|
|
701
|
-
)
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
p.log.
|
|
706
|
-
|
|
707
|
-
if (useS3) {
|
|
708
|
-
s.start('Uploading backup to S3...');
|
|
709
|
-
try {
|
|
710
|
-
await uploadComposeBackupToS3(serverIp, sshKeyPath, projectName, backupFile, s3Config);
|
|
711
|
-
s.stop(`Uploaded to S3: ${s3Config.bucket}/backups/${backupFile}`);
|
|
712
|
-
} catch (uploadError) {
|
|
713
|
-
s.stop(`S3 upload failed: ${uploadError.message}`);
|
|
714
|
-
p.log.warn('Backup is available on the server but was not uploaded to S3.');
|
|
715
|
-
// Dump the raw error to stderr so automation captures the
|
|
716
|
-
// actual AWS SDK error code + HTTP status — the spinner
|
|
717
|
-
// message above hides the detail e2e/CI needs.
|
|
718
|
-
console.error(
|
|
719
|
-
`[backup] S3 upload failed — name=${uploadError.name} code=${uploadError.Code ?? uploadError.code ?? 'n/a'} status=${uploadError.$metadata?.httpStatusCode ?? 'n/a'} bucket=${s3Config.bucket} endpoint=${s3Config.endpoint} region=${s3Config.region}`,
|
|
720
|
-
);
|
|
721
|
-
if (uploadError.stack) {
|
|
722
|
-
console.error(uploadError.stack);
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
} else {
|
|
726
|
-
p.log.info(`Download: scp root@${serverIp}:/opt/${projectName}/backups/${backupFile} .`);
|
|
727
|
-
}
|
|
667
|
+
const { backupCompose } = await import('./lib/deploy/compose/index.js');
|
|
668
|
+
backupCompose(serverIp, sshKeyPath, projectName, { retain: backupRetain });
|
|
669
|
+
// "uploaded to S3" wording matches the k8s path (line ~689) and the e2e
|
|
670
|
+
// backup-step verification (which greps /uploaded to s3/i to confirm the
|
|
671
|
+
// backup is durable before allowing destroy → restore).
|
|
672
|
+
s.stop('wal-g base backup uploaded to S3');
|
|
673
|
+
p.log.success('Backup uploaded to S3 — wal-g pushed the base backup directly.');
|
|
674
|
+
p.log.info(`List backups: ${c.info(`vibecarbon backup ${envName} -l`)}`);
|
|
728
675
|
} catch (error) {
|
|
729
676
|
s.stop('Backup failed');
|
|
730
677
|
p.log.error(error.message);
|
package/src/create.js
CHANGED
|
@@ -751,6 +751,12 @@ async function bootstrap(cliArgs) {
|
|
|
751
751
|
copyTemplate('.dockerignore', join(projectDir, '.dockerignore'), variables);
|
|
752
752
|
copyTemplate('docker-compose.yml', join(projectDir, 'docker-compose.yml'), variables);
|
|
753
753
|
copyTemplate('docker-compose.prod.yml', join(projectDir, 'docker-compose.prod.yml'), variables);
|
|
754
|
+
// DNS-01 ACME override — applied at deploy only for managed-DNS (cloudflare/hetzner).
|
|
755
|
+
copyTemplate(
|
|
756
|
+
'docker-compose.dns01.prod.yml',
|
|
757
|
+
join(projectDir, 'docker-compose.dns01.prod.yml'),
|
|
758
|
+
variables,
|
|
759
|
+
);
|
|
754
760
|
copyTemplate(
|
|
755
761
|
'docker-compose.override.yml',
|
|
756
762
|
join(projectDir, 'docker-compose.override.yml'),
|
|
@@ -818,10 +824,10 @@ async function bootstrap(cliArgs) {
|
|
|
818
824
|
variables,
|
|
819
825
|
);
|
|
820
826
|
|
|
821
|
-
|
|
827
|
+
// Compose backups run as a host cron (compose-backup.sh). k8s backups are
|
|
828
|
+
// wal-g co-located in the db pod (carbon/db image), so the old standalone
|
|
829
|
+
// backup container (backup.sh + backup/Dockerfile) was removed.
|
|
822
830
|
copyTemplate('backup/compose-backup.sh', join(projectDir, 'backup/compose-backup.sh'), variables);
|
|
823
|
-
copyTemplate('backup/Dockerfile', join(projectDir, 'backup/Dockerfile'), variables);
|
|
824
|
-
chmodSync(join(projectDir, 'backup/backup.sh'), 0o755);
|
|
825
831
|
chmodSync(join(projectDir, 'backup/compose-backup.sh'), 0o755);
|
|
826
832
|
// CI/CD workflow files are NOT shipped at create time — `vibecarbon
|
|
827
833
|
// configure` → CI/CD installs them from src/lib/ci-setup.js when the user
|
|
@@ -860,7 +866,6 @@ async function bootstrap(cliArgs) {
|
|
|
860
866
|
// Make shell scripts executable
|
|
861
867
|
const shellScripts = [
|
|
862
868
|
'k8s/test-local.sh',
|
|
863
|
-
'backup/backup.sh',
|
|
864
869
|
'docker-entrypoint.sh',
|
|
865
870
|
'volumes/kong/docker-entrypoint.sh',
|
|
866
871
|
'volumes/db/set-passwords.sh',
|
package/src/deploy.js
CHANGED
|
@@ -112,6 +112,12 @@ const SPEC = {
|
|
|
112
112
|
boolean: true,
|
|
113
113
|
description: 'Clear resume state and redo every step from scratch',
|
|
114
114
|
},
|
|
115
|
+
{
|
|
116
|
+
name: 'restore',
|
|
117
|
+
value: '<latest|timestamp>',
|
|
118
|
+
description:
|
|
119
|
+
'Disaster recovery: seed the fresh DB from the latest wal-g backup in S3 (or PITR to an ISO-8601 timestamp). Skips migrations — the restored DB is authoritative. k8s only.',
|
|
120
|
+
},
|
|
115
121
|
],
|
|
116
122
|
examples: [
|
|
117
123
|
{ command: 'vibecarbon deploy', description: 'prompts for env (defaults to prod)' },
|
|
@@ -124,6 +130,10 @@ const SPEC = {
|
|
|
124
130
|
command: 'vibecarbon deploy prod -full',
|
|
125
131
|
description: 'redo a previously-failed deploy from scratch',
|
|
126
132
|
},
|
|
133
|
+
{
|
|
134
|
+
command: 'vibecarbon deploy prod -mode k8s -restore latest -y',
|
|
135
|
+
description: 'stand up a fresh cluster and restore the DB from S3 (DR)',
|
|
136
|
+
},
|
|
127
137
|
],
|
|
128
138
|
};
|
|
129
139
|
|
|
@@ -168,6 +178,8 @@ function buildLegacyArgs(values, positional) {
|
|
|
168
178
|
// initialized for the orchestrator's resolveBuildMode read.
|
|
169
179
|
direct: false,
|
|
170
180
|
push: false,
|
|
181
|
+
// DR: seed the fresh DB from S3 via wal-g (k8s only). null = normal deploy.
|
|
182
|
+
restore: values.restore || null,
|
|
171
183
|
};
|
|
172
184
|
}
|
|
173
185
|
|
package/src/destroy.js
CHANGED
|
@@ -1055,49 +1055,11 @@ ${c.danger('WARNING: All data on these servers will be permanently lost!')}
|
|
|
1055
1055
|
backupSpinner.start('Creating backup...');
|
|
1056
1056
|
try {
|
|
1057
1057
|
if (envConfig.deployMode === 'compose' || envConfig.deployMode === 'compose-ha') {
|
|
1058
|
-
const { backupCompose
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
const backupS3 = envConfig.backupS3;
|
|
1064
|
-
if (backupS3) {
|
|
1065
|
-
const { getS3Credentials } = await import('./lib/hetzner-guided-setup.js');
|
|
1066
|
-
const creds = await getS3Credentials(projectConfig.projectName, { save: false });
|
|
1067
|
-
if (creds) {
|
|
1068
|
-
await uploadComposeBackupToS3(
|
|
1069
|
-
serverIp,
|
|
1070
|
-
sshKeyPath,
|
|
1071
|
-
projectConfig.projectName,
|
|
1072
|
-
backupFile,
|
|
1073
|
-
{
|
|
1074
|
-
...backupS3,
|
|
1075
|
-
accessKey: creds.accessKey,
|
|
1076
|
-
secretKey: creds.secretKey,
|
|
1077
|
-
},
|
|
1078
|
-
);
|
|
1079
|
-
backupSpinner.stop(`Backup saved to S3: ${backupS3.bucket}/backups/${backupFile}`);
|
|
1080
|
-
} else {
|
|
1081
|
-
backupSpinner.stop(`Backup created on server: ${backupFile}`);
|
|
1082
|
-
p.log.warn(
|
|
1083
|
-
'Could not upload to S3 (no credentials). Download before destroy completes:',
|
|
1084
|
-
);
|
|
1085
|
-
p.log.info(
|
|
1086
|
-
` scp root@${serverIp}:/opt/${projectConfig.projectName}/backups/${backupFile} .`,
|
|
1087
|
-
);
|
|
1088
|
-
}
|
|
1089
|
-
} else {
|
|
1090
|
-
// No S3 — download locally
|
|
1091
|
-
const { scpDownload } = await import('./lib/ssh.js');
|
|
1092
|
-
const localPath = join(cwd, backupFile);
|
|
1093
|
-
scpDownload(
|
|
1094
|
-
serverIp,
|
|
1095
|
-
sshKeyPath,
|
|
1096
|
-
`/opt/${projectConfig.projectName}/backups/${backupFile}`,
|
|
1097
|
-
localPath,
|
|
1098
|
-
);
|
|
1099
|
-
backupSpinner.stop(`Backup saved locally: ${backupFile}`);
|
|
1100
|
-
}
|
|
1058
|
+
const { backupCompose } = await import('./lib/deploy/compose/index.js');
|
|
1059
|
+
backupCompose(serverIp, sshKeyPath, projectConfig.projectName, {
|
|
1060
|
+
retain: envConfig.backup?.retentionDays,
|
|
1061
|
+
});
|
|
1062
|
+
backupSpinner.stop('wal-g base backup pushed to S3');
|
|
1101
1063
|
}
|
|
1102
1064
|
} catch (backupError) {
|
|
1103
1065
|
backupSpinner.stop(`Backup failed: ${backupError.message}`);
|
package/src/lib/backup-s3.js
CHANGED
|
@@ -137,37 +137,6 @@ export async function uploadS3Backup(s3Config, localPath, s3Key) {
|
|
|
137
137
|
throw lastErr;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
/**
|
|
141
|
-
* Delete expired backups from S3 based on retention policy.
|
|
142
|
-
* Parses dates from filenames: {project}_{YYYYMMDD}_{HHMMSS}_full.tar.gz
|
|
143
|
-
*/
|
|
144
|
-
export async function deleteExpiredS3Backups(s3Config, projectName, retentionDays) {
|
|
145
|
-
const { DeleteObjectCommand, ListObjectsV2Command } = await import('@aws-sdk/client-s3');
|
|
146
|
-
const client = await getS3Client(s3Config);
|
|
147
|
-
|
|
148
|
-
const command = new ListObjectsV2Command({
|
|
149
|
-
Bucket: s3Config.bucket,
|
|
150
|
-
Prefix: `backups/${projectName}_`,
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
const response = await client.send(command);
|
|
154
|
-
const objects = response.Contents || [];
|
|
155
|
-
|
|
156
|
-
const cutoffDate = new Date();
|
|
157
|
-
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
|
158
|
-
const cutoffStr = cutoffDate.toISOString().slice(0, 10).replace(/-/g, '');
|
|
159
|
-
|
|
160
|
-
let deleted = 0;
|
|
161
|
-
for (const obj of objects) {
|
|
162
|
-
const match = obj.Key.match(/_(\d{8})_\d{6}_full\.tar\.gz$/);
|
|
163
|
-
if (match && match[1] < cutoffStr) {
|
|
164
|
-
await client.send(new DeleteObjectCommand({ Bucket: s3Config.bucket, Key: obj.Key }));
|
|
165
|
-
deleted++;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
return deleted;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
140
|
/**
|
|
172
141
|
* Format a byte count as a human-readable string.
|
|
173
142
|
*/
|
package/src/lib/cloudflare.js
CHANGED
|
@@ -33,27 +33,6 @@ export async function getZones(apiToken) {
|
|
|
33
33
|
return data.result;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
* Get the Cloudflare account ID
|
|
38
|
-
*
|
|
39
|
-
* @param {string} apiToken - Cloudflare API token
|
|
40
|
-
* @returns {Promise<string|null>} - Account ID or null if not found
|
|
41
|
-
*/
|
|
42
|
-
export async function getAccountId(apiToken) {
|
|
43
|
-
const response = await fetchWithRetry('https://api.cloudflare.com/client/v4/accounts', {
|
|
44
|
-
headers: {
|
|
45
|
-
Authorization: `Bearer ${apiToken}`,
|
|
46
|
-
'Content-Type': 'application/json',
|
|
47
|
-
},
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
const data = await response.json();
|
|
51
|
-
if (data.result && data.result.length > 0) {
|
|
52
|
-
return data.result[0].id;
|
|
53
|
-
}
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
36
|
// ============================================================================
|
|
58
37
|
// DNS RECORD OPERATIONS
|
|
59
38
|
// ============================================================================
|
|
@@ -347,44 +326,6 @@ export async function setupHA(apiToken, zoneId, domain, servers) {
|
|
|
347
326
|
* @param {string} serverIp - Server IP address
|
|
348
327
|
* @returns {Promise<object>} - Setup result
|
|
349
328
|
*/
|
|
350
|
-
/**
|
|
351
|
-
* Set the zone-level SSL mode on Cloudflare. Used when we want Cloudflare's
|
|
352
|
-
* edge to terminate TLS (so the origin can skip ACME / cert-manager).
|
|
353
|
-
*
|
|
354
|
-
* - 'off' — no encryption (don't use in prod)
|
|
355
|
-
* - 'flexible' — CF↔browser HTTPS, CF↔origin HTTP (allows origin to run
|
|
356
|
-
* without any TLS; not recommended — redirect loops with origin-side
|
|
357
|
-
* HTTPS)
|
|
358
|
-
* - 'full' — CF↔browser HTTPS, CF↔origin HTTPS with ANY cert (self-signed
|
|
359
|
-
* OK). Our sweet spot when the user opts into the TLS-proxy path.
|
|
360
|
-
* - 'strict' — CF↔origin HTTPS with a valid cert. Requires real ACME,
|
|
361
|
-
* defeats the purpose of TLS proxy.
|
|
362
|
-
*
|
|
363
|
-
* @param {string} apiToken
|
|
364
|
-
* @param {string} zoneId
|
|
365
|
-
* @param {'off'|'flexible'|'full'|'strict'} mode
|
|
366
|
-
*/
|
|
367
|
-
export async function setZoneSslMode(apiToken, zoneId, mode) {
|
|
368
|
-
const response = await fetchWithRetry(
|
|
369
|
-
`https://api.cloudflare.com/client/v4/zones/${zoneId}/settings/ssl`,
|
|
370
|
-
{
|
|
371
|
-
method: 'PATCH',
|
|
372
|
-
headers: {
|
|
373
|
-
Authorization: `Bearer ${apiToken}`,
|
|
374
|
-
'Content-Type': 'application/json',
|
|
375
|
-
},
|
|
376
|
-
body: JSON.stringify({ value: mode }),
|
|
377
|
-
},
|
|
378
|
-
);
|
|
379
|
-
const data = await response.json();
|
|
380
|
-
if (!data.success) {
|
|
381
|
-
throw new Error(
|
|
382
|
-
`Cloudflare zone SSL mode update failed: ${JSON.stringify(data.errors ?? data)}`,
|
|
383
|
-
);
|
|
384
|
-
}
|
|
385
|
-
return data.result;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
329
|
export async function setupSimple(apiToken, zoneId, domain, serverIp, options = {}) {
|
|
389
330
|
// proxied default flipped to false (DNS-only / gray-cloud) on
|
|
390
331
|
// 2026-04-26 after e2e run #3 surfaced the orange-cloud
|
package/src/lib/config.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Project configuration management
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import { basename, join, resolve } from 'node:path';
|
|
8
8
|
|
|
@@ -155,18 +155,6 @@ export function loadS3Config(envName, cwd = process.cwd()) {
|
|
|
155
155
|
return config?.environments?.[envName]?.s3 || null;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
/**
|
|
159
|
-
* Load backup configuration for a specific environment
|
|
160
|
-
*
|
|
161
|
-
* @param {string} envName - Environment name (e.g., 'prod', 'staging')
|
|
162
|
-
* @param {string} [cwd] - Working directory (defaults to process.cwd())
|
|
163
|
-
* @returns {object|null} - Backup config or null if not configured
|
|
164
|
-
*/
|
|
165
|
-
export function loadBackupConfig(envName, cwd = process.cwd()) {
|
|
166
|
-
const config = loadProjectConfig(cwd);
|
|
167
|
-
return config?.environments?.[envName]?.backup || null;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
158
|
/**
|
|
171
159
|
* Load backup S3 configuration for a specific environment.
|
|
172
160
|
* This is the dedicated backup bucket (separate from storage bucket).
|
|
@@ -235,18 +223,6 @@ export function saveCredentials(credentials, configDir = DEFAULT_CONFIG_DIR) {
|
|
|
235
223
|
writeFileSync(credPath, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
|
|
236
224
|
}
|
|
237
225
|
|
|
238
|
-
/**
|
|
239
|
-
* Delete the credentials file
|
|
240
|
-
*
|
|
241
|
-
* @param {string} [configDir] - Config directory for testability
|
|
242
|
-
*/
|
|
243
|
-
export function clearCredentials(configDir = DEFAULT_CONFIG_DIR) {
|
|
244
|
-
const credPath = join(configDir, 'credentials.json');
|
|
245
|
-
if (existsSync(credPath)) {
|
|
246
|
-
unlinkSync(credPath);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
226
|
/**
|
|
251
227
|
* Load the global project registry
|
|
252
228
|
*
|
|
@@ -298,23 +274,6 @@ export function registerProject(name, projectPath, configDir = DEFAULT_CONFIG_DI
|
|
|
298
274
|
writeFileSync(registryPath, `${JSON.stringify(registry, null, 2)}\n`, { mode: 0o600 });
|
|
299
275
|
}
|
|
300
276
|
|
|
301
|
-
/**
|
|
302
|
-
* Remove a project from the global registry by path.
|
|
303
|
-
*
|
|
304
|
-
* @param {string} projectPath - Project directory path
|
|
305
|
-
* @param {string} [configDir] - Config directory for testability
|
|
306
|
-
*/
|
|
307
|
-
export function unregisterProject(projectPath, configDir = DEFAULT_CONFIG_DIR) {
|
|
308
|
-
const absPath = resolve(projectPath);
|
|
309
|
-
const registry = loadGlobalRegistry(configDir);
|
|
310
|
-
registry.projects = registry.projects.filter((p) => p.path !== absPath);
|
|
311
|
-
|
|
312
|
-
const registryPath = join(configDir, 'projects.json');
|
|
313
|
-
if (existsSync(join(configDir))) {
|
|
314
|
-
writeFileSync(registryPath, `${JSON.stringify(registry, null, 2)}\n`, { mode: 0o600 });
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
277
|
/**
|
|
319
278
|
* Remove registry entries where the directory no longer exists.
|
|
320
279
|
*
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACME / TLS challenge strategy for Compose deploys.
|
|
3
|
+
*
|
|
4
|
+
* Managed-DNS providers (cloudflare, hetzner) expose an API that lego — the
|
|
5
|
+
* ACME client embedded in Traefik — can use to solve the DNS-01 challenge:
|
|
6
|
+
* Traefik writes a `_acme-challenge` TXT record, Let's Encrypt validates it,
|
|
7
|
+
* and a cert is issued. DNS-01 needs no inbound port-80 reachability and does
|
|
8
|
+
* not race against A-record propagation, so it eliminates the cold-deploy ACME
|
|
9
|
+
* race that the DNS-propagation poll (src/lib/dns-propagation.js) and the
|
|
10
|
+
* Traefik cert self-heal were built to paper over.
|
|
11
|
+
*
|
|
12
|
+
* `manual` DNS has no API we can drive, so it falls back to the HTTP-01
|
|
13
|
+
* challenge (and keeps the DNS-propagation poll that guards it).
|
|
14
|
+
*
|
|
15
|
+
* Tokens: lego selects the provider implementation from ACME_DNS_PROVIDER and
|
|
16
|
+
* reads that provider's token env var. Hetzner consolidated its DNS into the
|
|
17
|
+
* core Cloud API (api.hetzner.cloud) — the legacy dns.hetzner.com console was
|
|
18
|
+
* retired May 2026 — so lego v4.27+ reads HETZNER_API_TOKEN, the SAME Cloud
|
|
19
|
+
* token used for server ops (no separate DNS credential). That lego ships in
|
|
20
|
+
* Traefik >= 3.6.6; we pin v3.6.11. Cloudflare uses CF_DNS_API_TOKEN.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Compose override file that swaps Traefik from HTTP-01 to DNS-01. */
|
|
24
|
+
export const DNS01_OVERRIDE_FILE = 'docker-compose.dns01.prod.yml';
|
|
25
|
+
|
|
26
|
+
const DNS01_PROVIDERS = new Set(['cloudflare', 'hetzner']);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True when the deploy's DNS provider exposes an API lego can use for DNS-01.
|
|
30
|
+
* @param {string|null|undefined} dnsProvider
|
|
31
|
+
* @returns {boolean}
|
|
32
|
+
*/
|
|
33
|
+
export function useDnsChallenge(dnsProvider) {
|
|
34
|
+
return DNS01_PROVIDERS.has(dnsProvider);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Env vars Traefik needs to solve the DNS-01 challenge for the given provider.
|
|
39
|
+
* Returns `{}` for non-DNS-01 (manual / unknown) providers so callers can
|
|
40
|
+
* spread it unconditionally. Token keys are omitted when the corresponding
|
|
41
|
+
* token is absent, so an empty `${VAR:-}` interpolation stays empty rather
|
|
42
|
+
* than landing a literal "undefined".
|
|
43
|
+
*
|
|
44
|
+
* @param {string|null|undefined} dnsProvider
|
|
45
|
+
* @param {{cloudflareApiToken?: string|null, hetznerApiToken?: string|null}} [tokens]
|
|
46
|
+
* @returns {Record<string, string>}
|
|
47
|
+
*/
|
|
48
|
+
export function dnsChallengeEnv(dnsProvider, { cloudflareApiToken, hetznerApiToken } = {}) {
|
|
49
|
+
if (!useDnsChallenge(dnsProvider)) return {};
|
|
50
|
+
const env = { ACME_DNS_PROVIDER: dnsProvider };
|
|
51
|
+
if (dnsProvider === 'cloudflare') {
|
|
52
|
+
if (cloudflareApiToken) env.CF_DNS_API_TOKEN = cloudflareApiToken;
|
|
53
|
+
} else if (dnsProvider === 'hetzner') {
|
|
54
|
+
if (hetznerApiToken) env.HETZNER_API_TOKEN = hetznerApiToken;
|
|
55
|
+
}
|
|
56
|
+
return env;
|
|
57
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared GoTrue admin-user provisioning.
|
|
3
|
+
*
|
|
4
|
+
* The production app super-admin — an `auth.users` row with
|
|
5
|
+
* `app_metadata.role = super_admin` — is created post-deploy by POSTing to
|
|
6
|
+
* GoTrue's admin API. The HTTP contract is identical across every deploy
|
|
7
|
+
* mode; only the tunnel used to reach GoTrue differs:
|
|
8
|
+
* - compose / compose-ha: `ssh -L` to the gateway (Kong :8000, `/auth/v1`).
|
|
9
|
+
* - k8s / k8s-ha: `kubectl port-forward` to auth (:9999, `/admin`).
|
|
10
|
+
*
|
|
11
|
+
* This module is the tunnel-agnostic half: given a reachable base URL it
|
|
12
|
+
* polls health and upserts the admin user. Callers own the tunnel lifecycle
|
|
13
|
+
* and pass the resolved URLs. Keeping the fetch logic here means the 422
|
|
14
|
+
* idempotency + bearer-auth contract lives in exactly one place (and is
|
|
15
|
+
* unit-tested without standing up SSH or a cluster).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The GoTrue admin-API request body for the super-admin.
|
|
20
|
+
* `email_confirm: true` skips the confirmation email (the operator can log in
|
|
21
|
+
* immediately); `app_metadata.role` is what the app's authz checks read.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} adminEmail
|
|
24
|
+
* @param {string} adminPassword
|
|
25
|
+
*/
|
|
26
|
+
export function adminUserBody(adminEmail, adminPassword) {
|
|
27
|
+
return {
|
|
28
|
+
email: adminEmail,
|
|
29
|
+
password: adminPassword,
|
|
30
|
+
email_confirm: true,
|
|
31
|
+
app_metadata: { role: 'super_admin' },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Poll a forwarded GoTrue `/health` endpoint until it answers as GoTrue.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} healthUrl - e.g. http://localhost:9999/health
|
|
39
|
+
* @param {object} [opts]
|
|
40
|
+
* @param {number} [opts.attempts=15]
|
|
41
|
+
* @param {number} [opts.intervalMs=1000]
|
|
42
|
+
* @param {typeof fetch} [opts.fetchImpl=fetch]
|
|
43
|
+
* @returns {Promise<boolean>} true once reachable, false after attempts
|
|
44
|
+
*/
|
|
45
|
+
export async function waitForGotrueHealth(
|
|
46
|
+
healthUrl,
|
|
47
|
+
{ attempts = 15, intervalMs = 1000, fetchImpl = fetch } = {},
|
|
48
|
+
) {
|
|
49
|
+
for (let i = 0; i < attempts; i++) {
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetchImpl(healthUrl, { signal: AbortSignal.timeout(2000) });
|
|
52
|
+
// GoTrue's /health returns 200 with a body naming itself; any 2xx from
|
|
53
|
+
// the forwarded port means the tunnel + service are live.
|
|
54
|
+
if (res.ok) return true;
|
|
55
|
+
} catch {
|
|
56
|
+
// tunnel/service not up yet
|
|
57
|
+
}
|
|
58
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Upsert the super-admin via GoTrue's admin API. Idempotent: a 422 means the
|
|
65
|
+
* user already exists, which we treat as success so re-deploys are clean.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} args
|
|
68
|
+
* @param {string} args.adminUsersUrl - e.g. http://localhost:9999/admin/users
|
|
69
|
+
* @param {string} args.serviceRoleKey - the Supabase service-role JWT
|
|
70
|
+
* @param {string} args.adminEmail
|
|
71
|
+
* @param {string} args.adminPassword
|
|
72
|
+
* @param {typeof fetch} [args.fetchImpl=fetch]
|
|
73
|
+
* @returns {Promise<{success: boolean, message: string}>}
|
|
74
|
+
*/
|
|
75
|
+
export async function postAdminUser({
|
|
76
|
+
adminUsersUrl,
|
|
77
|
+
serviceRoleKey,
|
|
78
|
+
adminEmail,
|
|
79
|
+
adminPassword,
|
|
80
|
+
fetchImpl = fetch,
|
|
81
|
+
}) {
|
|
82
|
+
const response = await fetchImpl(adminUsersUrl, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: {
|
|
85
|
+
Authorization: `Bearer ${serviceRoleKey}`,
|
|
86
|
+
apikey: serviceRoleKey,
|
|
87
|
+
'Content-Type': 'application/json',
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify(adminUserBody(adminEmail, adminPassword)),
|
|
90
|
+
signal: AbortSignal.timeout(15000),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (response.ok) {
|
|
94
|
+
return { success: true, message: `Admin user created: ${adminEmail}` };
|
|
95
|
+
}
|
|
96
|
+
// 422 = "User already registered" — already provisioned on a prior deploy.
|
|
97
|
+
if (response.status === 422) {
|
|
98
|
+
return { success: true, message: `Admin user already exists: ${adminEmail}` };
|
|
99
|
+
}
|
|
100
|
+
const body = await response.text().catch(() => '');
|
|
101
|
+
return {
|
|
102
|
+
success: false,
|
|
103
|
+
message: `GoTrue admin API returned ${response.status}: ${body.slice(0, 200)}`,
|
|
104
|
+
};
|
|
105
|
+
}
|