vibecarbon 0.4.0 → 0.5.1
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/biome.json +1 -1
- 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/traefik/certificate.yaml +13 -7
- package/carbon/k8s/values/supabase.values.yaml +8 -0
- package/carbon/package.json +25 -27
- package/carbon/scripts/docker-up.js +13 -1
- package/carbon/src/client/components/DeploymentScenariosSection.tsx +171 -0
- package/carbon/src/client/components/ui/calendar.tsx +1 -1
- 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 +13 -13
- package/src/backup.js +11 -30
- package/src/destroy.js +5 -43
- package/src/lib/deploy/bundle.js +120 -5
- package/src/lib/deploy/compose/ha.js +114 -1
- package/src/lib/deploy/compose/index.js +243 -177
- package/src/lib/deploy/k8s/k3s.js +120 -67
- package/src/lib/deploy/orchestrator.js +22 -0
- package/src/lib/iac/index.js +45 -7
- package/src/restore.js +135 -54
- package/src/scale.js +33 -57
|
@@ -394,6 +394,35 @@
|
|
|
394
394
|
"description": "Jede Route validiert mit Zod, jede Abfrage nutzt den richtigen Supabase-Client, jede Tabelle hat RLS. KI-Agenten replizieren jedes Mal dieselben korrekten Muster."
|
|
395
395
|
}
|
|
396
396
|
},
|
|
397
|
+
"deploy": {
|
|
398
|
+
"headline": "Deploye nach deinen Vorstellungen,",
|
|
399
|
+
"headlineHighlight": "mit einem Befehl",
|
|
400
|
+
"subheading": "Von einem einzelnen VPS bis zu Multi-Region-Kubernetes — wähle dein Maß an Skalierung und Ausfallsicherheit. Wechsle jederzeit in die nächste Stufe.",
|
|
401
|
+
"compose": {
|
|
402
|
+
"name": "Docker Compose",
|
|
403
|
+
"tagline": "Ein VPS, dein kompletter Stack inklusive.",
|
|
404
|
+
"features": ["1 Server", "Einzelne Region"],
|
|
405
|
+
"bestFor": "MVPs & interne Tools"
|
|
406
|
+
},
|
|
407
|
+
"composeHa": {
|
|
408
|
+
"name": "Compose HA",
|
|
409
|
+
"tagline": "Zwei Server, automatisches Failover.",
|
|
410
|
+
"features": ["2 Server", "Streaming-Replikation", "Auto-Failover"],
|
|
411
|
+
"bestFor": "Produktions-SaaS ohne Kubernetes"
|
|
412
|
+
},
|
|
413
|
+
"k8s": {
|
|
414
|
+
"name": "Kubernetes",
|
|
415
|
+
"tagline": "Automatisch skalierender k3s-Cluster.",
|
|
416
|
+
"features": ["Einzelne Region", "Horizontale Autoskalierung", "Rollierende Updates"],
|
|
417
|
+
"bestFor": "Anwendungen mit hohem Traffic"
|
|
418
|
+
},
|
|
419
|
+
"k8sHa": {
|
|
420
|
+
"name": "Kubernetes HA",
|
|
421
|
+
"tagline": "Multi-Region-Cluster, maximale Verfügbarkeit.",
|
|
422
|
+
"features": ["2 Regionen", "Regions-Failover", "Autoskalierung"],
|
|
423
|
+
"bestFor": "Geschäftskritisch & global"
|
|
424
|
+
}
|
|
425
|
+
},
|
|
397
426
|
"pricing": {
|
|
398
427
|
"headline1": "Von der",
|
|
399
428
|
"headlineSketch": "Idee",
|
|
@@ -394,6 +394,35 @@
|
|
|
394
394
|
"description": "Every route validates with Zod, every query uses the right Supabase client, every table has RLS. AI agents replicate the same correct patterns every time."
|
|
395
395
|
}
|
|
396
396
|
},
|
|
397
|
+
"deploy": {
|
|
398
|
+
"headline": "Deploy your way,",
|
|
399
|
+
"headlineHighlight": "in one command",
|
|
400
|
+
"subheading": "From a single VPS to multi-region Kubernetes — choose your level of scale and resilience. Grow into the next tier whenever you're ready.",
|
|
401
|
+
"compose": {
|
|
402
|
+
"name": "Docker Compose",
|
|
403
|
+
"tagline": "One VPS, your full stack included.",
|
|
404
|
+
"features": ["1 server", "Single region"],
|
|
405
|
+
"bestFor": "MVPs & internal tools"
|
|
406
|
+
},
|
|
407
|
+
"composeHa": {
|
|
408
|
+
"name": "Compose HA",
|
|
409
|
+
"tagline": "Two servers, automatic failover.",
|
|
410
|
+
"features": ["2 servers", "Streaming replication", "Auto-failover"],
|
|
411
|
+
"bestFor": "Production SaaS without Kubernetes"
|
|
412
|
+
},
|
|
413
|
+
"k8s": {
|
|
414
|
+
"name": "Kubernetes",
|
|
415
|
+
"tagline": "Auto-scaling k3s cluster.",
|
|
416
|
+
"features": ["Single region", "Horizontal autoscaling", "Rolling updates"],
|
|
417
|
+
"bestFor": "High-traffic apps"
|
|
418
|
+
},
|
|
419
|
+
"k8sHa": {
|
|
420
|
+
"name": "Kubernetes HA",
|
|
421
|
+
"tagline": "Multi-region clusters, maximum uptime.",
|
|
422
|
+
"features": ["2 regions", "Region failover", "Autoscaling"],
|
|
423
|
+
"bestFor": "Mission-critical & global"
|
|
424
|
+
}
|
|
425
|
+
},
|
|
397
426
|
"pricing": {
|
|
398
427
|
"headline1": "From",
|
|
399
428
|
"headlineSketch": "sketch",
|
|
@@ -394,6 +394,35 @@
|
|
|
394
394
|
"description": "Cada ruta valida con Zod, cada consulta usa el cliente Supabase correcto, cada tabla tiene RLS. Los agentes de IA replican los mismos patrones correctos en todo momento."
|
|
395
395
|
}
|
|
396
396
|
},
|
|
397
|
+
"deploy": {
|
|
398
|
+
"headline": "Despliega a tu manera,",
|
|
399
|
+
"headlineHighlight": "en un solo comando",
|
|
400
|
+
"subheading": "De un único VPS a Kubernetes multirregión — elige tu nivel de escalado y resiliencia. Sube al siguiente nivel cuando quieras.",
|
|
401
|
+
"compose": {
|
|
402
|
+
"name": "Docker Compose",
|
|
403
|
+
"tagline": "Un VPS, con todo tu stack incluido.",
|
|
404
|
+
"features": ["1 servidor", "Región única"],
|
|
405
|
+
"bestFor": "MVPs y herramientas internas"
|
|
406
|
+
},
|
|
407
|
+
"composeHa": {
|
|
408
|
+
"name": "Compose HA",
|
|
409
|
+
"tagline": "Dos servidores, conmutación automática.",
|
|
410
|
+
"features": ["2 servidores", "Replicación en streaming", "Conmutación automática"],
|
|
411
|
+
"bestFor": "SaaS en producción sin Kubernetes"
|
|
412
|
+
},
|
|
413
|
+
"k8s": {
|
|
414
|
+
"name": "Kubernetes",
|
|
415
|
+
"tagline": "Clúster k3s con autoescalado.",
|
|
416
|
+
"features": ["Región única", "Autoescalado horizontal", "Actualizaciones progresivas"],
|
|
417
|
+
"bestFor": "Apps de alto tráfico"
|
|
418
|
+
},
|
|
419
|
+
"k8sHa": {
|
|
420
|
+
"name": "Kubernetes HA",
|
|
421
|
+
"tagline": "Clústeres multirregión, máxima disponibilidad.",
|
|
422
|
+
"features": ["2 regiones", "Conmutación de región", "Autoescalado"],
|
|
423
|
+
"bestFor": "Críticos y globales"
|
|
424
|
+
}
|
|
425
|
+
},
|
|
397
426
|
"pricing": {
|
|
398
427
|
"headline1": "Del",
|
|
399
428
|
"headlineSketch": "boceto",
|
|
@@ -394,6 +394,35 @@
|
|
|
394
394
|
"description": "Chaque route valide avec Zod, chaque requête utilise le bon client Supabase, chaque table a RLS. Les agents IA reproduisent les mêmes schémas corrects à chaque fois."
|
|
395
395
|
}
|
|
396
396
|
},
|
|
397
|
+
"deploy": {
|
|
398
|
+
"headline": "Déployez à votre façon,",
|
|
399
|
+
"headlineHighlight": "en une commande",
|
|
400
|
+
"subheading": "D'un seul VPS à Kubernetes multi-région — choisissez votre niveau d'évolutivité et de résilience. Passez au palier supérieur quand vous le souhaitez.",
|
|
401
|
+
"compose": {
|
|
402
|
+
"name": "Docker Compose",
|
|
403
|
+
"tagline": "Un VPS, toute votre stack incluse.",
|
|
404
|
+
"features": ["1 serveur", "Région unique"],
|
|
405
|
+
"bestFor": "MVP et outils internes"
|
|
406
|
+
},
|
|
407
|
+
"composeHa": {
|
|
408
|
+
"name": "Compose HA",
|
|
409
|
+
"tagline": "Deux serveurs, bascule automatique.",
|
|
410
|
+
"features": ["2 serveurs", "Réplication en flux", "Bascule automatique"],
|
|
411
|
+
"bestFor": "SaaS en production sans Kubernetes"
|
|
412
|
+
},
|
|
413
|
+
"k8s": {
|
|
414
|
+
"name": "Kubernetes",
|
|
415
|
+
"tagline": "Cluster k3s à mise à l'échelle automatique.",
|
|
416
|
+
"features": ["Région unique", "Mise à l'échelle horizontale", "Mises à jour progressives"],
|
|
417
|
+
"bestFor": "Applications à fort trafic"
|
|
418
|
+
},
|
|
419
|
+
"k8sHa": {
|
|
420
|
+
"name": "Kubernetes HA",
|
|
421
|
+
"tagline": "Clusters multi-région, disponibilité maximale.",
|
|
422
|
+
"features": ["2 régions", "Bascule de région", "Mise à l'échelle auto"],
|
|
423
|
+
"bestFor": "Critique et mondial"
|
|
424
|
+
}
|
|
425
|
+
},
|
|
397
426
|
"pricing": {
|
|
398
427
|
"headline1": "Du",
|
|
399
428
|
"headlineSketch": "brouillon",
|
|
@@ -394,6 +394,35 @@
|
|
|
394
394
|
"description": "Cada rota valida com Zod, cada consulta usa o cliente Supabase correto, cada tabela tem RLS. Os agentes de IA replicam sempre os mesmos padrões corretos."
|
|
395
395
|
}
|
|
396
396
|
},
|
|
397
|
+
"deploy": {
|
|
398
|
+
"headline": "Implante do seu jeito,",
|
|
399
|
+
"headlineHighlight": "com um único comando",
|
|
400
|
+
"subheading": "De um único VPS a Kubernetes multirregião — escolha seu nível de escala e resiliência. Suba para o próximo nível quando quiser.",
|
|
401
|
+
"compose": {
|
|
402
|
+
"name": "Docker Compose",
|
|
403
|
+
"tagline": "Um VPS, com toda a sua stack incluída.",
|
|
404
|
+
"features": ["1 servidor", "Região única"],
|
|
405
|
+
"bestFor": "MVPs e ferramentas internas"
|
|
406
|
+
},
|
|
407
|
+
"composeHa": {
|
|
408
|
+
"name": "Compose HA",
|
|
409
|
+
"tagline": "Dois servidores, failover automático.",
|
|
410
|
+
"features": ["2 servidores", "Replicação em streaming", "Failover automático"],
|
|
411
|
+
"bestFor": "SaaS em produção sem Kubernetes"
|
|
412
|
+
},
|
|
413
|
+
"k8s": {
|
|
414
|
+
"name": "Kubernetes",
|
|
415
|
+
"tagline": "Cluster k3s com escalonamento automático.",
|
|
416
|
+
"features": ["Região única", "Escalonamento horizontal", "Atualizações graduais"],
|
|
417
|
+
"bestFor": "Apps de alto tráfego"
|
|
418
|
+
},
|
|
419
|
+
"k8sHa": {
|
|
420
|
+
"name": "Kubernetes HA",
|
|
421
|
+
"tagline": "Clusters multirregião, disponibilidade máxima.",
|
|
422
|
+
"features": ["2 regiões", "Failover de região", "Escalonamento automático"],
|
|
423
|
+
"bestFor": "Crítico e global"
|
|
424
|
+
}
|
|
425
|
+
},
|
|
397
426
|
"pricing": {
|
|
398
427
|
"headline1": "Do",
|
|
399
428
|
"headlineSketch": "esboço",
|
|
@@ -16,6 +16,7 @@ import { AIIntegrationSection } from '../components/AIIntegrationSection';
|
|
|
16
16
|
import { ArchitectureSection } from '../components/ArchitectureSection';
|
|
17
17
|
import { ComparisonSection } from '../components/ComparisonSection';
|
|
18
18
|
import { CTAFooter } from '../components/CTAFooter';
|
|
19
|
+
import { DeploymentScenariosSection } from '../components/DeploymentScenariosSection';
|
|
19
20
|
import { FilmGrainOverlay } from '../components/effects/FilmGrainOverlay';
|
|
20
21
|
import { VGlowEffect } from '../components/effects/VGlowEffect';
|
|
21
22
|
import { FAQSection } from '../components/FAQSection';
|
|
@@ -168,6 +169,10 @@ export default function VibecarbonHome() {
|
|
|
168
169
|
<ArchitectureSection />
|
|
169
170
|
</ScrollSection>
|
|
170
171
|
|
|
172
|
+
<ScrollSection id="deploy-scenarios">
|
|
173
|
+
<DeploymentScenariosSection />
|
|
174
|
+
</ScrollSection>
|
|
175
|
+
|
|
171
176
|
<ScrollSection id="pricing">
|
|
172
177
|
<PricingSection />
|
|
173
178
|
</ScrollSection>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibecarbon",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Create and manage production-ready Vibecarbon applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": "./src/cli.js",
|
|
@@ -57,12 +57,12 @@
|
|
|
57
57
|
"url": "https://github.com/hyperformant/vibecarbon.git"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@aws-sdk/client-s3": "^3.
|
|
61
|
-
"@clack/prompts": "^1.
|
|
62
|
-
"@pulumi/hcloud": "^1.
|
|
63
|
-
"@pulumi/pulumi": "^3.
|
|
60
|
+
"@aws-sdk/client-s3": "^3.1058.0",
|
|
61
|
+
"@clack/prompts": "^1.5.0",
|
|
62
|
+
"@pulumi/hcloud": "^1.38.0",
|
|
63
|
+
"@pulumi/pulumi": "^3.244.0",
|
|
64
64
|
"bcryptjs": "^3.0.3",
|
|
65
|
-
"undici": "^8.
|
|
65
|
+
"undici": "^8.3.0",
|
|
66
66
|
"which": "^7.0.0"
|
|
67
67
|
},
|
|
68
68
|
"pnpm": {
|
|
@@ -81,18 +81,18 @@
|
|
|
81
81
|
}
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
|
-
"@biomejs/biome": "^2.4.
|
|
84
|
+
"@biomejs/biome": "^2.4.16",
|
|
85
85
|
"@semantic-release/changelog": "^6.0.3",
|
|
86
86
|
"@semantic-release/git": "^10.0.1",
|
|
87
87
|
"@types/better-sqlite3": "^7.6.13",
|
|
88
|
-
"@vitest/coverage-v8": "^4.1.
|
|
88
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
89
89
|
"autocannon": "^8.0.0",
|
|
90
|
-
"better-sqlite3": "^12.
|
|
90
|
+
"better-sqlite3": "^12.10.0",
|
|
91
91
|
"conventional-changelog-conventionalcommits": "^9.3.1",
|
|
92
92
|
"semantic-release": "^25.0.3",
|
|
93
|
-
"tsx": "^4.
|
|
94
|
-
"vite": "^8.0.
|
|
95
|
-
"vitest": "^4.1.
|
|
96
|
-
"zod": "^4.3
|
|
93
|
+
"tsx": "^4.22.4",
|
|
94
|
+
"vite": "^8.0.16",
|
|
95
|
+
"vitest": "^4.1.8",
|
|
96
|
+
"zod": "^4.4.3"
|
|
97
97
|
}
|
|
98
98
|
}
|
package/src/backup.js
CHANGED
|
@@ -448,6 +448,7 @@ export async function run(args) {
|
|
|
448
448
|
sshKeyPath,
|
|
449
449
|
projectName,
|
|
450
450
|
envName,
|
|
451
|
+
backupRetain: envConfig.backup?.retentionDays,
|
|
451
452
|
yes: !!values.y,
|
|
452
453
|
});
|
|
453
454
|
tracker.finish();
|
|
@@ -647,6 +648,7 @@ async function runCreate({
|
|
|
647
648
|
sshKeyPath,
|
|
648
649
|
projectName,
|
|
649
650
|
envName,
|
|
651
|
+
backupRetain,
|
|
650
652
|
yes,
|
|
651
653
|
}) {
|
|
652
654
|
if (!yes) {
|
|
@@ -660,37 +662,16 @@ async function runCreate({
|
|
|
660
662
|
}
|
|
661
663
|
|
|
662
664
|
if (isCompose) {
|
|
663
|
-
s.start('Creating
|
|
665
|
+
s.start('Creating wal-g base backup...');
|
|
664
666
|
try {
|
|
665
|
-
const { backupCompose
|
|
666
|
-
|
|
667
|
-
)
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
p.log.
|
|
672
|
-
|
|
673
|
-
if (useS3) {
|
|
674
|
-
s.start('Uploading backup to S3...');
|
|
675
|
-
try {
|
|
676
|
-
await uploadComposeBackupToS3(serverIp, sshKeyPath, projectName, backupFile, s3Config);
|
|
677
|
-
s.stop(`Uploaded to S3: ${s3Config.bucket}/backups/${backupFile}`);
|
|
678
|
-
} catch (uploadError) {
|
|
679
|
-
s.stop(`S3 upload failed: ${uploadError.message}`);
|
|
680
|
-
p.log.warn('Backup is available on the server but was not uploaded to S3.');
|
|
681
|
-
// Dump the raw error to stderr so automation captures the
|
|
682
|
-
// actual AWS SDK error code + HTTP status — the spinner
|
|
683
|
-
// message above hides the detail e2e/CI needs.
|
|
684
|
-
console.error(
|
|
685
|
-
`[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}`,
|
|
686
|
-
);
|
|
687
|
-
if (uploadError.stack) {
|
|
688
|
-
console.error(uploadError.stack);
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
} else {
|
|
692
|
-
p.log.info(`Download: scp root@${serverIp}:/opt/${projectName}/backups/${backupFile} .`);
|
|
693
|
-
}
|
|
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`)}`);
|
|
694
675
|
} catch (error) {
|
|
695
676
|
s.stop('Backup failed');
|
|
696
677
|
p.log.error(error.message);
|
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/deploy/bundle.js
CHANGED
|
@@ -13,13 +13,21 @@ import { DNS01_OVERRIDE_FILE, dnsChallengeEnv } from './acme.js';
|
|
|
13
13
|
/**
|
|
14
14
|
* Render the reconcile.sh script
|
|
15
15
|
*/
|
|
16
|
-
function renderReconcileScript(projectName, composeFlags, isFast = false) {
|
|
16
|
+
export function renderReconcileScript(projectName, composeFlags, isFast = false) {
|
|
17
|
+
// On compose-ha servers the replication overlay (docker-compose.replication.yml)
|
|
18
|
+
// publishes port 5433 → db:5432 for pg_basebackup / streaming replication.
|
|
19
|
+
// reconcile.sh is baked with static composeFlags at deploy time, so we embed a
|
|
20
|
+
// shell conditional here that appends the overlay at runtime when present.
|
|
21
|
+
// On single-server compose the file never exists, so this is a safe no-op.
|
|
22
|
+
const replOverlayFlag =
|
|
23
|
+
"$([ -f docker-compose.replication.yml ] && echo '-f docker-compose.replication.yml')";
|
|
24
|
+
|
|
17
25
|
const pullStep = isFast
|
|
18
26
|
? ''
|
|
19
27
|
: `
|
|
20
28
|
# 1. Pull latest images if needed
|
|
21
29
|
echo "Pulling images..."
|
|
22
|
-
docker compose ${composeFlags} pull --quiet --policy missing --ignore-buildable || true
|
|
30
|
+
docker compose ${composeFlags} ${replOverlayFlag} pull --quiet --policy missing --ignore-buildable || true
|
|
23
31
|
`;
|
|
24
32
|
|
|
25
33
|
return `#!/bin/bash
|
|
@@ -47,7 +55,7 @@ fi
|
|
|
47
55
|
${pullStep}
|
|
48
56
|
# 2. Re-create containers if state differs
|
|
49
57
|
echo "Updating containers..."
|
|
50
|
-
docker compose ${composeFlags} up -d --remove-orphans
|
|
58
|
+
docker compose ${composeFlags} ${replOverlayFlag} up -d --remove-orphans
|
|
51
59
|
|
|
52
60
|
# 3. Ensure systemd service is active (if applicable)
|
|
53
61
|
if [ -f "/etc/systemd/system/${projectName}.service" ]; then
|
|
@@ -107,6 +115,90 @@ function composeFileFlags(options = {}) {
|
|
|
107
115
|
return files.join(' ');
|
|
108
116
|
}
|
|
109
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Render the Traefik dynamic-config file that instructs Traefik to obtain and
|
|
120
|
+
* serve ONE cert for the entire deploy as the TLS default store entry.
|
|
121
|
+
*
|
|
122
|
+
* With `defaultGeneratedCert`, Traefik proactively requests a single cert via
|
|
123
|
+
* the named resolver at startup and serves it by default for every TLS router
|
|
124
|
+
* that does NOT carry an explicit `tls.certresolver` label. All our production
|
|
125
|
+
* routers use `tls=true` without a resolver — they all get this cert via SNI.
|
|
126
|
+
*
|
|
127
|
+
* Challenge-type differences:
|
|
128
|
+
* - DNS-01 (managed DNS — cloudflare/hetzner): `sans: ["*.${domain}"]`.
|
|
129
|
+
* One wildcard cert covers all subdomains, no per-subdomain ACME orders.
|
|
130
|
+
* - HTTP-01 (manual DNS): wildcards require DNS-01 (LE policy), so we list
|
|
131
|
+
* the explicit subdomains that are active for this deploy instead.
|
|
132
|
+
* `main` stays `${domain}` (the app root); `sans` lists api/studio/dashboard
|
|
133
|
+
* plus any enabled feature subdomains (grafana/prometheus, n8n, metabase).
|
|
134
|
+
*
|
|
135
|
+
* The file is written into `volumes/traefik/` which is already bind-mounted
|
|
136
|
+
* as `/etc/traefik/dynamic:ro`. Traefik hot-reloads files from that dir.
|
|
137
|
+
* Multiple files in the dir coexist fine; this file adds only a top-level
|
|
138
|
+
* `tls:` key that has no overlap with the `http:` keys in middlewares.yml.
|
|
139
|
+
*
|
|
140
|
+
* @param {{domain: string, dnsChallenge: boolean, features?: {observability?: boolean, n8n?: boolean, metabase?: boolean}}} opts
|
|
141
|
+
* @returns {string} YAML string suitable for writing to `volumes/traefik/tls-default.yml`
|
|
142
|
+
*/
|
|
143
|
+
export function renderTraefikDefaultCert({ domain, dnsChallenge, features = {} }) {
|
|
144
|
+
// The default store cert covers the SUBDOMAINS only. The app at the bare apex
|
|
145
|
+
// (`${domain}`) gets its OWN single-domain cert via the app router's
|
|
146
|
+
// `tls.certresolver` label — it is deliberately NOT in this cert.
|
|
147
|
+
//
|
|
148
|
+
// Why split apex off the wildcard: a cert for [`${domain}`, `*.${domain}`] has
|
|
149
|
+
// TWO ACME authorizations that BOTH validate via the SAME TXT record
|
|
150
|
+
// `_acme-challenge.${domain}`, so both challenge values must be present there
|
|
151
|
+
// simultaneously. The Hetzner lego DNS provider REPLACES the TXT rrset instead
|
|
152
|
+
// of holding both values, so one authorization always fails with
|
|
153
|
+
// "403 unauthorized :: Incorrect TXT record". (Cloudflare holds both and
|
|
154
|
+
// worked — RCA 2026-06-01, compose e2e wildcard verify-deploy.) As two
|
|
155
|
+
// SEPARATE single-authz certs (apex via the app router, `*.${domain}` here),
|
|
156
|
+
// Traefik obtains them serially, so `_acme-challenge.${domain}` only ever
|
|
157
|
+
// needs one value at a time — robust on Hetzner AND Cloudflare.
|
|
158
|
+
let main;
|
|
159
|
+
let sans;
|
|
160
|
+
if (dnsChallenge) {
|
|
161
|
+
// DNS-01: one wildcard covers every subdomain (most general level).
|
|
162
|
+
main = `*.${domain}`;
|
|
163
|
+
sans = [];
|
|
164
|
+
} else {
|
|
165
|
+
// HTTP-01: wildcards require DNS-01 (LE policy), so enumerate the active
|
|
166
|
+
// subdomains. `api` (kong), `studio`, `dashboard` always exist; the rest are
|
|
167
|
+
// feature-gated. The apex app is covered by its own router cert, not here.
|
|
168
|
+
const subs = ['studio', 'dashboard'];
|
|
169
|
+
if (features.observability) subs.push('grafana', 'prometheus');
|
|
170
|
+
if (features.n8n) subs.push('n8n');
|
|
171
|
+
if (features.metabase) subs.push('metabase');
|
|
172
|
+
main = `api.${domain}`;
|
|
173
|
+
sans = subs.map((s) => `${s}.${domain}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const lines = [
|
|
177
|
+
'tls:',
|
|
178
|
+
' stores:',
|
|
179
|
+
' default:',
|
|
180
|
+
' defaultGeneratedCert:',
|
|
181
|
+
' resolver: letsencrypt',
|
|
182
|
+
' domain:',
|
|
183
|
+
` main: "${main}"`,
|
|
184
|
+
];
|
|
185
|
+
if (sans.length) {
|
|
186
|
+
lines.push(' sans:');
|
|
187
|
+
for (const d of sans) lines.push(` - "${d}"`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return `# Traefik default TLS certificate — generated by Vibecarbon at deploy time.
|
|
191
|
+
#
|
|
192
|
+
# Serves as the default cert for ALL TLS routers carrying \`tls=true\` without an
|
|
193
|
+
# explicit certresolver (SNI fallback). Covers the SUBDOMAINS; the apex app gets
|
|
194
|
+
# its own cert via the app router's \`tls.certresolver\`.
|
|
195
|
+
#
|
|
196
|
+
# DNS-01 (cloudflare/hetzner): one wildcard cert covering every subdomain.
|
|
197
|
+
# HTTP-01 (manual DNS): explicit subdomain SANs (LE policy forbids wildcards).
|
|
198
|
+
${lines.join('\n')}
|
|
199
|
+
`;
|
|
200
|
+
}
|
|
201
|
+
|
|
110
202
|
/**
|
|
111
203
|
* Render the deployment bundle to a local temp directory
|
|
112
204
|
* @param {string} projectName
|
|
@@ -233,13 +325,36 @@ export function renderBundle(projectName, options = {}) {
|
|
|
233
325
|
// template ships a stub at functions/main/index.ts. RCA: prod-1 2026-05-26.
|
|
234
326
|
copyDirIfExist('functions', 'functions');
|
|
235
327
|
|
|
236
|
-
// volumes/traefik/
|
|
328
|
+
// volumes/traefik/ — dynamic-config directory mounted into Traefik.
|
|
329
|
+
// Always ensure the dir exists in the bundle so Traefik's file provider
|
|
330
|
+
// has a valid mount target even if the source dir is absent.
|
|
331
|
+
mkdirSync(join(stageDir, 'volumes', 'traefik'), { recursive: true });
|
|
332
|
+
|
|
237
333
|
const traefikMiddlewares = join(cwd, 'volumes', 'traefik', 'middlewares.yml');
|
|
238
334
|
if (existsSync(traefikMiddlewares)) {
|
|
239
|
-
mkdirSync(join(stageDir, 'volumes', 'traefik'), { recursive: true });
|
|
240
335
|
cpSync(traefikMiddlewares, join(stageDir, 'volumes', 'traefik', 'middlewares.yml'));
|
|
241
336
|
}
|
|
242
337
|
|
|
338
|
+
// Render tls-default.yml only when a domain is known (production deploys).
|
|
339
|
+
// This instructs Traefik to proactively obtain ONE cert for the deploy and
|
|
340
|
+
// serve it as the default for all TLS routers via SNI. DNS-01 deploys get
|
|
341
|
+
// a wildcard SAN (*.${domain}); HTTP-01 deploys get explicit subdomain SANs.
|
|
342
|
+
// The file coexists with middlewares.yml — both live under the same `tls:`
|
|
343
|
+
// / `http:` top-level keys respectively; Traefik merges all files in the dir.
|
|
344
|
+
if (options.domain) {
|
|
345
|
+
const tlsDefaultYaml = renderTraefikDefaultCert({
|
|
346
|
+
domain: options.domain,
|
|
347
|
+
dnsChallenge: Boolean(options.dnsChallenge),
|
|
348
|
+
features: {
|
|
349
|
+
observability: options.observability,
|
|
350
|
+
n8n: options.n8n,
|
|
351
|
+
metabase: options.metabase,
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
writeFileSync(join(stageDir, 'volumes', 'traefik', 'tls-default.yml'), tlsDefaultYaml);
|
|
355
|
+
if (options.verbose) console.log('[bundle] Rendered volumes/traefik/tls-default.yml');
|
|
356
|
+
}
|
|
357
|
+
|
|
243
358
|
copyDirIfExist('backup', 'backup');
|
|
244
359
|
if (options.n8n) copyDirIfExist('volumes/n8n', 'volumes/n8n');
|
|
245
360
|
if (options.metabase) copyDirIfExist('volumes/metabase', 'volumes/metabase');
|