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,1401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deployment Orchestrator
|
|
3
|
+
* Logic for executing the deployment after configuration is gathered
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import dns from 'node:dns';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import * as p from '@clack/prompts';
|
|
10
|
+
import { c } from '../colors.js';
|
|
11
|
+
import { runCommand, runCommandAsync } from '../command.js';
|
|
12
|
+
import {
|
|
13
|
+
loadCredentials,
|
|
14
|
+
loadProjectConfig,
|
|
15
|
+
registerProject,
|
|
16
|
+
saveProjectConfig,
|
|
17
|
+
} from '../config.js';
|
|
18
|
+
import { waitForDNSPropagation } from '../dns-propagation.js';
|
|
19
|
+
import { ensureOperatorIpAccess } from '../operator-ip.js';
|
|
20
|
+
import { perfAsync, perfTimer } from '../perf.js';
|
|
21
|
+
import { createTracker } from '../tracker.js';
|
|
22
|
+
import { renderBundle } from './bundle.js';
|
|
23
|
+
import { sideloadCompose } from './image.js';
|
|
24
|
+
import { buildRemote } from './remote-build.js';
|
|
25
|
+
import { StateTracker } from './state.js';
|
|
26
|
+
import { generateSSHKeyPair } from './utils.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Probe the public /api/health endpoint to verify the app is reachable
|
|
30
|
+
* over the internet.
|
|
31
|
+
*
|
|
32
|
+
* K8s cold deploys need a generous budget: after the GHA workflow returns,
|
|
33
|
+
* cert-manager may still be finishing cert issuance, DNS may be
|
|
34
|
+
* propagating, and the ingress may be picking up new routes. 10 minutes
|
|
35
|
+
* handles first-deploy cert+DNS tail without false-negatives. Compose
|
|
36
|
+
* already has a faster on-server health gate before we ever get here, so
|
|
37
|
+
* 60s is enough to catch DNS edge cases.
|
|
38
|
+
*
|
|
39
|
+
* When `ACME_CA_SERVER` is set to a Let's Encrypt staging URL (e2e
|
|
40
|
+
* tests, dev envs avoiding LE rate limits), cert-manager issues a cert
|
|
41
|
+
* signed by the LE Staging CA which Node's default trust store does not
|
|
42
|
+
* include — fetch() would reject every probe with "self-signed cert"
|
|
43
|
+
* even though the app is healthy. Detect that case and use an undici
|
|
44
|
+
* dispatcher that skips TLS verification. Real-world prod runs are
|
|
45
|
+
* unaffected.
|
|
46
|
+
*
|
|
47
|
+
* Returns `{ ok: true, status }` on first 2xx, or a structured failure
|
|
48
|
+
* descriptor on timeout — never null. Capturing last-status and last-error
|
|
49
|
+
* lets the caller throw with actionable context (e.g. "lastStatus=503 over
|
|
50
|
+
* 120 attempts" tells you ingress is up but app is failing) instead of the
|
|
51
|
+
* old opaque null which made the error message default to whatever stderr
|
|
52
|
+
* tail the runner happened to scrape (the 2026-04-27 morning matrix's k8s
|
|
53
|
+
* timeout surfaced as a CSP header dump because of this exact gap).
|
|
54
|
+
*/
|
|
55
|
+
async function probePublicHealth(domain, { timeoutMs = 60_000, intervalMs = 10_000 } = {}) {
|
|
56
|
+
const url = `https://${domain}/api/health`;
|
|
57
|
+
const deadline = Date.now() + timeoutMs;
|
|
58
|
+
let firstAttempt = true;
|
|
59
|
+
let attempts = 0;
|
|
60
|
+
let lastStatus = null;
|
|
61
|
+
let lastErrorClass = null;
|
|
62
|
+
let lastErrorMessage = null;
|
|
63
|
+
let lastErrorCause = null;
|
|
64
|
+
// The probe runs IMMEDIATELY after we (re)write the DNS record, so the
|
|
65
|
+
// system resolver may still have a cached NXDOMAIN or a prior placeholder
|
|
66
|
+
// IP. Fetch then fails for the entire probe budget while curl (run once
|
|
67
|
+
// in the diagnostic dump much later) gets a fresh getaddrinfo and
|
|
68
|
+
// succeeds — the symptom that surfaced as "TypeError: fetch failed (121
|
|
69
|
+
// attempts)" while the diagnostic curl returned `{"status":"ok"}` in
|
|
70
|
+
// matrix #3 (k8s + k8s-ha). Pin the resolver to public DNS for the
|
|
71
|
+
// probe so we always see the freshest record. setServers is per-process
|
|
72
|
+
// and doesn't affect anything else in this orchestrator instance.
|
|
73
|
+
try {
|
|
74
|
+
dns.setDefaultResultOrder('verbatim');
|
|
75
|
+
dns.setServers(['1.1.1.1', '8.8.8.8']);
|
|
76
|
+
} catch {
|
|
77
|
+
// setServers can throw if called with an empty list or invalid IPs;
|
|
78
|
+
// both are caller errors, not runtime concerns. Fall through and use
|
|
79
|
+
// whatever resolver is currently configured.
|
|
80
|
+
}
|
|
81
|
+
// Lazy-load undici only when we need an insecure dispatcher — keeps the
|
|
82
|
+
// happy path zero-cost and avoids a hard dep on undici internals.
|
|
83
|
+
//
|
|
84
|
+
// CRITICAL: when we use undici's Agent we MUST also use undici's `fetch`,
|
|
85
|
+
// not Node 24's `globalThis.fetch`. Node 24 ships its own bundled undici;
|
|
86
|
+
// its built-in fetch's dispatcher protocol is incompatible with the npm
|
|
87
|
+
// undici package's Agent (different `onRequestStart` signature). Mixing
|
|
88
|
+
// them produces `InvalidArgumentError: invalid onRequestStart method` on
|
|
89
|
+
// every probe call, with the misleading wrapper "TypeError: fetch failed"
|
|
90
|
+
// — which is what dominated every k8s + k8s-ha matrix failure for weeks.
|
|
91
|
+
// Always pair Agent + fetch from the same undici instance.
|
|
92
|
+
let dispatcher = null;
|
|
93
|
+
let fetchFn = fetch;
|
|
94
|
+
if ((process.env.ACME_CA_SERVER || '').includes('staging')) {
|
|
95
|
+
const undici = await import('undici');
|
|
96
|
+
dispatcher = new undici.Agent({ connect: { rejectUnauthorized: false } });
|
|
97
|
+
fetchFn = undici.fetch;
|
|
98
|
+
}
|
|
99
|
+
// Periodic progress logging: every 5 attempts, write a one-line summary
|
|
100
|
+
// of the most recent failure class + lastStatus to stderr. Without this,
|
|
101
|
+
// the only signal we get from a 5-min probe is the FINAL outcome — which
|
|
102
|
+
// tells us nothing about whether the early failures were DNS-related,
|
|
103
|
+
// TCP-refused, or app-side 5xx. iter-perfwave2 analysis: k8s-ha probe
|
|
104
|
+
// hits exactly 300s = 30 × 10s (DNS TTL match suspicious), but with no
|
|
105
|
+
// per-attempt visibility we can't distinguish "DNS cached for 300s" from
|
|
106
|
+
// "ACME issuance pending for 300s" from "app slow to converge".
|
|
107
|
+
const startTime = Date.now();
|
|
108
|
+
const logProgress = () => {
|
|
109
|
+
const elapsedSec = Math.round((Date.now() - startTime) / 1000);
|
|
110
|
+
const lastSig = lastStatus
|
|
111
|
+
? `lastStatus=${lastStatus}`
|
|
112
|
+
: lastErrorCause
|
|
113
|
+
? `lastError=${lastErrorClass}/${lastErrorCause}`
|
|
114
|
+
: lastErrorClass
|
|
115
|
+
? `lastError=${lastErrorClass}`
|
|
116
|
+
: 'no-response-yet';
|
|
117
|
+
console.error(`[probe] ${url} attempt=${attempts} elapsed=${elapsedSec}s ${lastSig}`);
|
|
118
|
+
};
|
|
119
|
+
while (Date.now() < deadline) {
|
|
120
|
+
if (!firstAttempt) await new Promise((r) => setTimeout(r, intervalMs));
|
|
121
|
+
firstAttempt = false;
|
|
122
|
+
attempts++;
|
|
123
|
+
try {
|
|
124
|
+
const res = await fetchFn(url, {
|
|
125
|
+
method: 'GET',
|
|
126
|
+
signal: AbortSignal.timeout(8_000),
|
|
127
|
+
...(dispatcher && { dispatcher }),
|
|
128
|
+
});
|
|
129
|
+
lastStatus = res.status;
|
|
130
|
+
if (res.ok) return { ok: true, status: String(res.status), attempts };
|
|
131
|
+
} catch (err) {
|
|
132
|
+
// Capture failure class + short message so the eventual throw can
|
|
133
|
+
// say *why* the probe never reached 2xx. Common patterns:
|
|
134
|
+
// - TimeoutError → ingress not routing or pod hung
|
|
135
|
+
// - TypeError "fetch failed" → DNS or TCP refused
|
|
136
|
+
// - Error "self-signed certificate" → LE staging cert + dispatcher
|
|
137
|
+
// misconfig (we should have set the insecure dispatcher above)
|
|
138
|
+
// Undici nests the actual reason in err.cause (e.g. ENOTFOUND,
|
|
139
|
+
// ECONNREFUSED, EAI_AGAIN, UND_ERR_SOCKET). The wrapper "fetch
|
|
140
|
+
// failed" is useless without it — capture the cause's name+message
|
|
141
|
+
// so we can distinguish DNS failure from TCP refused from TLS
|
|
142
|
+
// handshake failure without re-running the matrix.
|
|
143
|
+
lastErrorClass = err?.name ?? 'Unknown';
|
|
144
|
+
lastErrorMessage = (err instanceof Error ? err.message : String(err)).slice(0, 200);
|
|
145
|
+
const cause = err && typeof err === 'object' && 'cause' in err ? err.cause : null;
|
|
146
|
+
if (cause) {
|
|
147
|
+
const causeName = cause?.name ?? cause?.code ?? 'UnknownCause';
|
|
148
|
+
const causeMsg = (cause instanceof Error ? cause.message : String(cause)).slice(0, 200);
|
|
149
|
+
lastErrorCause = `${causeName}: ${causeMsg}`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (attempts % 5 === 0) logProgress();
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
attempts,
|
|
157
|
+
timeoutMs,
|
|
158
|
+
lastStatus,
|
|
159
|
+
lastErrorClass,
|
|
160
|
+
lastErrorMessage,
|
|
161
|
+
lastErrorCause,
|
|
162
|
+
url,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Execute the deployment based on gathered configuration
|
|
168
|
+
*/
|
|
169
|
+
export async function executeDeployment(args, gatheredConfig) {
|
|
170
|
+
const {
|
|
171
|
+
projectConfig,
|
|
172
|
+
envConfig,
|
|
173
|
+
environment,
|
|
174
|
+
config,
|
|
175
|
+
apiToken,
|
|
176
|
+
region,
|
|
177
|
+
secondaryRegion,
|
|
178
|
+
serverType,
|
|
179
|
+
masterServerType,
|
|
180
|
+
supabaseServerType,
|
|
181
|
+
workerServerType,
|
|
182
|
+
minWorkers,
|
|
183
|
+
maxWorkers,
|
|
184
|
+
domain,
|
|
185
|
+
dnsProvider,
|
|
186
|
+
cloudflareApiToken,
|
|
187
|
+
cloudflareZoneId,
|
|
188
|
+
hetznerDnsZoneId,
|
|
189
|
+
s3Config,
|
|
190
|
+
backupS3Config,
|
|
191
|
+
backupConfig,
|
|
192
|
+
services,
|
|
193
|
+
} = gatheredConfig;
|
|
194
|
+
|
|
195
|
+
const { deployMode } = config;
|
|
196
|
+
const isComposeDeploy = deployMode === 'compose' || deployMode === 'compose-ha';
|
|
197
|
+
const isHA = deployMode === 'compose-ha' || !!config.ha;
|
|
198
|
+
|
|
199
|
+
// Operator-IP access: detect + persist + (when env already deployed) patch
|
|
200
|
+
// the live Hetzner firewall. On a fresh first deploy this just appends to
|
|
201
|
+
// projectConfig.operatorCidrs in memory and on disk — the firewall itself
|
|
202
|
+
// is created later in this function with the correct source_ips because
|
|
203
|
+
// every program-builder call site reads projectConfig.operatorCidrs.
|
|
204
|
+
const accessResult = await ensureOperatorIpAccess({
|
|
205
|
+
projectConfig,
|
|
206
|
+
environment,
|
|
207
|
+
isHA,
|
|
208
|
+
apiToken,
|
|
209
|
+
yes: !!args.yes,
|
|
210
|
+
onMessage: (msg) => p.log.info(msg),
|
|
211
|
+
});
|
|
212
|
+
if (accessResult.added && accessResult.fromEnv) {
|
|
213
|
+
p.log.info(`Bootstrapped operator CIDRs from ALLOWED_SSH_IPS env var: ${accessResult.cidr}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Initialize state tracker for granular resume
|
|
217
|
+
const state = new StateTracker(projectConfig.projectName, environment);
|
|
218
|
+
|
|
219
|
+
// Clear state if --full is passed
|
|
220
|
+
if (args.full) {
|
|
221
|
+
state.clear();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const s3Spinner = p.spinner();
|
|
225
|
+
const { HetznerS3Provider } = await import('../providers/hetzner-s3.js');
|
|
226
|
+
const s3Provider = new HetznerS3Provider(s3Config.accessKey, s3Config.secretKey, s3Config.region);
|
|
227
|
+
|
|
228
|
+
// --- STEP 1: S3 Setup (Idempotent) ---
|
|
229
|
+
const s3Inputs = {
|
|
230
|
+
bucket: s3Config.bucket,
|
|
231
|
+
backupBucket: backupS3Config.bucket,
|
|
232
|
+
region: s3Config.region,
|
|
233
|
+
};
|
|
234
|
+
if (!state.shouldSkip('s3-setup', s3Inputs)) {
|
|
235
|
+
state.startStep('s3-setup', s3Inputs);
|
|
236
|
+
try {
|
|
237
|
+
s3Spinner.start(`Creating S3 bucket: ${s3Config.bucket}`);
|
|
238
|
+
const bucket = await s3Provider.createBucket(s3Config.bucket);
|
|
239
|
+
s3Spinner.stop(
|
|
240
|
+
bucket.created
|
|
241
|
+
? `S3 bucket created: ${s3Config.bucket}`
|
|
242
|
+
: `S3 bucket exists: ${s3Config.bucket}`,
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
s3Spinner.start('Configuring bucket CORS');
|
|
247
|
+
const corsOrigins = domain ? [`https://${domain}`] : ['*'];
|
|
248
|
+
await s3Provider.configureCORS(s3Config.bucket, corsOrigins);
|
|
249
|
+
s3Spinner.stop('CORS configured');
|
|
250
|
+
} catch {
|
|
251
|
+
s3Spinner.stop('CORS configuration skipped (not supported by provider)');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
s3Spinner.start(`Creating backup bucket: ${backupS3Config.bucket}`);
|
|
255
|
+
try {
|
|
256
|
+
const backupBucket = await s3Provider.createBucket(backupS3Config.bucket);
|
|
257
|
+
s3Spinner.stop(
|
|
258
|
+
backupBucket.created
|
|
259
|
+
? `Backup bucket created: ${backupS3Config.bucket}`
|
|
260
|
+
: `Backup bucket exists: ${backupS3Config.bucket}`,
|
|
261
|
+
);
|
|
262
|
+
} catch (backupBucketError) {
|
|
263
|
+
s3Spinner.stop(`Backup bucket creation failed: ${backupBucketError.message}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const result = { region: s3Provider.region, endpoint: s3Provider.getEndpoint() };
|
|
267
|
+
state.completeStep('s3-setup', result);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
const errMsg =
|
|
270
|
+
error.message ||
|
|
271
|
+
error.Code ||
|
|
272
|
+
error.name ||
|
|
273
|
+
`HTTP ${error.$metadata?.httpStatusCode || 'unknown'}`;
|
|
274
|
+
s3Spinner.stop(`S3 setup failed: ${errMsg}`);
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Apply resolved S3 values from state
|
|
280
|
+
const s3Result = state.getStepResult('s3-setup');
|
|
281
|
+
if (s3Result) {
|
|
282
|
+
s3Config.region = s3Result.region;
|
|
283
|
+
s3Config.endpoint = s3Result.endpoint;
|
|
284
|
+
backupS3Config.region = s3Config.region;
|
|
285
|
+
backupS3Config.endpoint = s3Config.endpoint;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// SKELETON SAVE: persist the minimum env entry that `vibecarbon destroy`
|
|
289
|
+
// needs to recover, BEFORE any `pulumi up` runs. Without this, a crash
|
|
290
|
+
// during k3s install / cloud-init / health probe leaves Pulumi state in
|
|
291
|
+
// S3 + real Hetzner infra alive while .vibecarbon.json has no entry —
|
|
292
|
+
// destroy reads the empty config, finds nothing, exits cleanly, and the
|
|
293
|
+
// PG/network/firewall/FIP outlive the test until the sweep catches them
|
|
294
|
+
// (or, worse, blocks the next deploy with a quota error). Observed in
|
|
295
|
+
// matrix #3 (3 PGs + 3 networks + 1 firewall leaked from k8s/k8s-ha
|
|
296
|
+
// health-probe failures). Fields: deployMode + region + s3 give k8s
|
|
297
|
+
// destroy the Pulumi backend; secondaryRegion + ha.enabled give k8s-ha
|
|
298
|
+
// destroy the `-primary` / `-standby` stack enumeration; the compose
|
|
299
|
+
// destroy path already falls back to the deterministic Pulumi name
|
|
300
|
+
// `${projectName}-${envName}` when servers[] is empty (see
|
|
301
|
+
// src/destroy.js:1067-1093), so the skeleton is sufficient there too.
|
|
302
|
+
// Subsequent saves (mid-flight from compose/ha.js + k8s/ha, plus the
|
|
303
|
+
// final save at the end of executeDeployment) merge with on-disk state,
|
|
304
|
+
// so the skeleton is non-destructive — they layer on top of it.
|
|
305
|
+
{
|
|
306
|
+
const existingEnv = projectConfig.environments?.[environment] ?? {};
|
|
307
|
+
const skeletonEnv = {
|
|
308
|
+
...existingEnv,
|
|
309
|
+
envName: environment,
|
|
310
|
+
status: 'deploying',
|
|
311
|
+
deployMode,
|
|
312
|
+
provider: config.provider,
|
|
313
|
+
region,
|
|
314
|
+
...(secondaryRegion ? { secondaryRegion } : {}),
|
|
315
|
+
...(domain ? { domain } : {}),
|
|
316
|
+
...(dnsProvider ? { dnsProvider } : {}),
|
|
317
|
+
s3: { bucket: s3Config.bucket, region: s3Config.region, endpoint: s3Config.endpoint },
|
|
318
|
+
backupS3: backupS3Config?.bucket
|
|
319
|
+
? {
|
|
320
|
+
bucket: backupS3Config.bucket,
|
|
321
|
+
region: backupS3Config.region,
|
|
322
|
+
endpoint: backupS3Config.endpoint,
|
|
323
|
+
}
|
|
324
|
+
: existingEnv.backupS3,
|
|
325
|
+
...(deployMode === 'compose-ha' || (deployMode === 'kubernetes' && secondaryRegion)
|
|
326
|
+
? {
|
|
327
|
+
ha: {
|
|
328
|
+
...(existingEnv.ha ?? {}),
|
|
329
|
+
enabled: true,
|
|
330
|
+
...(secondaryRegion ? { failoverRegion: secondaryRegion } : {}),
|
|
331
|
+
},
|
|
332
|
+
}
|
|
333
|
+
: {}),
|
|
334
|
+
lastAttempt: new Date().toISOString(),
|
|
335
|
+
};
|
|
336
|
+
projectConfig.environments = projectConfig.environments ?? {};
|
|
337
|
+
projectConfig.environments[environment] = skeletonEnv;
|
|
338
|
+
saveProjectConfig(projectConfig);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const tracker = createTracker('deploy', {
|
|
342
|
+
environment: config.environment,
|
|
343
|
+
provider: config.provider,
|
|
344
|
+
region,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// --- STEP 2: Image Resolve ---
|
|
348
|
+
// resolveBuildMode returns one of:
|
|
349
|
+
// 'local' — k3s default: deployK3s builds + sideloads + applies
|
|
350
|
+
// manifests inline. No CI image dependency.
|
|
351
|
+
// 'direct' — compose: build locally on dev, push image directly
|
|
352
|
+
// to server (auto-detected when CI/CD is not configured).
|
|
353
|
+
// 'push' — compose: git push triggers GHA build + Flux reconcile
|
|
354
|
+
// (auto-detected when CI/CD is configured).
|
|
355
|
+
const { ensureCIImageReady, buildImageRef, resolveBuildMode, waitForGhcrManifest } = await import(
|
|
356
|
+
'../ci-setup.js'
|
|
357
|
+
);
|
|
358
|
+
const imageResolveTimer = perfTimer('deploy.image.resolve');
|
|
359
|
+
const buildMode = resolveBuildMode(args, process.cwd(), deployMode);
|
|
360
|
+
const isDirectDeploy = buildMode === 'direct';
|
|
361
|
+
const isComposeMode = deployMode === 'compose' || deployMode === 'compose-ha';
|
|
362
|
+
const isComposeLocal = buildMode === 'local' && isComposeMode;
|
|
363
|
+
let imageReadyPromise;
|
|
364
|
+
// For compose-local: kicked off here in parallel with iac.upStack below.
|
|
365
|
+
// The build is awaited later (just before sideload). Other modes leave
|
|
366
|
+
// this null and use the existing remote-build / push paths.
|
|
367
|
+
let composeLocalBuildPromise = null;
|
|
368
|
+
|
|
369
|
+
const localImageTag = `${projectConfig.projectName}-app:local`;
|
|
370
|
+
|
|
371
|
+
if (isComposeLocal) {
|
|
372
|
+
// Build the app image locally NOW, in parallel with iac.upStack which
|
|
373
|
+
// starts ~immediately below. The tag is fixed (no serverIp dependency)
|
|
374
|
+
// and matches what bundle.js stamps into APP_IMAGE in the rendered
|
|
375
|
+
// .env, so docker-compose.prod.yml's `image: ${APP_IMAGE}` resolves to
|
|
376
|
+
// the sideloaded image. iac.upStack typically runs ~109s; a warm
|
|
377
|
+
// operator-side build runs ~15-30s, so the build finishes well inside
|
|
378
|
+
// the upStack window — net saving is the full build duration off the
|
|
379
|
+
// critical path. State-tracker stepId 'compose-localbuild' is distinct
|
|
380
|
+
// from 'compose-upstack' so concurrent state writes don't collide
|
|
381
|
+
// (state.js uses last-writer-wins per stepId).
|
|
382
|
+
// silent: true so BuildKit progress doesn't interleave with the
|
|
383
|
+
// spinner output from upStack (which is running concurrently). On
|
|
384
|
+
// failure runCommandAsync includes captured stdout/stderr in the
|
|
385
|
+
// thrown error — operator still sees what went wrong.
|
|
386
|
+
// Plumb VITE_* build args through to docker build so the browser bundle
|
|
387
|
+
// ships with real values for VITE_SUPABASE_URL etc. Without these the
|
|
388
|
+
// Dockerfile's ARG VITE_* defaults to empty strings and Vite inlines
|
|
389
|
+
// empty values, causing the client-side supabase init to throw at
|
|
390
|
+
// page load. Mirrors what docker-compose.yml's build.args block does
|
|
391
|
+
// for local-dev `docker compose up --build`. See
|
|
392
|
+
// src/lib/deploy/compose/build-args.js for the rationale.
|
|
393
|
+
const { collectComposeBuildArgs, buildArgFlags } = await import('./compose/build-args.js');
|
|
394
|
+
const localBuildArgs = collectComposeBuildArgs(process.cwd(), {
|
|
395
|
+
projectName: projectConfig.projectName,
|
|
396
|
+
domain,
|
|
397
|
+
});
|
|
398
|
+
composeLocalBuildPromise = perfAsync('deploy.image.localBuild', () =>
|
|
399
|
+
runCommandAsync(
|
|
400
|
+
['docker', 'build', ...buildArgFlags(localBuildArgs), '-t', localImageTag, process.cwd()],
|
|
401
|
+
{ silent: true },
|
|
402
|
+
),
|
|
403
|
+
);
|
|
404
|
+
imageReadyPromise = Promise.resolve({
|
|
405
|
+
imageTag: localImageTag,
|
|
406
|
+
githubOwner: 'noop',
|
|
407
|
+
repoName: projectConfig.projectName,
|
|
408
|
+
ghcrPullCreds: null,
|
|
409
|
+
isLocal: true,
|
|
410
|
+
});
|
|
411
|
+
} else if (buildMode === 'local') {
|
|
412
|
+
// K8s 'local' build mode: deployK3s handles the build + sideload +
|
|
413
|
+
// manifest apply inline. No CI image to wait for; stub the
|
|
414
|
+
// image-ready contract so downstream code sees a deterministic shape.
|
|
415
|
+
imageReadyPromise = Promise.resolve({
|
|
416
|
+
imageTag: localImageTag,
|
|
417
|
+
githubOwner: 'noop',
|
|
418
|
+
repoName: projectConfig.projectName,
|
|
419
|
+
ghcrPullCreds: null,
|
|
420
|
+
isLocal: true,
|
|
421
|
+
});
|
|
422
|
+
} else if (buildMode === 'direct') {
|
|
423
|
+
imageReadyPromise = Promise.resolve({ imageTag: localImageTag, isLocal: true });
|
|
424
|
+
} else {
|
|
425
|
+
// CI path: ensureCIImageReady performs the workflow dispatch + GHA-build
|
|
426
|
+
// wait. On a cold push deploy this is the dominant cost (~10 min).
|
|
427
|
+
imageReadyPromise = perfAsync('deploy.image.ciWait', () =>
|
|
428
|
+
ensureCIImageReady({ yes: args.yes, tracker }),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const ciReady = await imageReadyPromise;
|
|
433
|
+
if (!ciReady.isLocal) {
|
|
434
|
+
p.log.info(
|
|
435
|
+
`Deploying image: ${c.bold(buildImageRef(ciReady.githubOwner, ciReady.repoName, ciReady.imageTag))}`,
|
|
436
|
+
);
|
|
437
|
+
// Bridge the race between the GitHub packages API (ghcrTagExists) and
|
|
438
|
+
// the OCI manifests endpoint that kubelet/docker actually pull from.
|
|
439
|
+
// HEAD-probe the manifest URL with short polling instead of a blanket
|
|
440
|
+
// 30s sleep — typical hit is <5s, worst observed ~15s.
|
|
441
|
+
const s = p.spinner();
|
|
442
|
+
s.start('Waiting for GHCR manifest to propagate...');
|
|
443
|
+
const manifestReady = await perfAsync('deploy.image.manifestPropagate', () =>
|
|
444
|
+
waitForGhcrManifest(ciReady.githubOwner, ciReady.repoName, ciReady.imageTag),
|
|
445
|
+
);
|
|
446
|
+
s.stop(manifestReady ? 'Manifest propagated' : 'Manifest probe timed out (proceeding anyway)');
|
|
447
|
+
}
|
|
448
|
+
imageResolveTimer.end();
|
|
449
|
+
const imageRef = ciReady.isLocal
|
|
450
|
+
? ciReady.imageTag
|
|
451
|
+
: buildImageRef(ciReady.githubOwner, ciReady.repoName, ciReady.imageTag);
|
|
452
|
+
|
|
453
|
+
// --- STEP 3: Local Bundle Rendering (Unified) ---
|
|
454
|
+
// renderBundle is sync (cp + writeFile fan-out); perfTimer captures total
|
|
455
|
+
// wall-clock time for the staging-dir build. Typical ~50-300ms.
|
|
456
|
+
const bundleRenderTimer = perfTimer('deploy.bundle.pack');
|
|
457
|
+
// verbose=false: per-file `[bundle] Copying X` lines bypass the clack
|
|
458
|
+
// gutter and print flush-left, breaking the visual flow. Bundle content
|
|
459
|
+
// is reproducible from the project tree; debug it by re-running with
|
|
460
|
+
// VIBECARBON_PERF=1 + VIBECARBON_BUNDLE_VERBOSE=1 if needed.
|
|
461
|
+
const bundlePath = renderBundle(projectConfig.projectName, {
|
|
462
|
+
domain,
|
|
463
|
+
image: imageRef,
|
|
464
|
+
observability: services.observability,
|
|
465
|
+
n8n: services.n8n,
|
|
466
|
+
metabase: services.metabase,
|
|
467
|
+
redis: services.redis,
|
|
468
|
+
s3: s3Config,
|
|
469
|
+
backupS3: backupS3Config,
|
|
470
|
+
verbose: process.env.VIBECARBON_BUNDLE_VERBOSE === '1',
|
|
471
|
+
});
|
|
472
|
+
bundleRenderTimer.end();
|
|
473
|
+
|
|
474
|
+
// --- STEP 4: DNS Warm-up (parallel with VM provisioning) ---
|
|
475
|
+
// Kick off the placeholder-DNS write in the background so TTL starts
|
|
476
|
+
// draining while we spin up the VM. The real-IP update below awaits the
|
|
477
|
+
// warm-up handle to avoid racing two writes on the same record.
|
|
478
|
+
const dnsInputs = { domain, dnsProvider, cloudflareZoneId, hetznerDnsZoneId };
|
|
479
|
+
const dnsWarmupPromise =
|
|
480
|
+
domain && dnsProvider && dnsProvider !== 'manual' && !state.shouldSkip('dns-setup', dnsInputs)
|
|
481
|
+
? (async () => {
|
|
482
|
+
state.startStep('dns-setup', dnsInputs);
|
|
483
|
+
try {
|
|
484
|
+
const { setupSimple: updateDnsIp } =
|
|
485
|
+
dnsProvider === 'cloudflare'
|
|
486
|
+
? await import('../cloudflare.js')
|
|
487
|
+
: await import('../hetzner-dns.js');
|
|
488
|
+
const token = dnsProvider === 'cloudflare' ? cloudflareApiToken : apiToken;
|
|
489
|
+
const zoneId = dnsProvider === 'cloudflare' ? cloudflareZoneId : hetznerDnsZoneId;
|
|
490
|
+
if (!zoneId) {
|
|
491
|
+
p.log.info('DNS warm-up skipped (Zone ID not found)');
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
const result = await updateDnsIp(token, zoneId, domain, '0.0.0.0');
|
|
495
|
+
state.completeStep('dns-setup', result);
|
|
496
|
+
p.log.info('DNS records warmed up');
|
|
497
|
+
return result;
|
|
498
|
+
} catch (error) {
|
|
499
|
+
p.log.warn(`Initial DNS setup failed: ${error.message}`);
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
})()
|
|
503
|
+
: Promise.resolve(null);
|
|
504
|
+
|
|
505
|
+
// --- STEP 5: Architecture Dispatch ---
|
|
506
|
+
let deployResult;
|
|
507
|
+
if (isComposeDeploy) {
|
|
508
|
+
if (deployMode === 'compose-ha') {
|
|
509
|
+
const { deployComposeHA } = await import('./compose/ha.js');
|
|
510
|
+
const sshKeyPath = join(process.cwd(), '.vibecarbon', `deploy_key_${environment}`);
|
|
511
|
+
const s = tracker.spinner();
|
|
512
|
+
s.start('Starting HA deployment...');
|
|
513
|
+
// Wrap the entire compose-HA pipeline so the perf trace shows the
|
|
514
|
+
// headline cost (which contains compose-ha.deploy.* sub-stages from
|
|
515
|
+
// compose/ha.js).
|
|
516
|
+
deployResult = await perfAsync('deploy.ha.compose.full', () =>
|
|
517
|
+
deployComposeHA({
|
|
518
|
+
projectName: projectConfig.projectName,
|
|
519
|
+
environment,
|
|
520
|
+
sshKeyPath,
|
|
521
|
+
domain,
|
|
522
|
+
region,
|
|
523
|
+
secondaryRegion: secondaryRegion || undefined,
|
|
524
|
+
serverType: serverType || 'cx23',
|
|
525
|
+
apiToken,
|
|
526
|
+
operatorCidrs: projectConfig.operatorCidrs ?? [],
|
|
527
|
+
cloudflareApiToken,
|
|
528
|
+
cloudflareZoneId,
|
|
529
|
+
hetznerDnsZoneId,
|
|
530
|
+
dnsProvider,
|
|
531
|
+
services,
|
|
532
|
+
dockerHubCreds: loadCredentials().dockerHub || null,
|
|
533
|
+
ghcrPullCreds: ciReady.ghcrPullCreds,
|
|
534
|
+
imageRef,
|
|
535
|
+
envConfig,
|
|
536
|
+
s3Config,
|
|
537
|
+
backupS3Config,
|
|
538
|
+
backupConfig,
|
|
539
|
+
bundlePath,
|
|
540
|
+
onProgress: (msg) => s.message(msg),
|
|
541
|
+
}),
|
|
542
|
+
);
|
|
543
|
+
s.stop('Compose HA deployment complete');
|
|
544
|
+
} else {
|
|
545
|
+
const { setupServer, dockerLoginOnServer, startComposeStack } = await import(
|
|
546
|
+
'./compose/index.js'
|
|
547
|
+
);
|
|
548
|
+
const { setupServerFiles, waitForSSH: waitForComposeSSH } = await import(
|
|
549
|
+
'./compose/index.js'
|
|
550
|
+
);
|
|
551
|
+
const sshKeyPath = join(process.cwd(), '.vibecarbon', `deploy_key_${environment}`);
|
|
552
|
+
|
|
553
|
+
let serverIp = envConfig.servers?.[0]?.ip || null;
|
|
554
|
+
// Track the Hetzner server's API id + literal name so destroy can find
|
|
555
|
+
// the VPS without falling back to the literal string "master" (which
|
|
556
|
+
// never matches the Pulumi-assigned name `${projectName}-${env}`).
|
|
557
|
+
// Without this, destroy → re-deploy hits 'server name is already used
|
|
558
|
+
// (uniqueness_error)' because the previous VPS was never deleted
|
|
559
|
+
// (caught in compose restore matrix runs).
|
|
560
|
+
let serverHetznerId = envConfig.servers?.[0]?.id || null;
|
|
561
|
+
let serverHetznerName = envConfig.servers?.[0]?.hetznerName || null;
|
|
562
|
+
if (!serverIp) {
|
|
563
|
+
generateSSHKeyPair(sshKeyPath);
|
|
564
|
+
const { upStack } = await import('../iac/index.js');
|
|
565
|
+
const { buildHetznerComposeProgram } = await import('../iac/programs/hetzner-compose.js');
|
|
566
|
+
const program = buildHetznerComposeProgram({
|
|
567
|
+
projectName: projectConfig.projectName,
|
|
568
|
+
environment,
|
|
569
|
+
sshPublicKey: readFileSync(`${sshKeyPath}.pub`, 'utf-8').trim(),
|
|
570
|
+
location: region,
|
|
571
|
+
serverType: serverType || 'cx23',
|
|
572
|
+
labels: { 'managed-by': 'vibecarbon' },
|
|
573
|
+
allowedSshIps: (projectConfig.operatorCidrs ?? []).map((e) => e.cidr),
|
|
574
|
+
});
|
|
575
|
+
// VM provisioning via Pulumi — typical 30-90s on Hetzner cx23.
|
|
576
|
+
const result = await perfAsync('deploy.iac.upStack', () =>
|
|
577
|
+
upStack(environment, program, { hcloudToken: apiToken, s3Config }),
|
|
578
|
+
);
|
|
579
|
+
serverIp = result.outputs.serverIp;
|
|
580
|
+
serverHetznerId = result.outputs.serverId || null;
|
|
581
|
+
serverHetznerName = `${projectConfig.projectName}-${environment}`;
|
|
582
|
+
await perfAsync('deploy.iac.waitForSSH', () => waitForComposeSSH(serverIp, sshKeyPath, 30));
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// --- Warm path (StateTracker) ---
|
|
586
|
+
// On a second deploy against an already-provisioned server, skip the
|
|
587
|
+
// idempotent-but-slow steps (cloud-init probe, GHCR login, bundle rsync)
|
|
588
|
+
// when their inputs haven't changed. `docker compose up -d` is cheap
|
|
589
|
+
// and always runs — it applies diffs to the live stack.
|
|
590
|
+
const setupInputs = { serverIp };
|
|
591
|
+
if (!state.shouldSkip('compose-setup-server', setupInputs)) {
|
|
592
|
+
state.startStep('compose-setup-server', setupInputs);
|
|
593
|
+
// Cloud-init readiness probe — first-deploy spends 20-40s here while
|
|
594
|
+
// ufw + unattended-upgrades land. Warm deploys hit the ready marker
|
|
595
|
+
// immediately, so this is mostly cold-deploy noise.
|
|
596
|
+
await perfAsync('deploy.compose.cloudInitReady', async () => {
|
|
597
|
+
if (isDirectDeploy || isComposeLocal) {
|
|
598
|
+
// Both direct (build over DOCKER_HOST=ssh://) and local
|
|
599
|
+
// (sideload via docker save | ssh docker load) need dockerd
|
|
600
|
+
// up on the server before they can proceed. Push mode doesn't
|
|
601
|
+
// because the server pulls from GHCR after compose-up starts.
|
|
602
|
+
const { waitForDockerReady } = await import('./compose/index.js');
|
|
603
|
+
await setupServer(serverIp, sshKeyPath);
|
|
604
|
+
await waitForDockerReady(serverIp, sshKeyPath);
|
|
605
|
+
} else {
|
|
606
|
+
await setupServer(serverIp, sshKeyPath);
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
state.completeStep('compose-setup-server', { serverIp });
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// --- Image transfer to server (mode-dependent) ---
|
|
613
|
+
if (isComposeLocal) {
|
|
614
|
+
// Local-build path: the build was kicked off in parallel with
|
|
615
|
+
// iac.upStack at the top of this function. By the time we reach
|
|
616
|
+
// here (post-cloudInitReady), the build has almost always finished.
|
|
617
|
+
// Await the promise (cheap if already settled) then sideload the
|
|
618
|
+
// image tarball over SSH. Sideload uses docker save | gzip | ssh
|
|
619
|
+
// | docker load — gzip drops ~600MB → ~150-200MB on the wire.
|
|
620
|
+
await composeLocalBuildPromise;
|
|
621
|
+
await perfAsync('deploy.image.sideload', async () =>
|
|
622
|
+
sideloadCompose({
|
|
623
|
+
tag: localImageTag,
|
|
624
|
+
sshTarget: `root@${serverIp}`,
|
|
625
|
+
sshKey: sshKeyPath,
|
|
626
|
+
}),
|
|
627
|
+
);
|
|
628
|
+
} else if (isDirectDeploy) {
|
|
629
|
+
// Direct path: build the image on the server via DOCKER_HOST=ssh://.
|
|
630
|
+
// BuildKit caches aggressively, so unchanged source rebuilds in 1-3s.
|
|
631
|
+
// Slower than local+sideload (no upStack overlap) but works when the
|
|
632
|
+
// operator has no local Docker — selected by --direct or by
|
|
633
|
+
// resolveBuildMode's docker-not-found fallback. VITE_* args plumbed
|
|
634
|
+
// through here for the same reason as the local-build path above.
|
|
635
|
+
const { collectComposeBuildArgs } = await import('./compose/build-args.js');
|
|
636
|
+
const directBuildArgs = collectComposeBuildArgs(process.cwd(), {
|
|
637
|
+
projectName: projectConfig.projectName,
|
|
638
|
+
domain,
|
|
639
|
+
});
|
|
640
|
+
const success = await perfAsync('deploy.image.directBuild', () =>
|
|
641
|
+
buildRemote(serverIp, sshKeyPath, localImageTag, process.cwd(), directBuildArgs),
|
|
642
|
+
);
|
|
643
|
+
if (!success) process.exit(1);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// --- Docker Hub login (skip if creds unchanged) ---
|
|
647
|
+
// Without this, the reconcile script's `docker compose pull` for
|
|
648
|
+
// Supabase service images (rest, auth, storage, kong, imgproxy, etc.)
|
|
649
|
+
// hits Docker Hub anonymously. A fresh Hetzner VPS shares a NAT range
|
|
650
|
+
// with many other deploys, so the per-IP unauthenticated pull quota
|
|
651
|
+
// is routinely exhausted — observed 2026-04-26 matrix run #3
|
|
652
|
+
// ("toomanyrequests: You have reached your unauthenticated pull rate
|
|
653
|
+
// limit"). Compose-HA + scale already log in; this brings the
|
|
654
|
+
// single-compose deploy path in line.
|
|
655
|
+
const dockerHubCreds = loadCredentials().dockerHub || null;
|
|
656
|
+
if (dockerHubCreds) {
|
|
657
|
+
const dhLoginInputs = {
|
|
658
|
+
serverIp,
|
|
659
|
+
registry: 'docker.io',
|
|
660
|
+
user: dockerHubCreds.username,
|
|
661
|
+
tokenFp: dockerHubCreds.token ? dockerHubCreds.token.slice(0, 8) : '',
|
|
662
|
+
};
|
|
663
|
+
if (!state.shouldSkip('compose-dockerhub-login', dhLoginInputs)) {
|
|
664
|
+
state.startStep('compose-dockerhub-login', dhLoginInputs);
|
|
665
|
+
await dockerLoginOnServer(serverIp, sshKeyPath, dockerHubCreds);
|
|
666
|
+
state.completeStep('compose-dockerhub-login', { serverIp });
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// --- GHCR login (skip if creds unchanged) ---
|
|
671
|
+
if (ciReady.ghcrPullCreds) {
|
|
672
|
+
const loginInputs = {
|
|
673
|
+
serverIp,
|
|
674
|
+
registry: 'ghcr.io',
|
|
675
|
+
user: ciReady.ghcrPullCreds.owner,
|
|
676
|
+
// Hash the token's fingerprint, not the token itself — we don't
|
|
677
|
+
// want the token written to .vibecarbon/deploy-state-*.json.
|
|
678
|
+
tokenFp: ciReady.ghcrPullCreds.token ? ciReady.ghcrPullCreds.token.slice(0, 8) : '',
|
|
679
|
+
};
|
|
680
|
+
if (!state.shouldSkip('compose-ghcr-login', loginInputs)) {
|
|
681
|
+
state.startStep('compose-ghcr-login', loginInputs);
|
|
682
|
+
await dockerLoginOnServer(serverIp, sshKeyPath, {
|
|
683
|
+
username: ciReady.ghcrPullCreds.owner,
|
|
684
|
+
token: ciReady.ghcrPullCreds.token,
|
|
685
|
+
registry: 'ghcr.io',
|
|
686
|
+
});
|
|
687
|
+
state.completeStep('compose-ghcr-login', { serverIp });
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// --- Bundle rsync (skip when bundle inputs unchanged) ---
|
|
692
|
+
const bundleInputs = {
|
|
693
|
+
serverIp,
|
|
694
|
+
projectName: projectConfig.projectName,
|
|
695
|
+
imageRef,
|
|
696
|
+
domain,
|
|
697
|
+
services,
|
|
698
|
+
};
|
|
699
|
+
if (!state.shouldSkip('compose-setup-files', bundleInputs)) {
|
|
700
|
+
state.startStep('compose-setup-files', bundleInputs);
|
|
701
|
+
// setupServerFiles tars the bundle locally + streams over ssh +
|
|
702
|
+
// extracts on the server in one pipeline. Bucket the whole upload
|
|
703
|
+
// here; the inner tar/upload split is captured in compose/index.js.
|
|
704
|
+
await perfAsync('deploy.bundle.upload', () =>
|
|
705
|
+
setupServerFiles(serverIp, sshKeyPath, projectConfig.projectName, {
|
|
706
|
+
...services,
|
|
707
|
+
domain,
|
|
708
|
+
image: imageRef,
|
|
709
|
+
bundlePath,
|
|
710
|
+
}),
|
|
711
|
+
);
|
|
712
|
+
state.completeStep('compose-setup-files', { serverIp });
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// --- DNS update + propagation wait BEFORE compose-up ---
|
|
716
|
+
//
|
|
717
|
+
// Traefik attempts ACME HTTP-01 challenges immediately on container
|
|
718
|
+
// start and bursts ~7 attempts in ~30s before giving up. If LE's
|
|
719
|
+
// DNS resolver sees stale or absent records on those attempts,
|
|
720
|
+
// every challenge fails with "no valid A records found" and
|
|
721
|
+
// acme.json ends the deploy with zero certs issued — browser
|
|
722
|
+
// sees TRAEFIK DEFAULT CERT and NET::ERR_CERT_AUTHORITY_INVALID.
|
|
723
|
+
//
|
|
724
|
+
// The warm-up that fires in Step 4 writes a 0.0.0.0 placeholder;
|
|
725
|
+
// LE specifically rejects 0.0.0.0 as not-a-valid-A-record. So even
|
|
726
|
+
// when warm-up "succeeds," it doesn't help ACME — it actively
|
|
727
|
+
// poisons the resolver state until the real IP overwrites it.
|
|
728
|
+
//
|
|
729
|
+
// Fix (RCA from vibecarbon.com cold-deploy 2026-05-19): swap the
|
|
730
|
+
// ordering. Write the real IP now, wait for it to propagate to
|
|
731
|
+
// public resolvers, then start compose. Traefik's first ACME
|
|
732
|
+
// attempt sees real DNS, succeeds on the first try.
|
|
733
|
+
//
|
|
734
|
+
// The compose-HA path already writes DNS before compose-up at
|
|
735
|
+
// src/lib/deploy/compose/ha.js — single-region compose was the
|
|
736
|
+
// outlier that ran startComposeStack first then updated DNS.
|
|
737
|
+
if (domain && dnsProvider && dnsProvider !== 'manual') {
|
|
738
|
+
await perfAsync('deploy.dns.warm', async () => {
|
|
739
|
+
await dnsWarmupPromise;
|
|
740
|
+
});
|
|
741
|
+
const dnsUpdateInputs = { domain, dnsProvider, serverIp };
|
|
742
|
+
if (!state.shouldSkip('compose-dns-update', dnsUpdateInputs)) {
|
|
743
|
+
state.startStep('compose-dns-update', dnsUpdateInputs);
|
|
744
|
+
const { setupSimple: updateDnsIp } =
|
|
745
|
+
dnsProvider === 'cloudflare'
|
|
746
|
+
? await import('../cloudflare.js')
|
|
747
|
+
: await import('../hetzner-dns.js');
|
|
748
|
+
const token = dnsProvider === 'cloudflare' ? cloudflareApiToken : apiToken;
|
|
749
|
+
const zoneId = dnsProvider === 'cloudflare' ? cloudflareZoneId : hetznerDnsZoneId;
|
|
750
|
+
await perfAsync('deploy.dns.create', () => updateDnsIp(token, zoneId, domain, serverIp));
|
|
751
|
+
state.completeStep('compose-dns-update', { serverIp });
|
|
752
|
+
}
|
|
753
|
+
// Block compose-up until public resolvers see the real IP. 120s
|
|
754
|
+
// budget covers Cloudflare's typical edge propagation (under 60s)
|
|
755
|
+
// with margin. Returns false on timeout; we proceed anyway —
|
|
756
|
+
// Traefik can still acquire certs on its own retries, the
|
|
757
|
+
// customer just sees a brief self-signed-cert window. Hard-
|
|
758
|
+
// failing here would mask deploys that succeed eventually.
|
|
759
|
+
//
|
|
760
|
+
// Visible spinner so the operator isn't staring at a frozen
|
|
761
|
+
// "Yes" screen for up to 120s on a cold deploy where the DNS
|
|
762
|
+
// edge is slow. `timer` indicator forces the rendered string to
|
|
763
|
+
// change every second so progress remains visibly alive.
|
|
764
|
+
const dnsSpinner = p.spinner({ indicator: 'timer' });
|
|
765
|
+
dnsSpinner.start(`Waiting for ${domain} to resolve to ${serverIp}`);
|
|
766
|
+
const dnsPropagated = await perfAsync('deploy.dns.waitForPropagation', () =>
|
|
767
|
+
waitForDNSPropagation(domain, serverIp, 120_000),
|
|
768
|
+
);
|
|
769
|
+
dnsSpinner.stop(
|
|
770
|
+
dnsPropagated
|
|
771
|
+
? `DNS resolves: ${domain} → ${serverIp}`
|
|
772
|
+
: `DNS propagation timed out — proceeding anyway`,
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// `docker compose up -d` is idempotent and cheap — always run so a
|
|
777
|
+
// config tweak that didn't change the bundle hash (e.g. env var)
|
|
778
|
+
// still takes effect. Reconcile.sh on the server runs `docker compose
|
|
779
|
+
// pull` (when not local image) + `docker compose up -d` — see
|
|
780
|
+
// compose/index.js for the inner perf instrumentation.
|
|
781
|
+
await perfAsync('deploy.reconcile.run', () =>
|
|
782
|
+
startComposeStack(serverIp, sshKeyPath, projectConfig.projectName, services),
|
|
783
|
+
);
|
|
784
|
+
|
|
785
|
+
// --- Verifiable success gate ---
|
|
786
|
+
// Probe the app's own /api/health endpoint on the server itself (via
|
|
787
|
+
// localhost + Host header), bypassing DNS/TLS. A successful deploy
|
|
788
|
+
// must yield an HTTP 2xx here; otherwise fail the deploy loudly with
|
|
789
|
+
// container state + log tail.
|
|
790
|
+
//
|
|
791
|
+
// verifyAppHealth polls every 10s for up to 3 minutes while the app
|
|
792
|
+
// container restarts and binds port 3000. Without a visible spinner
|
|
793
|
+
// the operator just sees a frozen "Stack reconciled" line for that
|
|
794
|
+
// entire window — observed 2026-05-19 vibecarbon.com re-deploy
|
|
795
|
+
// where compose-up finished in ~5s but the app took ~3min to come
|
|
796
|
+
// back, leaving the screen looking hung. `timer` indicator so the
|
|
797
|
+
// elapsed-seconds counter ticks visibly even on terminals where
|
|
798
|
+
// the spinner frame-character animation gets throttled under load.
|
|
799
|
+
const { verifyAppHealth } = await import('./compose/index.js');
|
|
800
|
+
const healthSpinner = p.spinner({ indicator: 'timer' });
|
|
801
|
+
healthSpinner.start('Waiting for app to start serving requests');
|
|
802
|
+
let health;
|
|
803
|
+
try {
|
|
804
|
+
health = await perfAsync('deploy.health.probe', () =>
|
|
805
|
+
verifyAppHealth(serverIp, sshKeyPath, projectConfig.projectName, {
|
|
806
|
+
domain,
|
|
807
|
+
}),
|
|
808
|
+
);
|
|
809
|
+
} catch (err) {
|
|
810
|
+
healthSpinner.stop('App health probe errored', 1);
|
|
811
|
+
throw err;
|
|
812
|
+
}
|
|
813
|
+
if (!health.healthy) {
|
|
814
|
+
healthSpinner.stop(`App not serving requests (status: ${health.status})`, 1);
|
|
815
|
+
p.log.error(
|
|
816
|
+
`Deploy produced an unhealthy app (probe status: ${health.status}):\n${health.details}`,
|
|
817
|
+
);
|
|
818
|
+
process.exit(1);
|
|
819
|
+
}
|
|
820
|
+
healthSpinner.stop(`App is serving requests (HTTP ${health.status})`);
|
|
821
|
+
|
|
822
|
+
// --- ACME / TLS self-heal (compose only) ---
|
|
823
|
+
// The internal health probe above bypasses DNS/TLS and confirms the
|
|
824
|
+
// app container is up. It does NOT confirm Traefik has issued a real
|
|
825
|
+
// Let's Encrypt cert. Verify by reading acme.json; if Traefik is
|
|
826
|
+
// stuck on a stale failed-challenge state (typically because a
|
|
827
|
+
// previous deploy on v3.6.1 raced ahead of DNS propagation), restart
|
|
828
|
+
// Traefik in-line to retry ACME. RCA: vibecarbon.com customer hit
|
|
829
|
+
// this on 2026-05-19 — cold deploy "succeeded" with HTTP 200 from
|
|
830
|
+
// internal probe, but browsers saw NET::ERR_CERT_AUTHORITY_INVALID
|
|
831
|
+
// because Traefik served its self-signed default cert.
|
|
832
|
+
if (domain && dnsProvider && dnsProvider !== 'manual') {
|
|
833
|
+
const { ensureTraefikCert } = await import('./compose/acme-verify.js');
|
|
834
|
+
await perfAsync('deploy.acme.verify', () =>
|
|
835
|
+
ensureTraefikCert(serverIp, sshKeyPath, projectConfig.projectName, domain, p.log),
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
deployResult = {
|
|
840
|
+
masterIp: serverIp,
|
|
841
|
+
serverId: serverHetznerId || 'manual',
|
|
842
|
+
serverName: serverHetznerName,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
} else {
|
|
846
|
+
// Kubernetes (k3s on Ubuntu)
|
|
847
|
+
const { deployK3s } = await import('./k8s/k3s.js');
|
|
848
|
+
const { deployK8sHA } = await import('./k8s/ha/index.js');
|
|
849
|
+
|
|
850
|
+
const deploymentConfig = {
|
|
851
|
+
projectName: projectConfig.projectName,
|
|
852
|
+
environment: config.environment,
|
|
853
|
+
provider: config.provider,
|
|
854
|
+
region,
|
|
855
|
+
secondaryRegion,
|
|
856
|
+
masterServerType,
|
|
857
|
+
supabaseServerType,
|
|
858
|
+
workerServerType,
|
|
859
|
+
serverType,
|
|
860
|
+
minWorkers,
|
|
861
|
+
maxWorkers,
|
|
862
|
+
domain,
|
|
863
|
+
ha: config.ha,
|
|
864
|
+
observability: config.observability,
|
|
865
|
+
services,
|
|
866
|
+
apiToken,
|
|
867
|
+
cloudflareApiToken,
|
|
868
|
+
cloudflareZoneId,
|
|
869
|
+
hetznerDnsZoneId,
|
|
870
|
+
dnsProvider,
|
|
871
|
+
s3Config,
|
|
872
|
+
backupConfig,
|
|
873
|
+
backupBucketName: backupS3Config?.bucket || null,
|
|
874
|
+
operatorCidrs: projectConfig.operatorCidrs ?? [],
|
|
875
|
+
imageReadyPromise,
|
|
876
|
+
tracker,
|
|
877
|
+
state,
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
if (config.ha) {
|
|
881
|
+
// K8s HA = 2 clusters (primary + standby) provisioned in parallel,
|
|
882
|
+
// each running k3s + Helm + sideload. Cold HA observed ~25-40 min.
|
|
883
|
+
deployResult = await perfAsync('deploy.ha.k8s.full', () => deployK8sHA(deploymentConfig));
|
|
884
|
+
} else {
|
|
885
|
+
deployResult = await perfAsync('deploy.k3s.full', () => deployK3s(deploymentConfig));
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// K3s is local-first: deployK3s already built the image, sideloaded it
|
|
889
|
+
// to every node, and applied k8s manifests inline. Layer in Flux + GHA
|
|
890
|
+
// via `vibecarbon configure cicd <env>` (PR 7) after the cluster is up.
|
|
891
|
+
{
|
|
892
|
+
const kubeconfigPath = join(process.cwd(), '.vibecarbon', `kubeconfig-${environment}`);
|
|
893
|
+
const masterIp = deployResult.primary?.masterIp || deployResult.masterIp;
|
|
894
|
+
p.log.success('Local-first deploy complete. App is sideloaded + running on the cluster.');
|
|
895
|
+
p.note(
|
|
896
|
+
[
|
|
897
|
+
`Kubeconfig: ${kubeconfigPath}`,
|
|
898
|
+
`Master IP: ${masterIp ?? '(unknown — check Pulumi output)'}`,
|
|
899
|
+
'',
|
|
900
|
+
'Next steps:',
|
|
901
|
+
` vibecarbon shell ${environment} # bash with KUBECONFIG set`,
|
|
902
|
+
` vibecarbon diagnose ${environment} # full cluster state dump`,
|
|
903
|
+
` vibecarbon destroy ${environment} # tear down when done`,
|
|
904
|
+
'',
|
|
905
|
+
`To layer in Flux + GitHub Actions: vibecarbon configure cicd ${environment}`,
|
|
906
|
+
].join('\n'),
|
|
907
|
+
'Cluster + app ready',
|
|
908
|
+
);
|
|
909
|
+
// Fall through to Step 6 so .vibecarbon.json persists the serverlist —
|
|
910
|
+
// without it, `vibecarbon destroy` can't find the master/supabase IPs
|
|
911
|
+
// and the cluster leaks.
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// --- Post-deploy DNS update ---
|
|
915
|
+
// The dnsWarmupPromise (started in Step 4) created records pointing at
|
|
916
|
+
// 0.0.0.0 so Cloudflare's edge has the zone record cached before traffic
|
|
917
|
+
// hits. Now we know the cluster's actual floating IP and need to update
|
|
918
|
+
// the records to point at it — without this, the verify-app-health probe
|
|
919
|
+
// below resolves to 0.0.0.0 and times out.
|
|
920
|
+
//
|
|
921
|
+
// Compose does the equivalent in its own branch above (line ~436).
|
|
922
|
+
// Local-first k3s manages DNS records inline here rather than via
|
|
923
|
+
// Flux annotations + external-dns.
|
|
924
|
+
{
|
|
925
|
+
const k8sFloatingIp =
|
|
926
|
+
deployResult.primary?.floatingIp ||
|
|
927
|
+
deployResult.floatingIp ||
|
|
928
|
+
deployResult.primary?.masterIp ||
|
|
929
|
+
deployResult.masterIp;
|
|
930
|
+
if (domain && dnsProvider && dnsProvider !== 'manual' && k8sFloatingIp) {
|
|
931
|
+
await perfAsync('deploy.dns.warm', async () => {
|
|
932
|
+
await dnsWarmupPromise;
|
|
933
|
+
});
|
|
934
|
+
const dnsUpdateInputs = { domain, dnsProvider, serverIp: k8sFloatingIp };
|
|
935
|
+
if (!state.shouldSkip('k8s-dns-update', dnsUpdateInputs)) {
|
|
936
|
+
state.startStep('k8s-dns-update', dnsUpdateInputs);
|
|
937
|
+
const { setupSimple: updateDnsIp } =
|
|
938
|
+
dnsProvider === 'cloudflare'
|
|
939
|
+
? await import('../cloudflare.js')
|
|
940
|
+
: await import('../hetzner-dns.js');
|
|
941
|
+
const token = dnsProvider === 'cloudflare' ? cloudflareApiToken : apiToken;
|
|
942
|
+
const zoneId = dnsProvider === 'cloudflare' ? cloudflareZoneId : hetznerDnsZoneId;
|
|
943
|
+
await perfAsync('deploy.dns.create', () =>
|
|
944
|
+
updateDnsIp(token, zoneId, domain, k8sFloatingIp),
|
|
945
|
+
);
|
|
946
|
+
state.completeStep('k8s-dns-update', { serverIp: k8sFloatingIp });
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// --- Verifiable success gate for k8s ---
|
|
952
|
+
// The GHA deploy workflow already waits for Flux reconciliation; this
|
|
953
|
+
// extra probe confirms the app is reachable from the public internet
|
|
954
|
+
// (not just healthy inside the cluster). DNS propagation, cert issuance,
|
|
955
|
+
// and ingress routing all need to land before a user can actually load
|
|
956
|
+
// the URL — any of those failing mid-deploy would leave us exiting 0
|
|
957
|
+
// while the URL returns 5xx.
|
|
958
|
+
//
|
|
959
|
+
// K8s first-deploy timing on a cold cluster:
|
|
960
|
+
// - DNS warmup record (0.0.0.0) → real IP swap propagates through
|
|
961
|
+
// Cloudflare's edge in 1-3 min (TTL=auto = 5 min worst case).
|
|
962
|
+
// - cert-manager HTTP-01 challenge: needs DNS to resolve to origin,
|
|
963
|
+
// then LE issues. Typical 1-3 min once DNS is correct, but
|
|
964
|
+
// orange-cloud + CF redirect quirks can push it to 8-12 min.
|
|
965
|
+
// - Traefik picks up the new cert: ~30s.
|
|
966
|
+
// Old 10-min budget tripped on the cold tail (observed 2026-04-25 batch
|
|
967
|
+
// run #1 — k8s standalone, k8s-ha both failed at the probe). 20 min
|
|
968
|
+
// covers the p99 cold path; warm rollouts still finish in <60s.
|
|
969
|
+
if (domain) {
|
|
970
|
+
const s = p.spinner();
|
|
971
|
+
s.start(`Probing https://${domain}/api/health ...`);
|
|
972
|
+
// Public health probe — DNS propagation + cert issuance + ingress
|
|
973
|
+
// routing all roll up here. p99 cold k8s-ha = 8-12 min; warm <60s.
|
|
974
|
+
const probeResult = await perfAsync('deploy.health.probe', () =>
|
|
975
|
+
probePublicHealth(domain, { timeoutMs: 1_200_000 }),
|
|
976
|
+
);
|
|
977
|
+
if (probeResult?.ok) {
|
|
978
|
+
s.stop(`App is serving requests (HTTP ${probeResult.status})`);
|
|
979
|
+
} else {
|
|
980
|
+
// Build a concise one-line failure summary as the spinner stop text
|
|
981
|
+
// AND as the first line of the diagnostic dump below. The runner
|
|
982
|
+
// captures the stderr tail as the step's error message; if this
|
|
983
|
+
// line is the most recent error-level emission, the runner's
|
|
984
|
+
// failure attribution will say WHAT went wrong (last status, last
|
|
985
|
+
// network error class) instead of just dumping curl HEAD response
|
|
986
|
+
// headers — which was the 2026-04-27 morning matrix's "[unknown]
|
|
987
|
+
// CSP header dump" failure mode.
|
|
988
|
+
const r = probeResult ?? {};
|
|
989
|
+
const reason = r.lastStatus
|
|
990
|
+
? `last HTTP status ${r.lastStatus} after ${r.attempts} attempts over ${Math.round((r.timeoutMs ?? 0) / 1000)}s`
|
|
991
|
+
: r.lastErrorClass
|
|
992
|
+
? `${r.lastErrorClass}: ${r.lastErrorMessage}${r.lastErrorCause ? ` (cause: ${r.lastErrorCause})` : ''} (${r.attempts} attempts)`
|
|
993
|
+
: 'never connected';
|
|
994
|
+
s.stop(`Public health probe failed against https://${domain}/api/health — ${reason}`);
|
|
995
|
+
// Capture diagnostics inline so a CI run failure surfaces the
|
|
996
|
+
// root cause without requiring a follow-up `vibecarbon diagnose`.
|
|
997
|
+
// The cluster is about to be destroyed by the test runner; once
|
|
998
|
+
// it's gone, kubectl probes return nothing. We spawn each tool via
|
|
999
|
+
// execFileSync (array argv, no shell) — domain and environment come
|
|
1000
|
+
// from validated config, but we still avoid shell-string interp.
|
|
1001
|
+
try {
|
|
1002
|
+
const { execFileSync } = await import('node:child_process');
|
|
1003
|
+
// HA deploys split into kubeconfig-<env>-primary / -standby; standalone
|
|
1004
|
+
// uses just kubeconfig-<env>. Prefer primary (the user-facing cluster
|
|
1005
|
+
// — Traefik/cert-manager state we want to inspect lives there) and
|
|
1006
|
+
// fall back to the standalone path. fs.existsSync check would race
|
|
1007
|
+
// against the FS, so prefer the file-not-found error in execFileSync.
|
|
1008
|
+
const { existsSync } = await import('node:fs');
|
|
1009
|
+
const kubeconfigCandidates = [
|
|
1010
|
+
join(process.cwd(), '.vibecarbon', `kubeconfig-${environment}-primary`),
|
|
1011
|
+
join(process.cwd(), '.vibecarbon', `kubeconfig-${environment}`),
|
|
1012
|
+
];
|
|
1013
|
+
const kubeconfig =
|
|
1014
|
+
kubeconfigCandidates.find((c) => existsSync(c)) ?? kubeconfigCandidates[1];
|
|
1015
|
+
// Run each tool via execFileSync. Capture stderr alongside stdout
|
|
1016
|
+
// (kubectl prints errors to stderr; without merging we get the
|
|
1017
|
+
// useless first line of err.message — observed 2026-04-26 run #5
|
|
1018
|
+
// where every kubectl probe surfaced as "(kubectl failed: Command
|
|
1019
|
+
// failed: kubectl ...)" with no actual diagnostic).
|
|
1020
|
+
const safeRun = (cmd, argv) => {
|
|
1021
|
+
try {
|
|
1022
|
+
return execFileSync(cmd, argv, {
|
|
1023
|
+
encoding: 'utf8',
|
|
1024
|
+
timeout: 30_000,
|
|
1025
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1026
|
+
}).trim();
|
|
1027
|
+
} catch (err) {
|
|
1028
|
+
const stdout = err?.stdout?.toString?.()?.trim() ?? '';
|
|
1029
|
+
const stderr = err?.stderr?.toString?.()?.trim() ?? '';
|
|
1030
|
+
const tail = [stdout, stderr].filter(Boolean).join('\n').slice(0, 2000);
|
|
1031
|
+
return `(${cmd} failed exit=${err?.status ?? '?'}):\n${tail || (err instanceof Error ? err.message : String(err))}`;
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
const dig = safeRun('dig', ['+short', domain, '@1.1.1.1']);
|
|
1035
|
+
const kcExists = existsSync(kubeconfig);
|
|
1036
|
+
const certs = kcExists
|
|
1037
|
+
? safeRun('kubectl', [
|
|
1038
|
+
'--kubeconfig',
|
|
1039
|
+
kubeconfig,
|
|
1040
|
+
'get',
|
|
1041
|
+
'certificate,certificaterequest,order,challenge',
|
|
1042
|
+
'-n',
|
|
1043
|
+
'vibecarbon',
|
|
1044
|
+
'-o',
|
|
1045
|
+
'wide',
|
|
1046
|
+
])
|
|
1047
|
+
: `(kubeconfig not present at ${kubeconfig} — checked ${kubeconfigCandidates.join(', ')})`;
|
|
1048
|
+
const events = kcExists
|
|
1049
|
+
? safeRun('kubectl', [
|
|
1050
|
+
'--kubeconfig',
|
|
1051
|
+
kubeconfig,
|
|
1052
|
+
'get',
|
|
1053
|
+
'events',
|
|
1054
|
+
'-n',
|
|
1055
|
+
'vibecarbon',
|
|
1056
|
+
'--sort-by=.lastTimestamp',
|
|
1057
|
+
])
|
|
1058
|
+
: '(skipped — no kubeconfig)';
|
|
1059
|
+
// Trim events to the most recent 30 lines.
|
|
1060
|
+
const recentEvents = events.split('\n').slice(-30).join('\n');
|
|
1061
|
+
// Direct curl probes — surface the actual TLS/HTTP error from the
|
|
1062
|
+
// public side (cert-manager not yet issued, traefik not routing,
|
|
1063
|
+
// kong 503, etc.). curl exits non-zero on connect/TLS errors so we
|
|
1064
|
+
// route through safeRun. -k skips cert validation — we want the
|
|
1065
|
+
// body even if origin's cert is staging or self-signed.
|
|
1066
|
+
const curlVerbose = safeRun('curl', [
|
|
1067
|
+
'-skvI',
|
|
1068
|
+
'--max-time',
|
|
1069
|
+
'10',
|
|
1070
|
+
`https://${domain}/api/health`,
|
|
1071
|
+
]);
|
|
1072
|
+
const curlPodIp = safeRun('curl', [
|
|
1073
|
+
'-skv',
|
|
1074
|
+
'--max-time',
|
|
1075
|
+
'10',
|
|
1076
|
+
'--http1.1',
|
|
1077
|
+
`https://${domain}/api/health`,
|
|
1078
|
+
]);
|
|
1079
|
+
// Pods + endpoints + app logs. When traefik returns a 19-byte
|
|
1080
|
+
// "404 page not found" but everything in cert-manager is green,
|
|
1081
|
+
// the cause is almost always "service `app` has zero endpoints
|
|
1082
|
+
// because all app pods are NotReady". Capturing pod state +
|
|
1083
|
+
// endpoints + the app pod's stdout (where the readiness probe
|
|
1084
|
+
// failure logs live — `Readiness check failed: { error: ... }`)
|
|
1085
|
+
// turns "deploy probe got 404" from a riddle into a directly
|
|
1086
|
+
// actionable error.
|
|
1087
|
+
const pods = kcExists
|
|
1088
|
+
? safeRun('kubectl', [
|
|
1089
|
+
'--kubeconfig',
|
|
1090
|
+
kubeconfig,
|
|
1091
|
+
'get',
|
|
1092
|
+
'pods',
|
|
1093
|
+
'-n',
|
|
1094
|
+
'vibecarbon',
|
|
1095
|
+
'-o',
|
|
1096
|
+
'wide',
|
|
1097
|
+
])
|
|
1098
|
+
: '(skipped — no kubeconfig)';
|
|
1099
|
+
const endpoints = kcExists
|
|
1100
|
+
? safeRun('kubectl', [
|
|
1101
|
+
'--kubeconfig',
|
|
1102
|
+
kubeconfig,
|
|
1103
|
+
'get',
|
|
1104
|
+
'endpoints',
|
|
1105
|
+
'-n',
|
|
1106
|
+
'vibecarbon',
|
|
1107
|
+
])
|
|
1108
|
+
: '(skipped — no kubeconfig)';
|
|
1109
|
+
// App pod logs (last 100 lines). If multiple replicas, this picks
|
|
1110
|
+
// by deployment selector — same as `kubectl logs deploy/app`.
|
|
1111
|
+
const appLogs = kcExists
|
|
1112
|
+
? safeRun('kubectl', [
|
|
1113
|
+
'--kubeconfig',
|
|
1114
|
+
kubeconfig,
|
|
1115
|
+
'logs',
|
|
1116
|
+
'deployment/app',
|
|
1117
|
+
'-n',
|
|
1118
|
+
'vibecarbon',
|
|
1119
|
+
'--tail=100',
|
|
1120
|
+
'--all-containers',
|
|
1121
|
+
])
|
|
1122
|
+
: '(skipped — no kubeconfig)';
|
|
1123
|
+
p.log.error(
|
|
1124
|
+
`Deploy completed but the app is not serving traffic.\n` +
|
|
1125
|
+
`\n--- DNS (dig +short ${domain}) ---\n${dig || '(empty)'}\n` +
|
|
1126
|
+
`\n--- cert-manager state (vibecarbon ns) ---\n${certs}\n` +
|
|
1127
|
+
`\n--- Pods (vibecarbon ns) ---\n${pods}\n` +
|
|
1128
|
+
`\n--- Service endpoints (vibecarbon ns) ---\n${endpoints}\n` +
|
|
1129
|
+
`\n--- App pod logs (last 100) ---\n${appLogs}\n` +
|
|
1130
|
+
`\n--- Recent events (last 30) ---\n${recentEvents}\n` +
|
|
1131
|
+
`\n--- curl -kvI https://${domain}/api/health (HEAD) ---\n${curlVerbose}\n` +
|
|
1132
|
+
`\n--- curl -kv https://${domain}/api/health (GET) ---\n${curlPodIp}\n`,
|
|
1133
|
+
);
|
|
1134
|
+
} catch (diagErr) {
|
|
1135
|
+
p.log.error(
|
|
1136
|
+
`Deploy completed but the app is not serving traffic. Check:\n` +
|
|
1137
|
+
` - DNS: dig +short ${domain}\n` +
|
|
1138
|
+
` - Pods: kubectl --kubeconfig ~/.vibecarbon/kubeconfig-${environment} get pods -A\n` +
|
|
1139
|
+
` - Certs: kubectl --kubeconfig ~/.vibecarbon/kubeconfig-${environment} get certificate -n vibecarbon\n` +
|
|
1140
|
+
`(Auto-diagnostic capture failed: ${diagErr instanceof Error ? diagErr.message : String(diagErr)})`,
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
// Emit the structured probe reason as the VERY LAST line on BOTH
|
|
1144
|
+
// stdout and stderr so the e2e runner's failure-tail capture
|
|
1145
|
+
// grabs it regardless of which stream wins. An earlier version
|
|
1146
|
+
// wrote it to stderr only, but the runner's `detail = stdoutClean
|
|
1147
|
+
// || stderrClean` prefers stdout — where @clack's p.log.error
|
|
1148
|
+
// wrote a CSP header dump from the curl -kvI diagnostic, masking
|
|
1149
|
+
// the structured reason in matrix attributions. Writing to both
|
|
1150
|
+
// streams costs nothing and guarantees the runner sees it as the
|
|
1151
|
+
// last line.
|
|
1152
|
+
const failLine = `\nFAIL: public health probe never reached 2xx — ${reason}\n`;
|
|
1153
|
+
console.error(failLine);
|
|
1154
|
+
console.log(failLine);
|
|
1155
|
+
// Throw rather than process.exit(1): process.exit aborts before
|
|
1156
|
+
// the deploy-logger's stream flushes, so the entire diagnostic
|
|
1157
|
+
// dump above is silently lost from the .vibecarbon/logs/ file.
|
|
1158
|
+
// (RCA from k8s-ha probe failure 2026-04-30: detail log ended
|
|
1159
|
+
// mid-spinner with no diagnostic preserved.) withDeployLog's
|
|
1160
|
+
// catch path writes the error trace, awaits the file close, then
|
|
1161
|
+
// re-throws — so the deploy still exits non-zero, but with a
|
|
1162
|
+
// self-contained log.
|
|
1163
|
+
throw new Error(`Public health probe failed: ${reason}`);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// --- STEP 6: Finalize Config (Consolidated) ---
|
|
1169
|
+
// Persist supabaseIp alongside masterIp for k8s deploys. The failover flow
|
|
1170
|
+
// in src/failover.js needs the supabase node IP (where the postgres pod
|
|
1171
|
+
// runs) to pg_basebackup-reseed standby from primary — without it, the
|
|
1172
|
+
// failover uses whatever (potentially stale) state standby has. Compose
|
|
1173
|
+
// deploys only have a single masterIp so supabaseIp is omitted there.
|
|
1174
|
+
const servers = [];
|
|
1175
|
+
if (deployResult.primary) {
|
|
1176
|
+
// k8s-HA shape: { primary: { masterIp, supabaseIp }, standby: {...} }
|
|
1177
|
+
servers.push(
|
|
1178
|
+
{
|
|
1179
|
+
name: 'primary',
|
|
1180
|
+
ip: deployResult.primary.masterIp,
|
|
1181
|
+
supabaseIp: deployResult.primary.supabaseIp,
|
|
1182
|
+
region,
|
|
1183
|
+
},
|
|
1184
|
+
{
|
|
1185
|
+
name: 'standby',
|
|
1186
|
+
ip: deployResult.standby.masterIp,
|
|
1187
|
+
supabaseIp: deployResult.standby.supabaseIp,
|
|
1188
|
+
region: secondaryRegion,
|
|
1189
|
+
},
|
|
1190
|
+
);
|
|
1191
|
+
} else if (deployResult.primaryIp && deployResult.standbyIp) {
|
|
1192
|
+
// compose-HA shape: { primaryIp, standbyIp } — no supabaseIp because
|
|
1193
|
+
// compose-HA runs Supabase on the same VM as the app.
|
|
1194
|
+
servers.push(
|
|
1195
|
+
{ name: 'primary', ip: deployResult.primaryIp, region, serverType },
|
|
1196
|
+
{ name: 'standby', ip: deployResult.standbyIp, region: secondaryRegion, serverType },
|
|
1197
|
+
);
|
|
1198
|
+
} else if (deployResult.masterIp) {
|
|
1199
|
+
// Single-server fall-through: hit by compose (one VPS) and k8s
|
|
1200
|
+
// (master + supabase + worker[s]) deploys.
|
|
1201
|
+
//
|
|
1202
|
+
// Compose: persist the Hetzner-assigned `id` + literal name (e.g.
|
|
1203
|
+
// `${projectName}-${environment}`) so `vibecarbon destroy` can locate
|
|
1204
|
+
// the VPS via the API and `vibecarbon scale` can run its blue-green
|
|
1205
|
+
// migration through the Hetzner provider. The role-style
|
|
1206
|
+
// `name: 'master'` is kept because existing call-sites filter on it
|
|
1207
|
+
// (failover, etc.). Without `hetznerName`, destroy's name-fallback
|
|
1208
|
+
// searches Hetzner for the literal "master" — never matches — and the
|
|
1209
|
+
// next deploy collides on the unique server name. Without `id`, scale
|
|
1210
|
+
// treats the server as "deployed via --ssh, can't resize through the
|
|
1211
|
+
// API" and silently no-ops. scaleCompose replays the old server's full
|
|
1212
|
+
// `.env` into the new bundle as `envOverrides`, so the migration path
|
|
1213
|
+
// works end-to-end across both fields.
|
|
1214
|
+
//
|
|
1215
|
+
// K8s: in addition to the master entry, persist one entry per node
|
|
1216
|
+
// role (supabase + worker-N) so post-deploy commands (scale, destroy,
|
|
1217
|
+
// e2e verify-scale) can enumerate every IP without having to
|
|
1218
|
+
// re-derive worker IPs from Pulumi outputs. Master stays at index 0 —
|
|
1219
|
+
// a handful of compose-only paths read `envConfig.servers?.[0]`, and
|
|
1220
|
+
// preserving that contract is cheap. `supabaseIp` is also kept on the
|
|
1221
|
+
// master entry so failover.js can read `servers[0].supabaseIp` without
|
|
1222
|
+
// first iterating to find the supabase entry — redundant with the
|
|
1223
|
+
// dedicated supabase entry below, but keeps the existing failover
|
|
1224
|
+
// path working.
|
|
1225
|
+
servers.push({
|
|
1226
|
+
name: 'master',
|
|
1227
|
+
...(deployResult.serverId &&
|
|
1228
|
+
deployResult.serverId !== 'manual' && { id: deployResult.serverId }),
|
|
1229
|
+
...(deployResult.serverName && { hetznerName: deployResult.serverName }),
|
|
1230
|
+
ip: deployResult.masterIp,
|
|
1231
|
+
supabaseIp: deployResult.supabaseIp,
|
|
1232
|
+
region,
|
|
1233
|
+
serverType,
|
|
1234
|
+
...(isComposeDeploy ? {} : { role: 'master' }),
|
|
1235
|
+
});
|
|
1236
|
+
// K8s only — compose has neither a separate supabase node nor worker
|
|
1237
|
+
// pool. Compose deploys leave servers as a single-element array.
|
|
1238
|
+
if (!isComposeDeploy) {
|
|
1239
|
+
if (deployResult.supabaseIp) {
|
|
1240
|
+
servers.push({
|
|
1241
|
+
name: 'supabase',
|
|
1242
|
+
ip: deployResult.supabaseIp,
|
|
1243
|
+
region,
|
|
1244
|
+
serverType,
|
|
1245
|
+
role: 'supabase',
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
const workerIps = Array.isArray(deployResult.workerIps) ? deployResult.workerIps : [];
|
|
1249
|
+
workerIps.forEach((wIp, idx) => {
|
|
1250
|
+
if (!wIp) return;
|
|
1251
|
+
servers.push({
|
|
1252
|
+
name: `worker-${idx + 1}`,
|
|
1253
|
+
ip: wIp,
|
|
1254
|
+
region,
|
|
1255
|
+
serverType,
|
|
1256
|
+
role: 'worker',
|
|
1257
|
+
});
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
let deployedCommit = null;
|
|
1263
|
+
try {
|
|
1264
|
+
deployedCommit = runCommand(['git', 'rev-parse', 'HEAD'], {
|
|
1265
|
+
encoding: 'utf-8',
|
|
1266
|
+
silent: true,
|
|
1267
|
+
}).trim();
|
|
1268
|
+
} catch {}
|
|
1269
|
+
|
|
1270
|
+
// Persist backupS3 alongside the storage-s3 block. The backup + restore
|
|
1271
|
+
// commands both read it via loadBackupS3Config() from .vibecarbon.json
|
|
1272
|
+
// — without it here, `vibecarbon restore` against a destroyed-then-redeployed
|
|
1273
|
+
// environment falls through to server-side `ls /opt/*/backups/*` on a
|
|
1274
|
+
// fresh VM with no files, so DR from a populated backup bucket is broken.
|
|
1275
|
+
// Keep the shape in sync with loadBackupS3Config (bucket/region/endpoint,
|
|
1276
|
+
// no credentials — those live in ~/.vibecarbon/credentials.json).
|
|
1277
|
+
const backupS3Persist = backupS3Config
|
|
1278
|
+
? {
|
|
1279
|
+
bucket: backupS3Config.bucket,
|
|
1280
|
+
region: backupS3Config.region,
|
|
1281
|
+
endpoint: backupS3Config.endpoint,
|
|
1282
|
+
}
|
|
1283
|
+
: null;
|
|
1284
|
+
|
|
1285
|
+
// Persist HA flags + secondaryRegion so post-deploy commands can tell
|
|
1286
|
+
// a k8s-HA env (`deployMode='kubernetes' + ha.enabled + secondaryRegion`)
|
|
1287
|
+
// apart from a single-region k8s env (`deployMode='kubernetes'` only).
|
|
1288
|
+
// scale.js relies on this distinction to route Pulumi up against the
|
|
1289
|
+
// per-cluster `${env}-primary` / `${env}-standby` stacks instead of a
|
|
1290
|
+
// bare `${env}` stack that has no Pulumi state. Without these fields,
|
|
1291
|
+
// k8s-HA scale would try to create a fresh stack and Hetzner would
|
|
1292
|
+
// reject the duplicate SshKey post.
|
|
1293
|
+
const isHADeploy = !!config.ha && !!secondaryRegion;
|
|
1294
|
+
|
|
1295
|
+
// Re-read the on-disk project config before merging. Inner deploy paths
|
|
1296
|
+
// (compose/ha.js, k8s/ha/index.js) call saveProjectConfig themselves
|
|
1297
|
+
// mid-flight to persist server IDs / per-cluster details that this
|
|
1298
|
+
// outer save cannot reconstruct from `deployResult` alone (the
|
|
1299
|
+
// orchestrator's `servers` array drops `id`, for example). If we spread
|
|
1300
|
+
// the in-memory `projectConfig` (captured at function entry), we'd
|
|
1301
|
+
// silently overwrite those mid-flight saves — observed in compose-HA
|
|
1302
|
+
// matrix runs where destroyComposeHA found servers with no `id` and
|
|
1303
|
+
// skipped the VPS delete entirely, leaving Hetzner state to collide
|
|
1304
|
+
// on restore re-deploy.
|
|
1305
|
+
const persistedConfig = loadProjectConfig() || projectConfig;
|
|
1306
|
+
const persistedEnvConfig = persistedConfig.environments?.[environment] || envConfig;
|
|
1307
|
+
const finalConfig = {
|
|
1308
|
+
...persistedConfig,
|
|
1309
|
+
environments: {
|
|
1310
|
+
...persistedConfig.environments,
|
|
1311
|
+
[environment]: {
|
|
1312
|
+
...persistedEnvConfig,
|
|
1313
|
+
status: 'deployed',
|
|
1314
|
+
deployMode,
|
|
1315
|
+
region,
|
|
1316
|
+
...(isHADeploy && {
|
|
1317
|
+
secondaryRegion,
|
|
1318
|
+
// Persist per-cluster floatingIp + supabaseIp under ha.primary /
|
|
1319
|
+
// ha.standby so failover.identifyServers takes the structured
|
|
1320
|
+
// path that surfaces floatingIp. Without these, identifyServers
|
|
1321
|
+
// falls through to the flat `servers[]` lookup which has only
|
|
1322
|
+
// `ip` — failover then targets the standby's master IP for the
|
|
1323
|
+
// DNS A record instead of the floating IP, bypassing the
|
|
1324
|
+
// FIP-based design.
|
|
1325
|
+
ha: {
|
|
1326
|
+
enabled: true,
|
|
1327
|
+
failoverRegion: secondaryRegion,
|
|
1328
|
+
...(deployResult.primary && {
|
|
1329
|
+
primary: {
|
|
1330
|
+
masterIp: deployResult.primary.masterIp,
|
|
1331
|
+
floatingIp: deployResult.primary.floatingIp,
|
|
1332
|
+
supabaseIp: deployResult.primary.supabaseIp,
|
|
1333
|
+
region,
|
|
1334
|
+
},
|
|
1335
|
+
standby: {
|
|
1336
|
+
masterIp: deployResult.standby.masterIp,
|
|
1337
|
+
floatingIp: deployResult.standby.floatingIp,
|
|
1338
|
+
supabaseIp: deployResult.standby.supabaseIp,
|
|
1339
|
+
region: secondaryRegion,
|
|
1340
|
+
},
|
|
1341
|
+
}),
|
|
1342
|
+
},
|
|
1343
|
+
}),
|
|
1344
|
+
domain,
|
|
1345
|
+
dnsProvider,
|
|
1346
|
+
// Persist the nested `dns: { provider, cloudflareZoneId | hetznerZoneId }`
|
|
1347
|
+
// shape too. failover.detectScenario reads `envConfig.dns?.provider`
|
|
1348
|
+
// (NOT the flat `dnsProvider`) — without this, k8s/k8s-ha failover
|
|
1349
|
+
// falls through to ha_manual, prints "Update DNS yourself" instead of
|
|
1350
|
+
// calling Cloudflare/Hetzner, and the public health probe in
|
|
1351
|
+
// verify-failover never points at the standby. Mirror the shape
|
|
1352
|
+
// compose-ha already persists in src/lib/deploy/compose/ha.js.
|
|
1353
|
+
dns:
|
|
1354
|
+
dnsProvider === 'cloudflare' && cloudflareZoneId
|
|
1355
|
+
? { provider: 'cloudflare', cloudflareZoneId }
|
|
1356
|
+
: dnsProvider === 'hetzner' && hetznerDnsZoneId
|
|
1357
|
+
? { provider: 'hetzner', hetznerZoneId: hetznerDnsZoneId }
|
|
1358
|
+
: null,
|
|
1359
|
+
// Prefer the inner deploy's richer `servers` (with `id`) when it
|
|
1360
|
+
// already wrote one. Compose-HA persists
|
|
1361
|
+
// [{ name, id, ip, region, serverType, role }, ...]
|
|
1362
|
+
// The outer `servers` we'd build here drops `id` (deployResult
|
|
1363
|
+
// for compose-HA only carries primaryIp/standbyIp). Falling back
|
|
1364
|
+
// to the inner save preserves the IDs that destroyComposeHA needs.
|
|
1365
|
+
servers:
|
|
1366
|
+
Array.isArray(persistedEnvConfig.servers) &&
|
|
1367
|
+
persistedEnvConfig.servers.length > 0 &&
|
|
1368
|
+
persistedEnvConfig.servers.some((s) => s.id != null)
|
|
1369
|
+
? persistedEnvConfig.servers
|
|
1370
|
+
: servers,
|
|
1371
|
+
// Persist k8s sizing inputs so post-deploy `vibecarbon scale` can
|
|
1372
|
+
// rebuild the same Pulumi inputs without falling back to defaults.
|
|
1373
|
+
// Without `minWorkers/maxWorkers` here, scale.js would read
|
|
1374
|
+
// `envConfig.minWorkers/maxWorkers` as `undefined` and fall back to
|
|
1375
|
+
// built-in defaults — which on a clean type-bump made Pulumi plan a
|
|
1376
|
+
// delete of `worker-1`, replacing master+supabase along the way
|
|
1377
|
+
// (etcd loss). Master/supabase/worker types are also persisted so
|
|
1378
|
+
// the per-role split survives across deploy → scale.
|
|
1379
|
+
...(deployMode === 'kubernetes' && {
|
|
1380
|
+
masterServerType,
|
|
1381
|
+
supabaseServerType,
|
|
1382
|
+
workerServerType,
|
|
1383
|
+
minWorkers,
|
|
1384
|
+
maxWorkers,
|
|
1385
|
+
}),
|
|
1386
|
+
deployedAt: new Date().toISOString(),
|
|
1387
|
+
deployedCommit,
|
|
1388
|
+
s3: { bucket: s3Config.bucket, region: s3Config.region, endpoint: s3Config.endpoint },
|
|
1389
|
+
...(backupS3Persist && { backupS3: backupS3Persist }),
|
|
1390
|
+
backup: backupConfig,
|
|
1391
|
+
services,
|
|
1392
|
+
},
|
|
1393
|
+
},
|
|
1394
|
+
};
|
|
1395
|
+
saveProjectConfig(finalConfig);
|
|
1396
|
+
registerProject(projectConfig.projectName, process.cwd());
|
|
1397
|
+
|
|
1398
|
+
const { formatted: timeStr } = tracker.finish();
|
|
1399
|
+
p.log.success(`Deployment to ${c.bold(environment)} complete! ${c.dim(`(${timeStr})`)}`);
|
|
1400
|
+
if (domain) p.log.info(`URL: ${c.info(`https://${domain}`)}`);
|
|
1401
|
+
}
|