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.
Files changed (42) hide show
  1. package/carbon/db/Dockerfile +14 -3
  2. package/carbon/docker-compose.dns01.prod.yml +42 -0
  3. package/carbon/k8s/base/backup/configmap-walg.yaml +47 -0
  4. package/carbon/k8s/base/backup/cronjob.yaml +67 -92
  5. package/carbon/k8s/base/backup/kustomization.yaml +1 -0
  6. package/carbon/k8s/base/backup/network-policy.yaml +9 -14
  7. package/carbon/k8s/base/backup/rbac.yaml +40 -0
  8. package/carbon/k8s/base/network-policies.yaml +33 -0
  9. package/carbon/k8s/values/supabase.values.yaml +144 -0
  10. package/package.json +3 -3
  11. package/src/backup.js +2 -36
  12. package/src/create.js +9 -4
  13. package/src/deploy.js +12 -0
  14. package/src/lib/backup-s3.js +0 -31
  15. package/src/lib/cloudflare.js +0 -59
  16. package/src/lib/config.js +1 -42
  17. package/src/lib/deploy/acme.js +57 -0
  18. package/src/lib/deploy/admin-user.js +105 -0
  19. package/src/lib/deploy/bundle.js +17 -0
  20. package/src/lib/deploy/compose/ha.js +94 -91
  21. package/src/lib/deploy/compose/index.js +50 -248
  22. package/src/lib/deploy/k8s/ha/index.js +12 -148
  23. package/src/lib/deploy/k8s/index.js +1 -21
  24. package/src/lib/deploy/k8s/k3s.js +314 -128
  25. package/src/lib/deploy/orchestrator.js +70 -35
  26. package/src/lib/deploy/utils.js +20 -0
  27. package/src/lib/hetzner-guided-setup.js +0 -7
  28. package/src/lib/iac/index.js +0 -9
  29. package/src/lib/images.js +17 -0
  30. package/src/lib/licensing/index.js +1 -59
  31. package/src/lib/licensing/tiers.js +0 -8
  32. package/src/lib/pod-backups.js +53 -0
  33. package/src/lib/providers/index.js +0 -47
  34. package/src/lib/ssh.js +12 -0
  35. package/src/restore.js +98 -265
  36. package/src/scale.js +43 -20
  37. package/carbon/backup/Dockerfile +0 -75
  38. package/carbon/backup/backup.sh +0 -95
  39. package/carbon/runtime.Dockerfile +0 -17
  40. package/src/lib/deploy/compose/acme-verify.js +0 -121
  41. package/src/lib/deploy/index.js +0 -119
  42. 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
 
@@ -613,7 +579,7 @@ async function runList({
613
579
  return;
614
580
  }
615
581
 
616
- const backups = await listPodBackups(serverIp, sshKeyPath);
582
+ const backups = listPodBackups(serverIp, sshKeyPath, { sort: 'largest' });
617
583
  s.stop('Backups retrieved');
618
584
  printBackupList(backups, envName);
619
585
  }
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
- copyTemplate('backup/backup.sh', join(projectDir, 'backup/backup.sh'), variables);
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
 
@@ -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
  */
@@ -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, unlinkSync, writeFileSync } from 'node:fs';
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
+ }
@@ -8,6 +8,7 @@ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync
8
8
  import { tmpdir } from 'node:os';
9
9
  import { join } from 'node:path';
10
10
  import { escapeDotenv } from '../shell.js';
11
+ import { DNS01_OVERRIDE_FILE, dnsChallengeEnv } from './acme.js';
11
12
 
12
13
  /**
13
14
  * Render the reconcile.sh script
@@ -86,6 +87,10 @@ WantedBy=multi-user.target
86
87
  */
87
88
  function composeFileFlags(options = {}) {
88
89
  const files = ['-f', 'docker-compose.yml', '-f', 'docker-compose.prod.yml'];
90
+ // DNS-01 override must follow prod.yml so it replaces the Traefik command.
91
+ if (options.dnsChallenge) {
92
+ files.push('-f', DNS01_OVERRIDE_FILE);
93
+ }
89
94
  if (options.observability) {
90
95
  files.push('-f', 'docker-compose.observability.yml');
91
96
  files.push('-f', 'docker-compose.observability.prod.yml');
@@ -129,6 +134,7 @@ export function renderBundle(projectName, options = {}) {
129
134
  rootFiles.push('docker-compose.metabase.yml', 'docker-compose.metabase.prod.yml');
130
135
  }
131
136
  if (options.redis) rootFiles.push('docker-compose.redis.yml', 'docker-compose.redis.prod.yml');
137
+ if (options.dnsChallenge) rootFiles.push(DNS01_OVERRIDE_FILE);
132
138
 
133
139
  for (const file of rootFiles) {
134
140
  const src = join(cwd, file);
@@ -168,6 +174,17 @@ export function renderBundle(projectName, options = {}) {
168
174
  if (projectName) {
169
175
  envOverrides.PROJECT_NAME = projectName;
170
176
  }
177
+ // DNS-01: lego reads ACME_DNS_PROVIDER + the matching provider token from the
178
+ // server .env (consumed by docker-compose.dns01.prod.yml's Traefik env).
179
+ if (options.dnsChallenge) {
180
+ Object.assign(
181
+ envOverrides,
182
+ dnsChallengeEnv(options.dnsProvider, {
183
+ cloudflareApiToken: options.cloudflareApiToken,
184
+ hetznerApiToken: options.hetznerApiToken,
185
+ }),
186
+ );
187
+ }
171
188
 
172
189
  // Merge overrides into local copy
173
190
  const lines = envContent.split('\n');