vibecarbon 0.1.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/LICENSE +663 -0
- package/README.md +188 -0
- package/carbon/.claude/hooks/task-completed-gate.sh +19 -0
- package/carbon/.claude/hooks/teammate-idle-gate.sh +29 -0
- package/carbon/.claude/settings.json +29 -0
- package/carbon/.cursor/rules/vibecarbon.mdc +6 -0
- package/carbon/.dockerignore +57 -0
- package/carbon/.env.example +219 -0
- package/carbon/.github/copilot-instructions.md +3 -0
- package/carbon/.github/workflows/deploy.yml +346 -0
- package/carbon/.github/workflows/vibecarbon-build.yml +81 -0
- package/carbon/.windsurfrules +74 -0
- package/carbon/AGENTS.md +422 -0
- package/carbon/CLAUDE.md +3 -0
- package/carbon/DEVELOPMENT.md +187 -0
- package/carbon/Dockerfile +98 -0
- package/carbon/LICENSE +21 -0
- package/carbon/PRODUCTION.md +364 -0
- package/carbon/README.md +193 -0
- package/carbon/TESTING.md +138 -0
- package/carbon/backup/Dockerfile +75 -0
- package/carbon/backup/backup.sh +95 -0
- package/carbon/backup/compose-backup.sh +140 -0
- package/carbon/biome.json +83 -0
- package/carbon/cloud-init/docker-ce-setup.yaml +55 -0
- package/carbon/cloud-init/k3s/master-init.sh +215 -0
- package/carbon/cloud-init/k3s/supabase-init.sh +167 -0
- package/carbon/cloud-init/k3s/worker-init.sh +147 -0
- package/carbon/components.json +24 -0
- package/carbon/content/blog/authentication-guide.mdx +34 -0
- package/carbon/content/blog/getting-started.mdx +41 -0
- package/carbon/content/changelog/v0-1-0.mdx +40 -0
- package/carbon/content/docs/analytics.mdx +90 -0
- package/carbon/content/docs/authentication.mdx +231 -0
- package/carbon/content/docs/background-jobs.mdx +116 -0
- package/carbon/content/docs/cli.mdx +630 -0
- package/carbon/content/docs/database.mdx +236 -0
- package/carbon/content/docs/deployment.mdx +227 -0
- package/carbon/content/docs/development.mdx +238 -0
- package/carbon/content/docs/environments.mdx +84 -0
- package/carbon/content/docs/getting-started.mdx +112 -0
- package/carbon/content/docs/legal/privacy-policy.mdx +89 -0
- package/carbon/content/docs/legal/terms-of-service.mdx +99 -0
- package/carbon/content/docs/optional-services.mdx +160 -0
- package/carbon/db/Dockerfile +23 -0
- package/carbon/docker-compose.dev-init.yml +5 -0
- package/carbon/docker-compose.metabase.override.yml +14 -0
- package/carbon/docker-compose.metabase.prod.yml +28 -0
- package/carbon/docker-compose.metabase.yml +84 -0
- package/carbon/docker-compose.n8n.override.yml +14 -0
- package/carbon/docker-compose.n8n.prod.yml +31 -0
- package/carbon/docker-compose.n8n.yml +97 -0
- package/carbon/docker-compose.observability.override.yml +18 -0
- package/carbon/docker-compose.observability.prod.yml +28 -0
- package/carbon/docker-compose.observability.yml +125 -0
- package/carbon/docker-compose.override.yml +28 -0
- package/carbon/docker-compose.prod.yml +294 -0
- package/carbon/docker-compose.yml +508 -0
- package/carbon/docker-entrypoint.sh +12 -0
- package/carbon/ha/activate-standby.sh +52 -0
- package/carbon/ha/primary-init.sql +36 -0
- package/carbon/ha/standby-init.sh +74 -0
- package/carbon/k8s/LICENSE +99 -0
- package/carbon/k8s/README.md +272 -0
- package/carbon/k8s/base/app/deployment.yaml +130 -0
- package/carbon/k8s/base/app/hpa.yaml +44 -0
- package/carbon/k8s/base/app/kustomization.yaml +10 -0
- package/carbon/k8s/base/app/network-policy.yaml +124 -0
- package/carbon/k8s/base/app/pdb.yaml +14 -0
- package/carbon/k8s/base/app/rbac.yaml +36 -0
- package/carbon/k8s/base/app/service.yaml +16 -0
- package/carbon/k8s/base/backup/cronjob.yaml +120 -0
- package/carbon/k8s/base/backup/kustomization.yaml +7 -0
- package/carbon/k8s/base/backup/network-policy.yaml +31 -0
- package/carbon/k8s/base/backup/rbac.yaml +7 -0
- package/carbon/k8s/base/cluster-autoscaler/deployment.yaml +103 -0
- package/carbon/k8s/base/cluster-autoscaler/kustomization.yaml +17 -0
- package/carbon/k8s/base/cluster-autoscaler/rbac.yaml +109 -0
- package/carbon/k8s/base/config/configmap.yaml +48 -0
- package/carbon/k8s/base/config/kustomization.yaml +8 -0
- package/carbon/k8s/base/hetzner-ccm/kustomization.yaml +9 -0
- package/carbon/k8s/base/hetzner-csi/kustomization.yaml +9 -0
- package/carbon/k8s/base/kustomization.yaml +43 -0
- package/carbon/k8s/base/namespace.yaml +7 -0
- package/carbon/k8s/base/network-policies.yaml +95 -0
- package/carbon/k8s/base/registry/kustomization.yaml +5 -0
- package/carbon/k8s/base/registry/local-registry.yaml +193 -0
- package/carbon/k8s/base/traefik/certificate.yaml +21 -0
- package/carbon/k8s/base/traefik/configmap.yaml +13 -0
- package/carbon/k8s/base/traefik/deployment.yaml +165 -0
- package/carbon/k8s/base/traefik/ingressroute.yaml +141 -0
- package/carbon/k8s/base/traefik/kustomization.yaml +11 -0
- package/carbon/k8s/base/traefik/middleware.yaml +109 -0
- package/carbon/k8s/base/traefik/network-policy.yaml +92 -0
- package/carbon/k8s/base/traefik/service.yaml +38 -0
- package/carbon/k8s/flux/README.md +49 -0
- package/carbon/k8s/flux/clusters/primary/vibecarbon.yaml +128 -0
- package/carbon/k8s/flux/clusters/standby/vibecarbon.yaml +83 -0
- package/carbon/k8s/gitops/cert-manager-webhook-hetzner/helm-release.yaml +38 -0
- package/carbon/k8s/gitops/cert-manager-webhook-hetzner/helm-repository.yaml +16 -0
- package/carbon/k8s/gitops/cert-manager-webhook-hetzner/kustomization.yaml +10 -0
- package/carbon/k8s/gitops/supabase/helm-release.yaml +66 -0
- package/carbon/k8s/gitops/supabase/helm-repository.yaml +18 -0
- package/carbon/k8s/gitops/supabase/kustomization.yaml +33 -0
- package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-cloudflare.yaml +51 -0
- package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-hetzner.yaml +59 -0
- package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-manual.yaml +43 -0
- package/carbon/k8s/infra/cert-manager-resources/kustomization.yaml +15 -0
- package/carbon/k8s/infra/kustomization.yaml +21 -0
- package/carbon/k8s/infra/traefik-crds/kustomization.yaml +10 -0
- package/carbon/k8s/overlays/local/configmap.yaml +23 -0
- package/carbon/k8s/overlays/local/ingressroute-studio.yaml +104 -0
- package/carbon/k8s/overlays/local/kustomization.yaml +215 -0
- package/carbon/k8s/overlays/local/namespace.yaml +8 -0
- package/carbon/k8s/overlays/local/secrets.yaml +32 -0
- package/carbon/k8s/overlays/production/kustomization.yaml +34 -0
- package/carbon/k8s/test-local.sh +318 -0
- package/carbon/k8s/values/supabase.values.yaml +133 -0
- package/carbon/package.json +154 -0
- package/carbon/runtime.Dockerfile +17 -0
- package/carbon/scripts/_dev-jwt.mjs +45 -0
- package/carbon/scripts/check-rls.ts +53 -0
- package/carbon/scripts/dev-init.js +191 -0
- package/carbon/scripts/dev.js +191 -0
- package/carbon/scripts/docker-down.js +63 -0
- package/carbon/scripts/docker-logs.js +59 -0
- package/carbon/scripts/docker-up.js +222 -0
- package/carbon/scripts/generate-dev-configs.sh +131 -0
- package/carbon/scripts/generate-rss.ts +116 -0
- package/carbon/scripts/generate-sitemap.ts +102 -0
- package/carbon/scripts/k8s-apply.js +71 -0
- package/carbon/scripts/k8s-delete.js +75 -0
- package/carbon/scripts/lib/manifest.js +176 -0
- package/carbon/scripts/secret-scan.mjs +278 -0
- package/carbon/scripts/validate-dev-configs.sh +101 -0
- package/carbon/src/client/App.tsx +202 -0
- package/carbon/src/client/assets/hyperformant-dark.svg +29 -0
- package/carbon/src/client/assets/hyperformant-light.svg +29 -0
- package/carbon/src/client/assets/logos/aider.svg +1 -0
- package/carbon/src/client/assets/logos/antigravity.svg +1 -0
- package/carbon/src/client/assets/logos/bolt.svg +1 -0
- package/carbon/src/client/assets/logos/claude-code.svg +1 -0
- package/carbon/src/client/assets/logos/copilot.svg +1 -0
- package/carbon/src/client/assets/logos/cursor.svg +1 -0
- package/carbon/src/client/assets/logos/gemini-cli.svg +1 -0
- package/carbon/src/client/assets/logos/lovable.svg +1 -0
- package/carbon/src/client/assets/logos/openai-codex.svg +1 -0
- package/carbon/src/client/assets/logos/v0.svg +1 -0
- package/carbon/src/client/assets/logos/windsurf.svg +1 -0
- package/carbon/src/client/assets/vibecarbon-icon.svg +19 -0
- package/carbon/src/client/assets/vibecarbon-logo-dark.svg +29 -0
- package/carbon/src/client/assets/vibecarbon-logo-light.svg +29 -0
- package/carbon/src/client/components/AIIntegrationSection.tsx +120 -0
- package/carbon/src/client/components/AppSidebar.tsx +760 -0
- package/carbon/src/client/components/ArchitectureSection.tsx +46 -0
- package/carbon/src/client/components/CTAFooter.tsx +59 -0
- package/carbon/src/client/components/ComparisonSection.tsx +132 -0
- package/carbon/src/client/components/ContentPanel.tsx +66 -0
- package/carbon/src/client/components/ContentSkeleton.tsx +21 -0
- package/carbon/src/client/components/ErrorBoundary.tsx +46 -0
- package/carbon/src/client/components/FAQSection.tsx +76 -0
- package/carbon/src/client/components/FileUpload.tsx +210 -0
- package/carbon/src/client/components/HeaderActions.tsx +17 -0
- package/carbon/src/client/components/Hero.tsx +608 -0
- package/carbon/src/client/components/ImpersonationBanner.tsx +31 -0
- package/carbon/src/client/components/LanguageSwitcher.tsx +67 -0
- package/carbon/src/client/components/Logo.tsx +87 -0
- package/carbon/src/client/components/LogoStrip.tsx +76 -0
- package/carbon/src/client/components/MetricsStrip.tsx +48 -0
- package/carbon/src/client/components/Nav.tsx +195 -0
- package/carbon/src/client/components/NewsletterSignup.tsx +147 -0
- package/carbon/src/client/components/NotificationDrawer.tsx +171 -0
- package/carbon/src/client/components/PageHeader.tsx +24 -0
- package/carbon/src/client/components/PlanGate.tsx +36 -0
- package/carbon/src/client/components/PricingSection.tsx +371 -0
- package/carbon/src/client/components/SEO.tsx +34 -0
- package/carbon/src/client/components/SmoothScroll.tsx +45 -0
- package/carbon/src/client/components/TechStackSection.tsx +165 -0
- package/carbon/src/client/components/WorkflowSection.tsx +604 -0
- package/carbon/src/client/components/admin/DockerLogs.tsx +276 -0
- package/carbon/src/client/components/admin/PerformanceMetrics.tsx +230 -0
- package/carbon/src/client/components/admin/ServiceCard.tsx +45 -0
- package/carbon/src/client/components/admin/ServicesStatus.tsx +296 -0
- package/carbon/src/client/components/architecture/ArchitectureDiagram.tsx +413 -0
- package/carbon/src/client/components/auth/AuthProvider.tsx +466 -0
- package/carbon/src/client/components/effects/FilmGrainOverlay.tsx +35 -0
- package/carbon/src/client/components/effects/FresnelEdge.tsx +49 -0
- package/carbon/src/client/components/effects/GlowTracker.tsx +46 -0
- package/carbon/src/client/components/effects/SparkBurst.tsx +145 -0
- package/carbon/src/client/components/effects/VGlowEffect.tsx +83 -0
- package/carbon/src/client/components/effects/index.ts +2 -0
- package/carbon/src/client/components/layouts/SidebarLayout.tsx +20 -0
- package/carbon/src/client/components/scroll/ScrollSection.tsx +76 -0
- package/carbon/src/client/components/scroll/ScrollytellingProvider.tsx +81 -0
- package/carbon/src/client/components/scroll/index.ts +2 -0
- package/carbon/src/client/components/ui/accordion.tsx +71 -0
- package/carbon/src/client/components/ui/alert-dialog.tsx +162 -0
- package/carbon/src/client/components/ui/alert.tsx +73 -0
- package/carbon/src/client/components/ui/aspect-ratio.tsx +22 -0
- package/carbon/src/client/components/ui/avatar.tsx +91 -0
- package/carbon/src/client/components/ui/badge.tsx +50 -0
- package/carbon/src/client/components/ui/breadcrumb.tsx +100 -0
- package/carbon/src/client/components/ui/button-group.tsx +78 -0
- package/carbon/src/client/components/ui/button.tsx +120 -0
- package/carbon/src/client/components/ui/calendar.tsx +182 -0
- package/carbon/src/client/components/ui/card.tsx +85 -0
- package/carbon/src/client/components/ui/carousel.tsx +227 -0
- package/carbon/src/client/components/ui/chart.tsx +357 -0
- package/carbon/src/client/components/ui/checkbox.tsx +27 -0
- package/carbon/src/client/components/ui/collapsible.tsx +15 -0
- package/carbon/src/client/components/ui/command.tsx +178 -0
- package/carbon/src/client/components/ui/context-menu.tsx +233 -0
- package/carbon/src/client/components/ui/dialog.tsx +132 -0
- package/carbon/src/client/components/ui/drawer.tsx +118 -0
- package/carbon/src/client/components/ui/dropdown-menu.tsx +242 -0
- package/carbon/src/client/components/ui/empty.tsx +94 -0
- package/carbon/src/client/components/ui/field.tsx +226 -0
- package/carbon/src/client/components/ui/hover-card.tsx +44 -0
- package/carbon/src/client/components/ui/input-group.tsx +146 -0
- package/carbon/src/client/components/ui/input-otp.tsx +83 -0
- package/carbon/src/client/components/ui/input.tsx +20 -0
- package/carbon/src/client/components/ui/item.tsx +188 -0
- package/carbon/src/client/components/ui/kbd.tsx +26 -0
- package/carbon/src/client/components/ui/label.tsx +21 -0
- package/carbon/src/client/components/ui/menubar.tsx +250 -0
- package/carbon/src/client/components/ui/navigation-menu.tsx +155 -0
- package/carbon/src/client/components/ui/pagination.tsx +102 -0
- package/carbon/src/client/components/ui/popover.tsx +75 -0
- package/carbon/src/client/components/ui/progress.tsx +66 -0
- package/carbon/src/client/components/ui/radio-group.tsx +36 -0
- package/carbon/src/client/components/ui/resizable.tsx +46 -0
- package/carbon/src/client/components/ui/scroll-area.tsx +48 -0
- package/carbon/src/client/components/ui/select.tsx +186 -0
- package/carbon/src/client/components/ui/separator.tsx +21 -0
- package/carbon/src/client/components/ui/sheet.tsx +124 -0
- package/carbon/src/client/components/ui/sidebar.tsx +702 -0
- package/carbon/src/client/components/ui/skeleton.tsx +13 -0
- package/carbon/src/client/components/ui/slider.tsx +57 -0
- package/carbon/src/client/components/ui/sonner.tsx +45 -0
- package/carbon/src/client/components/ui/spinner.tsx +15 -0
- package/carbon/src/client/components/ui/switch.tsx +30 -0
- package/carbon/src/client/components/ui/table.tsx +89 -0
- package/carbon/src/client/components/ui/tabs.tsx +73 -0
- package/carbon/src/client/components/ui/textarea.tsx +18 -0
- package/carbon/src/client/components/ui/toggle-group.tsx +87 -0
- package/carbon/src/client/components/ui/toggle.tsx +44 -0
- package/carbon/src/client/components/ui/tooltip.tsx +56 -0
- package/carbon/src/client/hooks/api/index.ts +14 -0
- package/carbon/src/client/hooks/api/useAuthSettings.ts +80 -0
- package/carbon/src/client/hooks/api/useSubscription.ts +87 -0
- package/carbon/src/client/hooks/use-mobile.ts +21 -0
- package/carbon/src/client/hooks/useMousePosition.ts +91 -0
- package/carbon/src/client/hooks/useNotifications.tsx +124 -0
- package/carbon/src/client/hooks/useOrganizationMembers.ts +127 -0
- package/carbon/src/client/hooks/useOrganizations.tsx +230 -0
- package/carbon/src/client/hooks/usePackageManager.ts +69 -0
- package/carbon/src/client/hooks/useReducedMotion.ts +20 -0
- package/carbon/src/client/hooks/useRunningServices.ts +114 -0
- package/carbon/src/client/hooks/useScrollProgress.ts +56 -0
- package/carbon/src/client/index.css +467 -0
- package/carbon/src/client/index.html +56 -0
- package/carbon/src/client/lib/admin-services.ts +151 -0
- package/carbon/src/client/lib/api.ts +32 -0
- package/carbon/src/client/lib/blog.ts +35 -0
- package/carbon/src/client/lib/changelog.ts +33 -0
- package/carbon/src/client/lib/docs-search.ts +101 -0
- package/carbon/src/client/lib/docs.ts +37 -0
- package/carbon/src/client/lib/i18n.ts +32 -0
- package/carbon/src/client/lib/supabase.ts +72 -0
- package/carbon/src/client/lib/tailwind-colors.ts +357 -0
- package/carbon/src/client/lib/theme.ts +117 -0
- package/carbon/src/client/lib/utils.ts +22 -0
- package/carbon/src/client/locales/de.json +529 -0
- package/carbon/src/client/locales/en.json +461 -0
- package/carbon/src/client/locales/es.json +529 -0
- package/carbon/src/client/locales/fr.json +529 -0
- package/carbon/src/client/locales/pt.json +529 -0
- package/carbon/src/client/main.tsx +56 -0
- package/carbon/src/client/mdx.d.ts +13 -0
- package/carbon/src/client/pages/ApiDocs.tsx +76 -0
- package/carbon/src/client/pages/AuthCallback.tsx +34 -0
- package/carbon/src/client/pages/Blog.tsx +167 -0
- package/carbon/src/client/pages/Changelog.tsx +171 -0
- package/carbon/src/client/pages/Charts.tsx +388 -0
- package/carbon/src/client/pages/Checkout.tsx +227 -0
- package/carbon/src/client/pages/Contact.tsx +174 -0
- package/carbon/src/client/pages/Dashboard.tsx +368 -0
- package/carbon/src/client/pages/Docs.tsx +372 -0
- package/carbon/src/client/pages/ForgotPassword.tsx +111 -0
- package/carbon/src/client/pages/Home.tsx +187 -0
- package/carbon/src/client/pages/Legal.tsx +100 -0
- package/carbon/src/client/pages/Login.tsx +408 -0
- package/carbon/src/client/pages/MFAVerify.tsx +156 -0
- package/carbon/src/client/pages/NotFound.tsx +21 -0
- package/carbon/src/client/pages/Onboarding.tsx +246 -0
- package/carbon/src/client/pages/ResetPassword.tsx +200 -0
- package/carbon/src/client/pages/UIComponents.tsx +390 -0
- package/carbon/src/client/pages/admin/ContactSubmissions.tsx +220 -0
- package/carbon/src/client/pages/admin/Dashboard.tsx +24 -0
- package/carbon/src/client/pages/admin/Infrastructure.tsx +257 -0
- package/carbon/src/client/pages/admin/Jobs.tsx +225 -0
- package/carbon/src/client/pages/admin/Logs.tsx +18 -0
- package/carbon/src/client/pages/admin/Newsletter.tsx +300 -0
- package/carbon/src/client/pages/admin/Notifications.tsx +603 -0
- package/carbon/src/client/pages/admin/Organizations.tsx +306 -0
- package/carbon/src/client/pages/admin/Settings.tsx +314 -0
- package/carbon/src/client/pages/admin/Theme.tsx +465 -0
- package/carbon/src/client/pages/admin/Users.tsx +365 -0
- package/carbon/src/client/pages/api-docs.css +156 -0
- package/carbon/src/client/pages/organizations/Details.tsx +200 -0
- package/carbon/src/client/pages/organizations/Members.tsx +402 -0
- package/carbon/src/client/pages/settings/Billing.tsx +473 -0
- package/carbon/src/client/pages/settings/Profile.tsx +160 -0
- package/carbon/src/client/pages/settings/Security.tsx +341 -0
- package/carbon/src/client/public/favicon.svg +19 -0
- package/carbon/src/client/public/robots.txt +8 -0
- package/carbon/src/server/billing/index.ts +104 -0
- package/carbon/src/server/billing/provider.ts +81 -0
- package/carbon/src/server/billing/providers/paddle.ts +314 -0
- package/carbon/src/server/billing/providers/polar.ts +325 -0
- package/carbon/src/server/billing/providers/stripe.ts +233 -0
- package/carbon/src/server/emails/templates.ts +116 -0
- package/carbon/src/server/index.ts +554 -0
- package/carbon/src/server/lib/auth.ts +6 -0
- package/carbon/src/server/lib/email.ts +64 -0
- package/carbon/src/server/lib/env.ts +112 -0
- package/carbon/src/server/lib/errors.ts +21 -0
- package/carbon/src/server/lib/logger.ts +17 -0
- package/carbon/src/server/lib/rate-limiter.ts +288 -0
- package/carbon/src/server/lib/request.ts +34 -0
- package/carbon/src/server/lib/stripe.ts +42 -0
- package/carbon/src/server/lib/supabase.ts +65 -0
- package/carbon/src/server/middleware/requirePlan.ts +80 -0
- package/carbon/src/server/routes/_internal/services-status.ts +958 -0
- package/carbon/src/server/routes/_internal/verify-role.ts +185 -0
- package/carbon/src/server/routes/health.ts +48 -0
- package/carbon/src/server/routes/v1/admin/contact.ts +128 -0
- package/carbon/src/server/routes/v1/admin/jobs.ts +171 -0
- package/carbon/src/server/routes/v1/admin/newsletter.ts +237 -0
- package/carbon/src/server/routes/v1/auth.ts +390 -0
- package/carbon/src/server/routes/v1/billing.ts +718 -0
- package/carbon/src/server/routes/v1/contact.ts +93 -0
- package/carbon/src/server/routes/v1/index.ts +1333 -0
- package/carbon/src/server/routes/v1/newsletter.ts +181 -0
- package/carbon/src/server/routes/v1/performance.ts +157 -0
- package/carbon/src/server/routes/v1/stats.ts +170 -0
- package/carbon/src/server/routes/v1/theme.ts +106 -0
- package/carbon/src/server/routes/webhooks/billing.ts +376 -0
- package/carbon/src/server/routes/webhooks/stripe.ts +276 -0
- package/carbon/src/server/types.ts +11 -0
- package/carbon/src/shared/pricing.ts +155 -0
- package/carbon/src/shared/types.ts +338 -0
- package/carbon/supabase/migrations/00001_init.sql +717 -0
- package/carbon/supabase/migrations/00002_theme_settings.sql +13 -0
- package/carbon/supabase/migrations/00003_pg_cron.sql +121 -0
- package/carbon/supabase/migrations/00004_contact_newsletter.sql +81 -0
- package/carbon/supabase/migrations/00005_localization_languages.sql +22 -0
- package/carbon/supabase/seed.sql +16 -0
- package/carbon/tests/_helpers/app.ts +45 -0
- package/carbon/tests/_helpers/env.ts +37 -0
- package/carbon/tests/_helpers/factories.ts +69 -0
- package/carbon/tests/_helpers/jwt.ts +23 -0
- package/carbon/tests/_helpers/setup-integration.ts +20 -0
- package/carbon/tests/_helpers/setup-rtl.ts +12 -0
- package/carbon/tests/component/ErrorBoundary.test.tsx +53 -0
- package/carbon/tests/component/use-auth-settings.test.tsx +119 -0
- package/carbon/tests/integration/server/routes/contact.test.ts +162 -0
- package/carbon/tests/integration/server/routes/health.test.ts +61 -0
- package/carbon/tests/structural/i18n-parity.test.ts +42 -0
- package/carbon/tests/unit/client/utils.test.ts +49 -0
- package/carbon/tests/unit/shared/pricing.test.ts +93 -0
- package/carbon/tsconfig.json +27 -0
- package/carbon/tsconfig.server.json +27 -0
- package/carbon/tsconfig.test.json +9 -0
- package/carbon/vite.config.ts +110 -0
- package/carbon/vitest.config.ts +74 -0
- package/carbon/volumes/db/jwt.sql +57 -0
- package/carbon/volumes/db/metabase-init.sh +29 -0
- package/carbon/volumes/db/n8n-init.sh +25 -0
- package/carbon/volumes/db/realtime.sql +33 -0
- package/carbon/volumes/db/roles.sql +93 -0
- package/carbon/volumes/db/set-passwords.sh +12 -0
- package/carbon/volumes/db/super-admin.dev.sql +113 -0
- package/carbon/volumes/db/super-admin.generated.sql +113 -0
- package/carbon/volumes/db/super-admin.sql +114 -0
- package/carbon/volumes/grafana/dashboards/logs.json +179 -0
- package/carbon/volumes/grafana/dashboards/overview.json +523 -0
- package/carbon/volumes/grafana/dashboards/postgresql.json +337 -0
- package/carbon/volumes/grafana/dashboards.dev/logs.json +179 -0
- package/carbon/volumes/grafana/dashboards.dev/overview.json +156 -0
- package/carbon/volumes/grafana/dashboards.dev/postgresql.json +337 -0
- package/carbon/volumes/grafana/provisioning/dashboards/dashboards.dev.yml +16 -0
- package/carbon/volumes/grafana/provisioning/dashboards/dashboards.yml +16 -0
- package/carbon/volumes/grafana/provisioning/datasources/datasources.yml +43 -0
- package/carbon/volumes/kong/docker-entrypoint.sh +9 -0
- package/carbon/volumes/kong/kong.yml +208 -0
- package/carbon/volumes/loki/loki-config.yml +58 -0
- package/carbon/volumes/n8n/hooks.js +131 -0
- package/carbon/volumes/n8n/scripts/setup.sh +66 -0
- package/carbon/volumes/prometheus/prometheus.yml +43 -0
- package/carbon/volumes/promtail/promtail-config.yml +64 -0
- package/carbon/volumes/traefik/middlewares.dev.yml +83 -0
- package/carbon/volumes/traefik/middlewares.yml +95 -0
- package/carbon/volumes/traefik/vite-dev.yml +20 -0
- package/package.json +95 -0
- package/src/access.js +354 -0
- package/src/activate.js +187 -0
- package/src/add.js +718 -0
- package/src/backup.js +786 -0
- package/src/cli.js +350 -0
- package/src/configure.js +967 -0
- package/src/console.js +155 -0
- package/src/create.js +1499 -0
- package/src/deploy.js +311 -0
- package/src/destroy.js +2033 -0
- package/src/diagnose.js +735 -0
- package/src/down.js +80 -0
- package/src/failover.js +1032 -0
- package/src/lib/backup-s3.js +179 -0
- package/src/lib/build.js +33 -0
- package/src/lib/checksum.js +28 -0
- package/src/lib/ci-setup.js +666 -0
- package/src/lib/cli/help.js +129 -0
- package/src/lib/cli/parse-flags.js +160 -0
- package/src/lib/cli/select-action.js +65 -0
- package/src/lib/cli/select-environment.js +108 -0
- package/src/lib/cli/tty-guard.js +75 -0
- package/src/lib/cloudflare.js +447 -0
- package/src/lib/colors.js +52 -0
- package/src/lib/command.js +361 -0
- package/src/lib/config.js +359 -0
- package/src/lib/cost.js +103 -0
- package/src/lib/deploy/bundle.js +231 -0
- package/src/lib/deploy/compose/acme-verify.js +121 -0
- package/src/lib/deploy/compose/build-args.js +78 -0
- package/src/lib/deploy/compose/ha.js +1874 -0
- package/src/lib/deploy/compose/index.js +1294 -0
- package/src/lib/deploy/compose/reconcile.js +74 -0
- package/src/lib/deploy/github.js +382 -0
- package/src/lib/deploy/image.js +191 -0
- package/src/lib/deploy/index.js +119 -0
- package/src/lib/deploy/k8s/LICENSE +99 -0
- package/src/lib/deploy/k8s/gitops-deploy.js +298 -0
- package/src/lib/deploy/k8s/ha/index.js +1000 -0
- package/src/lib/deploy/k8s/index.js +62 -0
- package/src/lib/deploy/k8s/k3s.js +2515 -0
- package/src/lib/deploy/orchestrator.js +1401 -0
- package/src/lib/deploy/prompts.js +545 -0
- package/src/lib/deploy/remote-build.js +130 -0
- package/src/lib/deploy/state.js +120 -0
- package/src/lib/deploy/utils.js +328 -0
- package/src/lib/deploy-logger.js +93 -0
- package/src/lib/dns-propagation.js +48 -0
- package/src/lib/environment.js +58 -0
- package/src/lib/fetch-retry.js +103 -0
- package/src/lib/github-environments.js +335 -0
- package/src/lib/hetzner-dns.js +377 -0
- package/src/lib/hetzner-guided-setup.js +275 -0
- package/src/lib/host-keys.js +37 -0
- package/src/lib/iac/cloud-init.js +52 -0
- package/src/lib/iac/index.js +325 -0
- package/src/lib/iac/programs/hetzner-compose.js +130 -0
- package/src/lib/iac/programs/hetzner-k8s.js +320 -0
- package/src/lib/kubectl.js +81 -0
- package/src/lib/licensing/index.js +259 -0
- package/src/lib/licensing/tiers.js +181 -0
- package/src/lib/licensing/validator.js +171 -0
- package/src/lib/merge-package-json.js +90 -0
- package/src/lib/operator-ip.js +381 -0
- package/src/lib/package-manager.js +111 -0
- package/src/lib/perf.js +71 -0
- package/src/lib/project-guard.js +39 -0
- package/src/lib/project.js +334 -0
- package/src/lib/providers/base.js +276 -0
- package/src/lib/providers/hetzner-s3.js +656 -0
- package/src/lib/providers/hetzner.js +755 -0
- package/src/lib/providers/index.js +164 -0
- package/src/lib/s3.js +510 -0
- package/src/lib/secret-scan.js +583 -0
- package/src/lib/secrets.js +63 -0
- package/src/lib/server-types.js +195 -0
- package/src/lib/shell.js +91 -0
- package/src/lib/ssh.js +241 -0
- package/src/lib/tracker.js +242 -0
- package/src/lib/upgrade-policy.js +170 -0
- package/src/lib/validators.js +105 -0
- package/src/lib/version.js +5 -0
- package/src/remove.js +292 -0
- package/src/reset.js +97 -0
- package/src/restore.js +871 -0
- package/src/scale.js +1734 -0
- package/src/shell.js +222 -0
- package/src/status.js +981 -0
- package/src/up.js +264 -0
- package/src/upgrade.js +721 -0
|
@@ -0,0 +1,1874 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker Compose HA Deployment Module
|
|
3
|
+
*
|
|
4
|
+
* Deploys Vibecarbon to 2 VPS in different regions with:
|
|
5
|
+
* - Docker Compose on both servers
|
|
6
|
+
* - PostgreSQL streaming replication (primary → standby)
|
|
7
|
+
* - Cloudflare health checks + failover (or manual DNS instructions)
|
|
8
|
+
*
|
|
9
|
+
* Available on Diamond and Fullerene tiers.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import * as p from '@clack/prompts';
|
|
15
|
+
import { setupHA as setupCloudflareHA } from '../../cloudflare.js';
|
|
16
|
+
import { c } from '../../colors.js';
|
|
17
|
+
import { runCommand } from '../../command.js';
|
|
18
|
+
import {
|
|
19
|
+
loadCredentials,
|
|
20
|
+
loadProjectConfig,
|
|
21
|
+
registerProject,
|
|
22
|
+
saveProjectConfig,
|
|
23
|
+
} from '../../config.js';
|
|
24
|
+
import { fetchWithRetry } from '../../fetch-retry.js';
|
|
25
|
+
import { perfAsync } from '../../perf.js';
|
|
26
|
+
import { getProvider } from '../../providers/index.js';
|
|
27
|
+
import {
|
|
28
|
+
createAdminUser,
|
|
29
|
+
dockerLoginOnServer,
|
|
30
|
+
isLocalOnlyImageTag,
|
|
31
|
+
pullComposeImages,
|
|
32
|
+
runMigrations,
|
|
33
|
+
setupServer,
|
|
34
|
+
setupServerFiles,
|
|
35
|
+
sshRunAsync,
|
|
36
|
+
startComposeStack,
|
|
37
|
+
waitForSSH,
|
|
38
|
+
} from './index.js';
|
|
39
|
+
|
|
40
|
+
const SSH_OPTS = '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Run a command on a remote server via SSH
|
|
44
|
+
*/
|
|
45
|
+
function sshRun(ip, sshKeyPath, command, options = {}) {
|
|
46
|
+
const { timeout = 120_000 } = options;
|
|
47
|
+
return runCommand(['ssh', ...SSH_OPTS.split(' '), '-i', sshKeyPath, `root@${ip}`, command], {
|
|
48
|
+
stdio: 'pipe',
|
|
49
|
+
timeout,
|
|
50
|
+
encoding: 'utf-8',
|
|
51
|
+
...options,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Look up a Hetzner firewall by exact name and DELETE it. Polls afterwards
|
|
57
|
+
* until the GET returns nothing (firewall actually gone) or the deadline
|
|
58
|
+
* lapses. Uses the `?name=<exact>` filter so the listing is one row deep
|
|
59
|
+
* — pagination (Hetzner default per_page=25) was the original bug: when
|
|
60
|
+
* 25+ leaked firewalls accumulated in the project, the project's firewalls
|
|
61
|
+
* weren't on page 1, the destroy filter found nothing, and the next deploy
|
|
62
|
+
* collided on "name is already used (uniqueness_error)".
|
|
63
|
+
*
|
|
64
|
+
* Returns true if the firewall was deleted (or never existed), false on
|
|
65
|
+
* timeout. Non-throwing — destroy paths swallow the failure but it's
|
|
66
|
+
* surfaced to the caller for verification.
|
|
67
|
+
*/
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the VPS set that destroyComposeHA should delete.
|
|
70
|
+
*
|
|
71
|
+
* envConfig.servers is the canonical source when deploy completed and wrote
|
|
72
|
+
* it back. But when deploy gets canceled after Pulumi creates the VMs and
|
|
73
|
+
* before envConfig is persisted (the 2026-05-16 compose-ha matrix stall at
|
|
74
|
+
* "Waiting for primary services to initialize"), .vibecarbon.json never
|
|
75
|
+
* gets the IDs — destroy then sees `servers: []`, deletes nothing, and the
|
|
76
|
+
* test runner's orphan sweep has to mop up the leak.
|
|
77
|
+
*
|
|
78
|
+
* Fall back to name-based discovery: the Pulumi program names servers
|
|
79
|
+
* deterministically as `${projectName}-${environment}-(primary|standby)`,
|
|
80
|
+
* so a fresh Hetzner lookup recovers what envConfig is missing. Mirrors
|
|
81
|
+
* the pattern already used for firewall cleanup below.
|
|
82
|
+
*
|
|
83
|
+
* Exported for unit-test access; in production it's called only from
|
|
84
|
+
* destroyComposeHA.
|
|
85
|
+
*
|
|
86
|
+
* @param {object} args
|
|
87
|
+
* @param {string} args.projectName
|
|
88
|
+
* @param {string} args.environment
|
|
89
|
+
* @param {object} args.envConfig
|
|
90
|
+
* @param {string|null} args.hetznerToken
|
|
91
|
+
* @param {object} args.hetznerHeaders
|
|
92
|
+
* @returns {Promise<Array<{id: number|string, name?: string, hetznerName?: string}>>}
|
|
93
|
+
*/
|
|
94
|
+
export async function resolveHaServers({
|
|
95
|
+
projectName,
|
|
96
|
+
environment,
|
|
97
|
+
envConfig,
|
|
98
|
+
hetznerToken,
|
|
99
|
+
hetznerHeaders,
|
|
100
|
+
}) {
|
|
101
|
+
const expectedNames = [
|
|
102
|
+
`${projectName}-${environment}-primary`,
|
|
103
|
+
`${projectName}-${environment}-standby`,
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
const knownById = new Map(
|
|
107
|
+
(envConfig?.servers || []).filter((s) => s?.id && hetznerToken).map((s) => [String(s.id), s]),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
if (!hetznerToken) return Array.from(knownById.values());
|
|
111
|
+
|
|
112
|
+
for (const name of expectedNames) {
|
|
113
|
+
const alreadyKnown = [...knownById.values()].some(
|
|
114
|
+
(s) => s.hetznerName === name || s.name === name,
|
|
115
|
+
);
|
|
116
|
+
if (alreadyKnown) continue;
|
|
117
|
+
try {
|
|
118
|
+
const resp = await fetchWithRetry(
|
|
119
|
+
`https://api.hetzner.cloud/v1/servers?name=${encodeURIComponent(name)}`,
|
|
120
|
+
{ headers: hetznerHeaders },
|
|
121
|
+
);
|
|
122
|
+
const { servers } = await resp.json();
|
|
123
|
+
for (const found of servers || []) {
|
|
124
|
+
knownById.set(String(found.id), { id: found.id, name, hetznerName: name });
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// Discovery is best-effort — keep moving with whatever envConfig had.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return Array.from(knownById.values());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function deleteFirewallByName(name, hetznerHeaders, deadlineMs = 30_000) {
|
|
135
|
+
const lookupUrl = `https://api.hetzner.cloud/v1/firewalls?name=${encodeURIComponent(name)}`;
|
|
136
|
+
const start = Date.now();
|
|
137
|
+
while (Date.now() - start < deadlineMs) {
|
|
138
|
+
const listResp = await fetchWithRetry(lookupUrl, { headers: hetznerHeaders });
|
|
139
|
+
const { firewalls } = await listResp.json();
|
|
140
|
+
const fw = firewalls?.[0];
|
|
141
|
+
if (!fw) return true;
|
|
142
|
+
if (fw.applied_to?.length > 0) {
|
|
143
|
+
const serverResources = fw.applied_to
|
|
144
|
+
.filter((a) => a.type === 'server' && a.server?.id)
|
|
145
|
+
.map((a) => ({ type: 'server', server: { id: a.server.id } }));
|
|
146
|
+
if (serverResources.length > 0) {
|
|
147
|
+
try {
|
|
148
|
+
await fetchWithRetry(
|
|
149
|
+
`https://api.hetzner.cloud/v1/firewalls/${fw.id}/actions/remove_from_resources`,
|
|
150
|
+
{
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers: { ...hetznerHeaders, 'Content-Type': 'application/json' },
|
|
153
|
+
body: JSON.stringify({ remove_from: serverResources }),
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
} catch {
|
|
157
|
+
// Servers may already be gone — proceed to DELETE anyway
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const delResp = await fetchWithRetry(`https://api.hetzner.cloud/v1/firewalls/${fw.id}`, {
|
|
162
|
+
method: 'DELETE',
|
|
163
|
+
headers: hetznerHeaders,
|
|
164
|
+
});
|
|
165
|
+
if (delResp.ok || delResp.status === 404) return true;
|
|
166
|
+
// 409 = still attached; back off and retry the whole lookup so we get
|
|
167
|
+
// a fresh applied_to list (servers may have finished detaching).
|
|
168
|
+
await new Promise((r) => setTimeout(r, 3_000));
|
|
169
|
+
}
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Read REPL_PASSWORD — process.env first (CI may export it), then .env.local
|
|
175
|
+
* (where `vibecarbon create` writes it at project-init time). Every HA code
|
|
176
|
+
* path funnels through this so one fix handles all three call sites that
|
|
177
|
+
* previously threw `REPL_PASSWORD is not set` when run without the env var.
|
|
178
|
+
* Returns null if the value is not present anywhere.
|
|
179
|
+
*/
|
|
180
|
+
function readReplPassword(cwd = process.cwd()) {
|
|
181
|
+
if (process.env.REPL_PASSWORD) return process.env.REPL_PASSWORD;
|
|
182
|
+
const envLocalPath = join(cwd, '.env.local');
|
|
183
|
+
if (!existsSync(envLocalPath)) return null;
|
|
184
|
+
const content = readFileSync(envLocalPath, 'utf-8');
|
|
185
|
+
// Accept both double-quoted (machine secrets) and single-quoted
|
|
186
|
+
// (escapeDotenv'd user secrets) forms, matching create.js output.
|
|
187
|
+
const m =
|
|
188
|
+
content.match(/^REPL_PASSWORD="([^"]+)"/m) || content.match(/^REPL_PASSWORD='([^']+)'/m);
|
|
189
|
+
return m ? m[1] : null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Generate SSH key pair for deployment
|
|
194
|
+
*/
|
|
195
|
+
function generateSSHKeyPair(sshKeyPath) {
|
|
196
|
+
if (!existsSync(sshKeyPath)) {
|
|
197
|
+
runCommand(
|
|
198
|
+
['ssh-keygen', '-t', 'ed25519', '-f', sshKeyPath, '-N', '', '-C', 'vibecarbon-deploy'],
|
|
199
|
+
{ stdio: 'pipe' },
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ============================================================================
|
|
205
|
+
// POSTGRESQL STREAMING REPLICATION SETUP
|
|
206
|
+
// ============================================================================
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Configure the primary PostgreSQL server for streaming replication.
|
|
210
|
+
*
|
|
211
|
+
* 1. Create a dedicated replication user
|
|
212
|
+
* 2. Allow the standby IP in pg_hba.conf
|
|
213
|
+
* 3. Set wal_level=replica and max_wal_senders
|
|
214
|
+
* 4. Restart PostgreSQL to apply changes
|
|
215
|
+
*/
|
|
216
|
+
function configurePrimaryReplication(primaryIp, standbyIp, sshKeyPath, projectName) {
|
|
217
|
+
const remoteDir = `/opt/${projectName}`;
|
|
218
|
+
// replPassword is generated at create time from crypto.randomBytes and is
|
|
219
|
+
// restricted to base64url characters — no shell escaping needed.
|
|
220
|
+
// Read from process.env first (CI), then .env.local (the `vibecarbon create`
|
|
221
|
+
// default write target). Without the .env.local fallback, HA deploys fail
|
|
222
|
+
// the moment the CLI is invoked without the env var pre-exported.
|
|
223
|
+
const replPassword = readReplPassword();
|
|
224
|
+
if (!replPassword) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
'REPL_PASSWORD is not set in process.env or .env.local. Regenerate your .env.local or run `vibecarbon configure` — HA deploys require a replication password generated at create time.',
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Create or update replication user + physical replication slot.
|
|
231
|
+
//
|
|
232
|
+
// Without a replication slot, the standby's WAL follow position isn't
|
|
233
|
+
// persisted on the primary — if the standby falls behind by more than
|
|
234
|
+
// `wal_keep_size` (512MB), primary recycles WAL segments and streaming
|
|
235
|
+
// breaks silently. The standby keeps appearing "up" but stops receiving
|
|
236
|
+
// updates. A physical slot (vibecarbon_standby_slot) tells primary to
|
|
237
|
+
// hold WAL until the standby confirms receipt, closing the drift
|
|
238
|
+
// window.
|
|
239
|
+
//
|
|
240
|
+
// The SQL is base64-encoded so the password never appears in argv —
|
|
241
|
+
// the remote shell only sees the base64 blob.
|
|
242
|
+
const createRoleSql = `
|
|
243
|
+
DO $$
|
|
244
|
+
BEGIN
|
|
245
|
+
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'replicator') THEN
|
|
246
|
+
CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD '${replPassword}';
|
|
247
|
+
ELSE
|
|
248
|
+
ALTER ROLE replicator WITH PASSWORD '${replPassword}';
|
|
249
|
+
END IF;
|
|
250
|
+
END $$;
|
|
251
|
+
|
|
252
|
+
-- Create the physical replication slot if missing. pg_create_physical_replication_slot
|
|
253
|
+
-- returns a row when successful, so wrap in a DO block that swallows the
|
|
254
|
+
-- 'already exists' error for idempotent reruns.
|
|
255
|
+
DO $$
|
|
256
|
+
BEGIN
|
|
257
|
+
IF NOT EXISTS (
|
|
258
|
+
SELECT 1 FROM pg_replication_slots WHERE slot_name = 'vibecarbon_standby_slot'
|
|
259
|
+
) THEN
|
|
260
|
+
PERFORM pg_create_physical_replication_slot('vibecarbon_standby_slot');
|
|
261
|
+
END IF;
|
|
262
|
+
END $$;
|
|
263
|
+
`;
|
|
264
|
+
const createRoleB64 = Buffer.from(createRoleSql).toString('base64');
|
|
265
|
+
sshRun(
|
|
266
|
+
primaryIp,
|
|
267
|
+
sshKeyPath,
|
|
268
|
+
`cd ${remoteDir} && echo ${createRoleB64} | base64 -d | docker compose exec -T db psql -U supabase_admin -d postgres`,
|
|
269
|
+
{ timeout: 30_000 },
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
// Update pg_hba.conf to allow replication from standby. The supabase/postgres
|
|
273
|
+
// image overrides hba_file to /etc/postgresql/pg_hba.conf (NOT the default
|
|
274
|
+
// /var/lib/postgresql/data/pg_hba.conf). RCA: compose-ha 2026-05-01 fanout12
|
|
275
|
+
// — appended to the wrong file, postgres never saw the rule, every
|
|
276
|
+
// replication connection was rejected with "no pg_hba.conf entry" while
|
|
277
|
+
// pg_hba_file_rules reflected the actual /etc loaded contents (no replicator
|
|
278
|
+
// entry). Resolve the path at runtime via SHOW hba_file so we don't break
|
|
279
|
+
// again if the image moves the file.
|
|
280
|
+
sshRun(
|
|
281
|
+
primaryIp,
|
|
282
|
+
sshKeyPath,
|
|
283
|
+
`cd ${remoteDir} && HBA_FILE=$(docker compose exec -T db psql -U supabase_admin -d postgres -tAc 'SHOW hba_file;' | tr -d '[:space:]') && ` +
|
|
284
|
+
`docker compose exec -T db sh -c "echo 'host replication replicator ${standbyIp}/32 md5' >> $HBA_FILE && ` +
|
|
285
|
+
`psql -U supabase_admin -d postgres -tAc 'SELECT pg_reload_conf();'"`,
|
|
286
|
+
{ timeout: 15_000 },
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
// Set WAL settings for replication
|
|
290
|
+
sshRun(
|
|
291
|
+
primaryIp,
|
|
292
|
+
sshKeyPath,
|
|
293
|
+
`cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres -c "ALTER SYSTEM SET wal_level = 'replica';" && docker compose exec -T db psql -U supabase_admin -d postgres -c "ALTER SYSTEM SET max_wal_senders = 5;" && docker compose exec -T db psql -U supabase_admin -d postgres -c "ALTER SYSTEM SET wal_keep_size = '512MB';"`,
|
|
294
|
+
{ timeout: 30_000 },
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
// Restart PostgreSQL to apply WAL changes
|
|
298
|
+
sshRun(primaryIp, sshKeyPath, `cd ${remoteDir} && docker compose restart db`, {
|
|
299
|
+
timeout: 60_000,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Wait for PostgreSQL to be ready again
|
|
303
|
+
for (let i = 0; i < 15; i++) {
|
|
304
|
+
try {
|
|
305
|
+
sshRun(
|
|
306
|
+
primaryIp,
|
|
307
|
+
sshKeyPath,
|
|
308
|
+
`cd ${remoteDir} && docker compose exec -T db pg_isready -U postgres`,
|
|
309
|
+
{ timeout: 10_000 },
|
|
310
|
+
);
|
|
311
|
+
return;
|
|
312
|
+
} catch {
|
|
313
|
+
// Retry
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Configure the standby PostgreSQL server as a hot standby replica.
|
|
320
|
+
*
|
|
321
|
+
* 1. Stop the standby database
|
|
322
|
+
* 2. Wipe the data directory and run pg_basebackup from the primary
|
|
323
|
+
* 3. Write standby.signal and configure primary_conninfo
|
|
324
|
+
* 4. Restart as hot standby
|
|
325
|
+
*/
|
|
326
|
+
async function configureStandbyReplication(standbyIp, primaryIp, sshKeyPath, projectName) {
|
|
327
|
+
const remoteDir = `/opt/${projectName}`;
|
|
328
|
+
// replPassword is generated at create time from crypto.randomBytes and is
|
|
329
|
+
// restricted to base64url characters — no shell escaping needed.
|
|
330
|
+
// Read from process.env first (CI), then .env.local (the `vibecarbon create`
|
|
331
|
+
// default write target). Without the .env.local fallback, HA deploys fail
|
|
332
|
+
// the moment the CLI is invoked without the env var pre-exported.
|
|
333
|
+
const replPassword = readReplPassword();
|
|
334
|
+
if (!replPassword) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
'REPL_PASSWORD is not set in process.env or .env.local. Regenerate your .env.local or run `vibecarbon configure` — HA deploys require a replication password generated at create time.',
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Stop the standby database
|
|
341
|
+
sshRun(standbyIp, sshKeyPath, `cd ${remoteDir} && docker compose stop db`, {
|
|
342
|
+
timeout: 60_000,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Run pg_basebackup from primary into standby's data directory.
|
|
346
|
+
//
|
|
347
|
+
// -Fp: plain format (writes directly to PGDATA)
|
|
348
|
+
// -Xs: stream WAL during backup
|
|
349
|
+
// -R: auto-create standby.signal and write primary_conninfo to postgresql.auto.conf
|
|
350
|
+
// -C --slot: use the physical replication slot created by
|
|
351
|
+
// configurePrimaryReplication. This makes the primary hold
|
|
352
|
+
// WAL for this standby specifically — so streaming stays
|
|
353
|
+
// caught up indefinitely instead of silently falling behind
|
|
354
|
+
// when wal_keep_size runs out.
|
|
355
|
+
//
|
|
356
|
+
// After pg_basebackup, append `primary_slot_name` to postgresql.auto.conf
|
|
357
|
+
// so the continuous-streaming connection (after initial backup) also uses
|
|
358
|
+
// the slot. `-R` writes primary_conninfo but not primary_slot_name.
|
|
359
|
+
//
|
|
360
|
+
// `set -e -o pipefail` is load-bearing: without it, a failed
|
|
361
|
+
// pg_basebackup leaves the data dir with only `postgresql.auto.conf`
|
|
362
|
+
// (because the trailing `>>` succeeds creating it from scratch), no
|
|
363
|
+
// `PG_VERSION`. The next `docker compose start db` then loops forever
|
|
364
|
+
// on `initdb: directory exists but is not empty` (RCA from compose-ha
|
|
365
|
+
// failover failure 2026-04-30). With `set -e`, basebackup failure
|
|
366
|
+
// exits the script non-zero, sshRun throws, the caller can react.
|
|
367
|
+
const basebackupScript = `
|
|
368
|
+
set -e -o pipefail
|
|
369
|
+
rm -rf /var/lib/postgresql/data/*
|
|
370
|
+
PGPASSWORD='${replPassword}' pg_basebackup -h ${primaryIp} -p 5433 -U replicator -D /var/lib/postgresql/data -Fp -Xs -P -R -S vibecarbon_standby_slot -v
|
|
371
|
+
# pg_basebackup -R is supposed to (a) create standby.signal and (b)
|
|
372
|
+
# write primary_conninfo to postgresql.auto.conf. RCA from compose-ha
|
|
373
|
+
# fanout9 2026-05-01: in supabase/postgres:15.8.1.085, neither happened
|
|
374
|
+
# — postgresql.auto.conf came back at 88 bytes (just the ALTER SYSTEM
|
|
375
|
+
# header) and standby.signal was missing. Postgres on next start saw
|
|
376
|
+
# no standby.signal, came up as PRIMARY, started archiving WAL via
|
|
377
|
+
# wal-g, and pg_is_in_recovery() returned 'f' for the entire 60s probe
|
|
378
|
+
# window. Write everything ourselves — don't trust -R.
|
|
379
|
+
touch /var/lib/postgresql/data/standby.signal
|
|
380
|
+
# Replace postgresql.auto.conf wholesale (don't append — the upstream
|
|
381
|
+
# image's first start can ALTER SYSTEM RESET ALL and wipe whatever -R
|
|
382
|
+
# claimed to write). Both primary_conninfo and primary_slot_name are
|
|
383
|
+
# load-bearing: missing primary_conninfo means recovery has nowhere
|
|
384
|
+
# to read WAL from, missing primary_slot_name means primary won't
|
|
385
|
+
# hold WAL for this standby and streaming silently falls behind.
|
|
386
|
+
cat > /var/lib/postgresql/data/postgresql.auto.conf <<EOF
|
|
387
|
+
# Do not edit this file manually!
|
|
388
|
+
# It will be overwritten by the ALTER SYSTEM command.
|
|
389
|
+
primary_conninfo = 'host=${primaryIp} port=5433 user=replicator password=${replPassword} application_name=standby'
|
|
390
|
+
primary_slot_name = 'vibecarbon_standby_slot'
|
|
391
|
+
EOF
|
|
392
|
+
chown -R postgres:postgres /var/lib/postgresql/data
|
|
393
|
+
# Final sanity checks. Each one is the postcondition for a different
|
|
394
|
+
# downstream failure mode: PG_VERSION → docker compose start initdb
|
|
395
|
+
# loop; standby.signal → postgres comes up as primary; primary_conninfo
|
|
396
|
+
# in auto.conf → recovery has no source.
|
|
397
|
+
test -f /var/lib/postgresql/data/PG_VERSION
|
|
398
|
+
test -f /var/lib/postgresql/data/standby.signal
|
|
399
|
+
grep -q '^primary_conninfo' /var/lib/postgresql/data/postgresql.auto.conf
|
|
400
|
+
grep -q '^primary_slot_name' /var/lib/postgresql/data/postgresql.auto.conf
|
|
401
|
+
`;
|
|
402
|
+
const basebackupB64 = Buffer.from(basebackupScript).toString('base64');
|
|
403
|
+
try {
|
|
404
|
+
sshRun(
|
|
405
|
+
standbyIp,
|
|
406
|
+
sshKeyPath,
|
|
407
|
+
// Use bash, not sh: the supabase/postgres image symlinks /bin/sh →
|
|
408
|
+
// dash, which rejects `set -o pipefail` with "Illegal option" and
|
|
409
|
+
// — because the offending line *is* the one that activates `set -e`
|
|
410
|
+
// — silently neuters every subsequent failure check (chown, grep,
|
|
411
|
+
// test). The basebackup itself still succeeds, but the "verify
|
|
412
|
+
// recovery conf is in place" sanity checks become no-ops, and a
|
|
413
|
+
// missing standby.signal / wiped postgresql.auto.conf goes
|
|
414
|
+
// undetected until pg_is_in_recovery() returns 'f' an entire
|
|
415
|
+
// basebackup later. RCA: compose-ha 2026-05-01 fanout10 failover.
|
|
416
|
+
`cd ${remoteDir} && echo ${basebackupB64} | base64 -d | docker compose run --rm -T --entrypoint bash db 2>&1`,
|
|
417
|
+
// silent:true makes sshRun THROW on non-zero exit instead of returning
|
|
418
|
+
// false. Without it, a failed basebackup (e.g. pg_hba.conf rejecting
|
|
419
|
+
// the connection) is silently swallowed: try-catch never enters, code
|
|
420
|
+
// proceeds to docker compose start db, probe loop times out, and the
|
|
421
|
+
// operator sees "did not enter recovery mode within 60s" with no clue
|
|
422
|
+
// that pg_basebackup never even ran. RCA: fanout12 2026-05-01.
|
|
423
|
+
{ timeout: 300_000, silent: true },
|
|
424
|
+
);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
// Surface the actual basebackup output. sshRun on failure attaches
|
|
427
|
+
// stdout to the error; without re-raising it deliberately the caller
|
|
428
|
+
// only sees "Command failed" and has to ssh in to root-cause.
|
|
429
|
+
const detail = err.stdout?.toString?.()?.trim?.() || err.message;
|
|
430
|
+
throw new Error(
|
|
431
|
+
`configureStandbyReplication: pg_basebackup failed on ${standbyIp}. ` +
|
|
432
|
+
`The standby data dir may now be in an inconsistent state ` +
|
|
433
|
+
`(non-empty without PG_VERSION) and require manual recovery.\n` +
|
|
434
|
+
`--- pg_basebackup output ---\n${detail}`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Start the standby database
|
|
439
|
+
sshRun(standbyIp, sshKeyPath, `cd ${remoteDir} && docker compose start db`, {
|
|
440
|
+
timeout: 60_000,
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// Verify the standby is in recovery mode.
|
|
444
|
+
//
|
|
445
|
+
// Two independent signals — either is sufficient:
|
|
446
|
+
// 1. standby's pg_is_in_recovery() returns 't' (requires hot_standby on
|
|
447
|
+
// AND consistent recovery state — can take 60-180s after fresh
|
|
448
|
+
// basebackup of a Supabase cluster, especially under fanout-3 load)
|
|
449
|
+
// 2. primary's pg_stat_replication has a row with client_addr matching
|
|
450
|
+
// this standby AND state in ('streaming','catchup','startup')
|
|
451
|
+
//
|
|
452
|
+
// The standby probe alone is fragile: with hot_standby still applying
|
|
453
|
+
// initial WAL, psql gets "Hot standby mode is disabled / database system
|
|
454
|
+
// is not accepting connections" — sshRun returns false (without
|
|
455
|
+
// silent:true), result becomes empty, the 60s budget burns through, and
|
|
456
|
+
// failover (which calls this function for re-seed) hard-fails with
|
|
457
|
+
// "did not enter recovery mode" even though the standby is fine.
|
|
458
|
+
//
|
|
459
|
+
// RCA: compose-ha 2026-05-01 fanout13 — replication was actively
|
|
460
|
+
// streaming (verifyReplication on the primary saw "Streaming replication
|
|
461
|
+
// active") but failover's re-seed still aborted because the standby's
|
|
462
|
+
// psql probe returned empty for the entire 60s. Bumped the budget to
|
|
463
|
+
// 180s and added the primary-side signal.
|
|
464
|
+
const PROBE_BUDGET_S = 180;
|
|
465
|
+
const PROBE_INTERVAL_S = 3;
|
|
466
|
+
let lastProbeResult = '';
|
|
467
|
+
let lastPrimarySignal = '';
|
|
468
|
+
for (let i = 0; i < PROBE_BUDGET_S / PROBE_INTERVAL_S; i++) {
|
|
469
|
+
try {
|
|
470
|
+
const result = sshRun(
|
|
471
|
+
standbyIp,
|
|
472
|
+
sshKeyPath,
|
|
473
|
+
`cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc 'SELECT pg_is_in_recovery()'`,
|
|
474
|
+
{ timeout: 10_000 },
|
|
475
|
+
);
|
|
476
|
+
lastProbeResult = typeof result === 'string' ? result.trim() : '';
|
|
477
|
+
if (lastProbeResult === 't') return true;
|
|
478
|
+
} catch {
|
|
479
|
+
// Retry — database may still be applying initial WAL, hot_standby off
|
|
480
|
+
}
|
|
481
|
+
try {
|
|
482
|
+
const primaryResult = sshRun(
|
|
483
|
+
primaryIp,
|
|
484
|
+
sshKeyPath,
|
|
485
|
+
`cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc "SELECT state FROM pg_stat_replication WHERE client_addr = '${standbyIp}'::inet"`,
|
|
486
|
+
{ timeout: 10_000 },
|
|
487
|
+
);
|
|
488
|
+
lastPrimarySignal = typeof primaryResult === 'string' ? primaryResult.trim() : '';
|
|
489
|
+
if (
|
|
490
|
+
lastPrimarySignal === 'streaming' ||
|
|
491
|
+
lastPrimarySignal === 'catchup' ||
|
|
492
|
+
lastPrimarySignal === 'startup'
|
|
493
|
+
) {
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
} catch {
|
|
497
|
+
// Retry
|
|
498
|
+
}
|
|
499
|
+
await new Promise((r) => setTimeout(r, PROBE_INTERVAL_S * 1000));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
throw new Error(
|
|
503
|
+
`configureStandbyReplication: standby ${standbyIp} did not enter recovery mode ` +
|
|
504
|
+
`within ${PROBE_BUDGET_S}s after pg_basebackup. Last standby pg_is_in_recovery() = ` +
|
|
505
|
+
`${JSON.stringify(lastProbeResult)}, last primary pg_stat_replication.state for this ` +
|
|
506
|
+
`standby = ${JSON.stringify(lastPrimarySignal)}. SSH in and check: docker compose exec ` +
|
|
507
|
+
`db psql -U supabase_admin -d postgres -tAc 'SELECT pg_is_in_recovery()' and ` +
|
|
508
|
+
`ls /var/lib/postgresql/data/standby.signal`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ============================================================================
|
|
513
|
+
// FIREWALL: OPEN REPLICATION PORT BETWEEN SERVERS
|
|
514
|
+
// ============================================================================
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Open PostgreSQL port (5432) between the two servers via UFW.
|
|
518
|
+
* Only allows traffic from the peer's IP — not from the public internet.
|
|
519
|
+
*/
|
|
520
|
+
/**
|
|
521
|
+
* Open the dedicated replication port between HA peers.
|
|
522
|
+
*
|
|
523
|
+
* Port 5433 maps directly to the db container's PostgreSQL (bypassing supavisor
|
|
524
|
+
* on port 5432). This is required because supavisor doesn't support PostgreSQL
|
|
525
|
+
* replication protocol, and pg_basebackup / streaming replication need a raw
|
|
526
|
+
* PostgreSQL connection.
|
|
527
|
+
*/
|
|
528
|
+
async function openReplicationPort(ip, peerIp, sshKeyPath) {
|
|
529
|
+
await sshRunAsync(ip, sshKeyPath, `ufw allow from ${peerIp} to any port 5433 proto tcp`, {
|
|
530
|
+
timeout: 15_000,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Add an `in tcp 5433` rule scoped to the peer's IP on a server's Hetzner Cloud
|
|
536
|
+
* firewall. UFW alone isn't enough — Hetzner Cloud Firewall sits in front of
|
|
537
|
+
* the host and blocks SYN before it ever reaches UFW. Without this, every
|
|
538
|
+
* pg_basebackup attempt from the peer hits a TCP `Connection timed out`,
|
|
539
|
+
* configureStandbyReplication's bash script (with set -e + bash) fails on
|
|
540
|
+
* pg_basebackup, the catch path throws "pg_basebackup failed", but historically
|
|
541
|
+
* the script ran under dash (no `set -e -o pipefail` support) and silently
|
|
542
|
+
* proceeded — so the warning surfaced two stages later as "did not enter
|
|
543
|
+
* recovery mode within 60s" with the standby actually running its first-init
|
|
544
|
+
* primary-mode state. RCA: compose-ha 2026-05-01 fanout11.
|
|
545
|
+
*
|
|
546
|
+
* The Pulumi-managed firewall (src/lib/iac/programs/hetzner-compose.js) only
|
|
547
|
+
* declares 22/80/443 because the standby's IP isn't known at primary stack-up
|
|
548
|
+
* time. Patch in 5433 here, after both stacks are up.
|
|
549
|
+
*
|
|
550
|
+
* Idempotent: skips if a matching rule already exists. Non-fatal on error —
|
|
551
|
+
* the deploy continues with a warning so the operator can iterate the rest of
|
|
552
|
+
* the scenario; HA replication just won't actually work until 5433 is open.
|
|
553
|
+
*/
|
|
554
|
+
async function openReplicationPortHetznerFirewall(serverName, peerIp, hetznerToken) {
|
|
555
|
+
if (!hetznerToken) return;
|
|
556
|
+
const firewallName = `${serverName}-firewall`;
|
|
557
|
+
const headers = {
|
|
558
|
+
Authorization: `Bearer ${hetznerToken}`,
|
|
559
|
+
'Content-Type': 'application/json',
|
|
560
|
+
};
|
|
561
|
+
const fwListRes = await fetchWithRetry(
|
|
562
|
+
`https://api.hetzner.cloud/v1/firewalls?name=${encodeURIComponent(firewallName)}`,
|
|
563
|
+
{ headers },
|
|
564
|
+
);
|
|
565
|
+
const fwListData = await fwListRes.json();
|
|
566
|
+
const firewall = fwListData.firewalls?.find((fw) => fw.name === firewallName);
|
|
567
|
+
if (!firewall) return;
|
|
568
|
+
const existingRules = firewall.rules || [];
|
|
569
|
+
const peerCidr = `${peerIp}/32`;
|
|
570
|
+
const hasReplRule = existingRules.some(
|
|
571
|
+
(r) => r.port === '5433' && r.source_ips?.includes(peerCidr),
|
|
572
|
+
);
|
|
573
|
+
if (hasReplRule) return;
|
|
574
|
+
const updatedRules = [
|
|
575
|
+
...existingRules,
|
|
576
|
+
{
|
|
577
|
+
direction: 'in',
|
|
578
|
+
protocol: 'tcp',
|
|
579
|
+
port: '5433',
|
|
580
|
+
source_ips: [peerCidr],
|
|
581
|
+
description: 'PostgreSQL replication from HA peer',
|
|
582
|
+
},
|
|
583
|
+
];
|
|
584
|
+
await fetchWithRetry(`https://api.hetzner.cloud/v1/firewalls/${firewall.id}/actions/set_rules`, {
|
|
585
|
+
method: 'POST',
|
|
586
|
+
headers,
|
|
587
|
+
body: JSON.stringify({ rules: updatedRules }),
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Check whether the primary server is reachable and its Postgres accepting
|
|
593
|
+
* connections. Short timeout — the failover flow needs to decide in seconds
|
|
594
|
+
* whether to attempt a re-seed or skip it and promote with current state.
|
|
595
|
+
*
|
|
596
|
+
* Returns true only when both the SSH connection works AND Postgres inside
|
|
597
|
+
* the db container reports ready. A firewall-level reachable + Postgres-
|
|
598
|
+
* unhealthy state should be treated the same as unreachable, since a
|
|
599
|
+
* re-seed would stall.
|
|
600
|
+
*/
|
|
601
|
+
async function isPrimaryReachable(primaryIp, sshKeyPath, remoteDir) {
|
|
602
|
+
try {
|
|
603
|
+
const result = sshRun(
|
|
604
|
+
primaryIp,
|
|
605
|
+
sshKeyPath,
|
|
606
|
+
`cd ${remoteDir} && docker compose exec -T db pg_isready -U postgres -t 3`,
|
|
607
|
+
{ timeout: 10_000 },
|
|
608
|
+
);
|
|
609
|
+
return result?.includes('accepting connections') ?? false;
|
|
610
|
+
} catch {
|
|
611
|
+
return false;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ============================================================================
|
|
616
|
+
// DEPLOY
|
|
617
|
+
// ============================================================================
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Deploy Docker Compose HA (2 VPS, 2 regions, PostgreSQL replication)
|
|
621
|
+
*
|
|
622
|
+
* @param {object} options
|
|
623
|
+
* @param {string} options.projectName
|
|
624
|
+
* @param {string} options.environment
|
|
625
|
+
* @param {string} options.sshKeyPath
|
|
626
|
+
* @param {string} options.domain
|
|
627
|
+
* @param {string} options.region - Primary region
|
|
628
|
+
* @param {string} options.serverType
|
|
629
|
+
* @param {string} options.apiToken - Hetzner API token
|
|
630
|
+
* @param {string} [options.cloudflareApiToken]
|
|
631
|
+
* @param {string} [options.cloudflareZoneId]
|
|
632
|
+
* @param {string} [options.dnsProvider]
|
|
633
|
+
* @param {object} [options.services]
|
|
634
|
+
* @param {object} [options.envConfig] - Existing environment config
|
|
635
|
+
* @param {Function} [options.onProgress]
|
|
636
|
+
*/
|
|
637
|
+
export async function deployComposeHA(options) {
|
|
638
|
+
const {
|
|
639
|
+
projectName,
|
|
640
|
+
environment,
|
|
641
|
+
sshKeyPath,
|
|
642
|
+
domain,
|
|
643
|
+
region,
|
|
644
|
+
secondaryRegion: requestedSecondaryRegion,
|
|
645
|
+
serverType = 'cpx21',
|
|
646
|
+
apiToken,
|
|
647
|
+
cloudflareApiToken,
|
|
648
|
+
cloudflareZoneId,
|
|
649
|
+
dnsProvider,
|
|
650
|
+
services = {},
|
|
651
|
+
envConfig = {},
|
|
652
|
+
dockerHubCreds = null,
|
|
653
|
+
ghcrPullCreds = null,
|
|
654
|
+
imageRef,
|
|
655
|
+
s3Config = null,
|
|
656
|
+
backupS3Config = null,
|
|
657
|
+
backupConfig = null,
|
|
658
|
+
bundlePath = null,
|
|
659
|
+
operatorCidrs = [],
|
|
660
|
+
onProgress = () => {},
|
|
661
|
+
} = options;
|
|
662
|
+
|
|
663
|
+
if (!imageRef) {
|
|
664
|
+
throw new Error(
|
|
665
|
+
'deployComposeHA requires options.imageRef (ghcr.io/<owner>/<repo>:<tag>) — build pipeline changed in Lever 1',
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const _provider = getProvider('hetzner', apiToken);
|
|
670
|
+
|
|
671
|
+
// Use the caller-specified secondary region, or pick the first region that differs from primary
|
|
672
|
+
const HETZNER_REGIONS = ['nbg1', 'hel1', 'fsn1'];
|
|
673
|
+
const standbyRegion =
|
|
674
|
+
requestedSecondaryRegion || HETZNER_REGIONS.find((r) => r !== region) || 'hel1';
|
|
675
|
+
|
|
676
|
+
// Generate SSH key pair
|
|
677
|
+
generateSSHKeyPair(sshKeyPath);
|
|
678
|
+
const publicKey = readFileSync(`${sshKeyPath}.pub`, 'utf-8').trim();
|
|
679
|
+
|
|
680
|
+
// Upload SSH key to Hetzner once, then share its ID across both Pulumi
|
|
681
|
+
// stacks — otherwise primary + standby race to create the same key and
|
|
682
|
+
// one side hits HTTP 409 "SSH key not unique" (same public-key bytes
|
|
683
|
+
// cannot be registered twice in a Hetzner project, regardless of name).
|
|
684
|
+
const sharedSshKeyName = `${projectName}-${environment}-key`;
|
|
685
|
+
const sharedSshKeyId = String(await _provider.createSSHKey(sharedSshKeyName, publicKey));
|
|
686
|
+
|
|
687
|
+
// Provision both VPS via Pulumi — one stack per cluster, concurrent up().
|
|
688
|
+
// Each stack declares SSH key + firewall + server in a single reconcile, so
|
|
689
|
+
// partial failures on one cluster are cleaned up automatically on re-run.
|
|
690
|
+
const { initBackend, upStack } = await import('../../iac/index.js');
|
|
691
|
+
const { buildHetznerComposeProgram } = await import('../../iac/programs/hetzner-compose.js');
|
|
692
|
+
onProgress(`Provisioning VPS in ${region} and ${standbyRegion}...`);
|
|
693
|
+
|
|
694
|
+
// Pre-initialize the Pulumi S3 backend before launching parallel stack ops.
|
|
695
|
+
// RCA from compose-ha iter-perfwave3 (2026-05-01): destroy deletes the S3
|
|
696
|
+
// bucket; restore re-creates it; the parallel upStack(primary)+upStack(standby)
|
|
697
|
+
// then race to `pulumi stack select` against a freshly-created bucket and
|
|
698
|
+
// both fail in <2s with `could not list bucket: blob (code=NotFound):
|
|
699
|
+
// NoSuchBucket` — Hetzner Ceph hasn't propagated the new bucket for list
|
|
700
|
+
// operations yet. K8s-HA already does this same pre-init for the same reason
|
|
701
|
+
// (src/lib/deploy/k8s/ha/index.js:201-207); compose-HA was missing the call.
|
|
702
|
+
await perfAsync('deploy.ha.compose.initBackend', () =>
|
|
703
|
+
initBackend({ s3Config, hcloudToken: apiToken }),
|
|
704
|
+
);
|
|
705
|
+
const allowedSshIps = operatorCidrs.map((e) => e.cidr);
|
|
706
|
+
const primaryProgram = buildHetznerComposeProgram({
|
|
707
|
+
projectName,
|
|
708
|
+
environment: `${environment}-primary`,
|
|
709
|
+
sshPublicKey: publicKey,
|
|
710
|
+
existingSshKeyId: sharedSshKeyId,
|
|
711
|
+
location: region,
|
|
712
|
+
serverType,
|
|
713
|
+
labels: { 'managed-by': 'vibecarbon', role: 'primary' },
|
|
714
|
+
allowedSshIps,
|
|
715
|
+
});
|
|
716
|
+
const standbyProgram = buildHetznerComposeProgram({
|
|
717
|
+
projectName,
|
|
718
|
+
environment: `${environment}-standby`,
|
|
719
|
+
sshPublicKey: publicKey,
|
|
720
|
+
existingSshKeyId: sharedSshKeyId,
|
|
721
|
+
location: standbyRegion,
|
|
722
|
+
serverType,
|
|
723
|
+
labels: { 'managed-by': 'vibecarbon', role: 'standby' },
|
|
724
|
+
allowedSshIps,
|
|
725
|
+
});
|
|
726
|
+
// Pass s3Config so Pulumi state lives in the project's S3 bucket rather
|
|
727
|
+
// than the GLOBAL local backend at ~/.vibecarbon/pulumi-state/. Without
|
|
728
|
+
// this, parallel test scenarios share a backend, and another scenario's
|
|
729
|
+
// orphan-detect path can find compose-ha's mid-deploy stacks and destroy
|
|
730
|
+
// them — observed 2026-04-26 batch run #5 where compose's final-destroy
|
|
731
|
+
// wiped compose-ha's e2-primary/e2-standby while compose-ha was still
|
|
732
|
+
// mid-flight in verify-deploy.
|
|
733
|
+
// Pulumi up of two stacks in parallel — typically the largest single
|
|
734
|
+
// wall-clock chunk in compose-ha deploy (~2-3 min cold; image+firewall
|
|
735
|
+
// creation in two regions concurrently). Per-stack timers expose which
|
|
736
|
+
// region/stack is the slow one in regression analysis.
|
|
737
|
+
const [primaryResult, standbyResult] = await perfAsync('deploy.ha.compose.iac.upStack.fan', () =>
|
|
738
|
+
Promise.all([
|
|
739
|
+
perfAsync('deploy.ha.compose.iac.upStack.primary', () =>
|
|
740
|
+
upStack(`${environment}-primary`, primaryProgram, { hcloudToken: apiToken, s3Config }),
|
|
741
|
+
),
|
|
742
|
+
perfAsync('deploy.ha.compose.iac.upStack.standby', () =>
|
|
743
|
+
upStack(`${environment}-standby`, standbyProgram, { hcloudToken: apiToken, s3Config }),
|
|
744
|
+
),
|
|
745
|
+
]),
|
|
746
|
+
);
|
|
747
|
+
const primaryIp = primaryResult.outputs.serverIp;
|
|
748
|
+
const primaryServerId = primaryResult.outputs.serverId;
|
|
749
|
+
const standbyIp = standbyResult.outputs.serverIp;
|
|
750
|
+
const standbyServerId = standbyResult.outputs.serverId;
|
|
751
|
+
|
|
752
|
+
p.log.success(`Primary: ${c.info(primaryIp)} (${region})`);
|
|
753
|
+
p.log.success(`Standby: ${c.info(standbyIp)} (${standbyRegion})`);
|
|
754
|
+
|
|
755
|
+
// Persist server info immediately so `destroy` can clean up if deploy fails later
|
|
756
|
+
const projectConfig = loadProjectConfig() || { projectName, environments: {} };
|
|
757
|
+
const pendingEnvConfig = {
|
|
758
|
+
...envConfig,
|
|
759
|
+
status: 'deploying',
|
|
760
|
+
deployMode: 'compose-ha',
|
|
761
|
+
provider: 'hetzner',
|
|
762
|
+
region,
|
|
763
|
+
secondaryRegion: standbyRegion,
|
|
764
|
+
serverType,
|
|
765
|
+
ha: { enabled: true, failoverRegion: standbyRegion },
|
|
766
|
+
servers: [
|
|
767
|
+
{
|
|
768
|
+
name: `${projectName}-${environment}-primary`,
|
|
769
|
+
id: primaryServerId,
|
|
770
|
+
ip: primaryIp,
|
|
771
|
+
region,
|
|
772
|
+
serverType,
|
|
773
|
+
role: 'primary',
|
|
774
|
+
},
|
|
775
|
+
{
|
|
776
|
+
name: `${projectName}-${environment}-standby`,
|
|
777
|
+
id: standbyServerId,
|
|
778
|
+
ip: standbyIp,
|
|
779
|
+
region: standbyRegion,
|
|
780
|
+
serverType,
|
|
781
|
+
role: 'standby',
|
|
782
|
+
},
|
|
783
|
+
],
|
|
784
|
+
domain: domain || null,
|
|
785
|
+
dnsProvider: dnsProvider || null,
|
|
786
|
+
dns:
|
|
787
|
+
dnsProvider === 'cloudflare' && cloudflareZoneId
|
|
788
|
+
? { provider: 'cloudflare', cloudflareZoneId }
|
|
789
|
+
: dnsProvider === 'hetzner' && options.hetznerDnsZoneId
|
|
790
|
+
? { provider: 'hetzner', hetznerZoneId: options.hetznerDnsZoneId }
|
|
791
|
+
: null,
|
|
792
|
+
// s3.accessKey is intentionally NOT persisted — the secret scanner
|
|
793
|
+
// gates every `vibecarbon deploy` invocation against tracked + untracked
|
|
794
|
+
// files in cwd, and .vibecarbon.json sits at the project root with
|
|
795
|
+
// neither a gitignore entry nor an isUniversalLocalEnv match. The
|
|
796
|
+
// orchestrator's skeleton save (src/lib/deploy/orchestrator.js:316)
|
|
797
|
+
// already omits accessKey; compose-ha was the lone outlier that wrote
|
|
798
|
+
// it back here, which is what made warm-deploy fast-fail with
|
|
799
|
+
// "Refusing to deploy: secrets detected" while single-region compose
|
|
800
|
+
// and both k8s modes passed. prompts.js:472-475 falls back to
|
|
801
|
+
// getS3Credentials() (reading ~/.vibecarbon/credentials.json) when
|
|
802
|
+
// envConfig.s3.accessKey is missing, so runtime still resolves.
|
|
803
|
+
...(s3Config && {
|
|
804
|
+
s3: {
|
|
805
|
+
bucket: s3Config.bucket,
|
|
806
|
+
region: s3Config.region,
|
|
807
|
+
endpoint: s3Config.endpoint,
|
|
808
|
+
},
|
|
809
|
+
}),
|
|
810
|
+
...(backupS3Config && { backupS3: backupS3Config }),
|
|
811
|
+
...(backupConfig && { backup: backupConfig }),
|
|
812
|
+
services,
|
|
813
|
+
lastAttempt: new Date().toISOString(),
|
|
814
|
+
};
|
|
815
|
+
saveProjectConfig({
|
|
816
|
+
...projectConfig,
|
|
817
|
+
provider: 'hetzner',
|
|
818
|
+
environments: {
|
|
819
|
+
...projectConfig.environments,
|
|
820
|
+
[environment]: pendingEnvConfig,
|
|
821
|
+
},
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
// Wait for SSH on both servers
|
|
825
|
+
onProgress('Waiting for servers to become available...');
|
|
826
|
+
const [primaryReady, standbyReady] = await perfAsync('deploy.ha.compose.waitForSSH.both', () =>
|
|
827
|
+
Promise.all([waitForSSH(primaryIp, sshKeyPath, 30), waitForSSH(standbyIp, sshKeyPath, 30)]),
|
|
828
|
+
);
|
|
829
|
+
|
|
830
|
+
if (!primaryReady || !standbyReady) {
|
|
831
|
+
throw new Error('Timed out waiting for SSH on one or both servers');
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Ensure Docker is running, configure firewall, enable auto-updates
|
|
835
|
+
onProgress('Configuring servers...');
|
|
836
|
+
await perfAsync('deploy.ha.compose.cloudInitReady.both', () =>
|
|
837
|
+
Promise.all([setupServer(primaryIp, sshKeyPath), setupServer(standbyIp, sshKeyPath)]),
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
// Wait for Docker daemon to be ready on both servers before logins/pulls/builds.
|
|
841
|
+
// dockerLoginOnServer can fire before Docker is listening, silently "succeed"
|
|
842
|
+
// (errors are swallowed with a warning), then reconcile.sh hits `unauthorized`
|
|
843
|
+
// when pulling from ghcr. The 03:55 UTC run's compose-ha failure was exactly
|
|
844
|
+
// this race on standby — primary completed login first, standby's login ran
|
|
845
|
+
// against a not-yet-ready dockerd, ghcr credentials never persisted, pull
|
|
846
|
+
// failed.
|
|
847
|
+
const { waitForDockerReady } = await import('./index.js');
|
|
848
|
+
await perfAsync('deploy.ha.compose.dockerReady.both', () =>
|
|
849
|
+
Promise.all([
|
|
850
|
+
waitForDockerReady(primaryIp, sshKeyPath),
|
|
851
|
+
waitForDockerReady(standbyIp, sshKeyPath),
|
|
852
|
+
]),
|
|
853
|
+
);
|
|
854
|
+
|
|
855
|
+
// --- Local-image build + sideload (HA) ---
|
|
856
|
+
// When the image tag is local-only (no `:` registry prefix), it was NOT
|
|
857
|
+
// pushed to a registry. The pre-PR-5 deploy gated this on `direct`; the
|
|
858
|
+
// CLI sweep collapsed `direct` and `local` build modes into a single
|
|
859
|
+
// auto-detected default and `direct` is now mostly false for compose-HA.
|
|
860
|
+
// The trigger that matters here is whether the image will be pullable
|
|
861
|
+
// from `docker compose up -d` on each server — which is purely a
|
|
862
|
+
// function of the tag. So we fire whenever the imageRef is local-only,
|
|
863
|
+
// regardless of how it got there. Without this sideload, standby's
|
|
864
|
+
// reconcile fails with `pull access denied for <project>-app, repository
|
|
865
|
+
// does not exist`.
|
|
866
|
+
//
|
|
867
|
+
// We build natively on BOTH servers in parallel (over DOCKER_HOST=ssh://)
|
|
868
|
+
// rather than building once locally and piping `docker save` cross-region.
|
|
869
|
+
// The previous docker-save-pipe hung at 1475s (= 24.6 min, past the
|
|
870
|
+
// deploy timeout) on the 2026-04-27 matrix when streaming a ~500MB tarball
|
|
871
|
+
// over a single TCP stream between Hetzner regions; per-region builds run
|
|
872
|
+
// in parallel against a tiny build context (~tens of KB) and the resulting
|
|
873
|
+
// image is byte-identical (same Dockerfile, same source). The same swap
|
|
874
|
+
// was made for compose scale; this completes it for compose-ha deploy.
|
|
875
|
+
if (isLocalOnlyImageTag(imageRef)) {
|
|
876
|
+
const { buildRemote } = await import('../remote-build.js');
|
|
877
|
+
// Plumb VITE_* build args through to docker build on each host so the
|
|
878
|
+
// browser bundle ships with real Supabase URL + project name etc.
|
|
879
|
+
// (mirrors the single-region fix in orchestrator.js; without these the
|
|
880
|
+
// bundle gets empty strings and the client throws "Missing Supabase
|
|
881
|
+
// environment variables" at page load).
|
|
882
|
+
const { collectComposeBuildArgs } = await import('./build-args.js');
|
|
883
|
+
const haBuildArgs = collectComposeBuildArgs(process.cwd(), { projectName, domain });
|
|
884
|
+
onProgress('Building image natively on primary + standby (parallel)...');
|
|
885
|
+
const [primaryOk, standbyOk] = await perfAsync('deploy.ha.compose.remoteBuild.fan', () =>
|
|
886
|
+
Promise.all([
|
|
887
|
+
buildRemote(primaryIp, sshKeyPath, imageRef, process.cwd(), haBuildArgs),
|
|
888
|
+
buildRemote(standbyIp, sshKeyPath, imageRef, process.cwd(), haBuildArgs),
|
|
889
|
+
]),
|
|
890
|
+
);
|
|
891
|
+
if (!primaryOk) throw new Error('Remote build on primary failed');
|
|
892
|
+
if (!standbyOk) throw new Error('Remote build on standby failed');
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Docker login and file copy — all independent, run in parallel
|
|
896
|
+
onProgress('Copying project files...');
|
|
897
|
+
const serviceOpts = {
|
|
898
|
+
domain,
|
|
899
|
+
image: imageRef,
|
|
900
|
+
observability: services.observability,
|
|
901
|
+
n8n: services.n8n,
|
|
902
|
+
metabase: services.metabase,
|
|
903
|
+
redis: services.redis,
|
|
904
|
+
bundlePath,
|
|
905
|
+
};
|
|
906
|
+
const setupTasks = [
|
|
907
|
+
perfAsync('deploy.ha.compose.setupFiles.primary', () =>
|
|
908
|
+
setupServerFiles(primaryIp, sshKeyPath, projectName, serviceOpts),
|
|
909
|
+
),
|
|
910
|
+
perfAsync('deploy.ha.compose.setupFiles.standby', () =>
|
|
911
|
+
setupServerFiles(standbyIp, sshKeyPath, projectName, serviceOpts),
|
|
912
|
+
),
|
|
913
|
+
perfAsync('deploy.ha.compose.openReplPort.primary', () =>
|
|
914
|
+
openReplicationPort(primaryIp, standbyIp, sshKeyPath),
|
|
915
|
+
),
|
|
916
|
+
perfAsync('deploy.ha.compose.openReplPort.standby', () =>
|
|
917
|
+
openReplicationPort(standbyIp, primaryIp, sshKeyPath),
|
|
918
|
+
),
|
|
919
|
+
// Open 5433 in the Hetzner Cloud firewall for both peers. UFW is the
|
|
920
|
+
// host-level layer and was already configured above; without these two
|
|
921
|
+
// calls the Cloud Firewall (Pulumi-declared with only 22/80/443) silently
|
|
922
|
+
// drops every replication packet at the edge. Both directions because
|
|
923
|
+
// post-failover the standby becomes primary and needs its own 5433 open
|
|
924
|
+
// for the new standby (now the old primary) to reconnect.
|
|
925
|
+
perfAsync('deploy.ha.compose.cloudFirewall5433.primary', () =>
|
|
926
|
+
openReplicationPortHetznerFirewall(
|
|
927
|
+
`${projectName}-${environment}-primary`,
|
|
928
|
+
standbyIp,
|
|
929
|
+
apiToken,
|
|
930
|
+
),
|
|
931
|
+
),
|
|
932
|
+
perfAsync('deploy.ha.compose.cloudFirewall5433.standby', () =>
|
|
933
|
+
openReplicationPortHetznerFirewall(
|
|
934
|
+
`${projectName}-${environment}-standby`,
|
|
935
|
+
primaryIp,
|
|
936
|
+
apiToken,
|
|
937
|
+
),
|
|
938
|
+
),
|
|
939
|
+
];
|
|
940
|
+
if (dockerHubCreds) {
|
|
941
|
+
setupTasks.push(
|
|
942
|
+
perfAsync('deploy.ha.compose.dockerLogin.primary', () =>
|
|
943
|
+
dockerLoginOnServer(primaryIp, sshKeyPath, dockerHubCreds),
|
|
944
|
+
),
|
|
945
|
+
perfAsync('deploy.ha.compose.dockerLogin.standby', () =>
|
|
946
|
+
dockerLoginOnServer(standbyIp, sshKeyPath, dockerHubCreds),
|
|
947
|
+
),
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
if (ghcrPullCreds?.owner && ghcrPullCreds?.token) {
|
|
951
|
+
const ghcrLogin = {
|
|
952
|
+
username: ghcrPullCreds.owner,
|
|
953
|
+
token: ghcrPullCreds.token,
|
|
954
|
+
registry: 'ghcr.io',
|
|
955
|
+
};
|
|
956
|
+
setupTasks.push(
|
|
957
|
+
perfAsync('deploy.ha.compose.ghcrLogin.primary', () =>
|
|
958
|
+
dockerLoginOnServer(primaryIp, sshKeyPath, ghcrLogin),
|
|
959
|
+
),
|
|
960
|
+
perfAsync('deploy.ha.compose.ghcrLogin.standby', () =>
|
|
961
|
+
dockerLoginOnServer(standbyIp, sshKeyPath, ghcrLogin),
|
|
962
|
+
),
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
await perfAsync('deploy.ha.compose.setupFan', () => Promise.all(setupTasks));
|
|
966
|
+
|
|
967
|
+
// Pull app + base images on both servers in parallel. No remote build —
|
|
968
|
+
// image comes from ghcr.io (CI-built, multi-arch manifest; each node
|
|
969
|
+
// pulls its arch). Each server is independent; no cross-cluster dep.
|
|
970
|
+
onProgress('Pulling images on both servers...');
|
|
971
|
+
await perfAsync('deploy.ha.compose.pullFan', () =>
|
|
972
|
+
Promise.all([
|
|
973
|
+
perfAsync('deploy.ha.compose.pullImages.primary', () =>
|
|
974
|
+
pullComposeImages(primaryIp, sshKeyPath, projectName, serviceOpts),
|
|
975
|
+
),
|
|
976
|
+
perfAsync('deploy.ha.compose.pullImages.standby', () =>
|
|
977
|
+
pullComposeImages(standbyIp, sshKeyPath, projectName, serviceOpts),
|
|
978
|
+
),
|
|
979
|
+
]),
|
|
980
|
+
);
|
|
981
|
+
|
|
982
|
+
// Set up DNS BEFORE starting services so Traefik's ACME challenges can succeed
|
|
983
|
+
// immediately (subdomains use DNS-only records that Let's Encrypt validates directly)
|
|
984
|
+
if (domain && cloudflareApiToken && cloudflareZoneId) {
|
|
985
|
+
onProgress('Setting up Cloudflare HA (health checks + load balancer)...');
|
|
986
|
+
try {
|
|
987
|
+
await perfAsync('deploy.ha.compose.dns.cloudflare', () =>
|
|
988
|
+
setupCloudflareHA(cloudflareApiToken, cloudflareZoneId, domain, [
|
|
989
|
+
{ name: `${projectName}-${environment}-primary`, ip: primaryIp, region },
|
|
990
|
+
{ name: `${projectName}-${environment}-standby`, ip: standbyIp, region: standbyRegion },
|
|
991
|
+
]),
|
|
992
|
+
);
|
|
993
|
+
} catch (error) {
|
|
994
|
+
p.log.warn(`Cloudflare HA setup failed: ${error.message}`);
|
|
995
|
+
p.log.info('You can configure DNS manually.');
|
|
996
|
+
}
|
|
997
|
+
} else if (domain && options.hetznerDnsZoneId && apiToken) {
|
|
998
|
+
onProgress('Setting up Hetzner DNS for HA...');
|
|
999
|
+
try {
|
|
1000
|
+
const { setupHA: setupHetznerDnsHA } = await import('../../hetzner-dns.js');
|
|
1001
|
+
await perfAsync('deploy.ha.compose.dns.hetzner', () =>
|
|
1002
|
+
setupHetznerDnsHA(apiToken, options.hetznerDnsZoneId, domain, [
|
|
1003
|
+
{ name: `${projectName}-${environment}-primary`, ip: primaryIp, region },
|
|
1004
|
+
{ name: `${projectName}-${environment}-standby`, ip: standbyIp, region: standbyRegion },
|
|
1005
|
+
]),
|
|
1006
|
+
);
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
p.log.warn(`Hetzner DNS HA setup failed: ${error.message}`);
|
|
1009
|
+
p.log.info('You can configure DNS manually.');
|
|
1010
|
+
}
|
|
1011
|
+
} else if (domain && dnsProvider === 'manual') {
|
|
1012
|
+
p.log.info(c.bold('Manual DNS Configuration:'));
|
|
1013
|
+
p.log.message(` Point ${c.bold(domain)} to ${c.bold(primaryIp)} (primary)`);
|
|
1014
|
+
p.log.message(` Standby server: ${c.bold(standbyIp)} (for manual failover)`);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Block compose-up until public resolvers see the apex domain. Traefik's
|
|
1018
|
+
// ACME burst (~7 attempts in ~30s) fails if LE can't resolve the host
|
|
1019
|
+
// when challenges fire. HA has historically hidden this race by accident
|
|
1020
|
+
// because there's substantial setup work between the DNS write above and
|
|
1021
|
+
// compose-up below; making the wait explicit eliminates the surprise on
|
|
1022
|
+
// future deploys that shrink that gap. Same fix lives in orchestrator.js
|
|
1023
|
+
// for single-region compose where the race was actively biting (RCA from
|
|
1024
|
+
// vibecarbon.com cold-deploy 2026-05-19).
|
|
1025
|
+
if (domain && dnsProvider && dnsProvider !== 'manual') {
|
|
1026
|
+
// Surface this in the enclosing tracker spinner so the operator sees
|
|
1027
|
+
// why we're waiting (up to 120s on a cold edge). Without the message
|
|
1028
|
+
// the HA spinner sits on the prior "Configuring servers..." text for
|
|
1029
|
+
// potentially two minutes, looking hung.
|
|
1030
|
+
onProgress(`Waiting for ${domain} to propagate to ${primaryIp}...`);
|
|
1031
|
+
const { waitForDNSPropagation } = await import('../../dns-propagation.js');
|
|
1032
|
+
await perfAsync('deploy.ha.compose.dns.waitForPropagation', () =>
|
|
1033
|
+
waitForDNSPropagation(domain, primaryIp, 120_000),
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Start compose stacks on both servers in parallel
|
|
1038
|
+
onProgress('Starting services on both servers...');
|
|
1039
|
+
await perfAsync('deploy.ha.compose.composeUp.both', () =>
|
|
1040
|
+
Promise.all([
|
|
1041
|
+
startComposeStack(primaryIp, sshKeyPath, projectName, serviceOpts),
|
|
1042
|
+
startComposeStack(standbyIp, sshKeyPath, projectName, serviceOpts),
|
|
1043
|
+
]),
|
|
1044
|
+
);
|
|
1045
|
+
|
|
1046
|
+
// Run migrations on primary only
|
|
1047
|
+
onProgress('Running database migrations on primary...');
|
|
1048
|
+
try {
|
|
1049
|
+
await perfAsync('deploy.ha.compose.migrations', () =>
|
|
1050
|
+
runMigrations(primaryIp, sshKeyPath, projectName),
|
|
1051
|
+
);
|
|
1052
|
+
} catch {
|
|
1053
|
+
// Migrations may partially fail on first run, non-fatal
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// Create admin user in production Supabase (same as single-server deploy)
|
|
1057
|
+
onProgress('Creating admin user...');
|
|
1058
|
+
const adminResult = await perfAsync('deploy.ha.compose.createAdminUser', () =>
|
|
1059
|
+
createAdminUser(primaryIp, sshKeyPath, projectName),
|
|
1060
|
+
);
|
|
1061
|
+
if (adminResult.success) {
|
|
1062
|
+
p.log.success(adminResult.message);
|
|
1063
|
+
} else {
|
|
1064
|
+
p.log.warn(`${adminResult.message}. Run \`vibecarbon deploy\` again to retry.`);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Wait for primary Postgres to accept connections before configuring
|
|
1068
|
+
// replication. pg_basebackup needs the PG socket reachable; it doesn't
|
|
1069
|
+
// need GoTrue/Storage to have finished their own migrations.
|
|
1070
|
+
//
|
|
1071
|
+
// Earlier versions of this loop polled GoTrue's /health endpoint on the
|
|
1072
|
+
// assumption that pg_basebackup running before app migrations completed
|
|
1073
|
+
// would leave the standby with an incomplete schema. That's a
|
|
1074
|
+
// misreading of how physical replication works: pg_basebackup takes a
|
|
1075
|
+
// consistent snapshot at LSN T_0 and the standby replays the WAL stream
|
|
1076
|
+
// from T_0 onwards via the replication slot, so any GoTrue/Storage
|
|
1077
|
+
// migrations committed after the snapshot land on the standby
|
|
1078
|
+
// automatically. The previous run (2026-05-18) measured the GoTrue
|
|
1079
|
+
// probe at 2:25 wall-clock on a healthy primary; swapping to
|
|
1080
|
+
// pg_isready collapses that to <10s.
|
|
1081
|
+
//
|
|
1082
|
+
// Wrapped in perfAsync so the distribution stays visible in
|
|
1083
|
+
// perf_substep — same metric name as before for trend continuity, even
|
|
1084
|
+
// though the probe semantics have tightened.
|
|
1085
|
+
onProgress('Waiting for primary Postgres to accept connections...');
|
|
1086
|
+
const healthConfirmed = await perfAsync('deploy.ha.compose.primaryHealthProbe', async () => {
|
|
1087
|
+
for (let i = 0; i < 30; i++) {
|
|
1088
|
+
try {
|
|
1089
|
+
const health = sshRun(
|
|
1090
|
+
primaryIp,
|
|
1091
|
+
sshKeyPath,
|
|
1092
|
+
`cd /opt/${projectName} && docker compose exec -T db pg_isready -h localhost -p 5432 2>/dev/null`,
|
|
1093
|
+
{ timeout: 10_000 },
|
|
1094
|
+
);
|
|
1095
|
+
if (health?.includes('accepting connections')) return true;
|
|
1096
|
+
} catch {
|
|
1097
|
+
// Retry — non-zero exit from pg_isready (rejecting / no response)
|
|
1098
|
+
// bubbles up as a thrown error from sshRun; same handling either way.
|
|
1099
|
+
}
|
|
1100
|
+
if (i === 29) break;
|
|
1101
|
+
const ms = i < 5 ? 1000 : 3000;
|
|
1102
|
+
await new Promise((r) => setTimeout(r, ms));
|
|
1103
|
+
}
|
|
1104
|
+
return false;
|
|
1105
|
+
});
|
|
1106
|
+
if (!healthConfirmed) {
|
|
1107
|
+
// Breadcrumb so a downstream replication failure isn't surprising —
|
|
1108
|
+
// the 2026-05-16 e2e matrix's compose-ha cancel left the deploy
|
|
1109
|
+
// log with no signal that the wait had given up unconfirmed before
|
|
1110
|
+
// the SSH retries downstream blew up on a flaky local network.
|
|
1111
|
+
// Proceeding anyway: PG may still come up in time for the
|
|
1112
|
+
// replication step, and hard-failing here would mask genuine
|
|
1113
|
+
// late-init successes.
|
|
1114
|
+
console.error(
|
|
1115
|
+
`[ha] primary Postgres did not accept connections within ~6min; proceeding optimistically — configurePrimaryReplication is the next failure surface if PG is truly dead`,
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// Expose raw PostgreSQL on port 5433 for replication (supavisor owns 5432).
|
|
1120
|
+
// Add a docker-compose.replication.yml overlay that maps db:5432 → host:5433.
|
|
1121
|
+
// Fan out across primary + standby — the overlay write + db-only `compose up`
|
|
1122
|
+
// touch disjoint VPS hosts and need to land on both before
|
|
1123
|
+
// configurePrimaryReplication grants pub access. Previously serial via a
|
|
1124
|
+
// for-loop using sync sshRun (~4 ssh calls × 2-3s each = ~8-12s blocking the
|
|
1125
|
+
// event loop); Promise.all halves wall-clock and unblocks any concurrent
|
|
1126
|
+
// perfAsync timers from being charged for the dead time.
|
|
1127
|
+
const replOverlay = `version: "3.8"\nservices:\n db:\n ports:\n - "5433:5432"\n`;
|
|
1128
|
+
await Promise.all(
|
|
1129
|
+
[primaryIp, standbyIp].map(async (ip) => {
|
|
1130
|
+
await sshRunAsync(
|
|
1131
|
+
ip,
|
|
1132
|
+
sshKeyPath,
|
|
1133
|
+
`cat > /opt/${projectName}/docker-compose.replication.yml << 'REPL'\n${replOverlay}REPL`,
|
|
1134
|
+
{ timeout: 10_000 },
|
|
1135
|
+
);
|
|
1136
|
+
// Recreate only the db service with the new port mapping
|
|
1137
|
+
await sshRunAsync(
|
|
1138
|
+
ip,
|
|
1139
|
+
sshKeyPath,
|
|
1140
|
+
`cd /opt/${projectName} && docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.replication.yml up -d db 2>&1`,
|
|
1141
|
+
{ timeout: 60_000 },
|
|
1142
|
+
);
|
|
1143
|
+
}),
|
|
1144
|
+
);
|
|
1145
|
+
|
|
1146
|
+
// Configure PostgreSQL streaming replication.
|
|
1147
|
+
// Wrap the entire setup (primary + standby + slot creation + pg_basebackup)
|
|
1148
|
+
// so the perf trace shows the headline replication cost — typical 30-90s,
|
|
1149
|
+
// dominated by pg_basebackup for the standby seed.
|
|
1150
|
+
await perfAsync('deploy.ha.replication.setup', async () => {
|
|
1151
|
+
onProgress('Configuring PostgreSQL replication on primary...');
|
|
1152
|
+
configurePrimaryReplication(primaryIp, standbyIp, sshKeyPath, projectName);
|
|
1153
|
+
|
|
1154
|
+
onProgress('Configuring standby as hot replica...');
|
|
1155
|
+
let replicationOk = false;
|
|
1156
|
+
try {
|
|
1157
|
+
replicationOk = await configureStandbyReplication(
|
|
1158
|
+
standbyIp,
|
|
1159
|
+
primaryIp,
|
|
1160
|
+
sshKeyPath,
|
|
1161
|
+
projectName,
|
|
1162
|
+
);
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
// Soft-fail in deploy: replication has been chronically broken
|
|
1165
|
+
// (see project_replication_broken.md) and hard-failing every
|
|
1166
|
+
// compose-ha deploy here would block iteration on the rest of
|
|
1167
|
+
// the scenario. Surface the captured pg_basebackup output so
|
|
1168
|
+
// we can root-cause it, then continue with a clear warning.
|
|
1169
|
+
// Failover-path still hard-fails (see line ~1185) so we don't
|
|
1170
|
+
// cascade into a data-wipe + CrashLoopBackOff there.
|
|
1171
|
+
p.log.warn(`Replication setup error (continuing with warning): ${err.message}`);
|
|
1172
|
+
}
|
|
1173
|
+
if (!replicationOk) {
|
|
1174
|
+
p.log.warn('Replication setup may not have completed — verify manually');
|
|
1175
|
+
}
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
// Verify streaming replication is active on the primary. Once the standby
|
|
1179
|
+
// catches up, pg_stat_replication.state flips to 'streaming' usually
|
|
1180
|
+
// within 1-2s of standby start. Exponential pacing instead of the old
|
|
1181
|
+
// hardcoded 1s/3s shell-out: fast happy path (probe at 250ms, 750ms,
|
|
1182
|
+
// 1.75s, …) without giving up too early under load. Total budget ~25s.
|
|
1183
|
+
onProgress('Verifying replication...');
|
|
1184
|
+
const REPL_DELAYS_MS = [250, 500, 1000, 2000, 3000, 3000, 3000, 3000, 3000, 3000];
|
|
1185
|
+
let replActive = false;
|
|
1186
|
+
for (let i = 0; i < REPL_DELAYS_MS.length; i++) {
|
|
1187
|
+
try {
|
|
1188
|
+
const count = sshRun(
|
|
1189
|
+
primaryIp,
|
|
1190
|
+
sshKeyPath,
|
|
1191
|
+
`cd /opt/${projectName} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc "SELECT count(*) FROM pg_stat_replication WHERE state = 'streaming'"`,
|
|
1192
|
+
{ timeout: 10_000 },
|
|
1193
|
+
);
|
|
1194
|
+
if (Number.parseInt(count?.trim(), 10) > 0) {
|
|
1195
|
+
replActive = true;
|
|
1196
|
+
break;
|
|
1197
|
+
}
|
|
1198
|
+
} catch {
|
|
1199
|
+
// Retry
|
|
1200
|
+
}
|
|
1201
|
+
if (i === REPL_DELAYS_MS.length - 1) break;
|
|
1202
|
+
await new Promise((r) => setTimeout(r, REPL_DELAYS_MS[i]));
|
|
1203
|
+
}
|
|
1204
|
+
if (replActive) {
|
|
1205
|
+
p.log.success('Streaming replication active');
|
|
1206
|
+
} else {
|
|
1207
|
+
p.log.warn('Streaming replication not detected — standby may fall behind');
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Finalize config — promote pending → deployed
|
|
1211
|
+
saveProjectConfig({
|
|
1212
|
+
...projectConfig,
|
|
1213
|
+
provider: 'hetzner',
|
|
1214
|
+
environments: {
|
|
1215
|
+
...projectConfig.environments,
|
|
1216
|
+
[environment]: {
|
|
1217
|
+
...pendingEnvConfig,
|
|
1218
|
+
status: 'deployed',
|
|
1219
|
+
deployedAt: new Date().toISOString(),
|
|
1220
|
+
lastAttempt: undefined,
|
|
1221
|
+
},
|
|
1222
|
+
},
|
|
1223
|
+
});
|
|
1224
|
+
registerProject(projectName, process.cwd());
|
|
1225
|
+
|
|
1226
|
+
// Summary
|
|
1227
|
+
p.log.success('');
|
|
1228
|
+
if (domain) {
|
|
1229
|
+
p.log.message(` ${c.dim('URL')} ${c.info(`https://${domain}`)}`);
|
|
1230
|
+
}
|
|
1231
|
+
p.log.message(` ${c.dim('Primary')} ${c.info(primaryIp)} (${region})`);
|
|
1232
|
+
p.log.message(` ${c.dim('Standby')} ${c.info(standbyIp)} (${standbyRegion})`);
|
|
1233
|
+
p.log.message(` ${c.dim('Deploy method')} Docker Compose HA`);
|
|
1234
|
+
p.log.message(` ${c.dim('Replication')} PostgreSQL streaming`);
|
|
1235
|
+
p.log.message('');
|
|
1236
|
+
|
|
1237
|
+
return {
|
|
1238
|
+
success: true,
|
|
1239
|
+
primaryIp,
|
|
1240
|
+
standbyIp,
|
|
1241
|
+
domain,
|
|
1242
|
+
deployMode: 'compose-ha',
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// ============================================================================
|
|
1247
|
+
// DESTROY
|
|
1248
|
+
// ============================================================================
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Destroy a Compose HA environment (both VPS, firewalls, SSH keys, DNS/LB)
|
|
1252
|
+
*
|
|
1253
|
+
* @param {object} options
|
|
1254
|
+
* @param {string} options.projectName
|
|
1255
|
+
* @param {string} options.environment
|
|
1256
|
+
* @param {object} options.envConfig
|
|
1257
|
+
* @param {string} options.hetznerToken
|
|
1258
|
+
* @param {string} [options.cloudflareToken]
|
|
1259
|
+
* @param {Function} [options.onProgress]
|
|
1260
|
+
*/
|
|
1261
|
+
export async function destroyComposeHA(options) {
|
|
1262
|
+
const {
|
|
1263
|
+
projectName,
|
|
1264
|
+
environment,
|
|
1265
|
+
envConfig,
|
|
1266
|
+
hetznerToken,
|
|
1267
|
+
cloudflareToken,
|
|
1268
|
+
onProgress = () => {},
|
|
1269
|
+
} = options;
|
|
1270
|
+
|
|
1271
|
+
const sshKeyPath = join(process.cwd(), '.vibecarbon', `deploy_key_${environment}`);
|
|
1272
|
+
const hetznerHeaders = { Authorization: `Bearer ${hetznerToken}` };
|
|
1273
|
+
|
|
1274
|
+
// Stop compose services on both servers in parallel
|
|
1275
|
+
onProgress('Stopping services on all servers...');
|
|
1276
|
+
if (existsSync(sshKeyPath)) {
|
|
1277
|
+
await Promise.allSettled(
|
|
1278
|
+
(envConfig.servers || [])
|
|
1279
|
+
.filter((s) => s.ip)
|
|
1280
|
+
.map((server) =>
|
|
1281
|
+
sshRunAsync(
|
|
1282
|
+
server.ip,
|
|
1283
|
+
sshKeyPath,
|
|
1284
|
+
`cd /opt/${projectName} && docker compose down -v --remove-orphans 2>/dev/null; rm -rf /opt/${projectName}`,
|
|
1285
|
+
{ timeout: 120_000, ignoreError: true },
|
|
1286
|
+
),
|
|
1287
|
+
),
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// Delete both VPS in parallel.
|
|
1292
|
+
//
|
|
1293
|
+
// Hetzner's DELETE /servers/{id} is async: it returns 202 immediately
|
|
1294
|
+
// and the actual VM removal continues for several seconds afterwards.
|
|
1295
|
+
// The previous code only awaited the 202 — Pulumi state was wiped
|
|
1296
|
+
// moments later when the S3 bucket got deleted, then compose-ha
|
|
1297
|
+
// restore re-ran the deploy with a fresh Pulumi stack and tried to
|
|
1298
|
+
// create a server with the same `${projectName}-${environment}-primary`
|
|
1299
|
+
// name. Hetzner rejected with HTTP 409 'server name is already used
|
|
1300
|
+
// (uniqueness_error)' because the old VM was still draining
|
|
1301
|
+
// (observed in compose-ha restore matrix runs).
|
|
1302
|
+
//
|
|
1303
|
+
// Issue DELETE first, then poll GET /servers/{id} on each one until
|
|
1304
|
+
// it returns 404. 90s deadline is comfortable: empirical Hetzner
|
|
1305
|
+
// delete time is 5-30s on cx23.
|
|
1306
|
+
onProgress('Deleting VPS...');
|
|
1307
|
+
const serversToDelete = await resolveHaServers({
|
|
1308
|
+
projectName,
|
|
1309
|
+
environment,
|
|
1310
|
+
envConfig,
|
|
1311
|
+
hetznerToken,
|
|
1312
|
+
hetznerHeaders,
|
|
1313
|
+
});
|
|
1314
|
+
await Promise.allSettled(
|
|
1315
|
+
serversToDelete.map((server) =>
|
|
1316
|
+
fetchWithRetry(`https://api.hetzner.cloud/v1/servers/${server.id}`, {
|
|
1317
|
+
method: 'DELETE',
|
|
1318
|
+
headers: hetznerHeaders,
|
|
1319
|
+
}).catch(() => {}),
|
|
1320
|
+
),
|
|
1321
|
+
);
|
|
1322
|
+
if (serversToDelete.length > 0) {
|
|
1323
|
+
onProgress('Waiting for VPS deletion to complete...');
|
|
1324
|
+
const deadline = Date.now() + 90_000;
|
|
1325
|
+
const remaining = new Map(serversToDelete.map((s) => [s.id, s]));
|
|
1326
|
+
while (remaining.size > 0 && Date.now() < deadline) {
|
|
1327
|
+
await Promise.allSettled(
|
|
1328
|
+
Array.from(remaining.entries()).map(async ([id]) => {
|
|
1329
|
+
try {
|
|
1330
|
+
const resp = await fetch(`https://api.hetzner.cloud/v1/servers/${id}`, {
|
|
1331
|
+
headers: hetznerHeaders,
|
|
1332
|
+
});
|
|
1333
|
+
if (resp.status === 404) remaining.delete(id);
|
|
1334
|
+
} catch {
|
|
1335
|
+
// Transient — retry next loop iteration
|
|
1336
|
+
}
|
|
1337
|
+
}),
|
|
1338
|
+
);
|
|
1339
|
+
if (remaining.size > 0) await new Promise((r) => setTimeout(r, 2_000));
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// Delete firewalls — fetch list once, delete matching in parallel.
|
|
1344
|
+
// The Pulumi program (hetzner-compose.js) names firewalls
|
|
1345
|
+
// `${projectName}-${stackEnv}-firewall`
|
|
1346
|
+
// where stackEnv is `${environment}-primary` or `${environment}-standby`
|
|
1347
|
+
// (set by deployComposeHA above). Earlier the names tried below were
|
|
1348
|
+
// `vibecarbon-${projectName}-${environment}[-primary|-standby]` — none of
|
|
1349
|
+
// those match what Pulumi created, so destroy left every firewall behind
|
|
1350
|
+
// and the next deploy hit "name is already used (uniqueness_error)" on
|
|
1351
|
+
// pulumi up. Match the actual Pulumi naming convention.
|
|
1352
|
+
//
|
|
1353
|
+
// Detach from servers first — server deletion above is async (Hetzner
|
|
1354
|
+
// returns 202 and finishes the delete behind the scenes), so the firewall
|
|
1355
|
+
// can still be `applied_to` a deleting server when we hit DELETE. That
|
|
1356
|
+
// returns 409 "still attached" which the previous `.catch(() => {})` was
|
|
1357
|
+
// silently swallowing, leaving the firewall behind. The next re-deploy
|
|
1358
|
+
// (compose-ha restore step) then hit "name is already used
|
|
1359
|
+
// (uniqueness_error)" on Pulumi up. Mirror the detach + retry logic
|
|
1360
|
+
// from `hetznerDeleteFirewall` in src/destroy.js.
|
|
1361
|
+
// The previous implementation listed `/v1/firewalls` without `?name=` and
|
|
1362
|
+
// without pagination. Hetzner returns `per_page=25` by default, so once
|
|
1363
|
+
// the project (or test matrix) accumulates 25+ firewalls the project's
|
|
1364
|
+
// firewalls aren't in page 1 → the destroy silently no-ops → the next
|
|
1365
|
+
// deploy hits "name is already used (uniqueness_error)" on the firewall
|
|
1366
|
+
// create. Look up each expected name with the exact-name filter (no
|
|
1367
|
+
// pagination concern), then poll until DELETE actually removes it.
|
|
1368
|
+
onProgress('Deleting firewalls...');
|
|
1369
|
+
const fwNames = [
|
|
1370
|
+
`${projectName}-${environment}-primary-firewall`,
|
|
1371
|
+
`${projectName}-${environment}-standby-firewall`,
|
|
1372
|
+
`${projectName}-${environment}-firewall`,
|
|
1373
|
+
];
|
|
1374
|
+
await Promise.allSettled(fwNames.map((name) => deleteFirewallByName(name, hetznerHeaders)));
|
|
1375
|
+
|
|
1376
|
+
// Delete SSH key + DNS resources in parallel
|
|
1377
|
+
onProgress('Cleaning up resources...');
|
|
1378
|
+
await Promise.allSettled([
|
|
1379
|
+
// SSH key deletion. deployComposeHA creates a SHARED ssh key named
|
|
1380
|
+
// `${projectName}-${environment}-key`
|
|
1381
|
+
// (see line ~385 — `_provider.createSSHKey(sharedSshKeyName, ...)`)
|
|
1382
|
+
// and passes its ID into both Pulumi programs as existingSshKeyId so
|
|
1383
|
+
// Pulumi never creates per-cluster keys. The old name pattern here
|
|
1384
|
+
// (`vibecarbon-${projectName}-${environment}`) didn't match anything
|
|
1385
|
+
// Pulumi or HA actually created — the shared key always leaked.
|
|
1386
|
+
(async () => {
|
|
1387
|
+
const sshKeyName = `${projectName}-${environment}-key`;
|
|
1388
|
+
try {
|
|
1389
|
+
const listResponse = await fetchWithRetry('https://api.hetzner.cloud/v1/ssh_keys', {
|
|
1390
|
+
headers: hetznerHeaders,
|
|
1391
|
+
});
|
|
1392
|
+
const { ssh_keys } = await listResponse.json();
|
|
1393
|
+
const key = ssh_keys?.find((k) => k.name === sshKeyName);
|
|
1394
|
+
if (key) {
|
|
1395
|
+
await fetchWithRetry(`https://api.hetzner.cloud/v1/ssh_keys/${key.id}`, {
|
|
1396
|
+
method: 'DELETE',
|
|
1397
|
+
headers: hetznerHeaders,
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
} catch {
|
|
1401
|
+
// Non-fatal
|
|
1402
|
+
}
|
|
1403
|
+
})(),
|
|
1404
|
+
// DNS resource deletion (ownership-filtered — only records pointing at
|
|
1405
|
+
// this env's server IPs are removed; shared zones keep their other
|
|
1406
|
+
// tenants' records intact).
|
|
1407
|
+
(async () => {
|
|
1408
|
+
const ownedIps = (envConfig.servers || []).map((srv) => srv.ip).filter(Boolean);
|
|
1409
|
+
if (cloudflareToken && envConfig.dns?.cloudflareZoneId && envConfig.domain) {
|
|
1410
|
+
const { deleteDNSRecord, deleteHealthCheck } = await import('../../cloudflare.js');
|
|
1411
|
+
const zoneId = envConfig.dns.cloudflareZoneId;
|
|
1412
|
+
try {
|
|
1413
|
+
await deleteDNSRecord(cloudflareToken, zoneId, envConfig.domain, ownedIps);
|
|
1414
|
+
await Promise.allSettled(
|
|
1415
|
+
(envConfig.servers || []).map((server) =>
|
|
1416
|
+
deleteHealthCheck(cloudflareToken, zoneId, `${server.name}-health`),
|
|
1417
|
+
),
|
|
1418
|
+
);
|
|
1419
|
+
} catch {
|
|
1420
|
+
// Non-fatal
|
|
1421
|
+
}
|
|
1422
|
+
} else if (
|
|
1423
|
+
envConfig.dns?.provider === 'hetzner' &&
|
|
1424
|
+
envConfig.dns?.hetznerZoneId &&
|
|
1425
|
+
envConfig.domain
|
|
1426
|
+
) {
|
|
1427
|
+
const { deleteDNSRecord: hetznerDeleteDNS, getZone: hetznerGetZone } = await import(
|
|
1428
|
+
'../../hetzner-dns.js'
|
|
1429
|
+
);
|
|
1430
|
+
const hToken = hetznerToken || process.env.HETZNER_API_TOKEN;
|
|
1431
|
+
if (hToken) {
|
|
1432
|
+
try {
|
|
1433
|
+
const zone = await hetznerGetZone(hToken, envConfig.dns.hetznerZoneId);
|
|
1434
|
+
await Promise.allSettled([
|
|
1435
|
+
hetznerDeleteDNS(
|
|
1436
|
+
hToken,
|
|
1437
|
+
envConfig.dns.hetznerZoneId,
|
|
1438
|
+
zone.name,
|
|
1439
|
+
envConfig.domain,
|
|
1440
|
+
ownedIps,
|
|
1441
|
+
),
|
|
1442
|
+
hetznerDeleteDNS(
|
|
1443
|
+
hToken,
|
|
1444
|
+
envConfig.dns.hetznerZoneId,
|
|
1445
|
+
zone.name,
|
|
1446
|
+
`*.${envConfig.domain}`,
|
|
1447
|
+
ownedIps,
|
|
1448
|
+
),
|
|
1449
|
+
]);
|
|
1450
|
+
} catch {
|
|
1451
|
+
// Non-fatal
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
})(),
|
|
1456
|
+
]);
|
|
1457
|
+
|
|
1458
|
+
// Clean up local SSH key files
|
|
1459
|
+
for (const f of [sshKeyPath, `${sshKeyPath}.pub`]) {
|
|
1460
|
+
if (existsSync(f)) {
|
|
1461
|
+
try {
|
|
1462
|
+
rmSync(f);
|
|
1463
|
+
} catch {
|
|
1464
|
+
// Non-fatal
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// ============================================================================
|
|
1471
|
+
// STATUS
|
|
1472
|
+
// ============================================================================
|
|
1473
|
+
|
|
1474
|
+
/**
|
|
1475
|
+
* Get status of both Compose HA servers in parallel
|
|
1476
|
+
*/
|
|
1477
|
+
export async function getComposeHAStatus(envConfig, sshKeyPath, projectName) {
|
|
1478
|
+
const results = await Promise.all(
|
|
1479
|
+
(envConfig.servers || []).map(async (server) => {
|
|
1480
|
+
try {
|
|
1481
|
+
const remoteDir = `/opt/${projectName}`;
|
|
1482
|
+
const output = await sshRunAsync(
|
|
1483
|
+
server.ip,
|
|
1484
|
+
sshKeyPath,
|
|
1485
|
+
`cd ${remoteDir} && docker compose ps --format json`,
|
|
1486
|
+
{ timeout: 15_000 },
|
|
1487
|
+
);
|
|
1488
|
+
if (!output?.trim()) {
|
|
1489
|
+
return { ...server, running: false, services: [] };
|
|
1490
|
+
}
|
|
1491
|
+
const services = output
|
|
1492
|
+
.trim()
|
|
1493
|
+
.split('\n')
|
|
1494
|
+
.filter(Boolean)
|
|
1495
|
+
.map((line) => {
|
|
1496
|
+
try {
|
|
1497
|
+
return JSON.parse(line);
|
|
1498
|
+
} catch {
|
|
1499
|
+
return null;
|
|
1500
|
+
}
|
|
1501
|
+
})
|
|
1502
|
+
.filter(Boolean);
|
|
1503
|
+
return {
|
|
1504
|
+
...server,
|
|
1505
|
+
running: services.some((s) => s.State === 'running'),
|
|
1506
|
+
services,
|
|
1507
|
+
};
|
|
1508
|
+
} catch {
|
|
1509
|
+
return { ...server, running: false, error: 'Unreachable' };
|
|
1510
|
+
}
|
|
1511
|
+
}),
|
|
1512
|
+
);
|
|
1513
|
+
return results;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// ============================================================================
|
|
1517
|
+
// FAILOVER
|
|
1518
|
+
// ============================================================================
|
|
1519
|
+
|
|
1520
|
+
/**
|
|
1521
|
+
* Perform failover for a Compose HA environment.
|
|
1522
|
+
* Promotes the standby database and updates DNS if Cloudflare configured.
|
|
1523
|
+
*/
|
|
1524
|
+
export async function failoverComposeHA(envName, envConfig, projectConfig, parsed, tracker) {
|
|
1525
|
+
const servers = envConfig.servers || [];
|
|
1526
|
+
const primaryServer = servers.find((s) => s.role === 'primary');
|
|
1527
|
+
const standbyServer = servers.find((s) => s.role === 'standby');
|
|
1528
|
+
|
|
1529
|
+
if (!primaryServer || !standbyServer) {
|
|
1530
|
+
p.log.error('Could not identify primary and standby servers from config');
|
|
1531
|
+
p.log.info('Ensure Compose HA deployment was set up with vibecarbon deploy --ha');
|
|
1532
|
+
process.exit(1);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
const sshKeyPath = join(process.cwd(), '.vibecarbon', `deploy_key_${envName}`);
|
|
1536
|
+
if (!existsSync(sshKeyPath)) {
|
|
1537
|
+
p.log.error(`SSH key not found: ${sshKeyPath}`);
|
|
1538
|
+
process.exit(1);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
const projectName = projectConfig.projectName;
|
|
1542
|
+
const remoteDir = `/opt/${projectName}`;
|
|
1543
|
+
|
|
1544
|
+
p.log.info(c.bold('Current Compose HA state:'));
|
|
1545
|
+
p.log.message(` ${c.dim(`Primary (${primaryServer.region})`.padEnd(22))} ${primaryServer.ip}`);
|
|
1546
|
+
p.log.message(` ${c.dim(`Standby (${standbyServer.region})`.padEnd(22))} ${standbyServer.ip}`);
|
|
1547
|
+
|
|
1548
|
+
p.log.info(c.bold('This will:'));
|
|
1549
|
+
p.log.message(' 1. Promote the standby database to primary (docker compose exec)');
|
|
1550
|
+
p.log.message(' 2. Update Cloudflare DNS (if configured)');
|
|
1551
|
+
p.log.message(' 3. Print DNS update instructions (if manual DNS)');
|
|
1552
|
+
|
|
1553
|
+
if (parsed.dryRun) {
|
|
1554
|
+
p.log.info(c.dim('Dry run — no changes made'));
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
if (!parsed.yes) {
|
|
1559
|
+
const confirm = await p.confirm({
|
|
1560
|
+
message: `Execute failover for ${c.bold(envName)}?`,
|
|
1561
|
+
});
|
|
1562
|
+
if (p.isCancel(confirm) || !confirm) {
|
|
1563
|
+
p.cancel('Operation cancelled.');
|
|
1564
|
+
process.exit(0);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
const s = tracker.spinner();
|
|
1569
|
+
|
|
1570
|
+
// Step 0: Re-seed standby from primary if primary is still reachable.
|
|
1571
|
+
//
|
|
1572
|
+
// Streaming replication is best-effort; without a replication slot (which
|
|
1573
|
+
// we don't configure), the standby can fall behind if primary churns
|
|
1574
|
+
// through WAL faster than `wal_keep_size`. A stale standby yields
|
|
1575
|
+
// "undefined column" (PG code 42703) errors post-promotion because recent
|
|
1576
|
+
// migrations on primary never streamed over.
|
|
1577
|
+
//
|
|
1578
|
+
// For planned failover (the e2e-test case), primary is still alive
|
|
1579
|
+
// — run pg_basebackup to guarantee schema parity, then promote. For a
|
|
1580
|
+
// real disaster failover where primary is unreachable, skip the re-seed
|
|
1581
|
+
// and promote whatever state standby has (best-effort recovery).
|
|
1582
|
+
const primaryReachable = await isPrimaryReachable(primaryServer.ip, sshKeyPath, remoteDir);
|
|
1583
|
+
if (primaryReachable) {
|
|
1584
|
+
s.start('Re-seeding standby from primary (pg_basebackup)');
|
|
1585
|
+
try {
|
|
1586
|
+
await configureStandbyReplication(
|
|
1587
|
+
standbyServer.ip,
|
|
1588
|
+
primaryServer.ip,
|
|
1589
|
+
sshKeyPath,
|
|
1590
|
+
projectName,
|
|
1591
|
+
);
|
|
1592
|
+
s.stop('Standby re-seeded with primary state');
|
|
1593
|
+
} catch (err) {
|
|
1594
|
+
// Was: catch + warn + proceed. That was actively misleading —
|
|
1595
|
+
// configureStandbyReplication starts with `rm -rf .../data/*`,
|
|
1596
|
+
// so on failure there is NO "existing standby state" to fall
|
|
1597
|
+
// back to; the dir is wiped and (with the script's `set -e`)
|
|
1598
|
+
// basebackup didn't complete. Promoting from this state hits
|
|
1599
|
+
// a CrashLoopBackOff (initdb-refuses-non-empty-dir loop). Hard
|
|
1600
|
+
// fail so the operator sees the issue instead of a confusing
|
|
1601
|
+
// pg_promote failure 5 minutes later.
|
|
1602
|
+
s.stop('Re-seed failed — aborting failover');
|
|
1603
|
+
throw new Error(
|
|
1604
|
+
`Failover aborted: re-seed failed and the standby's data directory ` +
|
|
1605
|
+
`is now in an inconsistent state. Manual recovery required ` +
|
|
1606
|
+
`(redeploy the standby or restore from backup).\nUnderlying error: ${err.message}`,
|
|
1607
|
+
);
|
|
1608
|
+
}
|
|
1609
|
+
} else {
|
|
1610
|
+
p.log.warn('Primary unreachable — skipping re-seed. Standby will promote with current state.');
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
// Step 1: Promote standby database.
|
|
1614
|
+
//
|
|
1615
|
+
// Use SQL `pg_promote(wait => true, wait_seconds => 120)` instead of
|
|
1616
|
+
// `pg_ctl promote`. Why: pg_ctl is fire-and-forget — it writes the promote
|
|
1617
|
+
// signal file and returns. If the postmaster hasn't fully bound the socket
|
|
1618
|
+
// yet (configureStandbyReplication's `docker compose start db` finished
|
|
1619
|
+
// mere seconds earlier), pg_ctl can fail or no-op silently, leaving the
|
|
1620
|
+
// standby in recovery mode. The downstream poll loop then sees `t` for its
|
|
1621
|
+
// entire budget and we declare "Promotion may still be in progress" while
|
|
1622
|
+
// the function continues to restart app-tier + DNS-flip — verify-failover
|
|
1623
|
+
// ends up hitting a read-only DB and fails with 500 "Database error finding
|
|
1624
|
+
// user" (auth) + 544 "DatabaseTimeout" (storage).
|
|
1625
|
+
// (Observed compose-ha 2026-04-29 — 2m 40s of polling with promoted=false.)
|
|
1626
|
+
//
|
|
1627
|
+
// pg_promote() is server-side and synchronous: with `wait=>true` it blocks
|
|
1628
|
+
// until the standby has actually exited recovery (or `wait_seconds` lapses)
|
|
1629
|
+
// and returns the result. We retry a handful of times because the very
|
|
1630
|
+
// first call can race the postmaster startup ("connection refused" /
|
|
1631
|
+
// "the database system is starting up").
|
|
1632
|
+
// `silent: true` is load-bearing: ha.js's local sshRun wraps runCommand,
|
|
1633
|
+
// which on non-zero exit returns `false` (not throws) unless silent — so
|
|
1634
|
+
// without it, a still-booting postmaster's exit-1 manifests downstream as
|
|
1635
|
+
// `false?.trim is not a function`, the catch records it, and all 5 attempts
|
|
1636
|
+
// fail with the same TypeError instead of actually retrying the SQL call.
|
|
1637
|
+
// Helper: probe pg_is_in_recovery() and return 't'/'f'/'' (unknown).
|
|
1638
|
+
// Used after pg_promote to disambiguate three cases:
|
|
1639
|
+
// - pg_promote returned 't' → success
|
|
1640
|
+
// - pg_promote returned 'f' (timed out internally) → check probe
|
|
1641
|
+
// - pg_promote threw (psql exit 1, e.g. "recovery is not in progress"
|
|
1642
|
+
// because a previous attempt actually promoted) → check probe
|
|
1643
|
+
// Without the catch-side probe, the first attempt's success was silently
|
|
1644
|
+
// converted to "did not exit recovery mode after 5 attempts" because
|
|
1645
|
+
// attempts 2-5 hit the same not-in-recovery error and never re-checked.
|
|
1646
|
+
// RCA: compose-ha 2026-05-01 fanout4b failover.
|
|
1647
|
+
const probeRecovery = () => {
|
|
1648
|
+
try {
|
|
1649
|
+
const r = sshRun(
|
|
1650
|
+
standbyServer.ip,
|
|
1651
|
+
sshKeyPath,
|
|
1652
|
+
`cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc 'SELECT pg_is_in_recovery()'`,
|
|
1653
|
+
{ silent: true, timeout: 10_000 },
|
|
1654
|
+
);
|
|
1655
|
+
return typeof r === 'string' ? r.trim() : '';
|
|
1656
|
+
} catch {
|
|
1657
|
+
return '';
|
|
1658
|
+
}
|
|
1659
|
+
};
|
|
1660
|
+
|
|
1661
|
+
s.start('Promoting standby database');
|
|
1662
|
+
// Use SQL pg_promote(wait => true). hot_standby=on (set in carbon/
|
|
1663
|
+
// docker-compose.yml) guarantees the standby accepts read connections,
|
|
1664
|
+
// so psql can reach pg_promote even before promotion finishes. Verified
|
|
1665
|
+
// 2026-05-01 fanout13 — without hot_standby the standby rejects every
|
|
1666
|
+
// psql connection with "Hot standby mode is disabled" and pg_promote
|
|
1667
|
+
// can't be invoked at all.
|
|
1668
|
+
let promoted = false;
|
|
1669
|
+
let lastPromoteError = null;
|
|
1670
|
+
const MAX_PROMOTE_ATTEMPTS = 5;
|
|
1671
|
+
for (let attempt = 1; attempt <= MAX_PROMOTE_ATTEMPTS && !promoted; attempt++) {
|
|
1672
|
+
try {
|
|
1673
|
+
const result = sshRun(
|
|
1674
|
+
standbyServer.ip,
|
|
1675
|
+
sshKeyPath,
|
|
1676
|
+
`cd ${remoteDir} && docker compose exec -T db psql -U supabase_admin -d postgres -tAc "SELECT pg_promote(wait => true, wait_seconds => 120)"`,
|
|
1677
|
+
{ silent: true, timeout: 150_000 },
|
|
1678
|
+
);
|
|
1679
|
+
const trimmed = typeof result === 'string' ? result.trim() : '';
|
|
1680
|
+
if (trimmed === 't') {
|
|
1681
|
+
promoted = true;
|
|
1682
|
+
break;
|
|
1683
|
+
}
|
|
1684
|
+
const probeTrimmed = probeRecovery();
|
|
1685
|
+
if (probeTrimmed === 'f') {
|
|
1686
|
+
promoted = true;
|
|
1687
|
+
break;
|
|
1688
|
+
}
|
|
1689
|
+
lastPromoteError = new Error(
|
|
1690
|
+
`pg_promote returned ${JSON.stringify(trimmed)}; pg_is_in_recovery=${JSON.stringify(probeTrimmed)}`,
|
|
1691
|
+
);
|
|
1692
|
+
} catch (err) {
|
|
1693
|
+
const probeTrimmed = probeRecovery();
|
|
1694
|
+
if (probeTrimmed === 'f') {
|
|
1695
|
+
promoted = true;
|
|
1696
|
+
break;
|
|
1697
|
+
}
|
|
1698
|
+
lastPromoteError = err;
|
|
1699
|
+
}
|
|
1700
|
+
if (!promoted && attempt < MAX_PROMOTE_ATTEMPTS) {
|
|
1701
|
+
await new Promise((r) => setTimeout(r, 5_000));
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
if (!promoted) {
|
|
1706
|
+
// Capture diagnostics so the operator can see why the promotion failed
|
|
1707
|
+
// instead of guessing. Best-effort — failover-step abort is what matters.
|
|
1708
|
+
let dbLogs = '';
|
|
1709
|
+
try {
|
|
1710
|
+
dbLogs = sshRun(
|
|
1711
|
+
standbyServer.ip,
|
|
1712
|
+
sshKeyPath,
|
|
1713
|
+
`cd ${remoteDir} && docker compose logs db --tail=60`,
|
|
1714
|
+
{ silent: true, timeout: 30_000 },
|
|
1715
|
+
);
|
|
1716
|
+
if (typeof dbLogs !== 'string') dbLogs = '(db logs returned non-string)';
|
|
1717
|
+
} catch (err) {
|
|
1718
|
+
dbLogs = `(failed to capture db logs: ${err.message})`;
|
|
1719
|
+
}
|
|
1720
|
+
s.stop('Promotion failed — standby still in recovery mode');
|
|
1721
|
+
throw new Error(
|
|
1722
|
+
`Failover aborted: standby ${standbyServer.ip} did not exit recovery mode after ${MAX_PROMOTE_ATTEMPTS} pg_promote attempts. ` +
|
|
1723
|
+
`Last error: ${lastPromoteError?.message || 'unknown'}.\n` +
|
|
1724
|
+
`--- db container logs (last 60 lines) ---\n${dbLogs}`,
|
|
1725
|
+
);
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
s.stop('Standby database promoted to primary');
|
|
1729
|
+
|
|
1730
|
+
// Step 1b: Restart app-tier services on the new primary so they re-pool
|
|
1731
|
+
// against the now-read-write DB. These services were started during deploy
|
|
1732
|
+
// while the local DB was in recovery — supavisor's tenant pools and the
|
|
1733
|
+
// app/auth/rest/realtime/storage DB handles can hold stale connections that
|
|
1734
|
+
// surface as 500 "Database error" / 544 DatabaseTimeout right after
|
|
1735
|
+
// promotion. Restart is cheap (~10s) and forces a clean reconnect.
|
|
1736
|
+
s.start('Restarting new-primary app-tier');
|
|
1737
|
+
try {
|
|
1738
|
+
sshRun(
|
|
1739
|
+
standbyServer.ip,
|
|
1740
|
+
sshKeyPath,
|
|
1741
|
+
`cd ${remoteDir} && docker compose restart supavisor auth rest realtime storage app`,
|
|
1742
|
+
{ timeout: 120_000 },
|
|
1743
|
+
);
|
|
1744
|
+
} catch (err) {
|
|
1745
|
+
p.log.warn(`Restart on new primary failed: ${err.message}`);
|
|
1746
|
+
}
|
|
1747
|
+
s.stop('New-primary app-tier restarted');
|
|
1748
|
+
|
|
1749
|
+
// Step 2: Stop old primary services to prevent split-brain
|
|
1750
|
+
s.start('Stopping old primary services');
|
|
1751
|
+
try {
|
|
1752
|
+
sshRun(
|
|
1753
|
+
primaryServer.ip,
|
|
1754
|
+
sshKeyPath,
|
|
1755
|
+
`cd ${remoteDir} && docker compose stop app auth rest realtime storage supavisor`,
|
|
1756
|
+
{ timeout: 60_000 },
|
|
1757
|
+
);
|
|
1758
|
+
} catch {
|
|
1759
|
+
// Old primary may be unreachable — that's often why we're failing over
|
|
1760
|
+
p.log.warn('Could not reach old primary to stop services');
|
|
1761
|
+
}
|
|
1762
|
+
s.stop('Old primary services stopped');
|
|
1763
|
+
|
|
1764
|
+
// Step 3: Update DNS if Cloudflare configured
|
|
1765
|
+
if (envConfig.dns?.provider === 'cloudflare' && envConfig.dns?.cloudflareZoneId) {
|
|
1766
|
+
let cfToken = process.env.CLOUDFLARE_API_TOKEN;
|
|
1767
|
+
if (!cfToken) {
|
|
1768
|
+
const creds = loadCredentials();
|
|
1769
|
+
if (creds.cloudflare?.apiToken) {
|
|
1770
|
+
cfToken = creds.cloudflare.apiToken;
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
if (cfToken) {
|
|
1774
|
+
const domain = envConfig.domain;
|
|
1775
|
+
const zoneId = envConfig.dns.cloudflareZoneId;
|
|
1776
|
+
|
|
1777
|
+
// Clean up legacy load balancer if present from older deployments
|
|
1778
|
+
try {
|
|
1779
|
+
const { createDNSRecord } = await import('../../cloudflare.js');
|
|
1780
|
+
|
|
1781
|
+
// Update DNS A record to standby IP. proxied:false matches
|
|
1782
|
+
// the deploy-time setup in cloudflare.js setupHA (Cloudflare
|
|
1783
|
+
// LBs were removed 2026-03-26 — orange cloud is no longer
|
|
1784
|
+
// needed and was triggering 404 caching at CF's edge during
|
|
1785
|
+
// cert provisioning).
|
|
1786
|
+
s.start('Updating DNS A record');
|
|
1787
|
+
await createDNSRecord(cfToken, zoneId, {
|
|
1788
|
+
type: 'A',
|
|
1789
|
+
name: domain,
|
|
1790
|
+
content: standbyServer.ip,
|
|
1791
|
+
proxied: false,
|
|
1792
|
+
});
|
|
1793
|
+
await createDNSRecord(cfToken, zoneId, {
|
|
1794
|
+
type: 'A',
|
|
1795
|
+
name: `*.${domain}`,
|
|
1796
|
+
content: standbyServer.ip,
|
|
1797
|
+
proxied: false,
|
|
1798
|
+
});
|
|
1799
|
+
s.stop(`DNS updated: ${domain} → ${standbyServer.ip}`);
|
|
1800
|
+
} catch (error) {
|
|
1801
|
+
s.stop(`DNS update failed: ${error.message}`);
|
|
1802
|
+
p.log.warn(`Manually update the A record for ${domain} to ${standbyServer.ip}`);
|
|
1803
|
+
}
|
|
1804
|
+
} else {
|
|
1805
|
+
p.log.warn('CLOUDFLARE_API_TOKEN not set — update DNS A record manually');
|
|
1806
|
+
}
|
|
1807
|
+
} else if (envConfig.dns?.provider === 'hetzner' && envConfig.dns?.hetznerZoneId) {
|
|
1808
|
+
let hetznerToken = process.env.HETZNER_API_TOKEN;
|
|
1809
|
+
if (!hetznerToken) {
|
|
1810
|
+
const creds = loadCredentials();
|
|
1811
|
+
if (creds.hetzner?.apiToken) {
|
|
1812
|
+
hetznerToken = creds.hetzner.apiToken;
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
if (hetznerToken) {
|
|
1816
|
+
const { createDNSRecord, getZone } = await import('../../hetzner-dns.js');
|
|
1817
|
+
const zone = await getZone(hetznerToken, envConfig.dns.hetznerZoneId);
|
|
1818
|
+
s.start('Updating DNS A record');
|
|
1819
|
+
try {
|
|
1820
|
+
await createDNSRecord(hetznerToken, envConfig.dns.hetznerZoneId, zone.name, {
|
|
1821
|
+
type: 'A',
|
|
1822
|
+
name: envConfig.domain,
|
|
1823
|
+
value: standbyServer.ip,
|
|
1824
|
+
ttl: 60,
|
|
1825
|
+
});
|
|
1826
|
+
await createDNSRecord(hetznerToken, envConfig.dns.hetznerZoneId, zone.name, {
|
|
1827
|
+
type: 'A',
|
|
1828
|
+
name: `*.${envConfig.domain}`,
|
|
1829
|
+
value: standbyServer.ip,
|
|
1830
|
+
ttl: 60,
|
|
1831
|
+
});
|
|
1832
|
+
s.stop(`DNS updated: ${envConfig.domain} → ${standbyServer.ip}`);
|
|
1833
|
+
} catch (error) {
|
|
1834
|
+
s.stop(`DNS update failed: ${error.message}`);
|
|
1835
|
+
p.log.warn(`Manually update the A record for ${envConfig.domain} to ${standbyServer.ip}`);
|
|
1836
|
+
}
|
|
1837
|
+
} else {
|
|
1838
|
+
p.log.warn('Hetzner API token not found — update DNS A record manually');
|
|
1839
|
+
}
|
|
1840
|
+
} else {
|
|
1841
|
+
// Manual DNS instructions
|
|
1842
|
+
const domain = envConfig.domain || '<your-domain>';
|
|
1843
|
+
p.log.info(c.bold('Update DNS to complete failover:'));
|
|
1844
|
+
p.log.message(` ${c.dim('Domain'.padEnd(14))} ${c.bold(domain)}`);
|
|
1845
|
+
p.log.message(` ${c.dim('New IP'.padEnd(14))} ${c.bold(standbyServer.ip)} (promoted standby)`);
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// Update config: swap primary/standby roles so subsequent commands reflect reality
|
|
1849
|
+
const updatedServers = servers.map((s) => ({
|
|
1850
|
+
...s,
|
|
1851
|
+
role: s.role === 'primary' ? 'standby' : 'primary',
|
|
1852
|
+
}));
|
|
1853
|
+
const updatedEnvConfig = {
|
|
1854
|
+
...envConfig,
|
|
1855
|
+
servers: updatedServers,
|
|
1856
|
+
region: standbyServer.region,
|
|
1857
|
+
secondaryRegion: primaryServer.region,
|
|
1858
|
+
ha: { ...envConfig.ha, failoverRegion: primaryServer.region },
|
|
1859
|
+
lastFailover: new Date().toISOString(),
|
|
1860
|
+
};
|
|
1861
|
+
saveProjectConfig({
|
|
1862
|
+
...projectConfig,
|
|
1863
|
+
environments: {
|
|
1864
|
+
...projectConfig.environments,
|
|
1865
|
+
[envName]: updatedEnvConfig,
|
|
1866
|
+
},
|
|
1867
|
+
});
|
|
1868
|
+
|
|
1869
|
+
p.log.success('Failover complete');
|
|
1870
|
+
p.log.info(c.bold('Recovery instructions:'));
|
|
1871
|
+
p.log.message(` 1. Fix the issue on the original primary (${primaryServer.ip})`);
|
|
1872
|
+
p.log.message(' 2. Re-initialize it as a standby replica');
|
|
1873
|
+
p.log.message(' 3. Redeploy to update configuration');
|
|
1874
|
+
}
|