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
package/src/failover.js
ADDED
|
@@ -0,0 +1,1032 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vibecarbon Failover Command
|
|
3
|
+
*
|
|
4
|
+
* Promotes the standby region of an HA deployment to primary. Handles
|
|
5
|
+
* four scenarios: HA+Cloudflare DNS, HA+Hetzner DNS, HA+manual DNS,
|
|
6
|
+
* and single-server (where failover is a no-op + recovery instructions).
|
|
7
|
+
*
|
|
8
|
+
* Form rule: vibecarbon uses single-dash flags only — see
|
|
9
|
+
* memory:feedback_cli_single_dash_flags. `-dry` (not `-dry-run` / `-n`)
|
|
10
|
+
* previews the failover plan without executing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
14
|
+
import * as p from '@clack/prompts';
|
|
15
|
+
import { renderHelp } from './lib/cli/help.js';
|
|
16
|
+
import { parseFlags } from './lib/cli/parse-flags.js';
|
|
17
|
+
import { selectEnvironment } from './lib/cli/select-environment.js';
|
|
18
|
+
import { requireTTYOrFlags } from './lib/cli/tty-guard.js';
|
|
19
|
+
import { createDNSRecord } from './lib/cloudflare.js';
|
|
20
|
+
import { c, printBanner } from './lib/colors.js';
|
|
21
|
+
import { runCommand } from './lib/command.js';
|
|
22
|
+
import { loadCredentials, loadProjectConfig } from './lib/config.js';
|
|
23
|
+
import {
|
|
24
|
+
createDNSRecord as hetznerDnsCreateRecord,
|
|
25
|
+
getZone as hetznerDnsGetZone,
|
|
26
|
+
} from './lib/hetzner-dns.js';
|
|
27
|
+
import { requireLicense } from './lib/licensing/index.js';
|
|
28
|
+
import { ensureOperatorIpAccess } from './lib/operator-ip.js';
|
|
29
|
+
import { perfTimer } from './lib/perf.js';
|
|
30
|
+
import { assertInProjectDir } from './lib/project-guard.js';
|
|
31
|
+
import { getPostgresPod, getSSHKeyPath, sshKubectl, sshRun } from './lib/ssh.js';
|
|
32
|
+
import { createTracker } from './lib/tracker.js';
|
|
33
|
+
import { VERSION } from './lib/version.js';
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// COMMAND SPEC — single source of truth for argv parsing AND help output.
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
/** @type {import('./lib/cli/parse-flags.js').CommandSpec & { summary?: string, description?: string, examples?: Array<{ command: string, description?: string }> }} */
|
|
40
|
+
const SPEC = {
|
|
41
|
+
name: 'failover',
|
|
42
|
+
summary: 'Promote the standby region to primary',
|
|
43
|
+
description: [
|
|
44
|
+
'Scenarios:',
|
|
45
|
+
' HA + Cloudflare DNS Promotes standby DB, switches DNS A record automatically',
|
|
46
|
+
' HA + Hetzner DNS Promotes standby DB, switches DNS A record automatically',
|
|
47
|
+
' HA + Manual DNS Promotes standby DB, prints DNS update instructions',
|
|
48
|
+
' Single Server Prints backup/restore recovery steps',
|
|
49
|
+
].join('\n'),
|
|
50
|
+
positional: [
|
|
51
|
+
{
|
|
52
|
+
name: 'env',
|
|
53
|
+
optional: true,
|
|
54
|
+
description: 'Environment to fail over (skips the env prompt)',
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
flags: [
|
|
58
|
+
{ name: 'h', boolean: true, description: 'Show this help' },
|
|
59
|
+
{ name: 'v', boolean: true, description: 'Show version' },
|
|
60
|
+
{ name: 'y', boolean: true, description: 'Skip confirmation prompts' },
|
|
61
|
+
{ name: 'dry', boolean: true, description: 'Show plan without executing' },
|
|
62
|
+
{ name: 'env', value: '<name>', description: 'Environment seed (alternative to positional)' },
|
|
63
|
+
],
|
|
64
|
+
examples: [
|
|
65
|
+
{ command: 'vibecarbon failover', description: 'prompts for env' },
|
|
66
|
+
{ command: 'vibecarbon failover prod -dry', description: 'preview the failover plan' },
|
|
67
|
+
{ command: 'vibecarbon failover prod -y', description: 'execute without confirmation' },
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// SCENARIO DETECTION
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
export function detectScenario(envConfig) {
|
|
76
|
+
const ha = envConfig.ha?.enabled || envConfig.ha === true;
|
|
77
|
+
const cloudflare = envConfig.dns?.provider === 'cloudflare' && envConfig.dns?.cloudflareZoneId;
|
|
78
|
+
const hetznerDns = envConfig.dns?.provider === 'hetzner' && envConfig.dns?.hetznerZoneId;
|
|
79
|
+
if (ha && cloudflare) return 'ha_cloudflare';
|
|
80
|
+
if (ha && hetznerDns) return 'ha_hetzner_dns';
|
|
81
|
+
if (ha) return 'ha_manual';
|
|
82
|
+
return 'single_server';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Identify primary and standby servers from the environment config.
|
|
87
|
+
* HA deployments store server info in ha.primary/ha.standby within the environment,
|
|
88
|
+
* or as separate entries in the servers array with region identifiers in their names.
|
|
89
|
+
*/
|
|
90
|
+
export function identifyServers(envName, envConfig, _projectConfig) {
|
|
91
|
+
// Check for HA config with primary/standby info nested in the environment
|
|
92
|
+
if (envConfig.ha?.primary && envConfig.ha?.standby) {
|
|
93
|
+
return {
|
|
94
|
+
primary: {
|
|
95
|
+
ip: envConfig.ha.primary.masterIp,
|
|
96
|
+
supabaseIp: envConfig.ha.primary.supabaseIp,
|
|
97
|
+
floatingIp: envConfig.ha.primary.floatingIp || envConfig.ha.primary.masterIp,
|
|
98
|
+
region: envConfig.ha.primary.region,
|
|
99
|
+
envKey: envName,
|
|
100
|
+
},
|
|
101
|
+
standby: {
|
|
102
|
+
ip: envConfig.ha.standby.masterIp,
|
|
103
|
+
supabaseIp: envConfig.ha.standby.supabaseIp,
|
|
104
|
+
floatingIp: envConfig.ha.standby.floatingIp || envConfig.ha.standby.masterIp,
|
|
105
|
+
region: envConfig.ha.standby.region,
|
|
106
|
+
envKey: envName,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Fall back to servers array with role/region matching
|
|
112
|
+
const servers = envConfig.servers || [];
|
|
113
|
+
const primaryRegion = envConfig.region;
|
|
114
|
+
const standbyRegion = envConfig.secondaryRegion || envConfig.ha?.failoverRegion;
|
|
115
|
+
|
|
116
|
+
if (servers.length >= 2 && primaryRegion && standbyRegion) {
|
|
117
|
+
// Prefer name-based match (k8s HA stores { name: 'primary' / 'standby' });
|
|
118
|
+
// fall back to region match (pre-gitops compose-HA used server names
|
|
119
|
+
// that embedded the region).
|
|
120
|
+
const byName = (role) => servers.find((s) => s.name === role);
|
|
121
|
+
const byRegion = (region) => servers.find((s) => s.name?.includes(region));
|
|
122
|
+
const primary = byName('primary') || byRegion(primaryRegion) || servers[0];
|
|
123
|
+
const standby = byName('standby') || byRegion(standbyRegion) || servers[1];
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
primary: {
|
|
127
|
+
ip: primary.ip,
|
|
128
|
+
supabaseIp: primary.supabaseIp,
|
|
129
|
+
region: primaryRegion,
|
|
130
|
+
envKey: envName,
|
|
131
|
+
},
|
|
132
|
+
standby: {
|
|
133
|
+
ip: standby.ip,
|
|
134
|
+
supabaseIp: standby.supabaseIp,
|
|
135
|
+
region: standbyRegion,
|
|
136
|
+
envKey: envName,
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ============================================================================
|
|
145
|
+
// PROMOTION + SERVICE SCALE HELPERS
|
|
146
|
+
// ============================================================================
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Check whether the primary cluster's postgres is reachable. Used to decide
|
|
150
|
+
* whether to attempt a pg_basebackup re-seed before promoting the standby.
|
|
151
|
+
* Primary-dead failovers skip the re-seed (can't; best-effort promote).
|
|
152
|
+
*/
|
|
153
|
+
function isPrimaryPostgresReady(primaryIp, sshKeyPath) {
|
|
154
|
+
try {
|
|
155
|
+
const pod = getPostgresPod(primaryIp, sshKeyPath);
|
|
156
|
+
const result = sshKubectl(primaryIp, sshKeyPath, [
|
|
157
|
+
'exec',
|
|
158
|
+
'-n',
|
|
159
|
+
'vibecarbon',
|
|
160
|
+
pod,
|
|
161
|
+
'--',
|
|
162
|
+
'pg_isready',
|
|
163
|
+
'-U',
|
|
164
|
+
'postgres',
|
|
165
|
+
'-t',
|
|
166
|
+
'3',
|
|
167
|
+
]);
|
|
168
|
+
return result?.includes('accepting connections') ?? false;
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Re-seed the standby Postgres from the primary via pg_basebackup before
|
|
176
|
+
* promotion. Closes the drift window that opens when streaming replication
|
|
177
|
+
* silently falls behind (no replication slot) or when the standby's pod
|
|
178
|
+
* restarted and missed WAL. Without re-seeding, promoted standby can serve
|
|
179
|
+
* requests against a schema that's missing recent migrations, yielding
|
|
180
|
+
* "undefined column" (PG 42703) errors in auth/storage after failover.
|
|
181
|
+
*
|
|
182
|
+
* Requires `primarySupabaseIp` — the Hetzner private IP of the primary's
|
|
183
|
+
* supabase node (where the postgres pod runs with hostPort 5432). Returns
|
|
184
|
+
* true if re-seed succeeded, false if primary was unreachable or re-seed
|
|
185
|
+
* itself failed (caller proceeds to promote with existing state).
|
|
186
|
+
*/
|
|
187
|
+
function reseedStandbyFromPrimary(standbyIp, sshKeyPath, primarySupabaseIp) {
|
|
188
|
+
if (!primarySupabaseIp) return false;
|
|
189
|
+
try {
|
|
190
|
+
// REPL_PASSWORD is written to both primary and standby .env.local at
|
|
191
|
+
// create time; read it from the standby's filesystem (easier than
|
|
192
|
+
// threading through the failover args).
|
|
193
|
+
const replPassword = sshRun(standbyIp, sshKeyPath, [
|
|
194
|
+
'bash',
|
|
195
|
+
'-c',
|
|
196
|
+
'grep -oE "^REPL_PASSWORD=[\\"\']?[^\\"\'\\n]+" /opt/*/.env.local 2>/dev/null | head -1 | sed -E "s/^REPL_PASSWORD=[\\"\']?//; s/[\\"\']?$//"',
|
|
197
|
+
]);
|
|
198
|
+
if (!replPassword?.trim()) return false;
|
|
199
|
+
|
|
200
|
+
const pod = getPostgresPod(standbyIp, sshKeyPath);
|
|
201
|
+
const script = [
|
|
202
|
+
`rm -rf /var/lib/postgresql/data/*`,
|
|
203
|
+
`PGPASSWORD='${replPassword.trim()}' pg_basebackup -h ${primarySupabaseIp} -p 5432 -U replicator -D /var/lib/postgresql/data -Fp -Xs -R -c fast -S vibecarbon_standby_slot`,
|
|
204
|
+
`echo "primary_slot_name = 'vibecarbon_standby_slot'" >> /var/lib/postgresql/data/postgresql.auto.conf`,
|
|
205
|
+
`chown -R postgres:postgres /var/lib/postgresql/data`,
|
|
206
|
+
].join('\n');
|
|
207
|
+
sshKubectl(standbyIp, sshKeyPath, ['exec', '-i', '-n', 'vibecarbon', pod, '--', 'bash'], {
|
|
208
|
+
input: script,
|
|
209
|
+
});
|
|
210
|
+
// Restart the pod to pick up the new PGDATA + recovery config.
|
|
211
|
+
sshKubectl(standbyIp, sshKeyPath, [
|
|
212
|
+
'exec',
|
|
213
|
+
'-n',
|
|
214
|
+
'vibecarbon',
|
|
215
|
+
pod,
|
|
216
|
+
'--',
|
|
217
|
+
'su',
|
|
218
|
+
'postgres',
|
|
219
|
+
'-c',
|
|
220
|
+
'pg_ctl restart -D /var/lib/postgresql/data -m fast',
|
|
221
|
+
]);
|
|
222
|
+
return true;
|
|
223
|
+
} catch {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function promoteStandbyDatabase(standbyIp, sshKeyPath) {
|
|
229
|
+
const _t = perfTimer('failover.promoteStandby');
|
|
230
|
+
try {
|
|
231
|
+
return _promoteStandbyDatabaseImpl(standbyIp, sshKeyPath);
|
|
232
|
+
} finally {
|
|
233
|
+
_t.end();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function _promoteStandbyDatabaseImpl(standbyIp, sshKeyPath) {
|
|
238
|
+
// Get the postgres pod name via a separate SSH call (avoids local shell expansion of $())
|
|
239
|
+
const pod = getPostgresPod(standbyIp, sshKeyPath);
|
|
240
|
+
|
|
241
|
+
// Promote PostgreSQL on standby
|
|
242
|
+
try {
|
|
243
|
+
sshKubectl(standbyIp, sshKeyPath, [
|
|
244
|
+
'exec',
|
|
245
|
+
'-n',
|
|
246
|
+
'vibecarbon',
|
|
247
|
+
pod,
|
|
248
|
+
'--',
|
|
249
|
+
'su',
|
|
250
|
+
'postgres',
|
|
251
|
+
'-c',
|
|
252
|
+
'pg_ctl promote -D /var/lib/postgresql/data',
|
|
253
|
+
]);
|
|
254
|
+
} catch {
|
|
255
|
+
// pg_ctl promote may return non-zero even on success
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Poll until standby is no longer in recovery
|
|
259
|
+
for (let i = 0; i < 30; i++) {
|
|
260
|
+
try {
|
|
261
|
+
const result = sshKubectl(standbyIp, sshKeyPath, [
|
|
262
|
+
'exec',
|
|
263
|
+
'-n',
|
|
264
|
+
'vibecarbon',
|
|
265
|
+
pod,
|
|
266
|
+
'--',
|
|
267
|
+
'psql',
|
|
268
|
+
'-U',
|
|
269
|
+
'supabase_admin',
|
|
270
|
+
'-tAc',
|
|
271
|
+
'SELECT pg_is_in_recovery()',
|
|
272
|
+
]);
|
|
273
|
+
if (result.trim() === 'f') return true;
|
|
274
|
+
} catch {
|
|
275
|
+
// Retry
|
|
276
|
+
}
|
|
277
|
+
runCommand(['sleep', '2'], { silent: true, ignoreError: true });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function scaleUpServices(ip, sshKeyPath) {
|
|
284
|
+
const _t = perfTimer('failover.scaleUpServices');
|
|
285
|
+
try {
|
|
286
|
+
return _scaleUpServicesImpl(ip, sshKeyPath);
|
|
287
|
+
} finally {
|
|
288
|
+
_t.end();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Deployments scaled during failover. The user's `app` deployment is
|
|
294
|
+
* named verbatim in carbon/k8s/base/app/deployment.yaml. Supabase
|
|
295
|
+
* back-ends are owned by the Helm chart with `<release>-<chart>-<comp>`
|
|
296
|
+
* naming — release `supabase`, chart `supabase` → `supabase-supabase-*`.
|
|
297
|
+
*
|
|
298
|
+
* The previous implementation scaled `auth`/`rest`/`realtime`/`storage`
|
|
299
|
+
* (the bare component names) — kubectl returned "no objects passed to
|
|
300
|
+
* scale" and the calls became silent no-ops. Result: failover scaled
|
|
301
|
+
* ONLY the `app` deployment up on standby, leaving Supabase back-ends
|
|
302
|
+
* stuck at whatever replica count the chart left them at, and the
|
|
303
|
+
* old primary's auth/rest were never scaled to 0 — split-brain
|
|
304
|
+
* possible. Caught by k8s-ha matrix verify-failover.
|
|
305
|
+
*/
|
|
306
|
+
const FAILOVER_DEPLOYMENTS = [
|
|
307
|
+
{ name: 'app', upReplicas: 2 },
|
|
308
|
+
{ name: 'supabase-supabase-auth', upReplicas: 2 },
|
|
309
|
+
{ name: 'supabase-supabase-rest', upReplicas: 2 },
|
|
310
|
+
{ name: 'supabase-supabase-realtime', upReplicas: 2 },
|
|
311
|
+
{ name: 'supabase-supabase-storage', upReplicas: 1 },
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Scale a list of deployments to N replicas via SSH+kubectl, swallowing
|
|
316
|
+
* per-deployment "not found" so one missing chart-name doesn't abort
|
|
317
|
+
* the whole failover. Reports each result so the operator can see what
|
|
318
|
+
* actually scaled.
|
|
319
|
+
*/
|
|
320
|
+
function scaleDeployments(ip, sshKeyPath, deployments, replicas, label) {
|
|
321
|
+
for (const dep of deployments) {
|
|
322
|
+
try {
|
|
323
|
+
sshKubectl(ip, sshKeyPath, [
|
|
324
|
+
'scale',
|
|
325
|
+
'deployment',
|
|
326
|
+
dep.name,
|
|
327
|
+
'-n',
|
|
328
|
+
'vibecarbon',
|
|
329
|
+
`--replicas=${replicas}`,
|
|
330
|
+
]);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
333
|
+
// "no objects passed to scale" / "deployments.apps not found" → log and continue.
|
|
334
|
+
// Anything else (auth failure, network) we still want to surface.
|
|
335
|
+
const isMissing = /not found|no objects passed/i.test(msg);
|
|
336
|
+
if (isMissing) {
|
|
337
|
+
p.log.warn(`${label}: ${dep.name} not found — skipped`);
|
|
338
|
+
} else {
|
|
339
|
+
p.log.warn(`${label}: ${dep.name} failed: ${msg.split('\n')[0]}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function _scaleUpServicesImpl(ip, sshKeyPath) {
|
|
346
|
+
// Group by upReplicas so we don't touch the same deployment twice.
|
|
347
|
+
const byReplicas = new Map();
|
|
348
|
+
for (const dep of FAILOVER_DEPLOYMENTS) {
|
|
349
|
+
if (!byReplicas.has(dep.upReplicas)) byReplicas.set(dep.upReplicas, []);
|
|
350
|
+
byReplicas.get(dep.upReplicas).push(dep);
|
|
351
|
+
}
|
|
352
|
+
for (const [replicas, deps] of byReplicas.entries()) {
|
|
353
|
+
scaleDeployments(ip, sshKeyPath, deps, replicas, `scale-up standby (replicas=${replicas})`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function waitForHealth(ip, sshKeyPath) {
|
|
358
|
+
const _t = perfTimer('failover.waitForHealth');
|
|
359
|
+
try {
|
|
360
|
+
return _waitForHealthImpl(ip, sshKeyPath);
|
|
361
|
+
} finally {
|
|
362
|
+
_t.end();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function _waitForHealthImpl(ip, sshKeyPath) {
|
|
367
|
+
// The previous implementation curled http://localhost:3000/api/health from
|
|
368
|
+
// the standby master — but K8s app pods don't run on the master's host
|
|
369
|
+
// network (no hostPort:3000), so localhost:3000 has nothing listening and
|
|
370
|
+
// every probe returned "FAIL". 30 retries × 2s = 60s wasted. Use kubectl
|
|
371
|
+
// rollout status against the actual deployment, which is the cluster's
|
|
372
|
+
// own readiness signal — gives `app` time to come up after scaleUp and
|
|
373
|
+
// surfaces the real failure (ImagePullBackOff, CrashLoop, missing secret)
|
|
374
|
+
// in the kubectl error rather than a generic timeout.
|
|
375
|
+
try {
|
|
376
|
+
sshKubectl(ip, sshKeyPath, [
|
|
377
|
+
'rollout',
|
|
378
|
+
'status',
|
|
379
|
+
'deployment/app',
|
|
380
|
+
'-n',
|
|
381
|
+
'vibecarbon',
|
|
382
|
+
'--timeout=300s',
|
|
383
|
+
]);
|
|
384
|
+
return true;
|
|
385
|
+
} catch {
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ============================================================================
|
|
391
|
+
// FAILOVER: HA + CLOUDFLARE
|
|
392
|
+
// ============================================================================
|
|
393
|
+
|
|
394
|
+
async function failoverHACloudflare(envName, envConfig, projectConfig, parsed, tracker) {
|
|
395
|
+
const servers = identifyServers(envName, envConfig, projectConfig);
|
|
396
|
+
if (!servers) {
|
|
397
|
+
p.log.error('Could not identify primary and standby servers');
|
|
398
|
+
p.log.info('Ensure HA deployment was set up with vibecarbon deploy');
|
|
399
|
+
process.exit(1);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const sshKeyPath = getSSHKeyPath(envName);
|
|
403
|
+
if (!existsSync(sshKeyPath)) {
|
|
404
|
+
p.log.error(`SSH key not found: ${sshKeyPath}`);
|
|
405
|
+
process.exit(1);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Get Cloudflare token — check env var, then credentials file, then prompt
|
|
409
|
+
let cfToken = process.env.CLOUDFLARE_API_TOKEN;
|
|
410
|
+
if (!cfToken) {
|
|
411
|
+
const creds = loadCredentials();
|
|
412
|
+
if (creds.cloudflare?.apiToken) {
|
|
413
|
+
cfToken = creds.cloudflare.apiToken;
|
|
414
|
+
p.log.info('Using Cloudflare API token from ~/.vibecarbon/credentials.json');
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (!cfToken) {
|
|
418
|
+
cfToken = await p.text({
|
|
419
|
+
message: 'Cloudflare API token:',
|
|
420
|
+
validate: (v) => (!v ? 'Required' : undefined),
|
|
421
|
+
});
|
|
422
|
+
if (p.isCancel(cfToken)) {
|
|
423
|
+
p.cancel('Operation cancelled.');
|
|
424
|
+
process.exit(0);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const zoneId = envConfig.dns.cloudflareZoneId;
|
|
429
|
+
const domain = envConfig.domain || envConfig.dns?.domain;
|
|
430
|
+
const standbyFloatingIp = servers.standby.floatingIp || servers.standby.ip;
|
|
431
|
+
|
|
432
|
+
const s = tracker.spinner();
|
|
433
|
+
|
|
434
|
+
// Display current state
|
|
435
|
+
p.log.info(c.bold('Current HA state:'));
|
|
436
|
+
p.log.message(
|
|
437
|
+
` ${c.dim(`Primary (${servers.primary.region})`.padEnd(22))} ${servers.primary.floatingIp || servers.primary.ip}`,
|
|
438
|
+
);
|
|
439
|
+
p.log.message(
|
|
440
|
+
` ${c.dim(`Standby (${servers.standby.region})`.padEnd(22))} ${standbyFloatingIp}`,
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
p.log.info(c.bold('This will:'));
|
|
444
|
+
p.log.message(' 1. Promote the standby database to primary');
|
|
445
|
+
p.log.message(' 2. Scale down old primary services');
|
|
446
|
+
p.log.message(' 3. Scale up standby services');
|
|
447
|
+
p.log.message(` 4. Update DNS A record → ${standbyFloatingIp}`);
|
|
448
|
+
|
|
449
|
+
// Dry run stops here
|
|
450
|
+
if (parsed.dryRun) {
|
|
451
|
+
p.log.info(c.dim('Dry run — no changes made'));
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Confirmation
|
|
456
|
+
if (!parsed.yes) {
|
|
457
|
+
const confirm = await p.confirm({
|
|
458
|
+
message: `Execute failover for ${c.bold(envName)}?`,
|
|
459
|
+
});
|
|
460
|
+
if (p.isCancel(confirm) || !confirm) {
|
|
461
|
+
p.cancel('Operation cancelled.');
|
|
462
|
+
process.exit(0);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Step 0: Re-seed standby from primary (if primary is still reachable).
|
|
467
|
+
// Streaming replication can silently fall behind; a stale standby promotes
|
|
468
|
+
// to serve queries against an out-of-date schema and hits "undefined
|
|
469
|
+
// column" (PG 42703) errors. Skipped when primary is unreachable (true
|
|
470
|
+
// disaster failover) — promotion proceeds with whatever state standby
|
|
471
|
+
// has. Requires primarySupabaseIp from identifyServers (persisted in
|
|
472
|
+
// envConfig.servers by orchestrator.js post-deploy).
|
|
473
|
+
if (isPrimaryPostgresReady(servers.primary.ip, sshKeyPath)) {
|
|
474
|
+
s.start('Re-seeding standby from primary (pg_basebackup)');
|
|
475
|
+
const reseeded = reseedStandbyFromPrimary(
|
|
476
|
+
servers.standby.ip,
|
|
477
|
+
sshKeyPath,
|
|
478
|
+
servers.primary.supabaseIp,
|
|
479
|
+
);
|
|
480
|
+
s.stop(reseeded ? 'Standby re-seeded with primary state' : 'Re-seed skipped (proceeding)');
|
|
481
|
+
} else {
|
|
482
|
+
p.log.warn('Primary unreachable — skipping re-seed, promoting standby with current state');
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Step 1: Promote standby database
|
|
486
|
+
s.start('Promoting standby database');
|
|
487
|
+
const promoted = promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
|
|
488
|
+
s.stop(
|
|
489
|
+
promoted
|
|
490
|
+
? 'Standby database promoted to primary'
|
|
491
|
+
: 'Database promotion may still be in progress',
|
|
492
|
+
);
|
|
493
|
+
if (!promoted) {
|
|
494
|
+
p.log.warn('Could not confirm promotion completed — verify manually');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Step 2: Scale down old primary services to prevent split-brain.
|
|
498
|
+
// Per-deployment errors (not-found, unreachable) are logged inside
|
|
499
|
+
// scaleDeployments — don't abort the whole failover on one missing
|
|
500
|
+
// chart name.
|
|
501
|
+
s.start('Scaling down old primary services');
|
|
502
|
+
scaleDeployments(servers.primary.ip, sshKeyPath, FAILOVER_DEPLOYMENTS, 0, 'scale-down primary');
|
|
503
|
+
s.stop('Old primary services scaled down');
|
|
504
|
+
|
|
505
|
+
// Step 3: Scale up standby services
|
|
506
|
+
s.start('Scaling up standby services');
|
|
507
|
+
try {
|
|
508
|
+
scaleUpServices(servers.standby.ip, sshKeyPath);
|
|
509
|
+
} catch (error) {
|
|
510
|
+
s.stop('Some services failed to scale');
|
|
511
|
+
p.log.warn(`Scale error: ${error.message}`);
|
|
512
|
+
}
|
|
513
|
+
s.stop('Standby services scaled up');
|
|
514
|
+
|
|
515
|
+
// Wait for health endpoint
|
|
516
|
+
s.start('Waiting for standby health check');
|
|
517
|
+
const healthy = waitForHealth(servers.standby.ip, sshKeyPath);
|
|
518
|
+
s.stop(healthy ? 'Standby health check passed' : 'Health check timed out');
|
|
519
|
+
|
|
520
|
+
// Step 4: Switch DNS A record to standby floating IP. proxied:false
|
|
521
|
+
// matches the deploy-time setup (Cloudflare LBs removed 2026-03-26).
|
|
522
|
+
s.start('Updating DNS A record');
|
|
523
|
+
try {
|
|
524
|
+
await createDNSRecord(cfToken, zoneId, {
|
|
525
|
+
type: 'A',
|
|
526
|
+
name: domain,
|
|
527
|
+
content: standbyFloatingIp,
|
|
528
|
+
proxied: false,
|
|
529
|
+
});
|
|
530
|
+
await createDNSRecord(cfToken, zoneId, {
|
|
531
|
+
type: 'A',
|
|
532
|
+
name: `*.${domain}`,
|
|
533
|
+
content: standbyFloatingIp,
|
|
534
|
+
proxied: false,
|
|
535
|
+
});
|
|
536
|
+
s.stop(`DNS updated: ${domain} → ${standbyFloatingIp}`);
|
|
537
|
+
} catch (error) {
|
|
538
|
+
s.stop('DNS update failed');
|
|
539
|
+
p.log.error(error.message);
|
|
540
|
+
p.log.warn(`Manually update the A record for ${domain} to ${standbyFloatingIp}`);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Success
|
|
544
|
+
p.log.success('Failover complete');
|
|
545
|
+
p.log.info(c.bold('Recovery instructions:'));
|
|
546
|
+
p.log.message(' To resync the original primary as a new standby:');
|
|
547
|
+
p.log.message(` 1. Fix the issue on the original primary (${servers.primary.ip})`);
|
|
548
|
+
p.log.message(' 2. Re-initialize it as a standby replica');
|
|
549
|
+
p.log.message(' 3. Redeploy to update configuration');
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ============================================================================
|
|
553
|
+
// FAILOVER: HA + HETZNER DNS
|
|
554
|
+
// ============================================================================
|
|
555
|
+
|
|
556
|
+
async function failoverHAHetznerDNS(envName, envConfig, projectConfig, parsed, tracker) {
|
|
557
|
+
const servers = identifyServers(envName, envConfig, projectConfig);
|
|
558
|
+
if (!servers) {
|
|
559
|
+
p.log.error('Could not identify primary and standby servers');
|
|
560
|
+
p.log.info('Ensure HA deployment was set up with vibecarbon deploy');
|
|
561
|
+
process.exit(1);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const sshKeyPath = getSSHKeyPath(envName);
|
|
565
|
+
if (!existsSync(sshKeyPath)) {
|
|
566
|
+
p.log.error(`SSH key not found: ${sshKeyPath}`);
|
|
567
|
+
process.exit(1);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Get Hetzner API token (same token used for cloud operations)
|
|
571
|
+
let apiToken = process.env.HETZNER_API_TOKEN;
|
|
572
|
+
if (!apiToken) {
|
|
573
|
+
const creds = loadCredentials();
|
|
574
|
+
apiToken = creds.hetzner?.apiToken;
|
|
575
|
+
}
|
|
576
|
+
if (!apiToken) {
|
|
577
|
+
apiToken = await p.text({
|
|
578
|
+
message: 'Hetzner API token:',
|
|
579
|
+
validate: (v) => (!v ? 'Required' : undefined),
|
|
580
|
+
});
|
|
581
|
+
if (p.isCancel(apiToken)) {
|
|
582
|
+
p.cancel('Operation cancelled.');
|
|
583
|
+
process.exit(0);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const zoneId = envConfig.dns.hetznerZoneId;
|
|
588
|
+
const domain = envConfig.domain || envConfig.dns?.domain;
|
|
589
|
+
const standbyFloatingIp = servers.standby.floatingIp || servers.standby.ip;
|
|
590
|
+
|
|
591
|
+
const s = tracker.spinner();
|
|
592
|
+
|
|
593
|
+
// Display current state
|
|
594
|
+
p.log.info(c.bold('Current HA state:'));
|
|
595
|
+
p.log.message(
|
|
596
|
+
` ${c.dim(`Primary (${servers.primary.region})`.padEnd(22))} ${servers.primary.floatingIp || servers.primary.ip}`,
|
|
597
|
+
);
|
|
598
|
+
p.log.message(
|
|
599
|
+
` ${c.dim(`Standby (${servers.standby.region})`.padEnd(22))} ${standbyFloatingIp}`,
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
p.log.info(c.bold('This will:'));
|
|
603
|
+
p.log.message(' 1. Promote the standby database to primary');
|
|
604
|
+
p.log.message(' 2. Scale down old primary services');
|
|
605
|
+
p.log.message(' 3. Scale up standby services');
|
|
606
|
+
p.log.message(` 4. Update DNS A record → ${standbyFloatingIp}`);
|
|
607
|
+
|
|
608
|
+
if (parsed.dryRun) {
|
|
609
|
+
p.log.info(c.dim('Dry run — no changes made'));
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (!parsed.yes) {
|
|
614
|
+
const confirm = await p.confirm({
|
|
615
|
+
message: `Execute failover for ${c.bold(envName)}?`,
|
|
616
|
+
});
|
|
617
|
+
if (p.isCancel(confirm) || !confirm) {
|
|
618
|
+
p.cancel('Operation cancelled.');
|
|
619
|
+
process.exit(0);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Step 0: Re-seed standby from primary (if primary is still reachable).
|
|
624
|
+
// Streaming replication can silently fall behind; a stale standby promotes
|
|
625
|
+
// to serve queries against an out-of-date schema and hits "undefined
|
|
626
|
+
// column" (PG 42703) errors. Skipped when primary is unreachable (true
|
|
627
|
+
// disaster failover) — promotion proceeds with whatever state standby
|
|
628
|
+
// has. Requires primarySupabaseIp from identifyServers (persisted in
|
|
629
|
+
// envConfig.servers by orchestrator.js post-deploy).
|
|
630
|
+
if (isPrimaryPostgresReady(servers.primary.ip, sshKeyPath)) {
|
|
631
|
+
s.start('Re-seeding standby from primary (pg_basebackup)');
|
|
632
|
+
const reseeded = reseedStandbyFromPrimary(
|
|
633
|
+
servers.standby.ip,
|
|
634
|
+
sshKeyPath,
|
|
635
|
+
servers.primary.supabaseIp,
|
|
636
|
+
);
|
|
637
|
+
s.stop(reseeded ? 'Standby re-seeded with primary state' : 'Re-seed skipped (proceeding)');
|
|
638
|
+
} else {
|
|
639
|
+
p.log.warn('Primary unreachable — skipping re-seed, promoting standby with current state');
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Step 1: Promote standby database
|
|
643
|
+
s.start('Promoting standby database');
|
|
644
|
+
const promoted = promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
|
|
645
|
+
s.stop(
|
|
646
|
+
promoted
|
|
647
|
+
? 'Standby database promoted to primary'
|
|
648
|
+
: 'Database promotion may still be in progress',
|
|
649
|
+
);
|
|
650
|
+
if (!promoted) {
|
|
651
|
+
p.log.warn('Could not confirm promotion completed — verify manually');
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Step 2: Scale down old primary services to prevent split-brain.
|
|
655
|
+
// See FAILOVER_DEPLOYMENTS for the chart-canonical names — bare
|
|
656
|
+
// `auth`/`rest`/etc. silently no-op'd in an earlier version.
|
|
657
|
+
s.start('Scaling down old primary services');
|
|
658
|
+
scaleDeployments(servers.primary.ip, sshKeyPath, FAILOVER_DEPLOYMENTS, 0, 'scale-down primary');
|
|
659
|
+
s.stop('Old primary services scaled down');
|
|
660
|
+
|
|
661
|
+
// Step 3: Scale up standby services
|
|
662
|
+
s.start('Scaling up standby services');
|
|
663
|
+
try {
|
|
664
|
+
scaleUpServices(servers.standby.ip, sshKeyPath);
|
|
665
|
+
} catch (error) {
|
|
666
|
+
s.stop('Some services failed to scale');
|
|
667
|
+
p.log.warn(`Scale error: ${error.message}`);
|
|
668
|
+
}
|
|
669
|
+
s.stop('Standby services scaled up');
|
|
670
|
+
|
|
671
|
+
// Wait for health endpoint
|
|
672
|
+
s.start('Waiting for standby health check');
|
|
673
|
+
const healthy = waitForHealth(servers.standby.ip, sshKeyPath);
|
|
674
|
+
s.stop(healthy ? 'Standby health check passed' : 'Health check timed out');
|
|
675
|
+
|
|
676
|
+
// Step 4: Update DNS A record via Hetzner DNS API
|
|
677
|
+
s.start('Updating DNS A record');
|
|
678
|
+
try {
|
|
679
|
+
const zone = await hetznerDnsGetZone(apiToken, zoneId);
|
|
680
|
+
await hetznerDnsCreateRecord(apiToken, zoneId, zone.name, {
|
|
681
|
+
type: 'A',
|
|
682
|
+
name: domain,
|
|
683
|
+
value: standbyFloatingIp,
|
|
684
|
+
ttl: 60,
|
|
685
|
+
});
|
|
686
|
+
await hetznerDnsCreateRecord(apiToken, zoneId, zone.name, {
|
|
687
|
+
type: 'A',
|
|
688
|
+
name: `*.${domain}`,
|
|
689
|
+
value: standbyFloatingIp,
|
|
690
|
+
ttl: 60,
|
|
691
|
+
});
|
|
692
|
+
s.stop(`DNS updated: ${domain} → ${standbyFloatingIp}`);
|
|
693
|
+
} catch (error) {
|
|
694
|
+
s.stop('DNS update failed');
|
|
695
|
+
p.log.error(error.message);
|
|
696
|
+
p.log.warn(`Manually update the A record for ${domain} to ${standbyFloatingIp}`);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Success
|
|
700
|
+
p.log.success('Failover complete');
|
|
701
|
+
p.log.info(c.bold('Recovery instructions:'));
|
|
702
|
+
p.log.message(' To resync the original primary as a new standby:');
|
|
703
|
+
p.log.message(` 1. Fix the issue on the original primary (${servers.primary.ip})`);
|
|
704
|
+
p.log.message(' 2. Re-initialize it as a standby replica');
|
|
705
|
+
p.log.message(' 3. Redeploy to update configuration');
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// ============================================================================
|
|
709
|
+
// FAILOVER: HA + MANUAL DNS
|
|
710
|
+
// ============================================================================
|
|
711
|
+
|
|
712
|
+
async function failoverHAManual(envName, envConfig, projectConfig, parsed, tracker) {
|
|
713
|
+
const servers = identifyServers(envName, envConfig, projectConfig);
|
|
714
|
+
if (!servers) {
|
|
715
|
+
p.log.error('Could not identify primary and standby servers');
|
|
716
|
+
p.log.info('Ensure HA deployment was set up with vibecarbon deploy');
|
|
717
|
+
process.exit(1);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const sshKeyPath = getSSHKeyPath(envName);
|
|
721
|
+
if (!existsSync(sshKeyPath)) {
|
|
722
|
+
p.log.error(`SSH key not found: ${sshKeyPath}`);
|
|
723
|
+
process.exit(1);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const domain = envConfig.domain || envConfig.dns?.domain || '<your-domain>';
|
|
727
|
+
|
|
728
|
+
p.log.info(c.bold('Current HA state:'));
|
|
729
|
+
p.log.message(
|
|
730
|
+
` ${c.dim(`Primary (${servers.primary.region})`.padEnd(22))} ${servers.primary.ip}`,
|
|
731
|
+
);
|
|
732
|
+
p.log.message(
|
|
733
|
+
` ${c.dim(`Standby (${servers.standby.region})`.padEnd(22))} ${servers.standby.ip}`,
|
|
734
|
+
);
|
|
735
|
+
p.log.info(c.bold('This will:'));
|
|
736
|
+
p.log.message(' 1. Promote the standby database to primary');
|
|
737
|
+
p.log.message(' 2. Scale up standby services');
|
|
738
|
+
p.log.message(' 3. Print DNS update instructions for you to complete');
|
|
739
|
+
|
|
740
|
+
if (parsed.dryRun) {
|
|
741
|
+
p.log.info(c.dim('Dry run — no changes made'));
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (!parsed.yes) {
|
|
746
|
+
const confirm = await p.confirm({
|
|
747
|
+
message: `Execute failover for ${c.bold(envName)}?`,
|
|
748
|
+
});
|
|
749
|
+
if (p.isCancel(confirm) || !confirm) {
|
|
750
|
+
p.cancel('Operation cancelled.');
|
|
751
|
+
process.exit(0);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const s = tracker.spinner();
|
|
756
|
+
|
|
757
|
+
// Promote standby database
|
|
758
|
+
s.start('Promoting standby database');
|
|
759
|
+
const promoted = promoteStandbyDatabase(servers.standby.ip, sshKeyPath);
|
|
760
|
+
s.stop(promoted ? 'Standby database promoted' : 'Promotion may still be in progress');
|
|
761
|
+
|
|
762
|
+
// Scale down old primary services to prevent split-brain
|
|
763
|
+
s.start('Scaling down old primary services');
|
|
764
|
+
try {
|
|
765
|
+
sshKubectl(servers.primary.ip, sshKeyPath, [
|
|
766
|
+
'scale',
|
|
767
|
+
'deployment',
|
|
768
|
+
'app',
|
|
769
|
+
'-n',
|
|
770
|
+
'vibecarbon',
|
|
771
|
+
'--replicas=0',
|
|
772
|
+
]);
|
|
773
|
+
sshKubectl(servers.primary.ip, sshKeyPath, [
|
|
774
|
+
'scale',
|
|
775
|
+
'deployment',
|
|
776
|
+
'auth',
|
|
777
|
+
'-n',
|
|
778
|
+
'vibecarbon',
|
|
779
|
+
'--replicas=0',
|
|
780
|
+
]);
|
|
781
|
+
sshKubectl(servers.primary.ip, sshKeyPath, [
|
|
782
|
+
'scale',
|
|
783
|
+
'deployment',
|
|
784
|
+
'rest',
|
|
785
|
+
'-n',
|
|
786
|
+
'vibecarbon',
|
|
787
|
+
'--replicas=0',
|
|
788
|
+
]);
|
|
789
|
+
sshKubectl(servers.primary.ip, sshKeyPath, [
|
|
790
|
+
'scale',
|
|
791
|
+
'deployment',
|
|
792
|
+
'realtime',
|
|
793
|
+
'-n',
|
|
794
|
+
'vibecarbon',
|
|
795
|
+
'--replicas=0',
|
|
796
|
+
]);
|
|
797
|
+
} catch {
|
|
798
|
+
p.log.warn('Could not reach old primary to scale down services');
|
|
799
|
+
}
|
|
800
|
+
s.stop('Old primary services scaled down');
|
|
801
|
+
|
|
802
|
+
// Scale up services
|
|
803
|
+
s.start('Scaling up standby services');
|
|
804
|
+
try {
|
|
805
|
+
scaleUpServices(servers.standby.ip, sshKeyPath);
|
|
806
|
+
} catch (error) {
|
|
807
|
+
s.stop('Some services failed to scale');
|
|
808
|
+
p.log.warn(`Scale error: ${error.message}`);
|
|
809
|
+
}
|
|
810
|
+
s.stop('Standby services scaled up');
|
|
811
|
+
|
|
812
|
+
// Wait for health
|
|
813
|
+
s.start('Waiting for standby health check');
|
|
814
|
+
const healthy = waitForHealth(servers.standby.ip, sshKeyPath);
|
|
815
|
+
s.stop(healthy ? 'Standby health check passed' : 'Health check timed out');
|
|
816
|
+
|
|
817
|
+
// Print DNS instructions
|
|
818
|
+
p.log.success('Standby region activated');
|
|
819
|
+
p.log.info(c.bold('Update DNS to complete failover:'));
|
|
820
|
+
p.log.message(` ${c.dim('Domain'.padEnd(14))} ${c.bold(domain)}`);
|
|
821
|
+
p.log.message(` ${c.dim('New IP'.padEnd(14))} ${c.bold(servers.standby.ip)} (standby master)`);
|
|
822
|
+
p.log.info(c.bold('Steps:'));
|
|
823
|
+
p.log.message(' 1. Log into your DNS provider');
|
|
824
|
+
p.log.message(` 2. Update the A record for ${domain} to ${c.bold(servers.standby.ip)}`);
|
|
825
|
+
p.log.message(` 3. Wait for DNS propagation (check: ${c.dim(`dig ${domain}`)})`);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// ============================================================================
|
|
829
|
+
// FAILOVER: SINGLE SERVER (RECOVERY GUIDE)
|
|
830
|
+
// ============================================================================
|
|
831
|
+
|
|
832
|
+
async function failoverSingleServer(envName, envConfig) {
|
|
833
|
+
const serverIp = envConfig.servers?.[0]?.ip;
|
|
834
|
+
const sshKeyPath = getSSHKeyPath(envName);
|
|
835
|
+
|
|
836
|
+
p.log.warn('This environment has no standby region');
|
|
837
|
+
p.log.info('Recovery requires deploying a new server and restoring from backup');
|
|
838
|
+
|
|
839
|
+
// Check for available backups
|
|
840
|
+
if (serverIp && existsSync(sshKeyPath)) {
|
|
841
|
+
const s = p.spinner();
|
|
842
|
+
s.start('Checking for available backups');
|
|
843
|
+
try {
|
|
844
|
+
const pod = sshKubectl(serverIp, sshKeyPath, [
|
|
845
|
+
'get',
|
|
846
|
+
'pods',
|
|
847
|
+
'-n',
|
|
848
|
+
'vibecarbon',
|
|
849
|
+
'-l',
|
|
850
|
+
'app.kubernetes.io/name=supabase-db',
|
|
851
|
+
'-o',
|
|
852
|
+
'jsonpath={.items[0].metadata.name}',
|
|
853
|
+
]);
|
|
854
|
+
const output = sshKubectl(serverIp, sshKeyPath, [
|
|
855
|
+
'exec',
|
|
856
|
+
'-n',
|
|
857
|
+
'vibecarbon',
|
|
858
|
+
pod,
|
|
859
|
+
'--',
|
|
860
|
+
'sh',
|
|
861
|
+
'-c',
|
|
862
|
+
'ls -lt /backups/*_full.tar.gz /backups/*.sql.gz 2>/dev/null | head -5 || echo NO_BACKUPS',
|
|
863
|
+
]);
|
|
864
|
+
s.stop('Backup check complete');
|
|
865
|
+
|
|
866
|
+
if (output !== 'NO_BACKUPS' && output) {
|
|
867
|
+
p.log.info(c.bold('Available backups:'));
|
|
868
|
+
for (const line of output
|
|
869
|
+
.split('\n')
|
|
870
|
+
.filter((l) => l.includes('.tar.gz') || l.includes('.sql.gz'))) {
|
|
871
|
+
p.log.message(` ${line.trim()}`);
|
|
872
|
+
}
|
|
873
|
+
} else {
|
|
874
|
+
p.log.info('No backups found on the server');
|
|
875
|
+
p.log.info(`Create one with: ${c.info(`vibecarbon backup ${envName}`)}`);
|
|
876
|
+
}
|
|
877
|
+
} catch {
|
|
878
|
+
s.stop('Could not reach server');
|
|
879
|
+
p.log.warn('Server may be unreachable — backups may not be accessible');
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
p.log.info(c.bold('Recovery steps:'));
|
|
884
|
+
p.log.message(` 1. ${c.info(`vibecarbon deploy ${envName}`)}`);
|
|
885
|
+
p.log.message(` 2. ${c.info(`vibecarbon restore ${envName}`)}`);
|
|
886
|
+
p.log.message(` 3. ${c.info(`vibecarbon status ${envName}`)}`);
|
|
887
|
+
p.log.info('To enable HA for one-command failover in the future:');
|
|
888
|
+
p.log.message(` ${c.info(`vibecarbon deploy ${envName} --ha`)}`);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// ============================================================================
|
|
892
|
+
// MAIN
|
|
893
|
+
// ============================================================================
|
|
894
|
+
|
|
895
|
+
export async function run(args) {
|
|
896
|
+
const { values, positional, errors } = parseFlags(args, SPEC);
|
|
897
|
+
|
|
898
|
+
if (errors.length > 0) {
|
|
899
|
+
for (const e of errors) {
|
|
900
|
+
process.stderr.write(`${c.error('✗')} ${e}\n`);
|
|
901
|
+
}
|
|
902
|
+
process.stderr.write(`Run ${c.info('vibecarbon failover -h')} for usage.\n`);
|
|
903
|
+
process.exit(1);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (values.h) {
|
|
907
|
+
process.stdout.write(renderHelp(SPEC));
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (values.v) {
|
|
911
|
+
console.log(`vibecarbon v${VERSION}`);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Project guard runs before banner so an accidental `vibecarbon
|
|
916
|
+
// failover` from a parent directory emits the canonical message.
|
|
917
|
+
assertInProjectDir();
|
|
918
|
+
|
|
919
|
+
requireLicense('failover');
|
|
920
|
+
printBanner();
|
|
921
|
+
p.intro(`${c.bold('vibecarbon failover')} ${c.dim(`v${VERSION}`)}`);
|
|
922
|
+
|
|
923
|
+
const projectConfig = loadProjectConfig();
|
|
924
|
+
const envCount = Object.keys(projectConfig.environments || {}).length;
|
|
925
|
+
const envSeed =
|
|
926
|
+
/** @type {string|undefined} */ (positional.env) ||
|
|
927
|
+
/** @type {string|null} */ (values.env) ||
|
|
928
|
+
null;
|
|
929
|
+
|
|
930
|
+
requireTTYOrFlags({
|
|
931
|
+
requirements: [
|
|
932
|
+
{
|
|
933
|
+
flag: 'env',
|
|
934
|
+
description: 'name an environment to fail over',
|
|
935
|
+
satisfied: !!envSeed || envCount <= 1,
|
|
936
|
+
},
|
|
937
|
+
],
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
const { envName, envConfig } = await selectEnvironment(projectConfig, {
|
|
941
|
+
actionVerb: 'fail over',
|
|
942
|
+
seed: envSeed,
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
// Compatibility shim — the existing failover orchestration helpers
|
|
946
|
+
// read `parsed.dryRun` and `parsed.yes` from the legacy struct shape.
|
|
947
|
+
// Build that shape from the new flag values without changing the
|
|
948
|
+
// helpers (each is hundreds of lines and well-tested).
|
|
949
|
+
const parsed = {
|
|
950
|
+
env: envName,
|
|
951
|
+
dryRun: !!values.dry,
|
|
952
|
+
yes: !!values.y,
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
// Failover hits the active and standby clusters via SSH + kubectl, so the
|
|
956
|
+
// operator's IP must be in the firewall allowlist for both.
|
|
957
|
+
const apiToken = loadCredentials()?.hetzner?.apiToken;
|
|
958
|
+
if (apiToken) {
|
|
959
|
+
try {
|
|
960
|
+
const result = await ensureOperatorIpAccess({
|
|
961
|
+
projectConfig,
|
|
962
|
+
environment: envName,
|
|
963
|
+
isHA: true,
|
|
964
|
+
apiToken,
|
|
965
|
+
yes: !!parsed.yes,
|
|
966
|
+
onMessage: (msg) => p.log.info(msg),
|
|
967
|
+
});
|
|
968
|
+
if (result.added) {
|
|
969
|
+
p.log.success(`Firewall updated: SSH/k8s API now allow ${result.cidr}`);
|
|
970
|
+
}
|
|
971
|
+
} catch (err) {
|
|
972
|
+
p.log.error(`Operator-IP check failed: ${err.message}`);
|
|
973
|
+
process.exit(1);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Single-server compose has no failover target
|
|
978
|
+
if (envConfig.deployMode === 'compose') {
|
|
979
|
+
p.log.info('Failover is not available for single-server Compose deployments.');
|
|
980
|
+
p.log.info(`Environment "${envName}" uses Docker Compose on a single VPS.`);
|
|
981
|
+
p.log.info('');
|
|
982
|
+
p.log.info(`${c.dim('To enable failover, redeploy with --ha:')}`);
|
|
983
|
+
p.log.info(` ${c.info(`vibecarbon deploy ${envName} --ha`)}`);
|
|
984
|
+
p.outro('');
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Compose-HA failover uses docker compose exec instead of kubectl
|
|
989
|
+
if (envConfig.deployMode === 'compose-ha') {
|
|
990
|
+
const composeHATracker = createTracker('failover', {
|
|
991
|
+
environment: envName,
|
|
992
|
+
scenario: 'compose_ha',
|
|
993
|
+
});
|
|
994
|
+
const { failoverComposeHA } = await import('./lib/deploy/compose/ha.js');
|
|
995
|
+
await failoverComposeHA(envName, envConfig, projectConfig, parsed, composeHATracker);
|
|
996
|
+
composeHATracker.finish();
|
|
997
|
+
p.outro(c.success('Done'));
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// Detect scenario
|
|
1002
|
+
const scenario = detectScenario(envConfig);
|
|
1003
|
+
const tracker = createTracker('failover', { environment: envName, scenario });
|
|
1004
|
+
|
|
1005
|
+
switch (scenario) {
|
|
1006
|
+
case 'ha_cloudflare':
|
|
1007
|
+
p.log.info(`Scenario: ${c.bold('HA + Cloudflare')} (DNS failover)`);
|
|
1008
|
+
await failoverHACloudflare(envName, envConfig, projectConfig, parsed, tracker);
|
|
1009
|
+
break;
|
|
1010
|
+
|
|
1011
|
+
case 'ha_hetzner_dns':
|
|
1012
|
+
p.log.info(`Scenario: ${c.bold('HA + Hetzner DNS')} (automatic DNS failover)`);
|
|
1013
|
+
await failoverHAHetznerDNS(envName, envConfig, projectConfig, parsed, tracker);
|
|
1014
|
+
break;
|
|
1015
|
+
|
|
1016
|
+
case 'ha_manual':
|
|
1017
|
+
p.log.info(`Scenario: ${c.bold('HA + Manual DNS')}`);
|
|
1018
|
+
await failoverHAManual(envName, envConfig, projectConfig, parsed, tracker);
|
|
1019
|
+
break;
|
|
1020
|
+
|
|
1021
|
+
case 'single_server':
|
|
1022
|
+
p.log.info(`Scenario: ${c.bold('Single Server')} (no HA configured)`);
|
|
1023
|
+
await failoverSingleServer(envName, envConfig);
|
|
1024
|
+
break;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
tracker.finish();
|
|
1028
|
+
p.outro(c.success('Done'));
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Exported for tests.
|
|
1032
|
+
export { SPEC };
|