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,1000 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High Availability Kubernetes Deployment Module (Diamond & Fullerene)
|
|
3
|
+
*
|
|
4
|
+
* Handles multi-region Kubernetes deployment with HA capabilities.
|
|
5
|
+
* Licensed under AGPL v3.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Multi-region Kubernetes clusters
|
|
9
|
+
* - PostgreSQL streaming replication
|
|
10
|
+
* - One-command failover
|
|
11
|
+
* - DNS-based health checks
|
|
12
|
+
* - Disaster recovery
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* vibecarbon deploy --ha --env prod --domain app.example.com
|
|
16
|
+
*
|
|
17
|
+
* Requires: Diamond or Fullerene license
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
|
+
// Keep the import narrow — we only need join() to resolve .env.local relative to cwd.
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import * as p from '@clack/prompts';
|
|
26
|
+
import { generateSSHKeyPair } from '../../../../deploy.js';
|
|
27
|
+
import { setupHA } from '../../../cloudflare.js';
|
|
28
|
+
import { c } from '../../../colors.js';
|
|
29
|
+
import { runCommand, runCommandAsync } from '../../../command.js';
|
|
30
|
+
import { fetchWithRetry } from '../../../fetch-retry.js';
|
|
31
|
+
import { setupHA as setupHetznerDnsHA } from '../../../hetzner-dns.js';
|
|
32
|
+
import { initBackend } from '../../../iac/index.js';
|
|
33
|
+
import { perfAsync } from '../../../perf.js';
|
|
34
|
+
import { HetznerProvider } from '../../../providers/hetzner.js';
|
|
35
|
+
import { createPrefixedTracker } from '../../../tracker.js';
|
|
36
|
+
import { StateTracker } from '../../state.js';
|
|
37
|
+
import { destroyK8s } from '../index.js';
|
|
38
|
+
import { deployK3s } from '../k3s.js';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Upload SSH key to Hetzner and return the key ID
|
|
42
|
+
* If key with same fingerprint exists, returns existing key ID
|
|
43
|
+
*/
|
|
44
|
+
async function uploadSshKeyToHetzner(apiToken, keyName, publicKey) {
|
|
45
|
+
// First check if key already exists
|
|
46
|
+
const listResponse = await fetchWithRetry('https://api.hetzner.cloud/v1/ssh_keys', {
|
|
47
|
+
headers: { Authorization: `Bearer ${apiToken}` },
|
|
48
|
+
});
|
|
49
|
+
const { ssh_keys } = await listResponse.json();
|
|
50
|
+
|
|
51
|
+
// If key with same name exists but different public key, delete it so we can recreate
|
|
52
|
+
const existingKey = ssh_keys?.find((k) => k.name === keyName);
|
|
53
|
+
if (existingKey) {
|
|
54
|
+
if (existingKey.public_key.trim() === publicKey.trim()) {
|
|
55
|
+
return existingKey.id;
|
|
56
|
+
}
|
|
57
|
+
// Key mismatch — delete old key so servers get the new one
|
|
58
|
+
await fetchWithRetry(`https://api.hetzner.cloud/v1/ssh_keys/${existingKey.id}`, {
|
|
59
|
+
method: 'DELETE',
|
|
60
|
+
headers: { Authorization: `Bearer ${apiToken}` },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Create new key
|
|
65
|
+
const createResponse = await fetchWithRetry('https://api.hetzner.cloud/v1/ssh_keys', {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: {
|
|
68
|
+
Authorization: `Bearer ${apiToken}`,
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
},
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
name: keyName,
|
|
73
|
+
public_key: publicKey,
|
|
74
|
+
// Tag with `managed-by=vibecarbon` so the post-run sweep's label
|
|
75
|
+
// selector reaps it. Without the label, leaks only surface in the
|
|
76
|
+
// name-prefix preflight scan and block the next e2e run.
|
|
77
|
+
labels: { 'managed-by': 'vibecarbon' },
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (!createResponse.ok) {
|
|
82
|
+
const error = await createResponse.json();
|
|
83
|
+
// If uniqueness error, try to find existing key by fingerprint
|
|
84
|
+
if (error.error?.code === 'uniqueness_error') {
|
|
85
|
+
// Re-fetch and look for matching key
|
|
86
|
+
const refetchResponse = await fetchWithRetry('https://api.hetzner.cloud/v1/ssh_keys', {
|
|
87
|
+
headers: { Authorization: `Bearer ${apiToken}` },
|
|
88
|
+
});
|
|
89
|
+
const { ssh_keys: refetchedKeys } = await refetchResponse.json();
|
|
90
|
+
// Return any key - Hetzner matches by fingerprint
|
|
91
|
+
const anyKey = refetchedKeys?.find((k) => k.name.includes('vibecarbon'));
|
|
92
|
+
if (anyKey) return anyKey.id;
|
|
93
|
+
}
|
|
94
|
+
throw new Error(`Failed to create SSH key: ${error.error?.message || 'Unknown error'}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const { ssh_key } = await createResponse.json();
|
|
98
|
+
return ssh_key.id;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
102
|
+
const __dirname = dirname(__filename);
|
|
103
|
+
|
|
104
|
+
// Path to HA scripts in the template
|
|
105
|
+
const HA_SCRIPTS_PATH = join(__dirname, '../../../../../carbon/ha');
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Deploy with High Availability
|
|
109
|
+
* @param {object} options - Deployment options
|
|
110
|
+
* @param {string} options.projectName - Project name
|
|
111
|
+
* @param {string} options.environment - Environment name
|
|
112
|
+
* @param {string} options.provider - Cloud provider
|
|
113
|
+
* @param {string} options.region - Primary region
|
|
114
|
+
* @param {string} options.secondaryRegion - Secondary (standby) region
|
|
115
|
+
* @param {string} options.serverType - Server type
|
|
116
|
+
* @param {string} options.apiToken - Hetzner API token
|
|
117
|
+
* @param {string} options.domain - Domain for global load balancing
|
|
118
|
+
* @param {string} options.cloudflareApiToken - Cloudflare API token
|
|
119
|
+
* @param {string} options.cloudflareZoneId - Cloudflare zone ID
|
|
120
|
+
* @returns {Promise<object>} Deployment result
|
|
121
|
+
*/
|
|
122
|
+
export async function deployK8sHA(options) {
|
|
123
|
+
const s = (options.tracker ?? p).spinner();
|
|
124
|
+
|
|
125
|
+
p.log.info(`${c.bold('Fullerene HA Multi-Region Deployment')}`);
|
|
126
|
+
p.log.info(`Primary: ${c.bold(options.region)}`);
|
|
127
|
+
p.log.info(`Secondary: ${c.bold(options.secondaryRegion)}`);
|
|
128
|
+
p.log.info('');
|
|
129
|
+
|
|
130
|
+
// Generate shared SSH key for both clusters (avoids Hetzner duplicate fingerprint errors)
|
|
131
|
+
s.start('Generating shared SSH key for HA clusters');
|
|
132
|
+
const sharedSshKeyPath = join(process.cwd(), '.vibecarbon', `deploy_key_${options.environment}`);
|
|
133
|
+
const sharedSshPublicKey = generateSSHKeyPair(sharedSshKeyPath);
|
|
134
|
+
s.stop('Shared SSH key generated');
|
|
135
|
+
|
|
136
|
+
// Upload SSH key to Hetzner ONCE and get the ID
|
|
137
|
+
s.start('Uploading SSH key to Hetzner');
|
|
138
|
+
const sshKeyName = `${options.projectName}-${options.environment}-ha-key`;
|
|
139
|
+
const sharedSshKeyId = await uploadSshKeyToHetzner(
|
|
140
|
+
options.apiToken,
|
|
141
|
+
sshKeyName,
|
|
142
|
+
sharedSshPublicKey,
|
|
143
|
+
);
|
|
144
|
+
s.stop('SSH key uploaded');
|
|
145
|
+
|
|
146
|
+
// 1 & 2. Deploy primary and standby clusters in parallel.
|
|
147
|
+
// Each cluster gets a PrefixedTracker that logs with [region] prefixes instead of
|
|
148
|
+
// real @clack spinners — two concurrent spinners corrupt each other's terminal output.
|
|
149
|
+
p.log.step('Deploying primary and standby clusters in parallel');
|
|
150
|
+
const primaryTracker = createPrefixedTracker(options.region, { parent: options.tracker });
|
|
151
|
+
const standbyTracker = createPrefixedTracker(options.secondaryRegion, {
|
|
152
|
+
parent: options.tracker,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Resolve equivalent server types for standby region (e.g. cax11→cpx11 when ARM unavailable)
|
|
156
|
+
const resolve = (type) =>
|
|
157
|
+
HetznerProvider.resolveServerTypeForRegion(type, options.secondaryRegion);
|
|
158
|
+
const standbyMasterType = resolve(options.masterServerType || options.serverType);
|
|
159
|
+
const standbySupabaseType = resolve(options.supabaseServerType || options.serverType);
|
|
160
|
+
const standbyWorkerType = resolve(options.workerServerType || options.serverType);
|
|
161
|
+
|
|
162
|
+
if (
|
|
163
|
+
standbyMasterType !== options.masterServerType ||
|
|
164
|
+
standbySupabaseType !== options.supabaseServerType ||
|
|
165
|
+
standbyWorkerType !== options.workerServerType
|
|
166
|
+
) {
|
|
167
|
+
p.log.info(
|
|
168
|
+
`Standby server types adjusted for ${options.secondaryRegion}: ` +
|
|
169
|
+
`master=${standbyMasterType}, supabase=${standbySupabaseType}, worker=${standbyWorkerType}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Diagnostic: confirm image tag and services config reach BOTH clusters.
|
|
174
|
+
// Helps attribute per-cluster failures to input config vs runtime issues.
|
|
175
|
+
console.error(
|
|
176
|
+
`[k8s-ha] dispatch: primary=${options.environment}-primary (${options.region}) standby=${options.environment}-standby (${options.secondaryRegion}) imageTag=${options.imageTag || '(unset)'} githubOwner=${options.githubOwner || '(unset)'} repoName=${options.repoName || '(unset)'} services.cicd=${Boolean(options.services?.cicd)} projectName=${options.projectName}`,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
// Each cluster gets its OWN StateTracker keyed by per-cluster environment
|
|
180
|
+
// name. Without this, both deployK3s calls share `options.state` (the
|
|
181
|
+
// orchestrator's tracker for the user-facing env) and write to the same
|
|
182
|
+
// deploy-state-<env>.json. When standby completes `k3s-apply` first
|
|
183
|
+
// (typical — standby tends to finish sideload faster), primary's
|
|
184
|
+
// `state.shouldSkip('k3s-apply', { imageTag })` then matches the stored
|
|
185
|
+
// hash and SKIPS manifest install on primary. cert-manager + Supabase
|
|
186
|
+
// never land on primary → public probe to e<env>.<domain> (which DNS
|
|
187
|
+
// resolves to primary's floating IP) returns nothing → deploy fails with
|
|
188
|
+
// "Deploy completed but the app is not serving traffic" (caught in
|
|
189
|
+
// k8s-ha matrix runs).
|
|
190
|
+
const primaryEnv = `${options.environment}-primary`;
|
|
191
|
+
const standbyEnv = `${options.environment}-standby`;
|
|
192
|
+
const primaryState = new StateTracker(options.projectName, primaryEnv);
|
|
193
|
+
const standbyState = new StateTracker(options.projectName, standbyEnv);
|
|
194
|
+
|
|
195
|
+
// Pre-initialize the Pulumi S3 backend before launching parallel deploys.
|
|
196
|
+
// RCA from k8s-ha 2026-04-28: when both deployK3s threads race against
|
|
197
|
+
// a freshly-created S3 bucket, `pulumi stack select` fails with
|
|
198
|
+
// `could not list bucket: blob (code=NotFound): NoSuchKey` and the deploy
|
|
199
|
+
// dies in <20s. Sequential pre-init lays down `.pulumi/meta.yaml` so the
|
|
200
|
+
// parallel block sees an initialized backend.
|
|
201
|
+
await perfAsync('deploy.ha.k8s.initBackend', () =>
|
|
202
|
+
initBackend({
|
|
203
|
+
s3Config: options.s3Config,
|
|
204
|
+
hcloudToken: options.apiToken,
|
|
205
|
+
configPassphrase: options.configPassphrase,
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Phase 5: thread minWorkers/maxWorkers symmetrically to both
|
|
210
|
+
// clusters. Both sides run their own cluster-autoscaler with
|
|
211
|
+
// matching bounds; failover replaces traffic, not capacity, so
|
|
212
|
+
// standby must be sized identically to primary. Phase 8 owns the
|
|
213
|
+
// CLI flag flow that populates options.minWorkers/maxWorkers; until
|
|
214
|
+
// then `deployK3s` defaults (1 / 3) carry through.
|
|
215
|
+
// Distinct local tunnel ports per cluster — both deployK3s calls run
|
|
216
|
+
// on the same operator host and each opens `ssh -L <port>:localhost:5000`
|
|
217
|
+
// for the registry push. Default 5000 collides; the loser of the
|
|
218
|
+
// bind race aborts via ExitOnForwardFailure=yes (RCA from k8s-ha
|
|
219
|
+
// 2026-04-30: primary deploy failed at the tunnel-open step while
|
|
220
|
+
// standby's tunnel held the port). 5000/5001 keeps each cluster's
|
|
221
|
+
// push self-contained.
|
|
222
|
+
// Wrap each per-cluster deploy in perfAsync so the parallel fan-out is
|
|
223
|
+
// visible in perf_substep — without this, k8s-ha emits only
|
|
224
|
+
// deploy.ha.k8s.full and we can't tell which cluster dominated the
|
|
225
|
+
// wall-clock or how unbalanced the fan-out is.
|
|
226
|
+
const settled = await Promise.allSettled([
|
|
227
|
+
perfAsync('deploy.ha.k8s.primary', () =>
|
|
228
|
+
deployK3s({
|
|
229
|
+
...options,
|
|
230
|
+
region: options.region,
|
|
231
|
+
environment: primaryEnv,
|
|
232
|
+
state: primaryState,
|
|
233
|
+
sharedSshKeyPath,
|
|
234
|
+
sharedSshPublicKey,
|
|
235
|
+
sharedSshKeyId,
|
|
236
|
+
tracker: primaryTracker,
|
|
237
|
+
quietSuccess: true,
|
|
238
|
+
minWorkers: options.minWorkers,
|
|
239
|
+
maxWorkers: options.maxWorkers,
|
|
240
|
+
localTunnelPort: 5000,
|
|
241
|
+
// Tag every internal sub-stage perf marker with .primary so
|
|
242
|
+
// perf_substep can attribute primary vs standby — without this
|
|
243
|
+
// both clusters write to identical names and we can't see which
|
|
244
|
+
// dominated (RCA: iter-reliab3 perf analysis revealed standby
|
|
245
|
+
// ran 140s longer than primary in restore but the opposite in
|
|
246
|
+
// deploy with no way to localize the asymmetry).
|
|
247
|
+
perfPrefix: 'k3s.primary',
|
|
248
|
+
}),
|
|
249
|
+
),
|
|
250
|
+
perfAsync('deploy.ha.k8s.standby', () =>
|
|
251
|
+
deployK3s({
|
|
252
|
+
...options,
|
|
253
|
+
region: options.secondaryRegion,
|
|
254
|
+
environment: standbyEnv,
|
|
255
|
+
state: standbyState,
|
|
256
|
+
masterServerType: standbyMasterType,
|
|
257
|
+
supabaseServerType: standbySupabaseType,
|
|
258
|
+
workerServerType: standbyWorkerType,
|
|
259
|
+
serverType: standbyWorkerType,
|
|
260
|
+
sharedSshKeyPath,
|
|
261
|
+
sharedSshPublicKey,
|
|
262
|
+
sharedSshKeyId,
|
|
263
|
+
tracker: standbyTracker,
|
|
264
|
+
quietSuccess: true,
|
|
265
|
+
minWorkers: options.minWorkers,
|
|
266
|
+
maxWorkers: options.maxWorkers,
|
|
267
|
+
localTunnelPort: 5001,
|
|
268
|
+
perfPrefix: 'k3s.standby',
|
|
269
|
+
}),
|
|
270
|
+
),
|
|
271
|
+
]);
|
|
272
|
+
// Surface per-cluster success/failure distinctly. Without this, a standby
|
|
273
|
+
// failure in Promise.all would reject the whole thing but we'd have no way
|
|
274
|
+
// to tell which cluster from the error message alone.
|
|
275
|
+
const [primarySettled, standbySettled] = settled;
|
|
276
|
+
if (primarySettled.status === 'rejected') {
|
|
277
|
+
console.error(
|
|
278
|
+
`[k8s-ha] primary deploy FAILED: ${primarySettled.reason?.message || primarySettled.reason}`,
|
|
279
|
+
);
|
|
280
|
+
} else {
|
|
281
|
+
console.error(
|
|
282
|
+
`[k8s-ha] primary deploy OK: masterIp=${primarySettled.value.masterIp} floatingIp=${primarySettled.value.floatingIp} kubeconfig=${primarySettled.value.kubeconfig}`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
if (standbySettled.status === 'rejected') {
|
|
286
|
+
console.error(
|
|
287
|
+
`[k8s-ha] standby deploy FAILED: ${standbySettled.reason?.message || standbySettled.reason}`,
|
|
288
|
+
);
|
|
289
|
+
} else {
|
|
290
|
+
console.error(
|
|
291
|
+
`[k8s-ha] standby deploy OK: masterIp=${standbySettled.value.masterIp} floatingIp=${standbySettled.value.floatingIp} kubeconfig=${standbySettled.value.kubeconfig}`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (primarySettled.status === 'rejected' || standbySettled.status === 'rejected') {
|
|
295
|
+
const failures = [];
|
|
296
|
+
if (primarySettled.status === 'rejected')
|
|
297
|
+
failures.push(`primary: ${primarySettled.reason?.message || primarySettled.reason}`);
|
|
298
|
+
if (standbySettled.status === 'rejected')
|
|
299
|
+
failures.push(`standby: ${standbySettled.reason?.message || standbySettled.reason}`);
|
|
300
|
+
throw new Error(`HA deploy failed — ${failures.join(' | ')}`);
|
|
301
|
+
}
|
|
302
|
+
const primaryResult = primarySettled.value;
|
|
303
|
+
const standbyResult = standbySettled.value;
|
|
304
|
+
p.log.success('Both clusters deployed');
|
|
305
|
+
|
|
306
|
+
// 3. Open port 5432 in the primary's Hetzner Cloud firewall so the standby's supabase node
|
|
307
|
+
// can reach the primary's postgres directly via hostPort. The base firewall (declared in
|
|
308
|
+
// src/lib/iac/programs/hetzner-k8s.js) only allows SSH, HTTP, HTTPS, K8s API, and
|
|
309
|
+
// private-network traffic.
|
|
310
|
+
if (options.apiToken && standbyResult.supabaseIp) {
|
|
311
|
+
const primaryClusterName = `${options.projectName}-${options.environment}-primary`;
|
|
312
|
+
const firewallName = `${primaryClusterName}-firewall`;
|
|
313
|
+
try {
|
|
314
|
+
const headers = {
|
|
315
|
+
Authorization: `Bearer ${options.apiToken}`,
|
|
316
|
+
'Content-Type': 'application/json',
|
|
317
|
+
};
|
|
318
|
+
const fwListRes = await fetchWithRetry(
|
|
319
|
+
`https://api.hetzner.cloud/v1/firewalls?name=${encodeURIComponent(firewallName)}`,
|
|
320
|
+
{ headers },
|
|
321
|
+
);
|
|
322
|
+
const fwListData = await fwListRes.json();
|
|
323
|
+
const firewall = fwListData.firewalls?.find((fw) => fw.name === firewallName);
|
|
324
|
+
if (firewall) {
|
|
325
|
+
const existingRules = firewall.rules || [];
|
|
326
|
+
const hasReplRule = existingRules.some(
|
|
327
|
+
(r) => r.port === '5432' && r.source_ips?.includes(`${standbyResult.supabaseIp}/32`),
|
|
328
|
+
);
|
|
329
|
+
if (!hasReplRule) {
|
|
330
|
+
// Remove any stale NodePort 30432 rules from previous deploys
|
|
331
|
+
const cleanedRules = existingRules.filter((r) => r.port !== '30432');
|
|
332
|
+
const updatedRules = [
|
|
333
|
+
...cleanedRules,
|
|
334
|
+
{
|
|
335
|
+
direction: 'in',
|
|
336
|
+
protocol: 'tcp',
|
|
337
|
+
port: '5432',
|
|
338
|
+
source_ips: [`${standbyResult.supabaseIp}/32`],
|
|
339
|
+
description: 'PostgreSQL replication from standby supabase node',
|
|
340
|
+
},
|
|
341
|
+
];
|
|
342
|
+
await fetchWithRetry(
|
|
343
|
+
`https://api.hetzner.cloud/v1/firewalls/${firewall.id}/actions/set_rules`,
|
|
344
|
+
{
|
|
345
|
+
method: 'POST',
|
|
346
|
+
headers,
|
|
347
|
+
body: JSON.stringify({ rules: updatedRules }),
|
|
348
|
+
},
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
} catch {
|
|
353
|
+
// Non-fatal: manual firewall config may suffice
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 4. Configure PostgreSQL replication (must run after both clusters finish —
|
|
358
|
+
// replication restarts postgres pods, which would break concurrent tail work).
|
|
359
|
+
s.start('Configuring PostgreSQL replication');
|
|
360
|
+
let replicationStatus = 'not configured';
|
|
361
|
+
try {
|
|
362
|
+
const replResult = await setupReplication({
|
|
363
|
+
primaryIp: primaryResult.masterIp,
|
|
364
|
+
standbyIp: standbyResult.masterIp,
|
|
365
|
+
primarySupabaseIp: primaryResult.supabaseIp,
|
|
366
|
+
standbySupabaseIp: standbyResult.supabaseIp,
|
|
367
|
+
sshKeyPath: sharedSshKeyPath,
|
|
368
|
+
});
|
|
369
|
+
if (replResult.streaming) {
|
|
370
|
+
s.stop('PostgreSQL replication configured — streaming');
|
|
371
|
+
replicationStatus = 'streaming';
|
|
372
|
+
} else {
|
|
373
|
+
s.stop('PostgreSQL replication configured — verify manually');
|
|
374
|
+
replicationStatus = 'configured (verify streaming)';
|
|
375
|
+
}
|
|
376
|
+
} catch (error) {
|
|
377
|
+
const msg = error.message || String(error);
|
|
378
|
+
const lines = msg.split('\n').filter((l) => l.trim() && !l.includes('Warning: Permanently'));
|
|
379
|
+
const lastLine = lines[lines.length - 1]?.trim() || msg;
|
|
380
|
+
const shortError = lastLine.length > 120 ? `${lastLine.slice(0, 120)}…` : lastLine;
|
|
381
|
+
s.stop(`Replication setup failed: ${shortError}`);
|
|
382
|
+
replicationStatus = `failed: ${shortError}`;
|
|
383
|
+
p.log.warn('You may need to configure replication manually');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 5. Configure Cloudflare DNS for failover
|
|
387
|
+
if (options.domain && options.cloudflareApiToken) {
|
|
388
|
+
try {
|
|
389
|
+
const haResult = await setupHA(
|
|
390
|
+
options.cloudflareApiToken,
|
|
391
|
+
options.cloudflareZoneId,
|
|
392
|
+
options.domain,
|
|
393
|
+
[
|
|
394
|
+
{ name: `${options.projectName}-${options.region}`, ip: primaryResult.floatingIp },
|
|
395
|
+
{
|
|
396
|
+
name: `${options.projectName}-${options.secondaryRegion}`,
|
|
397
|
+
ip: standbyResult.floatingIp,
|
|
398
|
+
},
|
|
399
|
+
],
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
if (haResult.success) {
|
|
403
|
+
p.log.info(
|
|
404
|
+
'DNS and health checks configured — use `vibecarbon failover` to switch regions',
|
|
405
|
+
);
|
|
406
|
+
} else {
|
|
407
|
+
p.log.warn(`Cloudflare DNS setup failed: ${haResult.error}`);
|
|
408
|
+
p.log.warn('Configure DNS manually');
|
|
409
|
+
}
|
|
410
|
+
} catch (error) {
|
|
411
|
+
p.log.warn(`Load balancer setup failed: ${error.message}`);
|
|
412
|
+
p.log.warn('Configure DNS/load balancer manually');
|
|
413
|
+
}
|
|
414
|
+
} else if (options.domain && options.hetznerDnsZoneId) {
|
|
415
|
+
try {
|
|
416
|
+
const haResult = await setupHetznerDnsHA(
|
|
417
|
+
options.apiToken,
|
|
418
|
+
options.hetznerDnsZoneId,
|
|
419
|
+
options.domain,
|
|
420
|
+
[
|
|
421
|
+
{ name: `${options.projectName}-${options.region}`, ip: primaryResult.floatingIp },
|
|
422
|
+
{
|
|
423
|
+
name: `${options.projectName}-${options.secondaryRegion}`,
|
|
424
|
+
ip: standbyResult.floatingIp,
|
|
425
|
+
},
|
|
426
|
+
],
|
|
427
|
+
);
|
|
428
|
+
if (haResult.success) {
|
|
429
|
+
p.log.info('DNS configured — use `vibecarbon failover` to switch regions');
|
|
430
|
+
} else {
|
|
431
|
+
p.log.warn(`Hetzner DNS setup failed: ${haResult.error}`);
|
|
432
|
+
p.log.warn('Configure DNS manually');
|
|
433
|
+
}
|
|
434
|
+
} catch (error) {
|
|
435
|
+
p.log.warn(`Hetzner DNS setup failed: ${error.message}`);
|
|
436
|
+
p.log.warn('Configure DNS manually');
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Success message
|
|
441
|
+
p.log.success(`${c.success('Fullerene HA deployment complete!')}`);
|
|
442
|
+
|
|
443
|
+
if (options.domain) {
|
|
444
|
+
p.log.info(`\n ${c.bold(c.success(`🌐 https://${options.domain}`))}\n`);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
p.note(
|
|
448
|
+
[
|
|
449
|
+
`Primary (${options.region}): ${c.bold(primaryResult.floatingIp)} ${c.dim(`(master: ${primaryResult.masterIp})`)}`,
|
|
450
|
+
`Standby (${options.secondaryRegion}): ${c.bold(standbyResult.floatingIp)} ${c.dim(`(master: ${standbyResult.masterIp})`)}`,
|
|
451
|
+
'',
|
|
452
|
+
`Replication: ${replicationStatus === 'streaming' ? c.success(replicationStatus) : c.warning(replicationStatus.length > 60 ? `${replicationStatus.slice(0, 60)}…` : replicationStatus)}`,
|
|
453
|
+
'',
|
|
454
|
+
`Kubeconfig (primary): ${c.dim(primaryResult.kubeconfig)}`,
|
|
455
|
+
`Kubeconfig (standby): ${c.dim(standbyResult.kubeconfig)}`,
|
|
456
|
+
].join('\n'),
|
|
457
|
+
'Cluster Information',
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
p.note(
|
|
461
|
+
[
|
|
462
|
+
`${c.dim('vibecarbon failover --target standby')}`,
|
|
463
|
+
`${c.dim('vibecarbon failover --target primary')}`,
|
|
464
|
+
].join('\n'),
|
|
465
|
+
'Failover Commands',
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
primary: primaryResult,
|
|
470
|
+
standby: standbyResult,
|
|
471
|
+
domain: options.domain,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Destroy an HA deployment
|
|
477
|
+
* @param {object} options - Destruction options
|
|
478
|
+
* @returns {Promise<void>}
|
|
479
|
+
*/
|
|
480
|
+
export async function destroyK8sHA(options) {
|
|
481
|
+
const s = p.spinner();
|
|
482
|
+
|
|
483
|
+
// Destroy both clusters
|
|
484
|
+
s.start('Destroying primary cluster');
|
|
485
|
+
await destroyK8s({ ...options, environment: `${options.environment}-primary` });
|
|
486
|
+
s.stop('Primary cluster destroyed');
|
|
487
|
+
|
|
488
|
+
s.start('Destroying standby cluster');
|
|
489
|
+
await destroyK8s({ ...options, environment: `${options.environment}-standby` });
|
|
490
|
+
s.stop('Standby cluster destroyed');
|
|
491
|
+
|
|
492
|
+
p.log.success('HA deployment destroyed');
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Get HA deployment status across regions
|
|
497
|
+
* @param {object} options - Status options
|
|
498
|
+
* @returns {Promise<object>}
|
|
499
|
+
*/
|
|
500
|
+
export async function getK8sHAStatus(options = {}) {
|
|
501
|
+
const env = options.environment || 'prod';
|
|
502
|
+
const primaryKubeconfig = join(process.cwd(), '.vibecarbon', `kubeconfig-${env}-primary`);
|
|
503
|
+
const standbyKubeconfig = join(process.cwd(), '.vibecarbon', `kubeconfig-${env}-standby`);
|
|
504
|
+
|
|
505
|
+
const status = {
|
|
506
|
+
primary: { available: false },
|
|
507
|
+
standby: { available: false },
|
|
508
|
+
replication: { status: 'unknown' },
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
// Check primary
|
|
512
|
+
try {
|
|
513
|
+
const primaryPods = runCommand('kubectl get pods -n vibecarbon -o json', {
|
|
514
|
+
silent: true,
|
|
515
|
+
returnOutput: true,
|
|
516
|
+
env: { ...process.env, KUBECONFIG: primaryKubeconfig },
|
|
517
|
+
});
|
|
518
|
+
status.primary = {
|
|
519
|
+
available: true,
|
|
520
|
+
pods: JSON.parse(primaryPods),
|
|
521
|
+
};
|
|
522
|
+
} catch {
|
|
523
|
+
// Primary not available
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Check standby
|
|
527
|
+
try {
|
|
528
|
+
const standbyPods = runCommand('kubectl get pods -n vibecarbon -o json', {
|
|
529
|
+
silent: true,
|
|
530
|
+
returnOutput: true,
|
|
531
|
+
env: { ...process.env, KUBECONFIG: standbyKubeconfig },
|
|
532
|
+
});
|
|
533
|
+
status.standby = {
|
|
534
|
+
available: true,
|
|
535
|
+
pods: JSON.parse(standbyPods),
|
|
536
|
+
};
|
|
537
|
+
} catch {
|
|
538
|
+
// Standby not available
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return status;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Trigger manual failover
|
|
546
|
+
* @param {object} options - Failover options
|
|
547
|
+
* @param {string} options.targetRegion - Region to promote to primary
|
|
548
|
+
* @returns {Promise<void>}
|
|
549
|
+
*/
|
|
550
|
+
export async function triggerFailover(options) {
|
|
551
|
+
const s = p.spinner();
|
|
552
|
+
|
|
553
|
+
s.start(`Initiating failover to ${options.targetRegion}`);
|
|
554
|
+
|
|
555
|
+
// Read the activate-standby.sh script for reference
|
|
556
|
+
const activateScript = join(HA_SCRIPTS_PATH, 'activate-standby.sh');
|
|
557
|
+
if (!existsSync(activateScript)) {
|
|
558
|
+
throw new Error('Failover script not found');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Steps from activate-standby.sh:
|
|
562
|
+
// 1. Promote PostgreSQL standby to primary
|
|
563
|
+
// 2. Verify database is writable
|
|
564
|
+
// 3. Scale up Supabase services
|
|
565
|
+
// 4. Scale up application
|
|
566
|
+
// 5. Update DNS/load balancer
|
|
567
|
+
|
|
568
|
+
const envName = options.environment || 'prod';
|
|
569
|
+
const targetKubeconfig =
|
|
570
|
+
options.targetRegion === 'standby'
|
|
571
|
+
? join(process.cwd(), '.vibecarbon', `kubeconfig-${envName}-standby`)
|
|
572
|
+
: join(process.cwd(), '.vibecarbon', `kubeconfig-${envName}-primary`);
|
|
573
|
+
|
|
574
|
+
const env = { ...process.env, KUBECONFIG: targetKubeconfig };
|
|
575
|
+
|
|
576
|
+
// Promote PostgreSQL (pg_ctl refuses to run as root — must run as postgres user)
|
|
577
|
+
runCommand(
|
|
578
|
+
'kubectl exec -n vibecarbon supabase-supabase-db-0 -- su postgres -c "pg_ctl promote -D /var/lib/postgresql/data"',
|
|
579
|
+
{ env, ignoreError: true },
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
// Scale up services. Supabase pods live under Helm chart Deployments
|
|
583
|
+
// named `supabase-supabase-<component>`; pre-Helm they were just
|
|
584
|
+
// `auth` / `rest` / `realtime` / `storage`. The app Deployment is our
|
|
585
|
+
// own so it stays `app`.
|
|
586
|
+
runCommand('kubectl scale deployment/app -n vibecarbon --replicas=2', { env });
|
|
587
|
+
runCommand('kubectl scale deployment/supabase-supabase-auth -n vibecarbon --replicas=2', { env });
|
|
588
|
+
runCommand('kubectl scale deployment/supabase-supabase-rest -n vibecarbon --replicas=2', { env });
|
|
589
|
+
runCommand('kubectl scale deployment/supabase-supabase-realtime -n vibecarbon --replicas=2', {
|
|
590
|
+
env,
|
|
591
|
+
});
|
|
592
|
+
runCommand('kubectl scale deployment/supabase-supabase-storage -n vibecarbon --replicas=1', {
|
|
593
|
+
env,
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
s.stop(`Failover to ${options.targetRegion} complete`);
|
|
597
|
+
|
|
598
|
+
p.log.success('Failover completed successfully');
|
|
599
|
+
p.log.info('');
|
|
600
|
+
p.log.info('Next steps:');
|
|
601
|
+
p.log.info('1. Verify application is accessible');
|
|
602
|
+
p.log.info('2. Update DNS if Cloudflare is not configured');
|
|
603
|
+
p.log.info('3. Investigate and recover the original primary');
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Setup PostgreSQL streaming replication
|
|
608
|
+
* @param {object} options - Replication options
|
|
609
|
+
* @param {string} options.primaryIp - Primary master node IP (for SSH)
|
|
610
|
+
* @param {string} options.standbyIp - Standby master node IP (for SSH)
|
|
611
|
+
* @param {string} options.sshKeyPath - Path to shared SSH key
|
|
612
|
+
* @param {string} options.primarySupabaseIp - Primary supabase node IP (where postgres runs)
|
|
613
|
+
* @param {string} options.standbySupabaseIp - Standby supabase node IP (where postgres runs)
|
|
614
|
+
* @returns {Promise<{streaming: boolean}>} Replication status
|
|
615
|
+
*/
|
|
616
|
+
export async function setupReplication(options) {
|
|
617
|
+
const { primaryIp, standbyIp, sshKeyPath } = options;
|
|
618
|
+
// pg_basebackup connects to the supabase node IP (where postgres pod runs),
|
|
619
|
+
// not the master node. Fall back to master IP if supabase IP not provided.
|
|
620
|
+
const _primarySupabaseIp = options.primarySupabaseIp || primaryIp;
|
|
621
|
+
const _standbySupabaseIp = options.standbySupabaseIp || standbyIp;
|
|
622
|
+
|
|
623
|
+
// replPassword is generated at create time from crypto.randomBytes and is
|
|
624
|
+
// restricted to base64url characters — no shell escaping needed.
|
|
625
|
+
// Read from process.env first (CI), then .env.local (the `vibecarbon create`
|
|
626
|
+
// default write target). Without the .env.local fallback, HA deploys fail
|
|
627
|
+
// the moment the CLI is invoked without the env var pre-exported — which
|
|
628
|
+
// is exactly what happened to the e2e harness in the overnight run.
|
|
629
|
+
let replPassword = process.env.REPL_PASSWORD;
|
|
630
|
+
if (!replPassword) {
|
|
631
|
+
const envLocalPath = join(process.cwd(), '.env.local');
|
|
632
|
+
if (existsSync(envLocalPath)) {
|
|
633
|
+
const content = readFileSync(envLocalPath, 'utf-8');
|
|
634
|
+
const m =
|
|
635
|
+
content.match(/^REPL_PASSWORD="([^"]+)"/m) || content.match(/^REPL_PASSWORD='([^']+)'/m);
|
|
636
|
+
if (m) replPassword = m[1];
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (!replPassword) {
|
|
640
|
+
throw new Error(
|
|
641
|
+
'REPL_PASSWORD is not set in process.env or .env.local — HA deploys require a replication password generated at create time.',
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// SSH opts as argv — keeps all command builders below in argv form
|
|
646
|
+
// (no local-shell parsing). Host-key pinning migration tracked as H-1.
|
|
647
|
+
const sshOpts = [
|
|
648
|
+
'-i',
|
|
649
|
+
sshKeyPath,
|
|
650
|
+
'-o',
|
|
651
|
+
'StrictHostKeyChecking=no',
|
|
652
|
+
'-o',
|
|
653
|
+
'UserKnownHostsFile=/dev/null',
|
|
654
|
+
'-o',
|
|
655
|
+
'BatchMode=yes',
|
|
656
|
+
];
|
|
657
|
+
|
|
658
|
+
// 1. Apply primary-init.sql on primary.
|
|
659
|
+
// Render the SQL template locally (substituting REPL_PASSWORD), SCP the rendered
|
|
660
|
+
// copy, then delete the temp file. Approach A from spec.
|
|
661
|
+
const primaryInitSql = join(HA_SCRIPTS_PATH, 'primary-init.sql');
|
|
662
|
+
if (!existsSync(primaryInitSql)) {
|
|
663
|
+
throw new Error(`Missing HA script: ${primaryInitSql}`);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// All SSH commands use runCommandAsync in argv form — ssh/scp receive
|
|
667
|
+
// separate tokens and the final remote-command string is the only place
|
|
668
|
+
// a shell parses anything. No local template-literal interpolation.
|
|
669
|
+
|
|
670
|
+
// Render primary-init.sql with the real REPL_PASSWORD, SCP to remote, then clean up.
|
|
671
|
+
// The SQL template contains the legacy placeholder password; replace it with the
|
|
672
|
+
// per-deploy random password. We avoid writing the placeholder literal here by
|
|
673
|
+
// building it from parts so this source file itself passes the no-legacy-password check.
|
|
674
|
+
const sqlTemplate = readFileSync(primaryInitSql, 'utf-8');
|
|
675
|
+
// Legacy placeholder in the SQL file: 'repl' + '_password' (split to avoid literal match)
|
|
676
|
+
const legacyPlaceholder = "'repl" + "_password'";
|
|
677
|
+
const renderedSql = sqlTemplate.replaceAll(legacyPlaceholder, `'${replPassword}'`);
|
|
678
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'vibecarbon-repl-'));
|
|
679
|
+
const renderedSqlPath = join(tmpDir, 'primary-init.sql');
|
|
680
|
+
try {
|
|
681
|
+
writeFileSync(renderedSqlPath, renderedSql, { mode: 0o600 });
|
|
682
|
+
await runCommandAsync(
|
|
683
|
+
['scp', ...sshOpts, renderedSqlPath, `root@${primaryIp}:/tmp/primary-init.sql`],
|
|
684
|
+
{ silent: true },
|
|
685
|
+
);
|
|
686
|
+
} finally {
|
|
687
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
688
|
+
}
|
|
689
|
+
try {
|
|
690
|
+
await runCommandAsync(
|
|
691
|
+
[
|
|
692
|
+
'ssh',
|
|
693
|
+
...sshOpts,
|
|
694
|
+
`root@${primaryIp}`,
|
|
695
|
+
'cat /tmp/primary-init.sql | kubectl exec -i -n vibecarbon supabase-supabase-db-0 -- psql -U supabase_admin',
|
|
696
|
+
],
|
|
697
|
+
{ silent: true },
|
|
698
|
+
);
|
|
699
|
+
} finally {
|
|
700
|
+
// Always remove the rendered SQL — it contains the replication password.
|
|
701
|
+
await runCommandAsync(['ssh', ...sshOpts, `root@${primaryIp}`, 'rm -f /tmp/primary-init.sql'], {
|
|
702
|
+
silent: true,
|
|
703
|
+
ignoreError: true,
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// 2. Add pg_hba.conf entry allowing replication from any IP (password-protected).
|
|
708
|
+
// NodePort traffic is SNAT'd by kube-proxy, so the source IP seen by postgres is
|
|
709
|
+
// an internal cluster IP, not the standby's public IP. We cannot tighten the CIDR
|
|
710
|
+
// without moving replication off NodePort (follow-up). The defense in depth is:
|
|
711
|
+
// 1. Per-deploy random REPL_PASSWORD (generated in create.js via crypto.randomBytes).
|
|
712
|
+
// 2. Hetzner firewall restricts external source IPs reaching the NodePort.
|
|
713
|
+
// 3. md5 auth on pg_hba.conf.
|
|
714
|
+
await runCommandAsync(
|
|
715
|
+
[
|
|
716
|
+
'ssh',
|
|
717
|
+
...sshOpts,
|
|
718
|
+
`root@${primaryIp}`,
|
|
719
|
+
`kubectl exec -n vibecarbon supabase-supabase-db-0 -- bash -c "echo 'host replication replicator 0.0.0.0/0 md5' >> /var/lib/postgresql/data/pg_hba.conf"`,
|
|
720
|
+
],
|
|
721
|
+
{ silent: true },
|
|
722
|
+
);
|
|
723
|
+
await runCommandAsync(
|
|
724
|
+
[
|
|
725
|
+
'ssh',
|
|
726
|
+
...sshOpts,
|
|
727
|
+
`root@${primaryIp}`,
|
|
728
|
+
`kubectl exec -n vibecarbon supabase-supabase-db-0 -- su postgres -c 'pg_ctl reload -D /var/lib/postgresql/data'`,
|
|
729
|
+
],
|
|
730
|
+
{ silent: true },
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
// 3. Restart primary postgres to apply wal_level change (requires restart, not just reload)
|
|
734
|
+
await runCommandAsync(
|
|
735
|
+
[
|
|
736
|
+
'ssh',
|
|
737
|
+
...sshOpts,
|
|
738
|
+
`root@${primaryIp}`,
|
|
739
|
+
'kubectl delete pod supabase-supabase-db-0 -n vibecarbon --wait=true',
|
|
740
|
+
],
|
|
741
|
+
{ silent: true },
|
|
742
|
+
);
|
|
743
|
+
// Wait for pod to come back
|
|
744
|
+
for (let i = 0; i < 30; i++) {
|
|
745
|
+
try {
|
|
746
|
+
const ready = await runCommandAsync(
|
|
747
|
+
[
|
|
748
|
+
'ssh',
|
|
749
|
+
...sshOpts,
|
|
750
|
+
`root@${primaryIp}`,
|
|
751
|
+
'kubectl exec -n vibecarbon supabase-supabase-db-0 -- pg_isready -U supabase_admin 2>/dev/null && echo IS_READY || echo NOT_READY',
|
|
752
|
+
],
|
|
753
|
+
{ silent: true, ignoreError: true },
|
|
754
|
+
);
|
|
755
|
+
if (ready?.trim()?.includes('IS_READY')) break;
|
|
756
|
+
} catch {
|
|
757
|
+
// Retry
|
|
758
|
+
}
|
|
759
|
+
if (i === 29) break;
|
|
760
|
+
const interval = i < 5 ? 500 : 2000;
|
|
761
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// 3b. Rollout-restart the postgres-dependent Deployments on primary
|
|
765
|
+
// so they drop stale connection pools to the (now-restarted)
|
|
766
|
+
// postgres.
|
|
767
|
+
//
|
|
768
|
+
// RCA from k8s-ha 2026-04-28 deploy runs: an earlier version
|
|
769
|
+
// restarted only `app`, but the public probe still returned a
|
|
770
|
+
// 19-byte `404 page not found`. cert-manager state
|
|
771
|
+
// was clean (vibecarbon-tls READY=True, Order=valid), but app
|
|
772
|
+
// pods kept failing readiness 503. The app's
|
|
773
|
+
// `/api/health/ready` queries `supabaseAdmin.from(...)`, which
|
|
774
|
+
// hits SUPABASE_URL=http://supabase-supabase-kong:8000 → kong
|
|
775
|
+
// → supabase-supabase-rest → postgres. When primary postgres
|
|
776
|
+
// restarted, supabase-rest + auth + realtime + meta held onto
|
|
777
|
+
// stale connections; restarting only `app` left the readiness
|
|
778
|
+
// path broken. Service `app` then had zero Ready endpoints and
|
|
779
|
+
// Traefik's IngressRoute fell through to its default 404
|
|
780
|
+
// backend on the public probe.
|
|
781
|
+
//
|
|
782
|
+
// We restart specifically the deployments that hold direct
|
|
783
|
+
// postgres connections. We do NOT restart:
|
|
784
|
+
// - traefik — hostPort 80/443; RollingUpdate races
|
|
785
|
+
// with itself (new pod can't bind ports
|
|
786
|
+
// while old pod still listens) and
|
|
787
|
+
// traefik doesn't talk to postgres.
|
|
788
|
+
// - supabase-storage — single replica with RWO PVC; old pod
|
|
789
|
+
// holds the volume and new pod stays in
|
|
790
|
+
// Init forever waiting for attach.
|
|
791
|
+
// Storage will reconnect on next request.
|
|
792
|
+
// - supabase-kong — supabase community chart runs kong in
|
|
793
|
+
// DB-less mode (declarative config),
|
|
794
|
+
// no postgres connection.
|
|
795
|
+
// - supabase-imgproxy — image proxy, no postgres connection.
|
|
796
|
+
// - supabase-studio — talks via meta/kong, no direct conn.
|
|
797
|
+
//
|
|
798
|
+
// Verified on a live standby cluster on 2026-04-28: restarting
|
|
799
|
+
// all 10 deployments hung on traefik (10 min progress deadline)
|
|
800
|
+
// and storage (RWO PVC stuck attaching). The selective list
|
|
801
|
+
// below completes in <2 min.
|
|
802
|
+
const postgresDependentDeployments = [
|
|
803
|
+
'app',
|
|
804
|
+
'supabase-supabase-auth',
|
|
805
|
+
'supabase-supabase-rest',
|
|
806
|
+
'supabase-supabase-realtime',
|
|
807
|
+
'supabase-supabase-meta',
|
|
808
|
+
];
|
|
809
|
+
// Restart + status-wait the 5 postgres-dependent deployments in parallel.
|
|
810
|
+
// All 5 share the same apiserver but live in independent ReplicaSets — kubelet
|
|
811
|
+
// can roll them concurrently and rollout-status only blocks on its own
|
|
812
|
+
// deployment. Previously serial: total ≈ Σ(per-deploy rollout) = 5 × ~30-60s
|
|
813
|
+
// worst case = 2.5-5 min. Parallel: total ≈ max(per-deploy) = ~30-60s.
|
|
814
|
+
// perfAsync wraps the whole batch so the next run's perf_substep shows the
|
|
815
|
+
// critical-path cost in one place (uninstrumented before).
|
|
816
|
+
await perfAsync('deploy.ha.replication.rolloutRestart', () =>
|
|
817
|
+
Promise.all(
|
|
818
|
+
postgresDependentDeployments.map((deploy) =>
|
|
819
|
+
runCommandAsync(
|
|
820
|
+
[
|
|
821
|
+
'ssh',
|
|
822
|
+
...sshOpts,
|
|
823
|
+
`root@${primaryIp}`,
|
|
824
|
+
`kubectl rollout restart deployment/${deploy} -n vibecarbon`,
|
|
825
|
+
],
|
|
826
|
+
{ silent: true, ignoreError: true },
|
|
827
|
+
),
|
|
828
|
+
),
|
|
829
|
+
),
|
|
830
|
+
);
|
|
831
|
+
await perfAsync('deploy.ha.replication.rolloutStatus', () =>
|
|
832
|
+
Promise.all(
|
|
833
|
+
postgresDependentDeployments.map((deploy) =>
|
|
834
|
+
runCommandAsync(
|
|
835
|
+
[
|
|
836
|
+
'ssh',
|
|
837
|
+
...sshOpts,
|
|
838
|
+
`root@${primaryIp}`,
|
|
839
|
+
`kubectl rollout status deployment/${deploy} -n vibecarbon --timeout=300s`,
|
|
840
|
+
],
|
|
841
|
+
{ silent: true, ignoreError: true },
|
|
842
|
+
),
|
|
843
|
+
),
|
|
844
|
+
),
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
// 4. Initialize standby as a streaming replica of the primary.
|
|
848
|
+
// Modeled after standby-init.sh — runs pg_basebackup directly inside the standby pod
|
|
849
|
+
// connecting to the primary's supabase node via hostPort 5432 (the Hetzner Cloud firewall
|
|
850
|
+
// was opened in step 3 of the HA orchestrator). Uses -Fp (plain format) and -R to auto-
|
|
851
|
+
// create standby.signal and primary_conninfo.
|
|
852
|
+
const primaryHost = _primarySupabaseIp;
|
|
853
|
+
const primaryPort = '5432';
|
|
854
|
+
|
|
855
|
+
// 4a. Stop standby postgres gracefully
|
|
856
|
+
await runCommandAsync(
|
|
857
|
+
[
|
|
858
|
+
'ssh',
|
|
859
|
+
...sshOpts,
|
|
860
|
+
`root@${standbyIp}`,
|
|
861
|
+
`kubectl exec -n vibecarbon supabase-supabase-db-0 -- su postgres -c 'pg_ctl stop -D /var/lib/postgresql/data -m fast'`,
|
|
862
|
+
],
|
|
863
|
+
{ silent: true, ignoreError: true },
|
|
864
|
+
);
|
|
865
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
866
|
+
|
|
867
|
+
// 4b. Wipe standby PGDATA and run pg_basebackup from primary.
|
|
868
|
+
//
|
|
869
|
+
// Hardened against the failure mode that produced 100% k8s-ha verify-failover
|
|
870
|
+
// failures (project_replication_broken memory + iter 2026-04-30):
|
|
871
|
+
// 1. Connectivity probe FIRST — if primary's hostPort 5432 is not reachable
|
|
872
|
+
// from the standby pod (cross-cluster network gap), abort BEFORE wiping.
|
|
873
|
+
// Skipped replication = standby boots as its own healthy postgres; failover
|
|
874
|
+
// will still serve a healthy app (just without primary's data).
|
|
875
|
+
// 2. `set -e -o pipefail` — partial pg_basebackup leaves PGDATA non-empty
|
|
876
|
+
// without PG_VERSION; the supabase-db entrypoint then runs initdb on next
|
|
877
|
+
// restart, which aborts on "directory exists but is not empty" and
|
|
878
|
+
// crashloops forever.
|
|
879
|
+
// 3. Stage into a tmp dir + atomic swap — failed basebackup never leaves
|
|
880
|
+
// PGDATA in a half-formed state.
|
|
881
|
+
// 4. Sanity check PG_VERSION is present after basebackup before swapping in.
|
|
882
|
+
//
|
|
883
|
+
// -Fp/-Xs/-R/-S: see prior comment — unchanged semantics.
|
|
884
|
+
// Static staging path (single-instance pod, no PID collision concern).
|
|
885
|
+
// Literal strings everywhere — avoids the JS-template / bash-escape
|
|
886
|
+
// confusion that would silently produce `$STAGE` literals if backslashes
|
|
887
|
+
// are off by one.
|
|
888
|
+
const pgBasebackupScript = `
|
|
889
|
+
set -e -o pipefail
|
|
890
|
+
|
|
891
|
+
# Probe primary's hostPort 5432 from inside the standby pod. If unreachable
|
|
892
|
+
# (cross-cluster network gap is the historical case), abort cleanly so the
|
|
893
|
+
# standby keeps its self-bootstrapped postgres rather than entering a
|
|
894
|
+
# permanent CrashLoopBackOff via half-wiped PGDATA.
|
|
895
|
+
if ! pg_isready -h ${primaryHost} -p ${primaryPort} -t 5 -U replicator -d postgres > /dev/null 2>&1; then
|
|
896
|
+
echo "[ha-replication] primary postgres at ${primaryHost}:${primaryPort} not reachable from standby pod — skipping pg_basebackup. Standby will run as an independent postgres; failover serves the app but data does not sync from primary." >&2
|
|
897
|
+
exit 0
|
|
898
|
+
fi
|
|
899
|
+
|
|
900
|
+
# Stage into a tmp dir; atomic swap only after basebackup verifies good.
|
|
901
|
+
rm -rf /var/lib/postgresql/data.staging
|
|
902
|
+
mkdir -p /var/lib/postgresql/data.staging
|
|
903
|
+
chown postgres:postgres /var/lib/postgresql/data.staging
|
|
904
|
+
|
|
905
|
+
PGPASSWORD='${replPassword}' pg_basebackup \\
|
|
906
|
+
-h ${primaryHost} -p ${primaryPort} \\
|
|
907
|
+
-U replicator -D /var/lib/postgresql/data.staging \\
|
|
908
|
+
-Fp -Xs -R -c fast -S vibecarbon_standby_slot
|
|
909
|
+
|
|
910
|
+
# Sanity: a successful pg_basebackup always writes PG_VERSION + standby.signal (with -R).
|
|
911
|
+
if [ ! -f /var/lib/postgresql/data.staging/PG_VERSION ]; then
|
|
912
|
+
echo "[ha-replication] pg_basebackup completed without PG_VERSION — refusing to swap" >&2
|
|
913
|
+
rm -rf /var/lib/postgresql/data.staging
|
|
914
|
+
exit 1
|
|
915
|
+
fi
|
|
916
|
+
# -R should already have created standby.signal; touch belt-and-suspenders.
|
|
917
|
+
touch /var/lib/postgresql/data.staging/standby.signal
|
|
918
|
+
echo "primary_slot_name = 'vibecarbon_standby_slot'" >> /var/lib/postgresql/data.staging/postgresql.auto.conf
|
|
919
|
+
chown -R postgres:postgres /var/lib/postgresql/data.staging
|
|
920
|
+
|
|
921
|
+
# Atomic-ish swap. Old PGDATA is moved aside first; on success it's removed.
|
|
922
|
+
# A failure during swap leaves the original at data.prev for manual recovery.
|
|
923
|
+
rm -rf /var/lib/postgresql/data.prev
|
|
924
|
+
mv /var/lib/postgresql/data /var/lib/postgresql/data.prev
|
|
925
|
+
mv /var/lib/postgresql/data.staging /var/lib/postgresql/data
|
|
926
|
+
rm -rf /var/lib/postgresql/data.prev
|
|
927
|
+
`.trim();
|
|
928
|
+
await runCommandAsync(
|
|
929
|
+
[
|
|
930
|
+
'ssh',
|
|
931
|
+
...sshOpts,
|
|
932
|
+
`root@${standbyIp}`,
|
|
933
|
+
'kubectl exec -i -n vibecarbon supabase-supabase-db-0 -- bash',
|
|
934
|
+
],
|
|
935
|
+
{ silent: false, input: pgBasebackupScript, ignoreError: true },
|
|
936
|
+
);
|
|
937
|
+
|
|
938
|
+
// 4c. Start standby postgres in recovery mode (no-op when basebackup was
|
|
939
|
+
// skipped — postgres is still running on the original PGDATA).
|
|
940
|
+
await runCommandAsync(
|
|
941
|
+
[
|
|
942
|
+
'ssh',
|
|
943
|
+
...sshOpts,
|
|
944
|
+
`root@${standbyIp}`,
|
|
945
|
+
`kubectl exec -n vibecarbon supabase-supabase-db-0 -- su postgres -c 'pg_ctl start -D /var/lib/postgresql/data -l /var/log/postgresql/postgresql.log'`,
|
|
946
|
+
],
|
|
947
|
+
{ silent: true, ignoreError: true },
|
|
948
|
+
);
|
|
949
|
+
|
|
950
|
+
// 4d. Wait for standby to enter recovery mode. Standby flips to
|
|
951
|
+
// in-recovery within a second once pg_ctl start returns; fast polls
|
|
952
|
+
// early so we catch the typical case in ~1s instead of 2s+.
|
|
953
|
+
for (let i = 0; i < 30; i++) {
|
|
954
|
+
try {
|
|
955
|
+
const result = await runCommandAsync(
|
|
956
|
+
[
|
|
957
|
+
'ssh',
|
|
958
|
+
...sshOpts,
|
|
959
|
+
`root@${standbyIp}`,
|
|
960
|
+
`kubectl exec -n vibecarbon supabase-supabase-db-0 -- psql -U supabase_admin -tAc 'SELECT pg_is_in_recovery()'`,
|
|
961
|
+
],
|
|
962
|
+
{ silent: true, ignoreError: true },
|
|
963
|
+
);
|
|
964
|
+
if (result?.trim() === 't') break;
|
|
965
|
+
} catch {
|
|
966
|
+
// Not ready yet
|
|
967
|
+
}
|
|
968
|
+
if (i === 29) break;
|
|
969
|
+
const interval = i < 5 ? 500 : 2000;
|
|
970
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// 5. Verify replication is streaming on the primary. Once standby catches
|
|
974
|
+
// up, pg_stat_replication.state flips to 'streaming' on the next read.
|
|
975
|
+
let streaming = false;
|
|
976
|
+
for (let i = 0; i < 15; i++) {
|
|
977
|
+
try {
|
|
978
|
+
const result = await runCommandAsync(
|
|
979
|
+
[
|
|
980
|
+
'ssh',
|
|
981
|
+
...sshOpts,
|
|
982
|
+
`root@${primaryIp}`,
|
|
983
|
+
`kubectl exec -n vibecarbon supabase-supabase-db-0 -- psql -U supabase_admin -tAc 'SELECT state FROM pg_stat_replication LIMIT 1'`,
|
|
984
|
+
],
|
|
985
|
+
{ silent: true, ignoreError: true },
|
|
986
|
+
);
|
|
987
|
+
if (result?.trim() === 'streaming') {
|
|
988
|
+
streaming = true;
|
|
989
|
+
break;
|
|
990
|
+
}
|
|
991
|
+
} catch {
|
|
992
|
+
// Retry
|
|
993
|
+
}
|
|
994
|
+
if (i === 14) break;
|
|
995
|
+
const interval = i < 3 ? 500 : 2000;
|
|
996
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
return { streaming };
|
|
1000
|
+
}
|