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,2515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* k3s-on-Hetzner deploy backend.
|
|
3
|
+
*
|
|
4
|
+
* The Pulumi program is `hetzner-k8s.js`; cloud-init scripts are
|
|
5
|
+
* `carbon/cloud-init/k3s/{master,supabase,worker}-init.sh`.
|
|
6
|
+
*
|
|
7
|
+
* End-to-end flow on a cold deploy:
|
|
8
|
+
* 1. Preflight: docker, ssh, kubectl, helm on PATH; server-type architecture.
|
|
9
|
+
* 2. Generate (or reuse) the SSH keypair under projectDir/.vibecarbon/ssh-<env>.
|
|
10
|
+
* 3. Pulumi up — cloud-init installs k3s + hcloud-ccm/csi on each node.
|
|
11
|
+
* 4. Wait for SSH on master + `/tmp/k3s-ready` marker.
|
|
12
|
+
* 5. scp `/etc/rancher/k3s/k3s.yaml` from master, patch server URL to public IP.
|
|
13
|
+
* 6. Build app image locally (10.0.1.1:5000/<project>:<sha>[-dirty]-<ts>).
|
|
14
|
+
* 7. Sideload via `docker save TAG | ssh node 'k3s ctr images import -'` per node.
|
|
15
|
+
* 8. `kubectl apply -k k8s/base/` (cert-manager via infra/, traefik, app).
|
|
16
|
+
* 9. (helm-render supabase values + helm install supabase.)
|
|
17
|
+
* 10. `kubectl set image deploy/app app=<tag>` + rollout-status.
|
|
18
|
+
*
|
|
19
|
+
* Warm path: skip 1-5; rebuild image (6); sideload (7) skipping unchanged tag;
|
|
20
|
+
* apply changed manifests (8); rollout (10).
|
|
21
|
+
*
|
|
22
|
+
* Target cold-deploy time: ≤ 3 min for single-master + supabase + 0 workers.
|
|
23
|
+
* Spike on 2026-04-25 measured 82s for cluster bring-up alone.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { execFileSync, spawn, spawnSync } from 'node:child_process';
|
|
27
|
+
import {
|
|
28
|
+
chmodSync,
|
|
29
|
+
existsSync,
|
|
30
|
+
mkdirSync,
|
|
31
|
+
readdirSync,
|
|
32
|
+
readFileSync,
|
|
33
|
+
unlinkSync,
|
|
34
|
+
writeFileSync,
|
|
35
|
+
} from 'node:fs';
|
|
36
|
+
import { tmpdir } from 'node:os';
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
39
|
+
import * as p from '@clack/prompts';
|
|
40
|
+
import { runCommandAsync } from '../../command.js';
|
|
41
|
+
import { knownHostsPath } from '../../host-keys.js';
|
|
42
|
+
import { loadCloudInit, renderScript } from '../../iac/cloud-init.js';
|
|
43
|
+
import { perfAsync } from '../../perf.js';
|
|
44
|
+
|
|
45
|
+
export const K3S_VERSION = 'v1.31.5+k3s1';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Pattern of kube-apiserver transient signatures we've actually observed
|
|
49
|
+
* mid-RPC during k3s control-plane warm-up. See runKubectlWithRetry —
|
|
50
|
+
* exported so unit tests can exercise the regex without importing the
|
|
51
|
+
* helper itself (and so a future caller adding a new signature has one
|
|
52
|
+
* obvious place to do it).
|
|
53
|
+
*/
|
|
54
|
+
export const KUBECTL_TRANSIENT_PATTERN =
|
|
55
|
+
/http2: client connection lost|connection reset by peer|context deadline exceeded|EOF|i\/o timeout|TLS handshake|unexpected error when reading response body/i;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Run a short-lived kubectl command with retry-on-transient-error.
|
|
59
|
+
*
|
|
60
|
+
* RCA from k8s-ha runs on 2026-04-29: a freshly-bootstrapped k3s control
|
|
61
|
+
* plane drops one http2 connection mid-RPC, kubectl fails the call (the
|
|
62
|
+
* error message itself ends with "Please retry. Original error: http2:
|
|
63
|
+
* client connection lost"), and with no retry one transient kills the
|
|
64
|
+
* entire HA deploy. Both `kubectl apply Secret/hcloud-cluster-config`
|
|
65
|
+
* and `kubectl patch deploy/cluster-autoscaler` crashed on consecutive
|
|
66
|
+
* runs at different points in applyK3sManifests — so the fix lives at
|
|
67
|
+
* the helper layer instead of being grafted onto each call site as it
|
|
68
|
+
* surfaces.
|
|
69
|
+
*
|
|
70
|
+
* Three attempts with 2s/4s back-off (~6s ceiling, well under deploy
|
|
71
|
+
* budget). Pattern matches against KUBECTL_TRANSIENT_PATTERN; non-
|
|
72
|
+
* transient errors throw on the first attempt with stderr tail attached
|
|
73
|
+
* for debuggability.
|
|
74
|
+
*
|
|
75
|
+
* Don't use this for `kubectl wait` / `kubectl rollout status` /
|
|
76
|
+
* `kubectl logs --follow`. They have their own --timeout and piping
|
|
77
|
+
* their stdio through buffered spawnSync would hide live progress.
|
|
78
|
+
*
|
|
79
|
+
* @param {string[]} args kubectl argv
|
|
80
|
+
* @param {{env: NodeJS.ProcessEnv, input?: string, captureStdout?: boolean, description?: string}} options
|
|
81
|
+
* @returns {Promise<string>} stdout (always returned; empty when nothing was emitted)
|
|
82
|
+
*/
|
|
83
|
+
export async function runKubectlWithRetry(
|
|
84
|
+
args,
|
|
85
|
+
{ env, input, captureStdout = false, description } = {},
|
|
86
|
+
) {
|
|
87
|
+
const ATTEMPTS = 3;
|
|
88
|
+
const desc = description ?? `kubectl ${args.slice(0, 4).join(' ')}`;
|
|
89
|
+
let result;
|
|
90
|
+
let lastStderr = '';
|
|
91
|
+
let lastStdout = '';
|
|
92
|
+
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
|
|
93
|
+
result = spawnSync('kubectl', args, {
|
|
94
|
+
env,
|
|
95
|
+
input,
|
|
96
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
97
|
+
});
|
|
98
|
+
lastStdout = (result.stdout ?? '').toString();
|
|
99
|
+
lastStderr = (result.stderr ?? '').toString();
|
|
100
|
+
// Stream captured streams through to the operator's terminal so the
|
|
101
|
+
// success path looks identical to the legacy `stdio: 'inherit'` shape.
|
|
102
|
+
// captureStdout suppresses stdout streaming because the caller wants
|
|
103
|
+
// the bytes (e.g. `kubectl get -o jsonpath`) — stderr still streams
|
|
104
|
+
// so a real warning is still visible.
|
|
105
|
+
if (!captureStdout && lastStdout) process.stdout.write(lastStdout);
|
|
106
|
+
if (lastStderr) process.stderr.write(lastStderr);
|
|
107
|
+
if (result.status === 0) break;
|
|
108
|
+
if (!KUBECTL_TRANSIENT_PATTERN.test(lastStderr) || attempt === ATTEMPTS) break;
|
|
109
|
+
const backoffMs = 2000 * attempt;
|
|
110
|
+
console.log(
|
|
111
|
+
`${desc} hit transient error on attempt ${attempt}/${ATTEMPTS}, retrying in ${backoffMs}ms`,
|
|
112
|
+
);
|
|
113
|
+
await delay(backoffMs);
|
|
114
|
+
}
|
|
115
|
+
if (!result || result.status !== 0) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`${desc} failed with exit ${result?.status ?? '?'}: ${lastStderr.trim().slice(-500)}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return lastStdout;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Render the JSON cluster-config that cluster-autoscaler reads from its
|
|
125
|
+
* mounted Secret. The JSON describes the worker-pool node group: which
|
|
126
|
+
* cloud-init script to use, what serverLabels to attach to spawned
|
|
127
|
+
* servers, etc.
|
|
128
|
+
*
|
|
129
|
+
* Phase 5 writes the returned string into the `cluster-config.json`
|
|
130
|
+
* key of the `kube-system/hcloud-cluster-config` Secret. The
|
|
131
|
+
* cluster-autoscaler hcloud provider reads this at startup
|
|
132
|
+
* (HCLOUD_CLUSTER_CONFIG); on each scale-up, `serverLabels` are
|
|
133
|
+
* attached to the new Hetzner server and `cloudInit` (a plain UTF-8
|
|
134
|
+
* string) is base64-encoded into the user_data field of the
|
|
135
|
+
* `ServerCreate` API call.
|
|
136
|
+
*
|
|
137
|
+
* `serverLabels['cluster-autoscaler/node'] = 'worker-pool'` distinguishes
|
|
138
|
+
* CA-spawned workers from static workers (which carry
|
|
139
|
+
* `cluster-autoscaler/node: static` from the Pulumi program). Phase 7's
|
|
140
|
+
* destroy sweep relies on this distinction.
|
|
141
|
+
*
|
|
142
|
+
* CA-spawned workers join via the master's PRIVATE IP (default
|
|
143
|
+
* `10.0.1.1`) — the in-network join path is firewalled to the
|
|
144
|
+
* private-network range, so a public-IP join would be rejected and is
|
|
145
|
+
* also unnecessary since the spawned servers attach to the same Hetzner
|
|
146
|
+
* private network.
|
|
147
|
+
*
|
|
148
|
+
* @param {{
|
|
149
|
+
* k3sVersion: string,
|
|
150
|
+
* k3sToken: string,
|
|
151
|
+
* masterPrivateIp?: string,
|
|
152
|
+
* clusterName: string,
|
|
153
|
+
* }} args
|
|
154
|
+
* @returns {string} JSON-stringified cluster config
|
|
155
|
+
*/
|
|
156
|
+
export function renderCAClusterConfig({
|
|
157
|
+
k3sVersion,
|
|
158
|
+
k3sToken,
|
|
159
|
+
masterPrivateIp = '10.0.1.1',
|
|
160
|
+
clusterName,
|
|
161
|
+
}) {
|
|
162
|
+
const tmpl = loadCloudInit('worker-init.sh');
|
|
163
|
+
const cloudInit = renderScript(tmpl, {
|
|
164
|
+
k3s_version: k3sVersion,
|
|
165
|
+
k3s_token: k3sToken,
|
|
166
|
+
master_ip: masterPrivateIp,
|
|
167
|
+
cluster_name: clusterName,
|
|
168
|
+
});
|
|
169
|
+
const config = {
|
|
170
|
+
imagesForArch: {
|
|
171
|
+
amd64: 'ubuntu-24.04',
|
|
172
|
+
arm64: 'ubuntu-24.04',
|
|
173
|
+
},
|
|
174
|
+
nodeConfigs: {
|
|
175
|
+
'worker-pool': {
|
|
176
|
+
cloudInit,
|
|
177
|
+
labels: {},
|
|
178
|
+
serverLabels: {
|
|
179
|
+
'cluster-autoscaler/node': 'worker-pool',
|
|
180
|
+
},
|
|
181
|
+
taints: [],
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
return JSON.stringify(config, null, 2);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Map (dnsProvider, acmeServer) → ClusterIssuer name that
|
|
190
|
+
* `applyK3sManifests` patches into `Certificate/vibecarbon-tls`.
|
|
191
|
+
*
|
|
192
|
+
* - cloudflare/hetzner: DNS-01 solver — works behind reverse proxies and
|
|
193
|
+
* has no port-80 dependency. Requires per-provider Secret + (for
|
|
194
|
+
* hetzner) the cert-manager-webhook-hetzner deployment.
|
|
195
|
+
* - manual (default): HTTP-01 solver — needs port 80 reachable from
|
|
196
|
+
* Let's Encrypt. Used when no programmable DNS provider is wired up.
|
|
197
|
+
*
|
|
198
|
+
* Unknown providers fall back to `manual` so a future enum addition
|
|
199
|
+
* never deploys a Certificate that references a non-existent issuer.
|
|
200
|
+
*
|
|
201
|
+
* @param {{dnsProvider?: string|null, acmeServer?: string|null}} args
|
|
202
|
+
* @returns {string}
|
|
203
|
+
*/
|
|
204
|
+
export function pickIssuerName({ dnsProvider, acmeServer }) {
|
|
205
|
+
const stage = (acmeServer || '').includes('staging') ? 'staging' : 'prod';
|
|
206
|
+
const providerSuffix =
|
|
207
|
+
dnsProvider === 'cloudflare' ? 'cloudflare' : dnsProvider === 'hetzner' ? 'hetzner' : 'manual';
|
|
208
|
+
return `letsencrypt-${stage}-${providerSuffix}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Render the per-DNS-provider Secret YAML that cert-manager's DNS-01
|
|
213
|
+
* solvers read for API auth, or null when the provider doesn't need a
|
|
214
|
+
* Secret (`manual` uses HTTP-01).
|
|
215
|
+
*
|
|
216
|
+
* - cloudflare: `cloudflare-api-token` / key `api-token` — referenced by
|
|
217
|
+
* `letsencrypt-{prod,staging}-cloudflare`.
|
|
218
|
+
* - hetzner: `hetzner` / key `token` — referenced by
|
|
219
|
+
* `letsencrypt-{prod,staging}-hetzner` AND by the official
|
|
220
|
+
* `cert-manager-webhook-hetzner` chart (matches the chart's expected
|
|
221
|
+
* `tokenSecretKeyRef` shape). The token here is the SAME Cloud API
|
|
222
|
+
* token used elsewhere — Hetzner shut down the separate DNS Console
|
|
223
|
+
* API in May 2026 and folded zone management into the main Cloud
|
|
224
|
+
* Console + api.hetzner.cloud/v1/zones.
|
|
225
|
+
*
|
|
226
|
+
* Throws when a token is required but missing — the early throw makes
|
|
227
|
+
* "Order pinned Pending forever" failures show up at deploy-start with
|
|
228
|
+
* an actionable error message instead.
|
|
229
|
+
*
|
|
230
|
+
* @param {{dnsProvider?: string|null, cloudflareApiToken?: string|null, hetznerApiToken?: string|null}} args
|
|
231
|
+
* @returns {{name: string, yaml: string}|null}
|
|
232
|
+
*/
|
|
233
|
+
export function buildDnsProviderSecret({ dnsProvider, cloudflareApiToken, hetznerApiToken }) {
|
|
234
|
+
if (dnsProvider === 'cloudflare') {
|
|
235
|
+
if (!cloudflareApiToken) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
'Cloudflare DNS-01 requires a Cloudflare API token. ' +
|
|
238
|
+
'Set CLOUDFLARE_API_TOKEN or add cloudflare.apiToken to ~/.vibecarbon/credentials.json. ' +
|
|
239
|
+
'The token must have Zone:Zone:Read on all zones + Zone:DNS:Edit on the target zone.',
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
name: 'cloudflare-api-token',
|
|
244
|
+
yaml: [
|
|
245
|
+
'apiVersion: v1',
|
|
246
|
+
'kind: Secret',
|
|
247
|
+
'metadata:',
|
|
248
|
+
' name: cloudflare-api-token',
|
|
249
|
+
' namespace: cert-manager',
|
|
250
|
+
'type: Opaque',
|
|
251
|
+
'stringData:',
|
|
252
|
+
` api-token: ${JSON.stringify(cloudflareApiToken)}`,
|
|
253
|
+
].join('\n'),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
if (dnsProvider === 'hetzner') {
|
|
257
|
+
if (!hetznerApiToken) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
'Hetzner DNS-01 requires a Cloud API token with read+write on the zones API. ' +
|
|
260
|
+
'Set HETZNER_API_TOKEN or add hetzner.apiToken to ~/.vibecarbon/credentials.json.',
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
name: 'hetzner',
|
|
265
|
+
yaml: [
|
|
266
|
+
'apiVersion: v1',
|
|
267
|
+
'kind: Secret',
|
|
268
|
+
'metadata:',
|
|
269
|
+
' name: hetzner',
|
|
270
|
+
' namespace: cert-manager',
|
|
271
|
+
'type: Opaque',
|
|
272
|
+
'stringData:',
|
|
273
|
+
` token: ${JSON.stringify(hetznerApiToken)}`,
|
|
274
|
+
].join('\n'),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Compose the `--nodes=MIN:MAX:TYPE:LOCATION:NAME` argv slot the
|
|
282
|
+
* cluster-autoscaler hcloud provider parses on startup. CA's pool
|
|
283
|
+
* sits ON TOP of the Pulumi-static floor: total cluster workers =
|
|
284
|
+
* minWorkers (Pulumi-managed) + N CA-spawned, where 0 ≤ N ≤
|
|
285
|
+
* (maxWorkers - minWorkers). So CA's MIN is always 0 and its MAX is
|
|
286
|
+
* the headroom above the static floor. Server type is uppercased
|
|
287
|
+
* per the upstream cluster-autoscaler README — Hetzner's API accepts
|
|
288
|
+
* either case but the autoscaler's own validator is case-sensitive.
|
|
289
|
+
*
|
|
290
|
+
* @param {{minWorkers: number, maxWorkers: number, workerServerType: string, region: string, poolName?: string}} args
|
|
291
|
+
* @returns {string}
|
|
292
|
+
*/
|
|
293
|
+
export function formatCaNodesSpec({
|
|
294
|
+
minWorkers,
|
|
295
|
+
maxWorkers,
|
|
296
|
+
workerServerType,
|
|
297
|
+
region,
|
|
298
|
+
poolName = 'worker-pool',
|
|
299
|
+
}) {
|
|
300
|
+
const caMin = 0;
|
|
301
|
+
const caMax = Math.max(0, maxWorkers - minWorkers);
|
|
302
|
+
return `${caMin}:${caMax}:${workerServerType.toUpperCase()}:${region}:${poolName}`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* @typedef {Object} K3sDeployOptions
|
|
307
|
+
* @property {string} projectName
|
|
308
|
+
* @property {string} environment
|
|
309
|
+
* @property {string} provider
|
|
310
|
+
* @property {string} region
|
|
311
|
+
* @property {string} [secondaryRegion]
|
|
312
|
+
* @property {string} masterServerType
|
|
313
|
+
* @property {string} supabaseServerType
|
|
314
|
+
* @property {string} workerServerType
|
|
315
|
+
* @property {string} serverType
|
|
316
|
+
* @property {number} minWorkers Static floor (Pulumi-provisioned). Default 1.
|
|
317
|
+
* @property {number} maxWorkers CA upper bound (CA pool max = maxWorkers - minWorkers). Default 3.
|
|
318
|
+
* @property {string} [domain]
|
|
319
|
+
* @property {string} apiToken Hetzner Cloud API token (CCM/CSI + cluster-autoscaler).
|
|
320
|
+
* @property {Object} state
|
|
321
|
+
* @property {Object} [tracker]
|
|
322
|
+
* @property {boolean} [quietSuccess]
|
|
323
|
+
* @property {string} [sharedSshKeyPath]
|
|
324
|
+
* @property {string} [sharedSshPublicKey]
|
|
325
|
+
* @property {string|number} [sharedSshKeyId]
|
|
326
|
+
* @property {Object} [s3Config]
|
|
327
|
+
*/
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Generate or reuse an SSH keypair for this environment.
|
|
331
|
+
* Stored under projectDir/.vibecarbon/ssh-<env>{,.pub}.
|
|
332
|
+
*
|
|
333
|
+
* @param {string} projectDir
|
|
334
|
+
* @param {string} environment
|
|
335
|
+
* @returns {{privateKeyPath: string, publicKey: string}}
|
|
336
|
+
*/
|
|
337
|
+
export function ensureSshKey(projectDir, environment) {
|
|
338
|
+
const dir = join(projectDir, '.vibecarbon');
|
|
339
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
340
|
+
const privateKeyPath = join(dir, `ssh-${environment}`);
|
|
341
|
+
const publicKeyPath = `${privateKeyPath}.pub`;
|
|
342
|
+
if (existsSync(privateKeyPath) && existsSync(publicKeyPath)) {
|
|
343
|
+
return { privateKeyPath, publicKey: readFileSync(publicKeyPath, 'utf-8').trim() };
|
|
344
|
+
}
|
|
345
|
+
// ssh-keygen writes both halves: privateKeyPath in OpenSSH format, and
|
|
346
|
+
// privateKeyPath.pub as the OpenSSH public key. -N "" = empty passphrase,
|
|
347
|
+
// -q = quiet so we don't pollute the spinner output, -C tags the key with
|
|
348
|
+
// a comment for operator-side identification.
|
|
349
|
+
execFileSync(
|
|
350
|
+
'ssh-keygen',
|
|
351
|
+
['-t', 'ed25519', '-N', '', '-f', privateKeyPath, '-C', `vibecarbon-${environment}`, '-q'],
|
|
352
|
+
{ stdio: 'ignore' },
|
|
353
|
+
);
|
|
354
|
+
// ssh-keygen already creates the private key 0600, but defensively re-apply
|
|
355
|
+
// in case it was regenerated atop a permission-loose existing file.
|
|
356
|
+
chmodSync(privateKeyPath, 0o600);
|
|
357
|
+
const publicKey = readFileSync(publicKeyPath, 'utf-8').trim();
|
|
358
|
+
return { privateKeyPath, publicKey };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Build the standard SSH host-key opts for k3s deploy SSH calls.
|
|
363
|
+
*
|
|
364
|
+
* Pinned per-env known_hosts file under `.vibecarbon/known_hosts_<env>` —
|
|
365
|
+
* `accept-new` adds entries on first connect, then strict-checks on reuse.
|
|
366
|
+
* `GlobalKnownHostsFile=/dev/null` ignores the system-wide file. Crucially we
|
|
367
|
+
* also bypass the operator's `~/.ssh/known_hosts` — Hetzner recycles public
|
|
368
|
+
* IPs, and a stale ECDSA entry from an unrelated server at the same IP would
|
|
369
|
+
* otherwise reject the connection with "REMOTE HOST IDENTIFICATION HAS
|
|
370
|
+
* CHANGED" (observed during early k3s real-infra testing).
|
|
371
|
+
*
|
|
372
|
+
* @param {string} khPath - absolute path to .vibecarbon/known_hosts_<env>
|
|
373
|
+
* @returns {string[]} -o argv tokens for ssh/scp
|
|
374
|
+
*/
|
|
375
|
+
function sshHostKeyOpts(khPath) {
|
|
376
|
+
return [
|
|
377
|
+
'-o',
|
|
378
|
+
`UserKnownHostsFile=${khPath}`,
|
|
379
|
+
'-o',
|
|
380
|
+
'GlobalKnownHostsFile=/dev/null',
|
|
381
|
+
'-o',
|
|
382
|
+
'StrictHostKeyChecking=accept-new',
|
|
383
|
+
// BatchMode=yes is required so a key-auth failure aborts cleanly with a
|
|
384
|
+
// non-zero exit instead of falling back to interactive password prompt.
|
|
385
|
+
// Without this, running deploy in a TTY (e2e runner inherits one)
|
|
386
|
+
// can hang waiting for input — and on stdio:'inherit' pipelines like
|
|
387
|
+
// sideloadK3s, the prompt actually appears on the operator's terminal.
|
|
388
|
+
'-o',
|
|
389
|
+
'BatchMode=yes',
|
|
390
|
+
];
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Block until the cloud-init `/tmp/k3s-ready` marker appears via SSH.
|
|
395
|
+
* Cloud-init writes this after `kubectl get nodes` shows the master Ready.
|
|
396
|
+
*
|
|
397
|
+
* Exponential backoff (2s → 4s → 8s, capped at 15s) so early-ready nodes
|
|
398
|
+
* (~80s on the spike) aren't held back. Long tails still back off so we
|
|
399
|
+
* don't hammer SSH during slow boots.
|
|
400
|
+
*
|
|
401
|
+
* @param {string} masterIp
|
|
402
|
+
* @param {string} sshKeyPath
|
|
403
|
+
* @param {string} khPath - per-env known_hosts file path
|
|
404
|
+
* @param {number} [maxWaitSec]
|
|
405
|
+
* @returns {Promise<void>}
|
|
406
|
+
*/
|
|
407
|
+
export async function waitForK3sReady(masterIp, sshKeyPath, khPath, maxWaitSec = 600) {
|
|
408
|
+
const start = Date.now();
|
|
409
|
+
let waitMs = 2000;
|
|
410
|
+
let lastErr;
|
|
411
|
+
while ((Date.now() - start) / 1000 < maxWaitSec) {
|
|
412
|
+
try {
|
|
413
|
+
execFileSync(
|
|
414
|
+
'ssh',
|
|
415
|
+
[
|
|
416
|
+
'-i',
|
|
417
|
+
sshKeyPath,
|
|
418
|
+
...sshHostKeyOpts(khPath),
|
|
419
|
+
'-o',
|
|
420
|
+
'ConnectTimeout=5',
|
|
421
|
+
'-o',
|
|
422
|
+
'BatchMode=yes',
|
|
423
|
+
`root@${masterIp}`,
|
|
424
|
+
'test -f /tmp/k3s-ready',
|
|
425
|
+
],
|
|
426
|
+
{ stdio: 'ignore' },
|
|
427
|
+
);
|
|
428
|
+
return;
|
|
429
|
+
} catch (err) {
|
|
430
|
+
lastErr = err;
|
|
431
|
+
await delay(waitMs);
|
|
432
|
+
waitMs = Math.min(waitMs * 2, 15000);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// Capture cloud-init + syslog before throwing so the failure log says
|
|
436
|
+
// WHY install timed out (apt slow? k3s download 5xx? script crashed?)
|
|
437
|
+
// instead of just "300s passed and the marker isn't there." Best-effort —
|
|
438
|
+
// if the box is unreachable, captureK3sInstallDiag returns an empty string
|
|
439
|
+
// and we surface only the SSH probe error.
|
|
440
|
+
const diag = captureK3sInstallDiag(masterIp, sshKeyPath, khPath);
|
|
441
|
+
throw new Error(
|
|
442
|
+
`k3s did not become ready on ${masterIp} within ${maxWaitSec}s. ` +
|
|
443
|
+
`Last SSH error: ${lastErr instanceof Error ? lastErr.message : 'unknown'}` +
|
|
444
|
+
(diag ? `\n\n--- install diagnostics from ${masterIp} ---\n${diag}` : ''),
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* scp the master's kubeconfig and patch its server URL to point at the
|
|
450
|
+
* public master IP (cloud-init writes `127.0.0.1:6443`, useless from
|
|
451
|
+
* the operator's laptop).
|
|
452
|
+
*
|
|
453
|
+
* @param {string} masterIp
|
|
454
|
+
* @param {string} sshKeyPath
|
|
455
|
+
* @param {string} khPath - per-env known_hosts file path
|
|
456
|
+
* @param {string} projectDir
|
|
457
|
+
* @param {string} environment
|
|
458
|
+
* @returns {string} absolute path to the local kubeconfig
|
|
459
|
+
*/
|
|
460
|
+
export function fetchKubeconfig(masterIp, sshKeyPath, khPath, projectDir, environment) {
|
|
461
|
+
const localPath = join(projectDir, '.vibecarbon', `kubeconfig-${environment}`);
|
|
462
|
+
execFileSync(
|
|
463
|
+
'scp',
|
|
464
|
+
[
|
|
465
|
+
'-i',
|
|
466
|
+
sshKeyPath,
|
|
467
|
+
...sshHostKeyOpts(khPath),
|
|
468
|
+
`root@${masterIp}:/etc/rancher/k3s/k3s.yaml`,
|
|
469
|
+
localPath,
|
|
470
|
+
],
|
|
471
|
+
{ stdio: 'pipe' },
|
|
472
|
+
);
|
|
473
|
+
const kc = readFileSync(localPath, 'utf-8');
|
|
474
|
+
const patched = kc.replace(
|
|
475
|
+
/server: https:\/\/127\.0\.0\.1:6443/g,
|
|
476
|
+
`server: https://${masterIp}:6443`,
|
|
477
|
+
);
|
|
478
|
+
writeFileSync(localPath, patched);
|
|
479
|
+
// Tighten perms — kubeconfig embeds a cluster-admin client cert + key.
|
|
480
|
+
// scp inherits the operator's umask, so without this the file lands at
|
|
481
|
+
// 0644 and helm/kubectl warn "Kubernetes configuration file is
|
|
482
|
+
// world-readable" on every invocation.
|
|
483
|
+
chmodSync(localPath, 0o600);
|
|
484
|
+
return localPath;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const CERT_MANAGER_VERSION = 'v1.20.2';
|
|
488
|
+
const CERT_MANAGER_URL = `https://github.com/cert-manager/cert-manager/releases/download/${CERT_MANAGER_VERSION}/cert-manager.yaml`;
|
|
489
|
+
const SUPABASE_HELM_REPO_URL = 'https://supabase-community.github.io/supabase-kubernetes';
|
|
490
|
+
const SUPABASE_HELM_REPO_NAME = 'supabase-community';
|
|
491
|
+
const SUPABASE_HELM_CHART = `${SUPABASE_HELM_REPO_NAME}/supabase`;
|
|
492
|
+
const SUPABASE_HELM_RELEASE_NAME = 'supabase';
|
|
493
|
+
const SUPABASE_HELM_TIMEOUT = '15m';
|
|
494
|
+
|
|
495
|
+
// cert-manager-webhook-hetzner: third-party community chart for the
|
|
496
|
+
// Hetzner DNS Console DNS-01 solver. Pinned explicitly — the chart's
|
|
497
|
+
// API surface (groupName / solverName / values keys) is stable but
|
|
498
|
+
// `latest` would silently drift on `helm repo update`. Bump in
|
|
499
|
+
// lockstep with CERT_MANAGER_VERSION; the new e2e scenario
|
|
500
|
+
// k8s-hetzner-dns is the canary that catches breakage.
|
|
501
|
+
const HETZNER_WEBHOOK_HELM_REPO_URL = 'https://charts.hetzner.cloud';
|
|
502
|
+
const HETZNER_WEBHOOK_HELM_REPO_NAME = 'hetzner-cloud';
|
|
503
|
+
const HETZNER_WEBHOOK_CHART = `${HETZNER_WEBHOOK_HELM_REPO_NAME}/cert-manager-webhook-hetzner`;
|
|
504
|
+
const HETZNER_WEBHOOK_CHART_VERSION = '0.7.0';
|
|
505
|
+
const HETZNER_WEBHOOK_RELEASE_NAME = 'cert-manager-webhook-hetzner';
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Parse a `.env.local` file into a plain object.
|
|
509
|
+
*
|
|
510
|
+
* Strict subset of dotenv: `KEY=VALUE` per line, optional surrounding
|
|
511
|
+
* single/double quotes stripped, lines starting with `#` or blank ignored.
|
|
512
|
+
* Values with `=` in them are preserved (split-once on the first `=`).
|
|
513
|
+
*
|
|
514
|
+
* @param {string} envPath
|
|
515
|
+
* @returns {Record<string, string>}
|
|
516
|
+
*/
|
|
517
|
+
export function loadEnvLocal(envPath) {
|
|
518
|
+
if (!existsSync(envPath)) {
|
|
519
|
+
throw new Error(`loadEnvLocal: ${envPath} not found. Run 'vibecarbon create' to generate it.`);
|
|
520
|
+
}
|
|
521
|
+
const out = {};
|
|
522
|
+
const raw = readFileSync(envPath, 'utf-8');
|
|
523
|
+
for (const line of raw.split('\n')) {
|
|
524
|
+
const trimmed = line.trim();
|
|
525
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
526
|
+
const eq = trimmed.indexOf('=');
|
|
527
|
+
if (eq === -1) continue;
|
|
528
|
+
const key = trimmed.slice(0, eq).trim();
|
|
529
|
+
let val = trimmed.slice(eq + 1).trim();
|
|
530
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
531
|
+
val = val.slice(1, -1);
|
|
532
|
+
}
|
|
533
|
+
out[key] = val;
|
|
534
|
+
}
|
|
535
|
+
return out;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Build the local container image and return its tag.
|
|
540
|
+
*
|
|
541
|
+
* Reuses the buildLocalImage primitive from src/lib/deploy/image.js, which
|
|
542
|
+
* already handles git sha + dirty-state + timestamp. Tag scheme:
|
|
543
|
+
* `10.0.1.1:5000/<projectName>:<sha>[-dirty]-<utcTimestamp>`.
|
|
544
|
+
*
|
|
545
|
+
* The `10.0.1.1:5000/` prefix points at the master's local OCI registry
|
|
546
|
+
* pod (Phase 1's `registries.yaml` configures kubelet on every node to
|
|
547
|
+
* resolve `10.0.1.1:5000` → that pod). The same tag is used at every
|
|
548
|
+
* step (build → sideload → push → Deployment), so:
|
|
549
|
+
* - static workers get the image via `sideloadK3s` (containerd cache
|
|
550
|
+
* hit, kubelet `imagePullPolicy: IfNotPresent` finds it),
|
|
551
|
+
* - CA-spawned workers (which don't exist at sideload time) pull the
|
|
552
|
+
* same tag from master's registry pod via `registries.yaml`.
|
|
553
|
+
*
|
|
554
|
+
* @param {string} projectDir
|
|
555
|
+
* @param {string} projectName
|
|
556
|
+
* @param {boolean} [rebuild]
|
|
557
|
+
* @returns {Promise<{tag: string, gitSha: string, isDirty: boolean}>}
|
|
558
|
+
*/
|
|
559
|
+
export async function buildAppImage(projectDir, projectName, rebuild = false) {
|
|
560
|
+
const { buildLocalImage } = await import('../image.js');
|
|
561
|
+
return buildLocalImage(projectDir, { projectName, rebuild, tagPrefix: '10.0.1.1:5000' });
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Wait until the `k3s` binary is installed and on PATH on a given node.
|
|
566
|
+
*
|
|
567
|
+
* waitForK3sReady only polls the master's /tmp/k3s-ready marker. Workers
|
|
568
|
+
* and the supabase node run their own k3s install asynchronously
|
|
569
|
+
* (worker-init.sh / supabase-init.sh) which can take 1-3 min after the
|
|
570
|
+
* master is ready (apt update, dpkg locks, k3s install script). If
|
|
571
|
+
* sideload runs in that window, the remote `k3s ctr images import -`
|
|
572
|
+
* exits 127 ("command not found") and one node's import fails,
|
|
573
|
+
* cancelling the entire batch (observed 2026-04-26 e2e run #2 —
|
|
574
|
+
* worker @ 204.168.185.124 returned 127).
|
|
575
|
+
*
|
|
576
|
+
* We poll up to maxWaitSec for `command -v k3s` to succeed. Cheap (~10ms
|
|
577
|
+
* SSH per probe), idempotent, and resolves the race without changing
|
|
578
|
+
* cloud-init.
|
|
579
|
+
*
|
|
580
|
+
* @param {string} target - SSH target (e.g. root@1.2.3.4)
|
|
581
|
+
* @param {string} sshKeyPath
|
|
582
|
+
* @param {string} khPath - per-env known_hosts file
|
|
583
|
+
* @param {number} [maxWaitSec=300]
|
|
584
|
+
* @returns {Promise<void>}
|
|
585
|
+
*/
|
|
586
|
+
export async function waitForK3sBinary(target, sshKeyPath, khPath, maxWaitSec = 300) {
|
|
587
|
+
const start = Date.now();
|
|
588
|
+
let waitMs = 2000;
|
|
589
|
+
let lastErr;
|
|
590
|
+
while ((Date.now() - start) / 1000 < maxWaitSec) {
|
|
591
|
+
try {
|
|
592
|
+
execFileSync(
|
|
593
|
+
'ssh',
|
|
594
|
+
[
|
|
595
|
+
'-i',
|
|
596
|
+
sshKeyPath,
|
|
597
|
+
...sshHostKeyOpts(khPath),
|
|
598
|
+
'-o',
|
|
599
|
+
'ConnectTimeout=5',
|
|
600
|
+
'-o',
|
|
601
|
+
'BatchMode=yes',
|
|
602
|
+
target,
|
|
603
|
+
'command -v k3s >/dev/null',
|
|
604
|
+
],
|
|
605
|
+
{ stdio: 'ignore' },
|
|
606
|
+
);
|
|
607
|
+
return;
|
|
608
|
+
} catch (err) {
|
|
609
|
+
lastErr = err;
|
|
610
|
+
await delay(waitMs);
|
|
611
|
+
waitMs = Math.min(waitMs * 2, 15000);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
// Same diagnostic capture as waitForK3sReady — see that function for why.
|
|
615
|
+
// `target` here is `root@<ip>` form; strip the user@ prefix for the host.
|
|
616
|
+
const ip = String(target).includes('@') ? String(target).split('@').pop() : String(target);
|
|
617
|
+
const diag = captureK3sInstallDiag(ip, sshKeyPath, khPath);
|
|
618
|
+
throw new Error(
|
|
619
|
+
`k3s binary did not appear on ${target} within ${maxWaitSec}s. ` +
|
|
620
|
+
`Last SSH error: ${lastErr instanceof Error ? lastErr.message : 'unknown'}` +
|
|
621
|
+
(diag ? `\n\n--- install diagnostics from ${ip} ---\n${diag}` : ''),
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Best-effort capture of cloud-init + syslog tails to answer "why didn't
|
|
627
|
+
* k3s install?" The caller has already exhausted its readiness budget;
|
|
628
|
+
* this runs ONE quick SSH per command (5s connect, 15s exec) and returns
|
|
629
|
+
* the joined output. Each command is wrapped in a short script so we get
|
|
630
|
+
* partial output when the box is sluggish but not dead.
|
|
631
|
+
*
|
|
632
|
+
* Probe order is "answer the most-likely question first":
|
|
633
|
+
* 1. cloud-init status — is user-data even done? (errored / running / done)
|
|
634
|
+
* 2. install-attempt grep — did our script try? how many attempts?
|
|
635
|
+
* 3. cloud-init-output.log (last 200) — install error or `exit 1` line
|
|
636
|
+
* 4. k3s binary presence — install never wrote /usr/local/bin/k3s
|
|
637
|
+
* 5. syslog tail — apt errors, kernel oopses
|
|
638
|
+
* 6. systemctl status k3s — service registered yet?
|
|
639
|
+
* 7. df -h — a full disk often masquerades as a hung install
|
|
640
|
+
*
|
|
641
|
+
* Without these, the 2026-04-27 morning-matrix k8s-ha primary failure
|
|
642
|
+
* read as "Unit k3s.service could not be found" with no signal as to
|
|
643
|
+
* whether the install script never started, errored mid-way, or hit
|
|
644
|
+
* the 5-attempt curl retry limit. The new probes localize the failure
|
|
645
|
+
* to one of: cloud-init didn't run, install script errored before line
|
|
646
|
+
* N, install retries exhausted, k3s binary present but service unit
|
|
647
|
+
* missing/disabled.
|
|
648
|
+
*
|
|
649
|
+
* Returns '' if even the first SSH fails (box unreachable / down).
|
|
650
|
+
*/
|
|
651
|
+
function captureK3sInstallDiag(host, sshKeyPath, khPath) {
|
|
652
|
+
const sshArgs = (cmd) => [
|
|
653
|
+
'-i',
|
|
654
|
+
sshKeyPath,
|
|
655
|
+
...sshHostKeyOpts(khPath),
|
|
656
|
+
'-o',
|
|
657
|
+
'ConnectTimeout=5',
|
|
658
|
+
'-o',
|
|
659
|
+
'BatchMode=yes',
|
|
660
|
+
`root@${host}`,
|
|
661
|
+
cmd,
|
|
662
|
+
];
|
|
663
|
+
|
|
664
|
+
// Each probe returns at most `slice` chars from the END of its output
|
|
665
|
+
// (so when the box dumps a wide cloud-init-output.log the tail — i.e. the
|
|
666
|
+
// failure point — is what survives truncation, not the early ci-info
|
|
667
|
+
// tables). Default is 4000; overridden per-probe where the relevant
|
|
668
|
+
// signal lives in a longer tail.
|
|
669
|
+
const probes = [
|
|
670
|
+
{
|
|
671
|
+
// `cloud-init status --long` reports `status: error|running|done` plus
|
|
672
|
+
// (on error) the failing module name. This single line tells us
|
|
673
|
+
// whether user-data even finished — the most useful signal of all.
|
|
674
|
+
label: 'cloud-init status',
|
|
675
|
+
cmd: 'cloud-init status --long 2>&1 | head -n 20 || echo "(cloud-init not installed)"',
|
|
676
|
+
},
|
|
677
|
+
{
|
|
678
|
+
// Grep for our master-init.sh markers so a long log boils down to
|
|
679
|
+
// "did install run, how many times, what was the result?" without
|
|
680
|
+
// having to read the whole tail. Markers come from the script:
|
|
681
|
+
// "Installing k3s version: X" → start of install section
|
|
682
|
+
// "k3s install attempt N failed" → curl|sh retry fired
|
|
683
|
+
// "k3s install failed after 5 attempts" → exhausted
|
|
684
|
+
// "k3s master installation complete!" → success line
|
|
685
|
+
label: 'install attempts (cloud-init-output grep)',
|
|
686
|
+
cmd: 'grep -E "Installing k3s|install attempt|installation complete|FATAL: k3s" /var/log/cloud-init-output.log 2>&1 | tail -n 30 || echo "(no log)"',
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
// The output we *most* need to see in a failure: the lines right
|
|
690
|
+
// before cloud-init bailed out. Wide ci-info tables at the start of
|
|
691
|
+
// the file used to drown the failure tail under the 4k char cap;
|
|
692
|
+
// we now keep the last 16k chars of `tail -n 400` so the actual
|
|
693
|
+
// bash-level error survives truncation.
|
|
694
|
+
label: 'cloud-init-output.log (last 400 lines)',
|
|
695
|
+
cmd: 'echo "=== wc -l ===" && wc -l /var/log/cloud-init-output.log 2>&1; echo "=== tail ===" && tail -n 400 /var/log/cloud-init-output.log 2>&1 || echo "(not present)"',
|
|
696
|
+
slice: 16_000,
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
// The framework log (different file from cloud-init-output.log).
|
|
700
|
+
// When cloud-init's own modules error before user-data runs, the
|
|
701
|
+
// traceback lands here, not in -output.log. Critical when scripts_user
|
|
702
|
+
// status says "error" but -output.log shows no command failure.
|
|
703
|
+
label: 'cloud-init.log (errors + last 200 lines)',
|
|
704
|
+
cmd: 'echo "=== ERROR / WARNING grep ===" && grep -nE "ERROR|WARNING|CRITICAL|Traceback" /var/log/cloud-init.log 2>&1 | tail -n 60; echo "=== last 200 lines ===" && tail -n 200 /var/log/cloud-init.log 2>&1 || echo "(not present)"',
|
|
705
|
+
slice: 12_000,
|
|
706
|
+
},
|
|
707
|
+
{
|
|
708
|
+
// Show what command in part-001 actually ran. cloud-init writes the
|
|
709
|
+
// user-data script verbatim to /var/lib/cloud/instance/scripts/part-001,
|
|
710
|
+
// so seeing its first/last lines plus an md5 lets us confirm the
|
|
711
|
+
// generated script matches what we sent (no truncation in transit).
|
|
712
|
+
label: 'part-001 user-data script',
|
|
713
|
+
cmd: 'echo "=== md5 + size ===" && md5sum /var/lib/cloud/instance/scripts/*.sh /var/lib/cloud/instance/scripts/part-* 2>&1 | head -n 10; echo "=== head ===" && head -n 5 /var/lib/cloud/instance/scripts/part-* 2>/dev/null || echo "(not present)"; echo "=== tail ===" && tail -n 5 /var/lib/cloud/instance/scripts/part-* 2>/dev/null || echo "(not present)"',
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
// Did the install at least produce a binary? Distinguishes "install
|
|
717
|
+
// script never ran" (no binary) from "install ran but service unit
|
|
718
|
+
// is borked" (binary present, unit missing).
|
|
719
|
+
label: 'k3s binary',
|
|
720
|
+
cmd: 'ls -la /usr/local/bin/k3s 2>&1; which k3s 2>&1 || echo "(not in PATH)"',
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
// cloud-final is the systemd unit that runs scripts_user. Its
|
|
724
|
+
// journal output captures any stderr that didn't make it into
|
|
725
|
+
// cloud-init-output.log (e.g. when the script process is killed by
|
|
726
|
+
// OOM or the box reboots mid-script).
|
|
727
|
+
label: 'journalctl -u cloud-final',
|
|
728
|
+
cmd: 'journalctl -u cloud-final --no-pager -n 100 2>&1 || echo "(no journal)"',
|
|
729
|
+
slice: 8_000,
|
|
730
|
+
},
|
|
731
|
+
{
|
|
732
|
+
label: 'syslog (last 50 lines)',
|
|
733
|
+
cmd: 'tail -n 50 /var/log/syslog 2>&1 || journalctl -n 50 --no-pager 2>&1 || echo "(no log)"',
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
label: 'systemctl status k3s',
|
|
737
|
+
cmd: 'systemctl status k3s --no-pager 2>&1 | head -n 30 || echo "(no service)"',
|
|
738
|
+
},
|
|
739
|
+
{ label: 'disk usage', cmd: 'df -h / 2>&1' },
|
|
740
|
+
];
|
|
741
|
+
|
|
742
|
+
const out = [];
|
|
743
|
+
for (const probe of probes) {
|
|
744
|
+
const sliceLen = probe.slice ?? 4000;
|
|
745
|
+
try {
|
|
746
|
+
const result = execFileSync('ssh', sshArgs(probe.cmd), {
|
|
747
|
+
encoding: 'utf-8',
|
|
748
|
+
timeout: 20_000,
|
|
749
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
750
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
751
|
+
});
|
|
752
|
+
// Keep the END of the output (failure tail), not the start. ci-info
|
|
753
|
+
// tables at the head of cloud-init-output.log used to push the
|
|
754
|
+
// failing line off the end; `slice(-sliceLen)` flips that.
|
|
755
|
+
const trimmed = result.trim();
|
|
756
|
+
const kept =
|
|
757
|
+
trimmed.length > sliceLen
|
|
758
|
+
? `…(truncated; kept last ${sliceLen} chars)…\n${trimmed.slice(-sliceLen)}`
|
|
759
|
+
: trimmed;
|
|
760
|
+
out.push(`### ${probe.label}\n${kept}`);
|
|
761
|
+
} catch (err) {
|
|
762
|
+
// First probe fails -> box is dead, no point trying the others.
|
|
763
|
+
if (out.length === 0) return '';
|
|
764
|
+
out.push(
|
|
765
|
+
`### ${probe.label}\n(probe failed: ${err instanceof Error ? err.message.split('\n')[0] : 'unknown'})`,
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return out.join('\n\n');
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Sideload an image to every node in a k3s cluster via parallel SSH.
|
|
774
|
+
*
|
|
775
|
+
* Per node: `docker save TAG | ssh -i KEY -o ... TARGET 'k3s ctr images import -'`.
|
|
776
|
+
* Pipeline streams the tarball over SSH; no temp files on either end, no registry.
|
|
777
|
+
* Parallel via Promise.all + spawn — wall-clock dominated by the slowest node.
|
|
778
|
+
*
|
|
779
|
+
* Before each `docker save | ssh` we wait for `command -v k3s` on the target
|
|
780
|
+
* (workers/supabase finish their k3s install asynchronously after the master
|
|
781
|
+
* marker — see waitForK3sBinary). 5-min cap per node; total budget bounded
|
|
782
|
+
* by the deploy step's lifecycle timeout.
|
|
783
|
+
*
|
|
784
|
+
* @param {{tag: string, sshTargets: string[], sshKey: string, khPath: string}} args
|
|
785
|
+
* @returns {Promise<void>}
|
|
786
|
+
*/
|
|
787
|
+
export async function sideloadK3s({ tag, sshTargets, sshKey, khPath }) {
|
|
788
|
+
if (!sshTargets || sshTargets.length === 0) {
|
|
789
|
+
throw new Error('sideloadK3s: at least one SSH target is required');
|
|
790
|
+
}
|
|
791
|
+
if (!khPath) {
|
|
792
|
+
throw new Error('sideloadK3s: khPath is required');
|
|
793
|
+
}
|
|
794
|
+
const { spawn } = await import('node:child_process');
|
|
795
|
+
// We're going through `bash -c` because of the `docker save | ssh` pipe, so
|
|
796
|
+
// khPath/sshKey/target are interpolated into the shell. Single-quote the
|
|
797
|
+
// file paths so spaces in cwd don't break the command (and so a hostile
|
|
798
|
+
// env name — already validated upstream — couldn't smuggle metacharacters).
|
|
799
|
+
const q = (s) => `'${String(s).replace(/'/g, "'\\''")}'`;
|
|
800
|
+
// BatchMode=yes mirrors sshHostKeyOpts() — without it, a missing key on
|
|
801
|
+
// the target falls back to interactive password prompt and the docker
|
|
802
|
+
// save | gzip | ssh pipeline hangs (stdio:'inherit' surfaces the prompt
|
|
803
|
+
// on the operator's terminal).
|
|
804
|
+
const sshOptsStr = `-o UserKnownHostsFile=${q(khPath)} -o GlobalKnownHostsFile=/dev/null -o StrictHostKeyChecking=accept-new -o BatchMode=yes`;
|
|
805
|
+
await Promise.all(
|
|
806
|
+
sshTargets.map(async (target) => {
|
|
807
|
+
// Pre-flight: ensure k3s is on PATH before piping the tarball.
|
|
808
|
+
// Docker save streams ~600MB; if we ssh into a node that doesn't
|
|
809
|
+
// have k3s yet, the import errors mid-stream and we waste the
|
|
810
|
+
// bandwidth + cancel parallel imports on other healthy nodes.
|
|
811
|
+
// 600s mirrors waitForK3sReady — same cold-init story, same
|
|
812
|
+
// remedy as the comment there.
|
|
813
|
+
await waitForK3sBinary(target, sshKey, khPath, 600);
|
|
814
|
+
|
|
815
|
+
// Retry the docker-save | ssh pipe on transient failures.
|
|
816
|
+
// Observed in iter-wave1a (2026-05-01): standby k3s sideload failed
|
|
817
|
+
// with `kex_exchange_identification: read: Connection timed out`
|
|
818
|
+
// 21min into a successful deploy — cluster healthy, just an SSH
|
|
819
|
+
// socket flake. Retrying the pipe is the cheapest fix; the cost on
|
|
820
|
+
// the genuine-failure path is one extra ~5-10 min attempt, vs the
|
|
821
|
+
// current cost of throwing away an entire 21-min HA deploy.
|
|
822
|
+
const SIDELOAD_DELAYS_MS = [5_000, 15_000];
|
|
823
|
+
const runOnce = () =>
|
|
824
|
+
new Promise((resolve, reject) => {
|
|
825
|
+
// gzip on the wire: Node app images compress 3-5x (most layers
|
|
826
|
+
// are JS source + node_modules text). Without gzip, sideloading
|
|
827
|
+
// ~600MB to 6 nodes (HA) saturated the laptop's upload at ~25
|
|
828
|
+
// Mbps each, taking 23-29 min wall-clock (e2e run #1+#2).
|
|
829
|
+
// With gzip, the same payload is ~150-200MB → expected 5-10 min.
|
|
830
|
+
// We could reach for `pigz` for parallel compression, but plain
|
|
831
|
+
// `gzip -1` is universally present and dominant cost is network,
|
|
832
|
+
// not CPU. `gunzip` is available on Ubuntu 24.04 cloud-init by
|
|
833
|
+
// default (part of base image). pipefail propagates either side
|
|
834
|
+
// of the pipeline failing.
|
|
835
|
+
const cmd =
|
|
836
|
+
`set -o pipefail && docker save ${q(tag)} | gzip -1 | ` +
|
|
837
|
+
`ssh -i ${q(sshKey)} ${sshOptsStr} ${q(target)} ` +
|
|
838
|
+
`'gunzip | k3s ctr images import -'`;
|
|
839
|
+
const child = spawn('bash', ['-c', cmd], { stdio: 'inherit' });
|
|
840
|
+
child.on('error', (err) =>
|
|
841
|
+
reject(new Error(`sideloadK3s spawn failed for ${target}: ${err.message}`)),
|
|
842
|
+
);
|
|
843
|
+
child.on('exit', (code) => {
|
|
844
|
+
if (code === 0) resolve(undefined);
|
|
845
|
+
else reject(new Error(`sideloadK3s failed on ${target} with exit code ${code}`));
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
let lastErr;
|
|
850
|
+
for (let attempt = 0; attempt <= SIDELOAD_DELAYS_MS.length; attempt++) {
|
|
851
|
+
try {
|
|
852
|
+
await runOnce();
|
|
853
|
+
return;
|
|
854
|
+
} catch (err) {
|
|
855
|
+
lastErr = err;
|
|
856
|
+
if (attempt === SIDELOAD_DELAYS_MS.length) break;
|
|
857
|
+
console.warn(
|
|
858
|
+
`[sideloadK3s] ${target} attempt ${attempt + 1} failed (${err.message}); retrying in ${SIDELOAD_DELAYS_MS[attempt] / 1000}s`,
|
|
859
|
+
);
|
|
860
|
+
await new Promise((r) => setTimeout(r, SIDELOAD_DELAYS_MS[attempt]));
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
throw lastErr;
|
|
864
|
+
}),
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Push a locally-built image to the master's local OCI registry via an
|
|
870
|
+
* SSH local-port-forward.
|
|
871
|
+
*
|
|
872
|
+
* Why an SSH tunnel: the registry pod (Phase 1) listens on the master's
|
|
873
|
+
* hostPort 5000 inside the cluster network — not exposed to the public
|
|
874
|
+
* internet (the firewall blocks 5000). Without DNS/L4 pointing the
|
|
875
|
+
* operator → master:5000, we forward locally:
|
|
876
|
+
*
|
|
877
|
+
* ssh -L 5000:localhost:5000 -N -f <master>
|
|
878
|
+
* docker tag 10.0.1.1:5000/<repo>:<tag> localhost:5000/<repo>:<tag>
|
|
879
|
+
* docker push localhost:5000/<repo>:<tag>
|
|
880
|
+
* pkill -f "ssh -L 5000:localhost:5000"
|
|
881
|
+
*
|
|
882
|
+
* Why retag to localhost first: docker resolves the registry hostname in
|
|
883
|
+
* a tag literally, so `docker push 10.0.1.1:5000/...` would dial
|
|
884
|
+
* 10.0.1.1 directly — bypassing the SSH tunnel (which is bound to
|
|
885
|
+
* `localhost:5000`) and timing out, since 10.0.1.1 is a Hetzner private
|
|
886
|
+
* IP unreachable from the operator. Pushing through `localhost:5000`
|
|
887
|
+
* routes through the tunnel AND inherits docker's loopback insecure-by-
|
|
888
|
+
* default behavior (the registry serves plain HTTP). The OCI registry
|
|
889
|
+
* stores content by `<repo>:<tag>` only — the host portion is just used
|
|
890
|
+
* to find the registry — so cluster-side pulls of the original
|
|
891
|
+
* `10.0.1.1:5000/<repo>:<tag>` reference resolve to the same content
|
|
892
|
+
* (Phase 1's `registries.yaml` maps that hostname to the registry pod).
|
|
893
|
+
*
|
|
894
|
+
* The `10.0.1.1:5000/` build prefix is set in `buildAppImage` (above);
|
|
895
|
+
* keep these two strings in sync.
|
|
896
|
+
*
|
|
897
|
+
* Sideload remains the primary distribution path for static workers; the
|
|
898
|
+
* registry push is additive — it lets CA-spawned workers (which don't
|
|
899
|
+
* exist at sideload time) pull the same tag on first schedule via
|
|
900
|
+
* Phase 1's `registries.yaml`.
|
|
901
|
+
*
|
|
902
|
+
* Idempotency: a stale forward from a prior crashed run blocks `-L 5000`
|
|
903
|
+
* binding. We tear down any existing tunnel first (best-effort), open a
|
|
904
|
+
* fresh one, push, then tear it down again. If the push fails because
|
|
905
|
+
* the tunnel didn't bind in time, we retry once with a fresh tunnel.
|
|
906
|
+
*
|
|
907
|
+
* Per `feedback_ssh_batchmode_required.md`, every ssh invocation
|
|
908
|
+
* includes `-o BatchMode=yes` so a key-auth failure aborts cleanly
|
|
909
|
+
* instead of falling back to interactive prompt. Mirrors the
|
|
910
|
+
* `sshHostKeyOpts` pattern used elsewhere.
|
|
911
|
+
*
|
|
912
|
+
* Throws on persistent push failure; tunnel-teardown and local-tag
|
|
913
|
+
* cleanup failures are non-fatal.
|
|
914
|
+
*
|
|
915
|
+
* @param {{
|
|
916
|
+
* tag: string,
|
|
917
|
+
* masterIp: string,
|
|
918
|
+
* sshKey: string,
|
|
919
|
+
* khPath: string,
|
|
920
|
+
* settleDelaysMs?: number[],
|
|
921
|
+
* }} args
|
|
922
|
+
* `settleDelaysMs` controls the per-attempt sleep between opening the
|
|
923
|
+
* tunnel and dialing it. Length of the array is the attempt count.
|
|
924
|
+
* Production callers should omit it (the default 1s/15s/30s schedule
|
|
925
|
+
* is the considered budget — see comment in the loop). Tests pass
|
|
926
|
+
* `[0, 0, 0]` to keep the suite fast.
|
|
927
|
+
* @returns {Promise<void>}
|
|
928
|
+
*/
|
|
929
|
+
export async function pushImageToLocalRegistry({
|
|
930
|
+
tag,
|
|
931
|
+
masterIp,
|
|
932
|
+
sshKey,
|
|
933
|
+
khPath,
|
|
934
|
+
settleDelaysMs,
|
|
935
|
+
localTunnelPort = 5000,
|
|
936
|
+
}) {
|
|
937
|
+
if (!tag) throw new Error('pushImageToLocalRegistry: tag is required');
|
|
938
|
+
if (!masterIp) throw new Error('pushImageToLocalRegistry: masterIp is required');
|
|
939
|
+
if (!sshKey) throw new Error('pushImageToLocalRegistry: sshKey is required');
|
|
940
|
+
if (!khPath) throw new Error('pushImageToLocalRegistry: khPath is required');
|
|
941
|
+
if (!Number.isInteger(localTunnelPort) || localTunnelPort < 1 || localTunnelPort > 65535) {
|
|
942
|
+
throw new Error(
|
|
943
|
+
`pushImageToLocalRegistry: localTunnelPort must be a port in [1, 65535], got: ${localTunnelPort}`,
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Build prefix is hardcoded in buildAppImage as `10.0.1.1:5000`. Rewrite
|
|
948
|
+
// to localhost:<chosenPort> so the push hits THIS deploy's SSH tunnel
|
|
949
|
+
// and uses HTTP. The remote-side port is always 5000 (the local-registry
|
|
950
|
+
// hostPort); only the operator-side listen port varies.
|
|
951
|
+
const remotePrefix = '10.0.1.1:5000/';
|
|
952
|
+
if (!tag.startsWith(remotePrefix)) {
|
|
953
|
+
throw new Error(
|
|
954
|
+
`pushImageToLocalRegistry: expected tag prefixed with '${remotePrefix}', got: ${tag}`,
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const target = `root@${masterIp}`;
|
|
959
|
+
|
|
960
|
+
// chosenPort + localTag get re-bound on each open-tunnel attempt below.
|
|
961
|
+
// We can't pick the port up-front because of a TOCTOU race: probing
|
|
962
|
+
// `isPortFree(5000)`, releasing the listener, then `ssh -L 5000:...` is
|
|
963
|
+
// not atomic — a sibling deploy in the matrix (k8s + k8s-ha primary
|
|
964
|
+
// both default to 5000) can win the bind in between. The fix is to try
|
|
965
|
+
// ssh directly and walk forward on failure, treating "tunnel open" as
|
|
966
|
+
// the only true free-port signal. Observed iter-wave1b 2026-05-01:
|
|
967
|
+
// single-k8s and k8s-ha-primary collided here.
|
|
968
|
+
let localTag = '';
|
|
969
|
+
let forwardSpec = '';
|
|
970
|
+
let tunnelPattern = '';
|
|
971
|
+
const setPort = (port) => {
|
|
972
|
+
forwardSpec = `${port}:localhost:5000`;
|
|
973
|
+
tunnelPattern = `ssh.*-L.*${forwardSpec}`;
|
|
974
|
+
localTag = `localhost:${port}/${tag.slice(remotePrefix.length)}`;
|
|
975
|
+
};
|
|
976
|
+
setPort(localTunnelPort);
|
|
977
|
+
|
|
978
|
+
/** Best-effort teardown of any existing tunnel on localhost:<port>. */
|
|
979
|
+
const teardown = () => {
|
|
980
|
+
try {
|
|
981
|
+
execFileSync('pkill', ['-f', tunnelPattern], { stdio: 'ignore' });
|
|
982
|
+
} catch {
|
|
983
|
+
// pkill exits 1 when no matching process — that's fine.
|
|
984
|
+
}
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Try to open the SSH tunnel on the currently-set port; if SSH bind
|
|
989
|
+
* fails (sibling deploy holds the port), walk forward up to 20 ports.
|
|
990
|
+
* Does NOT call teardown() in the walk — the pkill pattern would
|
|
991
|
+
* match sibling processes' tunnels too. The walk is bind-race-resilient
|
|
992
|
+
* because ssh exits non-zero immediately on bind failure (via
|
|
993
|
+
* ExitOnForwardFailure=yes).
|
|
994
|
+
*/
|
|
995
|
+
const openTunnelOrWalk = () => {
|
|
996
|
+
const errors = [];
|
|
997
|
+
for (let probe = 0; probe < 20; probe++) {
|
|
998
|
+
const candidate = localTunnelPort + probe;
|
|
999
|
+
if (candidate > 65535) break;
|
|
1000
|
+
setPort(candidate);
|
|
1001
|
+
try {
|
|
1002
|
+
execFileSync(
|
|
1003
|
+
'ssh',
|
|
1004
|
+
[
|
|
1005
|
+
'-i',
|
|
1006
|
+
sshKey,
|
|
1007
|
+
...sshHostKeyOpts(khPath),
|
|
1008
|
+
'-o',
|
|
1009
|
+
'ExitOnForwardFailure=yes',
|
|
1010
|
+
'-L',
|
|
1011
|
+
forwardSpec,
|
|
1012
|
+
'-N',
|
|
1013
|
+
'-f',
|
|
1014
|
+
target,
|
|
1015
|
+
],
|
|
1016
|
+
{ stdio: 'inherit' },
|
|
1017
|
+
);
|
|
1018
|
+
return;
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
errors.push(`port ${candidate}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
throw new Error(
|
|
1024
|
+
`pushImageToLocalRegistry: failed to open SSH tunnel on any port in [${localTunnelPort}, ${localTunnelPort + 19}]. ` +
|
|
1025
|
+
`Errors: ${errors.join(' | ')}`,
|
|
1026
|
+
);
|
|
1027
|
+
};
|
|
1028
|
+
// openTunnel signature stays the same so the retry loop below is
|
|
1029
|
+
// unchanged; it's now bind-race-resilient instead of TOCTOU-racy.
|
|
1030
|
+
const openTunnel = openTunnelOrWalk;
|
|
1031
|
+
|
|
1032
|
+
/** Push the localhost-aliased tag through the local-forwarded port. */
|
|
1033
|
+
const push = () => {
|
|
1034
|
+
execFileSync('docker', ['push', localTag], { stdio: 'inherit' });
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
// Three attempts with progressive backoff. Failure modes we're
|
|
1038
|
+
// covering, in priority order:
|
|
1039
|
+
// 1. Stale tunnel from a parallel run holds :5000 → reopen tunnel.
|
|
1040
|
+
// Recovers in seconds (handled by the per-attempt teardown).
|
|
1041
|
+
// 2. Registry pod restarted between back-to-back pushes (e.g.
|
|
1042
|
+
// probe-killed mid-prior-push) → manifest PUT returns EOF or
|
|
1043
|
+
// `manifest blob unknown to registry`. Pod recovery is ~10–20s,
|
|
1044
|
+
// so the second/third attempt has the longer backoff to let the
|
|
1045
|
+
// replacement pod become Ready before re-pushing.
|
|
1046
|
+
// 3. S3 transient (Hetzner Object Storage 5xx during blob upload)
|
|
1047
|
+
// → registry returns the same `blob unknown` after the layer is
|
|
1048
|
+
// "uploaded" but not yet readable. Same recovery shape as (2).
|
|
1049
|
+
// The 1s/15s/30s schedule keeps the happy path fast (single attempt
|
|
1050
|
+
// ~6s end-to-end) while giving genuine restarts time to recover.
|
|
1051
|
+
const settleDelays = settleDelaysMs ?? [1_000, 15_000, 30_000];
|
|
1052
|
+
const maxAttempts = settleDelays.length;
|
|
1053
|
+
const attemptErrors = [];
|
|
1054
|
+
const taggedAliases = new Set();
|
|
1055
|
+
let pushed = false;
|
|
1056
|
+
// Per-attempt structured logging — without this, the only visible signal
|
|
1057
|
+
// for a slow push is the final perf duration (which collapses retry
|
|
1058
|
+
// count + per-attempt time into a single number). iter-perfwave2 showed
|
|
1059
|
+
// primary registryPush.backup 2m53s vs standby 41s with no way to tell
|
|
1060
|
+
// if the gap was 1 slow push or 3 retried pushes. The log lines below
|
|
1061
|
+
// make next run's analysis trivial: count `[push] attempt=` lines for
|
|
1062
|
+
// attempt-count, sum `dur=` for actual push wall-clock.
|
|
1063
|
+
const startedAt = Date.now();
|
|
1064
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1065
|
+
const attemptStart = Date.now();
|
|
1066
|
+
let outcome = 'ok';
|
|
1067
|
+
try {
|
|
1068
|
+
// openTunnel may walk to a different port on bind-collision; localTag
|
|
1069
|
+
// is updated in lockstep so push targets the actual chosen port.
|
|
1070
|
+
openTunnel();
|
|
1071
|
+
// Add the localhost-prefixed alias for the chosen port. Skip the
|
|
1072
|
+
// re-tag if we already aliased this port on an earlier attempt
|
|
1073
|
+
// (same port → same alias, docker tag is idempotent but the call
|
|
1074
|
+
// is wasteful). Track all aliases for cleanup so a port walk
|
|
1075
|
+
// doesn't leak local image refs.
|
|
1076
|
+
if (!taggedAliases.has(localTag)) {
|
|
1077
|
+
execFileSync('docker', ['tag', tag, localTag], { stdio: 'inherit' });
|
|
1078
|
+
taggedAliases.add(localTag);
|
|
1079
|
+
}
|
|
1080
|
+
await delay(settleDelays[attempt]);
|
|
1081
|
+
push();
|
|
1082
|
+
pushed = true;
|
|
1083
|
+
break;
|
|
1084
|
+
} catch (err) {
|
|
1085
|
+
attemptErrors.push(err instanceof Error ? err.message : String(err));
|
|
1086
|
+
outcome = `fail(${(err instanceof Error ? err.message : String(err)).split('\n')[0].slice(0, 80)})`;
|
|
1087
|
+
} finally {
|
|
1088
|
+
// Tear down between attempts so a stuck tunnel from this attempt
|
|
1089
|
+
// doesn't bind-block the next attempt's openTunnel walk.
|
|
1090
|
+
teardown();
|
|
1091
|
+
const dur = Math.round((Date.now() - attemptStart) / 1000);
|
|
1092
|
+
const totalDur = Math.round((Date.now() - startedAt) / 1000);
|
|
1093
|
+
console.error(
|
|
1094
|
+
`[push] tag=${tag.split('/').pop()} attempt=${attempt + 1}/${maxAttempts} port=${localTag.split('/')[0]} settle=${settleDelays[attempt] / 1000}s dur=${dur}s totalDur=${totalDur}s ${outcome}`,
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
// Cleanup every localhost alias we created. Underlying image content
|
|
1099
|
+
// stays addressable as the original `tag` (used by sideload + Deployment).
|
|
1100
|
+
for (const alias of taggedAliases) {
|
|
1101
|
+
try {
|
|
1102
|
+
execFileSync('docker', ['rmi', alias], { stdio: 'ignore' });
|
|
1103
|
+
} catch {
|
|
1104
|
+
// non-fatal — leftover alias just sits in the local docker cache.
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
if (!pushed) {
|
|
1108
|
+
throw new Error(
|
|
1109
|
+
`pushImageToLocalRegistry: docker push for ${tag} failed after ${maxAttempts} attempts. ` +
|
|
1110
|
+
`Errors: ${attemptErrors.map((m, i) => `[#${i + 1}] ${m}`).join(' | ')}`,
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Subset of .env.local keys that must land in the in-cluster
|
|
1117
|
+
* vibecarbon-secrets Secret. The Supabase Helm chart reads JWT_SECRET +
|
|
1118
|
+
* ANON_KEY + SERVICE_ROLE_KEY via secretRef from this Secret; the app
|
|
1119
|
+
* Deployment reads DB_PASSWORD + the rest via envFrom.
|
|
1120
|
+
*
|
|
1121
|
+
* Defined as an explicit allowlist (not "everything in .env.local")
|
|
1122
|
+
* because .env.local also carries client-side VITE_* values that don't
|
|
1123
|
+
* belong in a Secret.
|
|
1124
|
+
*/
|
|
1125
|
+
const SECRET_KEYS = [
|
|
1126
|
+
'DB_PASSWORD',
|
|
1127
|
+
'JWT_SECRET',
|
|
1128
|
+
'ANON_KEY',
|
|
1129
|
+
'SERVICE_ROLE_KEY',
|
|
1130
|
+
'REALTIME_SECRET',
|
|
1131
|
+
'LOGFLARE_API_KEY',
|
|
1132
|
+
'VAULT_ENC_KEY',
|
|
1133
|
+
'PG_META_CRYPTO_KEY',
|
|
1134
|
+
'DB_ENC_KEY',
|
|
1135
|
+
'REPL_PASSWORD',
|
|
1136
|
+
'ADMIN_EMAIL',
|
|
1137
|
+
'ADMIN_PASSWORD',
|
|
1138
|
+
'ADMIN_PASSWORD_HASH',
|
|
1139
|
+
];
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Apply the in-cluster `vibecarbon-secrets` Secret from the project's
|
|
1143
|
+
* `.env.local`. Idempotent: rendered as a Secret manifest and piped to
|
|
1144
|
+
* `kubectl apply -f -` (server-side apply semantics) so re-runs upsert
|
|
1145
|
+
* cleanly.
|
|
1146
|
+
*
|
|
1147
|
+
* Must run BEFORE the base/ kustomization apply, because the app
|
|
1148
|
+
* Deployment in base/ references this Secret via envFrom.
|
|
1149
|
+
*
|
|
1150
|
+
* S3 backup credentials (S3_ACCESS_KEY/SECRET_KEY/BUCKET/ENDPOINT/REGION)
|
|
1151
|
+
* are merged in if `s3Config` is provided — backup CronJob reads them.
|
|
1152
|
+
* `backupBucketName` is the dedicated backup bucket (separate from the
|
|
1153
|
+
* storage bucket in s3Config.bucket); without it, backup.sh falls back to
|
|
1154
|
+
* the storage bucket and uploads dumps next to user files (observed
|
|
1155
|
+
* 2026-04-27 k8s-ha matrix #6 — restore couldn't find backups).
|
|
1156
|
+
*
|
|
1157
|
+
* @param {{kubeconfig: string, envLocal: Record<string,string>, s3Config?: {accessKey: string, secretKey: string, bucket: string, endpoint: string, region: string}, backupBucketName?: string|null}} args
|
|
1158
|
+
*/
|
|
1159
|
+
export async function applyVibecarbonSecrets({ kubeconfig, envLocal, s3Config, backupBucketName }) {
|
|
1160
|
+
const env = { ...process.env, KUBECONFIG: kubeconfig };
|
|
1161
|
+
/** @type {Record<string,string>} */
|
|
1162
|
+
const stringData = {};
|
|
1163
|
+
for (const key of SECRET_KEYS) {
|
|
1164
|
+
if (envLocal[key]) stringData[key] = envLocal[key];
|
|
1165
|
+
}
|
|
1166
|
+
// .env.local stores Supabase keys with the SUPABASE_ prefix (matches the
|
|
1167
|
+
// app's runtime env vars), but the supabase Helm chart's secretRefKey
|
|
1168
|
+
// mapping reads them under their bare names from this Secret. Translate.
|
|
1169
|
+
// Without this, kubelet fails kong/storage/studio/app pods with
|
|
1170
|
+
// "couldn't find key ANON_KEY in Secret vibecarbon/vibecarbon-secrets".
|
|
1171
|
+
if (!stringData.ANON_KEY && envLocal.SUPABASE_ANON_KEY) {
|
|
1172
|
+
stringData.ANON_KEY = envLocal.SUPABASE_ANON_KEY;
|
|
1173
|
+
}
|
|
1174
|
+
if (!stringData.SERVICE_ROLE_KEY && envLocal.SUPABASE_SERVICE_ROLE_KEY) {
|
|
1175
|
+
stringData.SERVICE_ROLE_KEY = envLocal.SUPABASE_SERVICE_ROLE_KEY;
|
|
1176
|
+
}
|
|
1177
|
+
// Supabase chart's db.secretRefKey.database maps to DB_NAME in this Secret.
|
|
1178
|
+
// Neither .env.local nor SECRET_KEYS supplies it, so the chart-canonical
|
|
1179
|
+
// default "postgres" is hardcoded — kubelet otherwise fails the supabase-db
|
|
1180
|
+
// pod with "couldn't find key DB_NAME in Secret vibecarbon/vibecarbon-secrets".
|
|
1181
|
+
if (!stringData.DB_NAME) stringData.DB_NAME = envLocal.POSTGRES_DB || 'postgres';
|
|
1182
|
+
if (s3Config) {
|
|
1183
|
+
stringData.S3_ACCESS_KEY = s3Config.accessKey;
|
|
1184
|
+
stringData.S3_SECRET_KEY = s3Config.secretKey;
|
|
1185
|
+
stringData.S3_BUCKET = s3Config.bucket;
|
|
1186
|
+
stringData.S3_ENDPOINT = s3Config.endpoint;
|
|
1187
|
+
stringData.S3_REGION = s3Config.region;
|
|
1188
|
+
}
|
|
1189
|
+
if (backupBucketName) {
|
|
1190
|
+
stringData.S3_BACKUP_BUCKET = backupBucketName;
|
|
1191
|
+
}
|
|
1192
|
+
// Ensure the namespace exists before we try to land a Secret in it.
|
|
1193
|
+
// base/ kustomization creates it too, but we run before base/.
|
|
1194
|
+
// Two-step (render → apply) instead of `bash -c '… | kubectl apply -f -'`
|
|
1195
|
+
// so each kubectl call goes through runKubectlWithRetry — apiserver
|
|
1196
|
+
// transients during k3s warm-up otherwise kill the deploy here too.
|
|
1197
|
+
const namespaceYaml = await runKubectlWithRetry(
|
|
1198
|
+
['create', 'namespace', 'vibecarbon', '--dry-run=client', '-o', 'yaml'],
|
|
1199
|
+
{ env, captureStdout: true, description: 'applyVibecarbonSecrets: render namespace yaml' },
|
|
1200
|
+
);
|
|
1201
|
+
await runKubectlWithRetry(['apply', '-f', '-'], {
|
|
1202
|
+
env,
|
|
1203
|
+
input: namespaceYaml,
|
|
1204
|
+
description: 'applyVibecarbonSecrets: kubectl apply namespace/vibecarbon',
|
|
1205
|
+
});
|
|
1206
|
+
// Build secret YAML; pipe to kubectl apply via stdin (values never hit
|
|
1207
|
+
// a shell argv). stringData → kubectl base64-encodes for us.
|
|
1208
|
+
const secretYaml = [
|
|
1209
|
+
'apiVersion: v1',
|
|
1210
|
+
'kind: Secret',
|
|
1211
|
+
'metadata:',
|
|
1212
|
+
' name: vibecarbon-secrets',
|
|
1213
|
+
' namespace: vibecarbon',
|
|
1214
|
+
'type: Opaque',
|
|
1215
|
+
'stringData:',
|
|
1216
|
+
...Object.entries(stringData).map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`),
|
|
1217
|
+
].join('\n');
|
|
1218
|
+
await runKubectlWithRetry(['apply', '-f', '-'], {
|
|
1219
|
+
env,
|
|
1220
|
+
input: secretYaml,
|
|
1221
|
+
description: 'applyVibecarbonSecrets: kubectl apply Secret/vibecarbon-secrets',
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Render the project's k8s/values/supabase.values.yaml template and
|
|
1227
|
+
* `helm upgrade --install` the supabase community chart. Waits for the
|
|
1228
|
+
* release to converge before returning.
|
|
1229
|
+
*
|
|
1230
|
+
* Direct `helm upgrade --install` keeps Flux off the deploy path; Flux
|
|
1231
|
+
* is opt-in via `vibecarbon configure cicd`.
|
|
1232
|
+
*
|
|
1233
|
+
* @param {{kubeconfig: string, projectDir: string, projectName: string, domain: string, envLocal?: Record<string,string>}} args
|
|
1234
|
+
*/
|
|
1235
|
+
export async function installSupabase({
|
|
1236
|
+
kubeconfig,
|
|
1237
|
+
projectDir,
|
|
1238
|
+
projectName,
|
|
1239
|
+
domain,
|
|
1240
|
+
s3Config,
|
|
1241
|
+
envLocal,
|
|
1242
|
+
}) {
|
|
1243
|
+
const env = { ...process.env, KUBECONFIG: kubeconfig };
|
|
1244
|
+
// Render the values template — substitute every {{TOKEN}} the template
|
|
1245
|
+
// uses so the rendered yaml is fully valid before helm sees it.
|
|
1246
|
+
const valuesTemplatePath = join(projectDir, 'k8s/values/supabase.values.yaml');
|
|
1247
|
+
if (!existsSync(valuesTemplatePath)) {
|
|
1248
|
+
throw new Error(
|
|
1249
|
+
`installSupabase: ${valuesTemplatePath} not found. Project must include the supabase values template.`,
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
const rendered = readFileSync(valuesTemplatePath, 'utf-8')
|
|
1253
|
+
.replace(/\{\{DOMAIN\}\}/g, domain || 'localhost')
|
|
1254
|
+
.replace(/\{\{PROJECT_NAME\}\}/g, projectName || 'vibecarbon')
|
|
1255
|
+
.replace(/\{\{S3_BACKUP_BUCKET\}\}/g, s3Config?.bucket ?? '')
|
|
1256
|
+
// Studio dashboard creds — sourced from .env.local at deploy time. Empty
|
|
1257
|
+
// string fallback keeps yaml valid; chart inlines them into the
|
|
1258
|
+
// chart-generated supabase-dashboard secret.
|
|
1259
|
+
.replace(/\{\{ADMIN_EMAIL\}\}/g, envLocal?.ADMIN_EMAIL ?? '')
|
|
1260
|
+
.replace(/\{\{ADMIN_PASSWORD\}\}/g, envLocal?.ADMIN_PASSWORD ?? '');
|
|
1261
|
+
const tmpValues = join(tmpdir(), `vibecarbon-supabase-values-${process.pid}-${Date.now()}.yaml`);
|
|
1262
|
+
writeFileSync(tmpValues, rendered);
|
|
1263
|
+
try {
|
|
1264
|
+
// Add the supabase-community helm repo (idempotent — non-zero if exists).
|
|
1265
|
+
try {
|
|
1266
|
+
execFileSync('helm', ['repo', 'add', SUPABASE_HELM_REPO_NAME, SUPABASE_HELM_REPO_URL], {
|
|
1267
|
+
stdio: 'inherit',
|
|
1268
|
+
env,
|
|
1269
|
+
});
|
|
1270
|
+
} catch {
|
|
1271
|
+
// helm repo add fails if the repo already exists; `helm repo update`
|
|
1272
|
+
// below refreshes either way.
|
|
1273
|
+
}
|
|
1274
|
+
execFileSync('helm', ['repo', 'update', SUPABASE_HELM_REPO_NAME], {
|
|
1275
|
+
stdio: 'inherit',
|
|
1276
|
+
env,
|
|
1277
|
+
});
|
|
1278
|
+
// --wait blocks until all pods are Ready. --create-namespace is a no-op
|
|
1279
|
+
// if vibecarbon namespace already exists.
|
|
1280
|
+
//
|
|
1281
|
+
// Async (spawn-based via runCommandAsync) — `helm --wait --timeout 15m`
|
|
1282
|
+
// can block for 1-15 minutes. A sync execFileSync would block Node's
|
|
1283
|
+
// event loop the whole time, inflating every concurrent perfAsync's
|
|
1284
|
+
// `Date.now()` measurement on parallel branches. In HA the standby
|
|
1285
|
+
// cluster's installSupabase runs in parallel with primary's; without
|
|
1286
|
+
// this, primary's blocking helm-wait makes standby's perf substep
|
|
1287
|
+
// numbers unreadable (RCA from iter-validate6, where standby's
|
|
1288
|
+
// certManager.wait reported 21 minutes despite kubectl wait finishing
|
|
1289
|
+
// in seconds — the wall-clock was inflated by primary's blocking).
|
|
1290
|
+
const helmStart = Date.now();
|
|
1291
|
+
await runCommandAsync(
|
|
1292
|
+
[
|
|
1293
|
+
'helm',
|
|
1294
|
+
'upgrade',
|
|
1295
|
+
'--install',
|
|
1296
|
+
SUPABASE_HELM_RELEASE_NAME,
|
|
1297
|
+
SUPABASE_HELM_CHART,
|
|
1298
|
+
'--namespace',
|
|
1299
|
+
'vibecarbon',
|
|
1300
|
+
'--create-namespace',
|
|
1301
|
+
'-f',
|
|
1302
|
+
tmpValues,
|
|
1303
|
+
'--wait',
|
|
1304
|
+
'--timeout',
|
|
1305
|
+
SUPABASE_HELM_TIMEOUT,
|
|
1306
|
+
],
|
|
1307
|
+
{ silent: false, env },
|
|
1308
|
+
);
|
|
1309
|
+
const helmDur = Math.round((Date.now() - helmStart) / 1000);
|
|
1310
|
+
// Snapshot pod state immediately after `--wait` returns. Each pod's
|
|
1311
|
+
// age tells us how long IT took to become Ready (kubectl AGE = now -
|
|
1312
|
+
// pod.creationTimestamp; for newly-created pods the age ≈ helm wait).
|
|
1313
|
+
// Without this, the only signal for a slow installSupabase is the
|
|
1314
|
+
// umbrella perfAsync number — which collapses 14+ pods into one
|
|
1315
|
+
// duration. iter-perfwave2 showed primary 3m47s vs standby 1m43s
|
|
1316
|
+
// with no way to localize which pod was the long pole.
|
|
1317
|
+
try {
|
|
1318
|
+
const podSnapshot = execFileSync(
|
|
1319
|
+
'kubectl',
|
|
1320
|
+
[
|
|
1321
|
+
'-n',
|
|
1322
|
+
'vibecarbon',
|
|
1323
|
+
'get',
|
|
1324
|
+
'pods',
|
|
1325
|
+
'-l',
|
|
1326
|
+
'app.kubernetes.io/instance=supabase',
|
|
1327
|
+
'-o',
|
|
1328
|
+
'custom-columns=NAME:.metadata.name,STATUS:.status.phase,RESTARTS:.status.containerStatuses[0].restartCount,AGE:.metadata.creationTimestamp,NODE:.spec.nodeName',
|
|
1329
|
+
'--no-headers',
|
|
1330
|
+
],
|
|
1331
|
+
{ encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'] },
|
|
1332
|
+
).trim();
|
|
1333
|
+
console.error(`[supabase] helm-wait ${helmDur}s; post-wait pods:\n${podSnapshot}`);
|
|
1334
|
+
} catch (e) {
|
|
1335
|
+
console.error(
|
|
1336
|
+
`[supabase] helm-wait ${helmDur}s; pod snapshot failed: ${e.message?.slice(0, 80)}`,
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
} finally {
|
|
1340
|
+
try {
|
|
1341
|
+
unlinkSync(tmpValues);
|
|
1342
|
+
} catch {
|
|
1343
|
+
// non-fatal: leave tmp file, OS will clean /tmp eventually
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* Wait until the supabase storage schema has fully initialized — specifically
|
|
1350
|
+
* until `storage.buckets.public` column exists. The supabase chart's
|
|
1351
|
+
* storage-api container runs its own schema migrations on startup which lag
|
|
1352
|
+
* helm's `--wait`-Ready signal by 30-90s on cold clusters; running our own
|
|
1353
|
+
* migrations against an in-progress storage schema fails with errors like
|
|
1354
|
+
* "column 'public' of relation 'buckets' does not exist" when an INSERT
|
|
1355
|
+
* INTO storage.buckets references a column the storage container hasn't
|
|
1356
|
+
* added yet (observed 2026-04-26 e2e run #4 — k8s-ha standby
|
|
1357
|
+
* cluster).
|
|
1358
|
+
*
|
|
1359
|
+
* Polls every 5s for up to maxWaitSec. Idempotent: if the column already
|
|
1360
|
+
* exists (warm path), returns immediately on first probe.
|
|
1361
|
+
*
|
|
1362
|
+
* Each kubectl exec is bounded to ~15s wall via `--request-timeout=15s` plus
|
|
1363
|
+
* a Node-side kill backstop. A bare `spawnSync(kubectl exec)` with no
|
|
1364
|
+
* timeout can hang for arbitrary time when the apiserver streaming
|
|
1365
|
+
* connection wedges, which silently bypasses maxWaitSec — observed
|
|
1366
|
+
* 2026-05-08 e2e run #3 where standby's poll ran ~1005s against a
|
|
1367
|
+
* nominal maxWaitSec=300 and rolled into the parallel primary's 16.7-min
|
|
1368
|
+
* rolloutApp window. Bounding each call also lets first-failure stderr
|
|
1369
|
+
* surface so a real storage-api crashloop is distinguishable from an exec
|
|
1370
|
+
* connectivity issue. Uses async spawn so the parent's event loop stays
|
|
1371
|
+
* free for the parallel HA branch.
|
|
1372
|
+
*
|
|
1373
|
+
* @param {{kubeconfig: string, maxWaitSec?: number}} args
|
|
1374
|
+
*/
|
|
1375
|
+
export async function waitForSupabaseStorageSchema({ kubeconfig, maxWaitSec = 600 }) {
|
|
1376
|
+
const env = { ...process.env, KUBECONFIG: kubeconfig };
|
|
1377
|
+
const probe = `SELECT 1 FROM information_schema.columns WHERE table_schema='storage' AND table_name='buckets' AND column_name='public';`;
|
|
1378
|
+
const start = Date.now();
|
|
1379
|
+
const waitMs = 5000;
|
|
1380
|
+
const perCallSec = 15;
|
|
1381
|
+
let attempt = 0;
|
|
1382
|
+
let firstErrLogged = false;
|
|
1383
|
+
while ((Date.now() - start) / 1000 < maxWaitSec) {
|
|
1384
|
+
attempt += 1;
|
|
1385
|
+
const { status, stdout, stderr } = await execKubectlOnce(
|
|
1386
|
+
[
|
|
1387
|
+
'-n',
|
|
1388
|
+
'vibecarbon',
|
|
1389
|
+
'exec',
|
|
1390
|
+
'-i',
|
|
1391
|
+
`--request-timeout=${perCallSec}s`,
|
|
1392
|
+
'supabase-supabase-db-0',
|
|
1393
|
+
'--',
|
|
1394
|
+
'psql',
|
|
1395
|
+
'-U',
|
|
1396
|
+
'supabase_admin',
|
|
1397
|
+
'-d',
|
|
1398
|
+
'postgres',
|
|
1399
|
+
'-tA',
|
|
1400
|
+
],
|
|
1401
|
+
{ env, input: probe, killAfterMs: (perCallSec + 5) * 1000 },
|
|
1402
|
+
);
|
|
1403
|
+
if (status === 0 && stdout.trim() === '1') return;
|
|
1404
|
+
if (!firstErrLogged && (status !== 0 || stderr)) {
|
|
1405
|
+
// Surface the first failure so a real crashloop / connectivity issue is
|
|
1406
|
+
// visible instead of silently looping. Subsequent polls stay quiet to
|
|
1407
|
+
// keep the deploy log readable.
|
|
1408
|
+
const msg = (stderr || stdout || '(no output)').trim().slice(-500);
|
|
1409
|
+
console.log(
|
|
1410
|
+
`[waitForSupabaseStorageSchema] attempt ${attempt} status=${status ?? '?'} — ${msg}`,
|
|
1411
|
+
);
|
|
1412
|
+
firstErrLogged = true;
|
|
1413
|
+
}
|
|
1414
|
+
await delay(waitMs);
|
|
1415
|
+
}
|
|
1416
|
+
throw new Error(
|
|
1417
|
+
`Supabase storage schema didn't add storage.buckets.public column within ${maxWaitSec}s. ` +
|
|
1418
|
+
`Storage-api container may have crashlooped — check kubectl logs supabase-supabase-storage-0`,
|
|
1419
|
+
);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* Single bounded kubectl exec: spawn-based (non-blocking), per-call kill
|
|
1424
|
+
* backstop on top of kubectl's own `--request-timeout`. Caller passes argv
|
|
1425
|
+
* starting after `kubectl`. Returns { status, stdout, stderr } with status
|
|
1426
|
+
* `null` if killed by the backstop.
|
|
1427
|
+
*
|
|
1428
|
+
* @param {string[]} argv
|
|
1429
|
+
* @param {{env: NodeJS.ProcessEnv, input?: string, killAfterMs: number}} opts
|
|
1430
|
+
*/
|
|
1431
|
+
function execKubectlOnce(argv, { env, input, killAfterMs }) {
|
|
1432
|
+
return new Promise((resolveFn) => {
|
|
1433
|
+
const child = spawn('kubectl', argv, {
|
|
1434
|
+
env,
|
|
1435
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1436
|
+
});
|
|
1437
|
+
let stdout = '';
|
|
1438
|
+
let stderr = '';
|
|
1439
|
+
child.stdout.on('data', (b) => {
|
|
1440
|
+
stdout += b.toString();
|
|
1441
|
+
});
|
|
1442
|
+
child.stderr.on('data', (b) => {
|
|
1443
|
+
stderr += b.toString();
|
|
1444
|
+
});
|
|
1445
|
+
const killTimer = setTimeout(() => {
|
|
1446
|
+
try {
|
|
1447
|
+
child.kill('SIGKILL');
|
|
1448
|
+
} catch {
|
|
1449
|
+
/* ignore */
|
|
1450
|
+
}
|
|
1451
|
+
}, killAfterMs);
|
|
1452
|
+
child.on('error', () => {
|
|
1453
|
+
clearTimeout(killTimer);
|
|
1454
|
+
resolveFn({ status: null, stdout, stderr });
|
|
1455
|
+
});
|
|
1456
|
+
child.on('close', (code) => {
|
|
1457
|
+
clearTimeout(killTimer);
|
|
1458
|
+
resolveFn({ status: code, stdout, stderr });
|
|
1459
|
+
});
|
|
1460
|
+
if (input != null) {
|
|
1461
|
+
child.stdin.write(input);
|
|
1462
|
+
child.stdin.end();
|
|
1463
|
+
} else {
|
|
1464
|
+
child.stdin.end();
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/**
|
|
1470
|
+
* Apply the project's `supabase/migrations/*.sql` against the chart-installed
|
|
1471
|
+
* supabase-postgres pod. Runs each file in lexicographic order via
|
|
1472
|
+
* `kubectl exec -i ... psql -U supabase_admin -d postgres`. Errors per file
|
|
1473
|
+
* are surfaced — unlike compose's `|| true` swallow — so a broken migration
|
|
1474
|
+
* stops the deploy with a useful error instead of a 503 readiness probe
|
|
1475
|
+
* loop downstream.
|
|
1476
|
+
*
|
|
1477
|
+
* The supabase-db StatefulSet pod must be Ready (helm `--wait` ensures this).
|
|
1478
|
+
*
|
|
1479
|
+
* @param {{kubeconfig: string, projectDir: string}} args
|
|
1480
|
+
*/
|
|
1481
|
+
export async function applyMigrations({ kubeconfig, projectDir }) {
|
|
1482
|
+
const env = { ...process.env, KUBECONFIG: kubeconfig };
|
|
1483
|
+
const migrationsDir = join(projectDir, 'supabase', 'migrations');
|
|
1484
|
+
if (!existsSync(migrationsDir)) {
|
|
1485
|
+
// Project has no migrations directory — skip silently. Matches compose
|
|
1486
|
+
// behaviour (which `|| true`'s past a missing dir).
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
const files = readdirSync(migrationsDir)
|
|
1490
|
+
.filter((f) => f.endsWith('.sql'))
|
|
1491
|
+
.sort();
|
|
1492
|
+
if (files.length === 0) return;
|
|
1493
|
+
// Wait for supabase's own storage-api schema to finish initializing —
|
|
1494
|
+
// some project migrations (e.g. INSERT INTO storage.buckets with a
|
|
1495
|
+
// `public` column) reference columns added by storage-api's startup
|
|
1496
|
+
// migrations, which can lag helm's --wait Ready signal.
|
|
1497
|
+
await waitForSupabaseStorageSchema({ kubeconfig });
|
|
1498
|
+
for (const file of files) {
|
|
1499
|
+
const sql = readFileSync(join(migrationsDir, file), 'utf-8');
|
|
1500
|
+
const result = spawnSync(
|
|
1501
|
+
'kubectl',
|
|
1502
|
+
[
|
|
1503
|
+
'-n',
|
|
1504
|
+
'vibecarbon',
|
|
1505
|
+
'exec',
|
|
1506
|
+
'-i',
|
|
1507
|
+
'supabase-supabase-db-0',
|
|
1508
|
+
'--',
|
|
1509
|
+
'psql',
|
|
1510
|
+
'-U',
|
|
1511
|
+
'supabase_admin',
|
|
1512
|
+
'-d',
|
|
1513
|
+
'postgres',
|
|
1514
|
+
'-v',
|
|
1515
|
+
'ON_ERROR_STOP=1',
|
|
1516
|
+
],
|
|
1517
|
+
{ env, input: sql, stdio: ['pipe', 'inherit', 'inherit'] },
|
|
1518
|
+
);
|
|
1519
|
+
if (result.status !== 0) {
|
|
1520
|
+
throw new Error(
|
|
1521
|
+
`applyMigrations: ${file} failed with exit ${result.status}. ` +
|
|
1522
|
+
`Fix the migration or set ON_ERROR_STOP=0 manually.`,
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
/**
|
|
1529
|
+
* Apply cert-manager + vibecarbon-secrets + the project's k8s/infra +
|
|
1530
|
+
* k8s/base kustomizations, install supabase via helm, patch the app
|
|
1531
|
+
* deployment to use the locally-sideloaded image, and wait for rollout.
|
|
1532
|
+
*
|
|
1533
|
+
* Local-first by default — no Flux on the deploy path; Flux is opt-in
|
|
1534
|
+
* via `vibecarbon configure cicd`.
|
|
1535
|
+
*
|
|
1536
|
+
* @param {{kubeconfig: string, projectDir: string, projectName: string, imageTag: string, backupImageTag?: string, envLocal: Record<string,string>, domain: string, s3Config?: {accessKey: string, secretKey: string, bucket: string, endpoint: string, region: string}, backupBucketName?: string|null, dnsProvider?: string|null, cloudflareApiToken?: string|null, hetznerApiToken?: string|null, apiToken?: string, region?: string, environment?: string, minWorkers?: number, maxWorkers?: number, workerServerType?: string, k3sToken?: string, masterIp?: string, sshKeyPath?: string, khPath?: string}} args
|
|
1537
|
+
*/
|
|
1538
|
+
export async function applyK3sManifests({
|
|
1539
|
+
kubeconfig,
|
|
1540
|
+
projectDir,
|
|
1541
|
+
projectName,
|
|
1542
|
+
imageTag,
|
|
1543
|
+
backupImageTag,
|
|
1544
|
+
envLocal,
|
|
1545
|
+
domain,
|
|
1546
|
+
s3Config,
|
|
1547
|
+
backupBucketName,
|
|
1548
|
+
dnsProvider,
|
|
1549
|
+
cloudflareApiToken,
|
|
1550
|
+
hetznerApiToken,
|
|
1551
|
+
// Phase 5: cluster-autoscaler wiring. `apiToken` is the Hetzner Cloud
|
|
1552
|
+
// API token (CCM/CSI/CA), distinct from `hetznerApiToken` which only
|
|
1553
|
+
// names the cert-manager DNS-01 token (same VALUE in practice since
|
|
1554
|
+
// Hetzner consolidated APIs in May 2026, but kept separate so the
|
|
1555
|
+
// wiring is explicit and a future split is a no-op).
|
|
1556
|
+
apiToken,
|
|
1557
|
+
region,
|
|
1558
|
+
environment,
|
|
1559
|
+
minWorkers,
|
|
1560
|
+
maxWorkers,
|
|
1561
|
+
workerServerType,
|
|
1562
|
+
k3sToken,
|
|
1563
|
+
// Phase 6: registry-push wiring. masterIp/sshKeyPath/khPath are the
|
|
1564
|
+
// SSH coordinates needed to open a local-port-forward to master:5000
|
|
1565
|
+
// for `docker push` (mirrors what sideloadK3s already received via a
|
|
1566
|
+
// different shape — this set is operator → master only).
|
|
1567
|
+
masterIp,
|
|
1568
|
+
sshKeyPath,
|
|
1569
|
+
khPath,
|
|
1570
|
+
// HA runs primary+standby deploys in parallel; both call this
|
|
1571
|
+
// function from the same operator host. Without distinct ports, the
|
|
1572
|
+
// second `ssh -L 5000:localhost:5000` loses the bind race and
|
|
1573
|
+
// ExitOnForwardFailure=yes aborts the deploy. Default 5000 is the
|
|
1574
|
+
// single-cluster path; HA passes 5000 (primary) and 5001 (standby).
|
|
1575
|
+
localTunnelPort = 5000,
|
|
1576
|
+
// perfPrefix tags internal sub-stage perf timings — see deployK3s.
|
|
1577
|
+
perfPrefix = 'k3s',
|
|
1578
|
+
}) {
|
|
1579
|
+
const env = { ...process.env, KUBECONFIG: kubeconfig };
|
|
1580
|
+
// 0. Validate DNS-01 prerequisites BEFORE any apply so the failure
|
|
1581
|
+
// surfaces at deploy-start, not 20 min in when an Order pins Pending.
|
|
1582
|
+
// Throws on missing token for cloudflare/hetzner; manual returns null.
|
|
1583
|
+
const dnsSecret = buildDnsProviderSecret({
|
|
1584
|
+
dnsProvider,
|
|
1585
|
+
cloudflareApiToken,
|
|
1586
|
+
hetznerApiToken,
|
|
1587
|
+
});
|
|
1588
|
+
// 1. Install cert-manager (raw YAML; not a kustomize base).
|
|
1589
|
+
await perfAsync(`deploy.${perfPrefix}.certManager.install`, () =>
|
|
1590
|
+
runKubectlWithRetry(['apply', '-f', CERT_MANAGER_URL], {
|
|
1591
|
+
env,
|
|
1592
|
+
description: 'applyK3sManifests: kubectl apply cert-manager',
|
|
1593
|
+
}),
|
|
1594
|
+
);
|
|
1595
|
+
// 2. Wait for cert-manager to be ready before applying ClusterIssuers.
|
|
1596
|
+
// Fan out the three deploys in parallel: kubectl wait blocks per-arg
|
|
1597
|
+
// sequentially, and the cainjector + webhook usually become Available
|
|
1598
|
+
// ~10-30s after the core deploy, so the serial form pays the longest
|
|
1599
|
+
// arrival time three times in a row. Two-stage retry preserves the
|
|
1600
|
+
// outer 180s+120s envelope of the previous --timeout=180s call.
|
|
1601
|
+
const certManagerDeploys = [
|
|
1602
|
+
'deploy/cert-manager',
|
|
1603
|
+
'deploy/cert-manager-webhook',
|
|
1604
|
+
'deploy/cert-manager-cainjector',
|
|
1605
|
+
];
|
|
1606
|
+
await perfAsync(`deploy.${perfPrefix}.certManager.wait`, () =>
|
|
1607
|
+
Promise.all(
|
|
1608
|
+
certManagerDeploys.map(async (deploy) => {
|
|
1609
|
+
const waitOnce = (timeoutSec) =>
|
|
1610
|
+
new Promise((resolveFn, reject) => {
|
|
1611
|
+
const child = spawn(
|
|
1612
|
+
'kubectl',
|
|
1613
|
+
[
|
|
1614
|
+
'-n',
|
|
1615
|
+
'cert-manager',
|
|
1616
|
+
'wait',
|
|
1617
|
+
'--for=condition=Available',
|
|
1618
|
+
`--timeout=${timeoutSec}s`,
|
|
1619
|
+
deploy,
|
|
1620
|
+
],
|
|
1621
|
+
{ stdio: 'inherit', env },
|
|
1622
|
+
);
|
|
1623
|
+
child.on('error', reject);
|
|
1624
|
+
child.on('exit', (code) =>
|
|
1625
|
+
code === 0 ? resolveFn() : reject(new Error(`kubectl wait ${deploy} exited ${code}`)),
|
|
1626
|
+
);
|
|
1627
|
+
});
|
|
1628
|
+
try {
|
|
1629
|
+
await waitOnce(90);
|
|
1630
|
+
} catch {
|
|
1631
|
+
await waitOnce(120);
|
|
1632
|
+
}
|
|
1633
|
+
}),
|
|
1634
|
+
),
|
|
1635
|
+
);
|
|
1636
|
+
// 2b. Land the per-DNS-provider Secret in the cert-manager namespace
|
|
1637
|
+
// BEFORE the kustomization that defines the issuers, so when the
|
|
1638
|
+
// ClusterIssuer is created the controller can immediately resolve
|
|
1639
|
+
// `apiTokenSecretRef`. Stdin pipe keeps the token out of argv.
|
|
1640
|
+
if (dnsSecret) {
|
|
1641
|
+
await runKubectlWithRetry(['apply', '-f', '-'], {
|
|
1642
|
+
env,
|
|
1643
|
+
input: dnsSecret.yaml,
|
|
1644
|
+
description: `applyK3sManifests: kubectl apply Secret/${dnsSecret.name}`,
|
|
1645
|
+
});
|
|
1646
|
+
}
|
|
1647
|
+
// 2c. Hetzner DNS-01 needs the cert-manager-webhook-hetzner deployment
|
|
1648
|
+
// because cert-manager core has no native Hetzner solver. The
|
|
1649
|
+
// webhook registers an APIService that cert-manager calls into for
|
|
1650
|
+
// dns01.webhook(groupName=acme.hetzner.com) — it must be
|
|
1651
|
+
// Available BEFORE any Certificate referencing the issuer is
|
|
1652
|
+
// reconciled, otherwise Orders fail with "no provider for solver".
|
|
1653
|
+
// Mirrors the Supabase pattern at step 7.
|
|
1654
|
+
if (dnsProvider === 'hetzner') {
|
|
1655
|
+
try {
|
|
1656
|
+
execFileSync(
|
|
1657
|
+
'helm',
|
|
1658
|
+
['repo', 'add', HETZNER_WEBHOOK_HELM_REPO_NAME, HETZNER_WEBHOOK_HELM_REPO_URL],
|
|
1659
|
+
{ stdio: 'inherit', env },
|
|
1660
|
+
);
|
|
1661
|
+
} catch {
|
|
1662
|
+
// helm repo add fails if the repo already exists; `helm repo update`
|
|
1663
|
+
// below refreshes either way.
|
|
1664
|
+
}
|
|
1665
|
+
execFileSync('helm', ['repo', 'update', HETZNER_WEBHOOK_HELM_REPO_NAME], {
|
|
1666
|
+
stdio: 'inherit',
|
|
1667
|
+
env,
|
|
1668
|
+
});
|
|
1669
|
+
try {
|
|
1670
|
+
// Chart 0.7.0 reads zero Helm values that bind the issuer's
|
|
1671
|
+
// hetzner-api-token Secret — that linkage lives entirely in the
|
|
1672
|
+
// ClusterIssuer's `config:` block (secretName + apiUrl). The
|
|
1673
|
+
// chart's `groupName` default `acme.hetzner.com` matches the
|
|
1674
|
+
// ClusterIssuer's groupName, so we don't override it either.
|
|
1675
|
+
// Async helm + kubectl wait — both can block for minutes; sync would
|
|
1676
|
+
// block Node's event loop and inflate parallel-branch perf timings.
|
|
1677
|
+
// See installSupabase above for the full RCA (iter-validate6 standby
|
|
1678
|
+
// certManager.wait reporting 21min when actual was seconds).
|
|
1679
|
+
await runCommandAsync(
|
|
1680
|
+
[
|
|
1681
|
+
'helm',
|
|
1682
|
+
'upgrade',
|
|
1683
|
+
'--install',
|
|
1684
|
+
HETZNER_WEBHOOK_RELEASE_NAME,
|
|
1685
|
+
HETZNER_WEBHOOK_CHART,
|
|
1686
|
+
'--namespace',
|
|
1687
|
+
'cert-manager',
|
|
1688
|
+
'--version',
|
|
1689
|
+
HETZNER_WEBHOOK_CHART_VERSION,
|
|
1690
|
+
'--wait',
|
|
1691
|
+
'--timeout',
|
|
1692
|
+
'5m',
|
|
1693
|
+
],
|
|
1694
|
+
{ silent: false, env },
|
|
1695
|
+
);
|
|
1696
|
+
await runCommandAsync(
|
|
1697
|
+
[
|
|
1698
|
+
'kubectl',
|
|
1699
|
+
'-n',
|
|
1700
|
+
'cert-manager',
|
|
1701
|
+
'wait',
|
|
1702
|
+
'--for=condition=Available',
|
|
1703
|
+
'--timeout=180s',
|
|
1704
|
+
`deploy/${HETZNER_WEBHOOK_RELEASE_NAME}`,
|
|
1705
|
+
],
|
|
1706
|
+
{ silent: false, env },
|
|
1707
|
+
);
|
|
1708
|
+
} catch (err) {
|
|
1709
|
+
// Surface webhook pod logs on failure — chart install rarely
|
|
1710
|
+
// tells the operator anything useful when the actual cause is
|
|
1711
|
+
// a CrashLoopBackOff or a missing dependency.
|
|
1712
|
+
try {
|
|
1713
|
+
execFileSync(
|
|
1714
|
+
'kubectl',
|
|
1715
|
+
['-n', 'cert-manager', 'logs', `deploy/${HETZNER_WEBHOOK_RELEASE_NAME}`, '--tail=200'],
|
|
1716
|
+
{ stdio: 'inherit', env },
|
|
1717
|
+
);
|
|
1718
|
+
} catch {
|
|
1719
|
+
// best-effort; continue to original throw
|
|
1720
|
+
}
|
|
1721
|
+
throw err;
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
// 3. Apply infra (cert-manager-resources/cluster-issuers + traefik-crds).
|
|
1725
|
+
await runKubectlWithRetry(['apply', '-k', join(projectDir, 'k8s/infra/cert-manager-resources')], {
|
|
1726
|
+
env,
|
|
1727
|
+
description: 'applyK3sManifests: kubectl apply -k cert-manager-resources',
|
|
1728
|
+
});
|
|
1729
|
+
await runKubectlWithRetry(['apply', '-k', join(projectDir, 'k8s/infra/traefik-crds')], {
|
|
1730
|
+
env,
|
|
1731
|
+
description: 'applyK3sManifests: kubectl apply -k traefik-crds',
|
|
1732
|
+
});
|
|
1733
|
+
// 4. Create vibecarbon-secrets BEFORE base/ — base/'s Deployment
|
|
1734
|
+
// references it via envFrom; without it kubelet fails container
|
|
1735
|
+
// creation with `secret "vibecarbon-secrets" not found`.
|
|
1736
|
+
await applyVibecarbonSecrets({ kubeconfig, envLocal, s3Config, backupBucketName });
|
|
1737
|
+
// 4b. Create the kube-system/hcloud-cluster-config Secret that
|
|
1738
|
+
// cluster-autoscaler reads on startup. Two keys:
|
|
1739
|
+
// - `token`: Hetzner Cloud API token, exposed to CA via
|
|
1740
|
+
// valueFrom→HCLOUD_TOKEN in deployment.yaml.
|
|
1741
|
+
// - `cluster-config.json`: BASE64-ENCODED rendered node-pool config
|
|
1742
|
+
// from renderCAClusterConfig (Phase 4), exposed via
|
|
1743
|
+
// valueFrom→HCLOUD_CLUSTER_CONFIG. The autoscaler binary expects
|
|
1744
|
+
// HCLOUD_CLUSTER_CONFIG to be base64-encoded JSON (decoded inline
|
|
1745
|
+
// at startup) — NOT raw JSON. We must base64-encode here because
|
|
1746
|
+
// Kubernetes secretKeyRef injection auto-decodes the Secret data
|
|
1747
|
+
// to plaintext: storing raw JSON in stringData means the env var
|
|
1748
|
+
// receives raw JSON, which fails with "illegal base64 data at
|
|
1749
|
+
// input byte 0". Storing base64-encoded JSON in stringData means
|
|
1750
|
+
// the env var receives the base64 string the autoscaler wants.
|
|
1751
|
+
// RCA from k8s e2e fanout5 2026-05-01: cluster-autoscaler
|
|
1752
|
+
// crashlooped throughout deploy because the previous
|
|
1753
|
+
// HCLOUD_CLUSTER_CONFIG_FILE mechanism is not in v1.32.7 and the
|
|
1754
|
+
// env var path needed double-encoding to round-trip through K8s.
|
|
1755
|
+
// Stdin pipe keeps the token + JSON off argv (mirrors the
|
|
1756
|
+
// dnsSecret pattern at step 2b; enforced by
|
|
1757
|
+
// tests/unit/security/secrets-not-in-argv.test.ts).
|
|
1758
|
+
if (!apiToken) {
|
|
1759
|
+
throw new Error(
|
|
1760
|
+
'applyK3sManifests: apiToken (Hetzner Cloud API token) required for cluster-autoscaler Secret/hcloud-cluster-config',
|
|
1761
|
+
);
|
|
1762
|
+
}
|
|
1763
|
+
if (!k3sToken) {
|
|
1764
|
+
throw new Error(
|
|
1765
|
+
'applyK3sManifests: k3sToken required to render cluster-autoscaler cluster-config.json',
|
|
1766
|
+
);
|
|
1767
|
+
}
|
|
1768
|
+
const caClusterConfigJson = renderCAClusterConfig({
|
|
1769
|
+
k3sVersion: K3S_VERSION,
|
|
1770
|
+
k3sToken,
|
|
1771
|
+
clusterName: `${projectName}-${environment}`,
|
|
1772
|
+
});
|
|
1773
|
+
// See block comment above: cluster-autoscaler reads HCLOUD_CLUSTER_CONFIG
|
|
1774
|
+
// as base64-encoded JSON. Storing the encoded form in stringData means the
|
|
1775
|
+
// secretKeyRef→env injection delivers the base64 string verbatim.
|
|
1776
|
+
const caClusterConfigB64 = Buffer.from(caClusterConfigJson, 'utf-8').toString('base64');
|
|
1777
|
+
{
|
|
1778
|
+
const caSecretYaml = [
|
|
1779
|
+
'apiVersion: v1',
|
|
1780
|
+
'kind: Secret',
|
|
1781
|
+
'metadata:',
|
|
1782
|
+
' name: hcloud-cluster-config',
|
|
1783
|
+
' namespace: kube-system',
|
|
1784
|
+
'type: Opaque',
|
|
1785
|
+
'stringData:',
|
|
1786
|
+
` token: ${JSON.stringify(apiToken)}`,
|
|
1787
|
+
` cluster-config.json: ${JSON.stringify(caClusterConfigB64)}`,
|
|
1788
|
+
'',
|
|
1789
|
+
].join('\n');
|
|
1790
|
+
await runKubectlWithRetry(['apply', '-f', '-'], {
|
|
1791
|
+
env,
|
|
1792
|
+
input: caSecretYaml,
|
|
1793
|
+
description: 'applyK3sManifests: kubectl apply Secret/hcloud-cluster-config',
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
// 5. Apply base (namespace, network-policies, app, backup, traefik, config).
|
|
1797
|
+
await perfAsync(`deploy.${perfPrefix}.applyBase`, () =>
|
|
1798
|
+
runKubectlWithRetry(['apply', '-k', join(projectDir, 'k8s/base')], {
|
|
1799
|
+
env,
|
|
1800
|
+
description: 'applyK3sManifests: kubectl apply -k k8s/base',
|
|
1801
|
+
}),
|
|
1802
|
+
);
|
|
1803
|
+
// 5b'. Wait for the local registry pod (Phase 1) to be Ready before
|
|
1804
|
+
// pushing images. The pod is part of base/, so it just landed.
|
|
1805
|
+
// Without this wait, the SSH-tunnel push race-targets a Pending
|
|
1806
|
+
// pod (registry container not started → connection refused on
|
|
1807
|
+
// :5000). 120s mirrors other base-pod readiness budgets.
|
|
1808
|
+
await perfAsync(`deploy.${perfPrefix}.localRegistry.wait`, async () =>
|
|
1809
|
+
execFileSync(
|
|
1810
|
+
'kubectl',
|
|
1811
|
+
[
|
|
1812
|
+
'-n',
|
|
1813
|
+
'vibecarbon',
|
|
1814
|
+
'wait',
|
|
1815
|
+
'--for=condition=Ready',
|
|
1816
|
+
'pod',
|
|
1817
|
+
'-l',
|
|
1818
|
+
'app=local-registry',
|
|
1819
|
+
'--timeout=120s',
|
|
1820
|
+
],
|
|
1821
|
+
{ stdio: 'inherit', env },
|
|
1822
|
+
),
|
|
1823
|
+
);
|
|
1824
|
+
// 5b''. Push the app image to the local registry on master so
|
|
1825
|
+
// CA-spawned workers (which don't exist at sideload time) can
|
|
1826
|
+
// pull on first schedule. Sideload remains primary distribution
|
|
1827
|
+
// for static workers — this is additive. Both paths use the
|
|
1828
|
+
// same `10.0.1.1:5000/<project>:<tag>` reference (Phase 1's
|
|
1829
|
+
// registries.yaml maps that hostname to the registry pod).
|
|
1830
|
+
if (!masterIp) {
|
|
1831
|
+
throw new Error('applyK3sManifests: masterIp required for registry push (Phase 6)');
|
|
1832
|
+
}
|
|
1833
|
+
if (!sshKeyPath) {
|
|
1834
|
+
throw new Error('applyK3sManifests: sshKeyPath required for registry push (Phase 6)');
|
|
1835
|
+
}
|
|
1836
|
+
if (!khPath) {
|
|
1837
|
+
throw new Error('applyK3sManifests: khPath required for registry push (Phase 6)');
|
|
1838
|
+
}
|
|
1839
|
+
// Push app + backup images in parallel. Each opens its own SSH tunnel,
|
|
1840
|
+
// so they need DISTINCT localTunnelPorts — pushImageToLocalRegistry's
|
|
1841
|
+
// teardown is `pkill -f 'ssh.*-L.*<port>:localhost:5000'`, which would
|
|
1842
|
+
// kill a sibling push's tunnel mid-operation if both used the same port.
|
|
1843
|
+
// Offset the backup push by +100 so even the openTunnelOrWalk's 20-port
|
|
1844
|
+
// fallback has 80 ports of headroom before colliding (plus HA's 5001
|
|
1845
|
+
// standby cluster lands on +100=5101, well clear of primary's 5000+19).
|
|
1846
|
+
// RCA: parallelizing without an offset would silently corrupt pushes
|
|
1847
|
+
// (registry returns "blob unknown" on a teardown'd partial upload).
|
|
1848
|
+
const appPushPort = localTunnelPort;
|
|
1849
|
+
const backupPushPort = localTunnelPort + 100;
|
|
1850
|
+
if (!masterIp) {
|
|
1851
|
+
throw new Error('applyK3sManifests: masterIp required for registry push (Phase 6)');
|
|
1852
|
+
}
|
|
1853
|
+
if (!sshKeyPath) {
|
|
1854
|
+
throw new Error('applyK3sManifests: sshKeyPath required for registry push (Phase 6)');
|
|
1855
|
+
}
|
|
1856
|
+
if (!khPath) {
|
|
1857
|
+
throw new Error('applyK3sManifests: khPath required for registry push (Phase 6)');
|
|
1858
|
+
}
|
|
1859
|
+
const pushTasks = [
|
|
1860
|
+
perfAsync(`deploy.${perfPrefix}.registryPush.app`, () =>
|
|
1861
|
+
pushImageToLocalRegistry({
|
|
1862
|
+
tag: imageTag,
|
|
1863
|
+
masterIp,
|
|
1864
|
+
sshKey: sshKeyPath,
|
|
1865
|
+
khPath,
|
|
1866
|
+
localTunnelPort: appPushPort,
|
|
1867
|
+
}),
|
|
1868
|
+
),
|
|
1869
|
+
];
|
|
1870
|
+
// 5b'''. Push the backup image too. The backup CronJob's pods may
|
|
1871
|
+
// eventually schedule onto a CA-spawned worker (no node
|
|
1872
|
+
// affinity pinning them to the static pool), so the same
|
|
1873
|
+
// registry-fallback story applies.
|
|
1874
|
+
if (backupImageTag) {
|
|
1875
|
+
pushTasks.push(
|
|
1876
|
+
perfAsync(`deploy.${perfPrefix}.registryPush.backup`, () =>
|
|
1877
|
+
pushImageToLocalRegistry({
|
|
1878
|
+
tag: backupImageTag,
|
|
1879
|
+
masterIp,
|
|
1880
|
+
sshKey: sshKeyPath,
|
|
1881
|
+
khPath,
|
|
1882
|
+
localTunnelPort: backupPushPort,
|
|
1883
|
+
}),
|
|
1884
|
+
),
|
|
1885
|
+
);
|
|
1886
|
+
}
|
|
1887
|
+
await Promise.all(pushTasks);
|
|
1888
|
+
// 5a'. Apply cluster-autoscaler manifests SEPARATELY. CA lives in
|
|
1889
|
+
// kube-system but base/'s parent kustomization sets
|
|
1890
|
+
// `namespace: vibecarbon`, whose namespace transformer would
|
|
1891
|
+
// override our kube-system metadata. Mirrors the CCM/CSI
|
|
1892
|
+
// separate-install pattern (those are commented in
|
|
1893
|
+
// base/kustomization.yaml and applied via cloud-init). The
|
|
1894
|
+
// child kustomization at carbon/k8s/base/cluster-autoscaler/
|
|
1895
|
+
// sets `namespace: kube-system` explicitly, which only takes
|
|
1896
|
+
// effect when applied standalone like this.
|
|
1897
|
+
await runKubectlWithRetry(['apply', '-k', join(projectDir, 'k8s/base/cluster-autoscaler')], {
|
|
1898
|
+
env,
|
|
1899
|
+
description: 'applyK3sManifests: kubectl apply -k cluster-autoscaler',
|
|
1900
|
+
});
|
|
1901
|
+
// 5b. Patch the domain-specific resources that the base/ kustomization
|
|
1902
|
+
// ships with placeholder values (`app.example.com` + a placeholder
|
|
1903
|
+
// ClusterIssuer name). Without this, cert-manager tries to issue a Let's
|
|
1904
|
+
// Encrypt prod cert for `app.example.com` — ACME refuses (IANA-reserved
|
|
1905
|
+
// domain), the Order errors, the Certificate's secret never lands,
|
|
1906
|
+
// ingress TLS fails, and the app's progressDeadlineSeconds expires
|
|
1907
|
+
// during rollout-status (step 9) → deploy fails ~26 min in. The
|
|
1908
|
+
// placeholder is intentional in the template (the comment says
|
|
1909
|
+
// "patched at deploy time"); this is the patch.
|
|
1910
|
+
//
|
|
1911
|
+
// Issuer choice: `ACME_CA_SERVER` points at staging for e2e tests
|
|
1912
|
+
// (and any dev env that wants to avoid LE rate limits). Read from
|
|
1913
|
+
// .env.local first (per-project), then fall back to process.env so the
|
|
1914
|
+
// e2e harness can inject the staging URL via env without writing
|
|
1915
|
+
// it into the project's .env.local. Anything else gets the prod issuer.
|
|
1916
|
+
// Provider suffix (cloudflare/hetzner/manual) determines which solver
|
|
1917
|
+
// — DNS-01 vs HTTP-01 — cert-manager actually uses; see pickIssuerName.
|
|
1918
|
+
const acmeServer = envLocal?.ACME_CA_SERVER || process.env.ACME_CA_SERVER || '';
|
|
1919
|
+
const issuerName = pickIssuerName({ dnsProvider, acmeServer });
|
|
1920
|
+
await runKubectlWithRetry(
|
|
1921
|
+
[
|
|
1922
|
+
'-n',
|
|
1923
|
+
'vibecarbon',
|
|
1924
|
+
'patch',
|
|
1925
|
+
'certificate',
|
|
1926
|
+
'vibecarbon-tls',
|
|
1927
|
+
'--type=merge',
|
|
1928
|
+
'-p',
|
|
1929
|
+
JSON.stringify({
|
|
1930
|
+
spec: { dnsNames: [domain], issuerRef: { name: issuerName, kind: 'ClusterIssuer' } },
|
|
1931
|
+
}),
|
|
1932
|
+
],
|
|
1933
|
+
{ env, description: 'applyK3sManifests: kubectl patch certificate/vibecarbon-tls' },
|
|
1934
|
+
);
|
|
1935
|
+
await runKubectlWithRetry(
|
|
1936
|
+
[
|
|
1937
|
+
'-n',
|
|
1938
|
+
'vibecarbon',
|
|
1939
|
+
'patch',
|
|
1940
|
+
'configmap',
|
|
1941
|
+
'vibecarbon-config',
|
|
1942
|
+
'--type=merge',
|
|
1943
|
+
'-p',
|
|
1944
|
+
JSON.stringify({ data: { SITE_URL: `https://${domain}` } }),
|
|
1945
|
+
],
|
|
1946
|
+
{ env, description: 'applyK3sManifests: kubectl patch configmap/vibecarbon-config' },
|
|
1947
|
+
);
|
|
1948
|
+
// 5d. Patch cluster-autoscaler Deployment placeholders. Phase 2's
|
|
1949
|
+
// deployment.yaml ships with `--nodes={{CA_NODES_SPEC}}` and
|
|
1950
|
+
// {{CLUSTER_NAME}}/{{REGION}} markers in the env vars. The
|
|
1951
|
+
// runtime values are computed here from operator-supplied
|
|
1952
|
+
// bounds + region. CA's pool sits ON TOP of the static floor
|
|
1953
|
+
// (see formatCaNodesSpec for the math). Patch via JSON-pointer
|
|
1954
|
+
// into the placeholder argv slot — index lookup keeps us
|
|
1955
|
+
// resilient to upstream argv reorder.
|
|
1956
|
+
const clusterName = `${projectName}-${environment}`;
|
|
1957
|
+
const caNodesSpec = formatCaNodesSpec({
|
|
1958
|
+
minWorkers,
|
|
1959
|
+
maxWorkers,
|
|
1960
|
+
workerServerType,
|
|
1961
|
+
region,
|
|
1962
|
+
});
|
|
1963
|
+
{
|
|
1964
|
+
const argvJson = await runKubectlWithRetry(
|
|
1965
|
+
[
|
|
1966
|
+
'-n',
|
|
1967
|
+
'kube-system',
|
|
1968
|
+
'get',
|
|
1969
|
+
'deploy/cluster-autoscaler',
|
|
1970
|
+
'-o',
|
|
1971
|
+
'jsonpath={.spec.template.spec.containers[0].args}',
|
|
1972
|
+
],
|
|
1973
|
+
{
|
|
1974
|
+
env,
|
|
1975
|
+
captureStdout: true,
|
|
1976
|
+
description: 'applyK3sManifests: kubectl get deploy/cluster-autoscaler args',
|
|
1977
|
+
},
|
|
1978
|
+
);
|
|
1979
|
+
const argv = JSON.parse(argvJson);
|
|
1980
|
+
const idx = argv.indexOf('--nodes={{CA_NODES_SPEC}}');
|
|
1981
|
+
if (idx < 0) {
|
|
1982
|
+
throw new Error(
|
|
1983
|
+
'applyK3sManifests: cluster-autoscaler args missing --nodes={{CA_NODES_SPEC}} placeholder',
|
|
1984
|
+
);
|
|
1985
|
+
}
|
|
1986
|
+
await runKubectlWithRetry(
|
|
1987
|
+
[
|
|
1988
|
+
'-n',
|
|
1989
|
+
'kube-system',
|
|
1990
|
+
'patch',
|
|
1991
|
+
'deploy/cluster-autoscaler',
|
|
1992
|
+
'--type=json',
|
|
1993
|
+
'-p',
|
|
1994
|
+
JSON.stringify([
|
|
1995
|
+
{
|
|
1996
|
+
op: 'replace',
|
|
1997
|
+
path: `/spec/template/spec/containers/0/args/${idx}`,
|
|
1998
|
+
value: `--nodes=${caNodesSpec}`,
|
|
1999
|
+
},
|
|
2000
|
+
]),
|
|
2001
|
+
],
|
|
2002
|
+
{ env, description: 'applyK3sManifests: kubectl patch deploy/cluster-autoscaler --nodes' },
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
// Patch the three by-name env vars. HCLOUD_CLUSTER_CONFIG is sourced
|
|
2006
|
+
// from the hcloud-cluster-config Secret (key cluster-config.json) at
|
|
2007
|
+
// pod-start, so no patch needed for it.
|
|
2008
|
+
await runKubectlWithRetry(
|
|
2009
|
+
[
|
|
2010
|
+
'-n',
|
|
2011
|
+
'kube-system',
|
|
2012
|
+
'set',
|
|
2013
|
+
'env',
|
|
2014
|
+
'deploy/cluster-autoscaler',
|
|
2015
|
+
`HCLOUD_NETWORK=${clusterName}-network`,
|
|
2016
|
+
`HCLOUD_FIREWALL=${clusterName}-firewall`,
|
|
2017
|
+
`HCLOUD_SSH_KEY=${clusterName}-${region}-key`,
|
|
2018
|
+
],
|
|
2019
|
+
{ env, description: 'applyK3sManifests: kubectl set env deploy/cluster-autoscaler' },
|
|
2020
|
+
);
|
|
2021
|
+
// (Considered: setting HCLOUD_NETWORK on the Hetzner CCM so it populates
|
|
2022
|
+
// each Node's InternalIP from the Hetzner private network. Reverted —
|
|
2023
|
+
// because k3s installs with `--node-ip=<public>` per master-init.sh,
|
|
2024
|
+
// the kubelet TLS cert SAN includes only the public IP. CCM then
|
|
2025
|
+
// overwriting InternalIP with 10.0.1.x makes kubectl logs/exec fail with
|
|
2026
|
+
// "tls: failed to verify certificate: x509: certificate is valid for
|
|
2027
|
+
// 127.0.0.1, <public>, not 10.0.1.x", which silently breaks every
|
|
2028
|
+
// diagnostic the lifecycle relies on. The actual fix for the
|
|
2029
|
+
// traefik→apiserver "connection refused" loop turned out to be the
|
|
2030
|
+
// NetworkPolicy egress widening in
|
|
2031
|
+
// carbon/k8s/base/{traefik,app}/network-policy.yaml plus the explicit
|
|
2032
|
+
// traefik rollout-restart below — kube-router's iptables can ship up
|
|
2033
|
+
// stale on first apply, and forcing traefik to re-establish its watch
|
|
2034
|
+
// after policies are in place clears it.)
|
|
2035
|
+
// 5e. Roll out cluster-autoscaler so the patched Deployment lands.
|
|
2036
|
+
// `set env` already triggers a rolling update, but a deliberate
|
|
2037
|
+
// rollout-restart + status-wait gives us a clean failure mode if
|
|
2038
|
+
// CA is misconfigured (e.g. wrong network name) instead of
|
|
2039
|
+
// surfacing as a downstream "no nodes scaled" mystery.
|
|
2040
|
+
await perfAsync(`deploy.${perfPrefix}.clusterAutoscaler.rollout`, async () => {
|
|
2041
|
+
await runKubectlWithRetry(
|
|
2042
|
+
['-n', 'kube-system', 'rollout', 'restart', 'deploy/cluster-autoscaler'],
|
|
2043
|
+
{ env, description: 'applyK3sManifests: kubectl rollout restart cluster-autoscaler' },
|
|
2044
|
+
);
|
|
2045
|
+
// rollout-status has its own --timeout and benefits from live progress;
|
|
2046
|
+
// leave on execFileSync. Internal apiserver-watch retries cover transient
|
|
2047
|
+
// drops within the budget.
|
|
2048
|
+
//
|
|
2049
|
+
// 300s (was 180s): Phase 5 fires THREE rolling updates back-to-back on a
|
|
2050
|
+
// `strategy: Recreate` Deployment (5d patch --nodes, 5d' set env,
|
|
2051
|
+
// 5e rollout restart). Each cycle = old pod terminate + image-pull-or-cache
|
|
2052
|
+
// + new pod start ≈ 30-60s. Three cycles + cold image pull from
|
|
2053
|
+
// registry.k8s.io stretches the wall-clock right against 180s; primary
|
|
2054
|
+
// finishes just under, standby hits the deadline (k8s-ha 2026-04-29 run).
|
|
2055
|
+
// Capture pod state on failure so future timeouts surface root cause
|
|
2056
|
+
// instead of just "rollout status timed out".
|
|
2057
|
+
try {
|
|
2058
|
+
execFileSync(
|
|
2059
|
+
'kubectl',
|
|
2060
|
+
['-n', 'kube-system', 'rollout', 'status', 'deploy/cluster-autoscaler', '--timeout=300s'],
|
|
2061
|
+
{ stdio: 'inherit', env },
|
|
2062
|
+
);
|
|
2063
|
+
} catch (err) {
|
|
2064
|
+
console.error('[k3s] cluster-autoscaler rollout failed — capturing diagnostics:');
|
|
2065
|
+
for (const args of [
|
|
2066
|
+
['-n', 'kube-system', 'get', 'pods', '-l', 'app=cluster-autoscaler', '-o', 'wide'],
|
|
2067
|
+
['-n', 'kube-system', 'describe', 'deploy/cluster-autoscaler'],
|
|
2068
|
+
['-n', 'kube-system', 'logs', '-l', 'app=cluster-autoscaler', '--tail=80'],
|
|
2069
|
+
]) {
|
|
2070
|
+
try {
|
|
2071
|
+
execFileSync('kubectl', args, { stdio: 'inherit', env });
|
|
2072
|
+
} catch {
|
|
2073
|
+
// Best-effort diagnostics; original error is what matters
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
throw err;
|
|
2077
|
+
}
|
|
2078
|
+
});
|
|
2079
|
+
// 6. Patch the app deployment to use the sideloaded image BEFORE installSupabase.
|
|
2080
|
+
// base/ ships image=ghcr.io/<owner>/<repo>:main — no pull secret in
|
|
2081
|
+
// local-first mode, so leaving it pointed at GHCR triggers
|
|
2082
|
+
// ImagePullBackOff that races against installSupabase's `--wait`.
|
|
2083
|
+
// Switching now means the app pods come up on the sideloaded image
|
|
2084
|
+
// immediately; we still wait for rollout after Supabase converges.
|
|
2085
|
+
await runKubectlWithRetry(
|
|
2086
|
+
['-n', 'vibecarbon', 'set', 'image', 'deployment/app', `app=${imageTag}`],
|
|
2087
|
+
{ env, description: 'applyK3sManifests: kubectl set image deployment/app' },
|
|
2088
|
+
);
|
|
2089
|
+
// 6b. Patch the backup CronJob image. base/backup/cronjob.yaml ships
|
|
2090
|
+
// image=ghcr.io/<owner>/<project>-backup:latest — never pushed in
|
|
2091
|
+
// direct/local mode, so the scheduled job pod fails ImagePullBackOff.
|
|
2092
|
+
// deployK3s sideloaded the backup image to every node (step 7b);
|
|
2093
|
+
// point the CronJob at it. imagePullPolicy: Always in the template
|
|
2094
|
+
// would defeat the sideload — switch to IfNotPresent at the same
|
|
2095
|
+
// time so kubelet uses the local copy.
|
|
2096
|
+
if (backupImageTag) {
|
|
2097
|
+
await runKubectlWithRetry(
|
|
2098
|
+
['-n', 'vibecarbon', 'set', 'image', 'cronjob/backup', `backup=${backupImageTag}`],
|
|
2099
|
+
{ env, description: 'applyK3sManifests: kubectl set image cronjob/backup' },
|
|
2100
|
+
);
|
|
2101
|
+
await runKubectlWithRetry(
|
|
2102
|
+
[
|
|
2103
|
+
'-n',
|
|
2104
|
+
'vibecarbon',
|
|
2105
|
+
'patch',
|
|
2106
|
+
'cronjob/backup',
|
|
2107
|
+
'--type=json',
|
|
2108
|
+
'-p',
|
|
2109
|
+
JSON.stringify([
|
|
2110
|
+
{
|
|
2111
|
+
op: 'replace',
|
|
2112
|
+
path: '/spec/jobTemplate/spec/template/spec/containers/0/imagePullPolicy',
|
|
2113
|
+
value: 'IfNotPresent',
|
|
2114
|
+
},
|
|
2115
|
+
]),
|
|
2116
|
+
],
|
|
2117
|
+
{ env, description: 'applyK3sManifests: kubectl patch cronjob/backup imagePullPolicy' },
|
|
2118
|
+
);
|
|
2119
|
+
}
|
|
2120
|
+
// 7. Install Supabase via helm. Blocks until release converges (15m
|
|
2121
|
+
// timeout in chart). App pods may stay Pending on supabase URLs in
|
|
2122
|
+
// their config until kong/auth/db are Ready, but that's fine — they
|
|
2123
|
+
// won't churn ImagePullBackOff because step 6 already set them to
|
|
2124
|
+
// the sideloaded image.
|
|
2125
|
+
await perfAsync(`deploy.${perfPrefix}.installSupabase`, async () =>
|
|
2126
|
+
installSupabase({ kubeconfig, projectDir, projectName, domain, s3Config, envLocal }),
|
|
2127
|
+
);
|
|
2128
|
+
// 8. Apply project SQL migrations against the chart-installed postgres.
|
|
2129
|
+
// Helm's `--wait` returned only after supabase-db was Ready, so the
|
|
2130
|
+
// pod is guaranteed up here. Without this, the app's
|
|
2131
|
+
// /api/health/ready returns 503 forever ("Could not find table
|
|
2132
|
+
// public.organizations").
|
|
2133
|
+
await perfAsync(`deploy.${perfPrefix}.applyMigrations`, async () =>
|
|
2134
|
+
applyMigrations({ kubeconfig, projectDir }),
|
|
2135
|
+
);
|
|
2136
|
+
// 9. Wait for app rollout. The deployment template already has
|
|
2137
|
+
// imagePullPolicy: IfNotPresent, so kubelet uses the sideloaded image
|
|
2138
|
+
// without pulling. 300s wasn't enough on a cold deploy where the app
|
|
2139
|
+
// pods stay Pending behind kong/auth converge — bump to 10m to match
|
|
2140
|
+
// the helm `--wait` budget.
|
|
2141
|
+
//
|
|
2142
|
+
// Retry on apiserver-warm-up transients. iter-postpush + iter-validate2
|
|
2143
|
+
// both saw `kubectl rollout status` fast-fail in ~10s on the k8s-ha
|
|
2144
|
+
// restore re-deploy with "Unable to connect to the server: net/http:
|
|
2145
|
+
// TLS handshake timeout" — the apiserver was still cycling certs after
|
|
2146
|
+
// the cluster came up. Pods rolled out fine within 2 minutes; kubectl
|
|
2147
|
+
// just bailed on the first connect. We can't use runKubectlWithRetry
|
|
2148
|
+
// here because rollout-status genuinely needs live stdout (operator
|
|
2149
|
+
// sees per-replica progress over a 10min window). Hand-rolled retry
|
|
2150
|
+
// keeps inherited stdout, captures stderr through a pipe so the
|
|
2151
|
+
// transient-error pattern can fire, and short-circuits non-transient
|
|
2152
|
+
// failures (real rollout failures or the deployment not existing
|
|
2153
|
+
// surface immediately, not after 3×retry).
|
|
2154
|
+
await perfAsync(`deploy.${perfPrefix}.rolloutApp`, async () => {
|
|
2155
|
+
const rolloutArgs = [
|
|
2156
|
+
'-n',
|
|
2157
|
+
'vibecarbon',
|
|
2158
|
+
'rollout',
|
|
2159
|
+
'status',
|
|
2160
|
+
'deployment/app',
|
|
2161
|
+
'--timeout=600s',
|
|
2162
|
+
];
|
|
2163
|
+
const ATTEMPTS = 3;
|
|
2164
|
+
// Async (spawn-based) — `kubectl rollout status --timeout=600s` can wait
|
|
2165
|
+
// up to 10 minutes for pods to roll. spawnSync would block Node's event
|
|
2166
|
+
// loop the whole time; in HA the parallel standby branch's perf substep
|
|
2167
|
+
// numbers would all be inflated by primary's rollout-wait. Stream stdout
|
|
2168
|
+
// to operator (inherit), pipe stderr so the transient-error classifier
|
|
2169
|
+
// can fire (KUBECTL_TRANSIENT_PATTERN check below).
|
|
2170
|
+
const runRollout = () =>
|
|
2171
|
+
new Promise((resolve, reject) => {
|
|
2172
|
+
const child = spawn('kubectl', rolloutArgs, {
|
|
2173
|
+
env,
|
|
2174
|
+
stdio: ['ignore', 'inherit', 'pipe'],
|
|
2175
|
+
});
|
|
2176
|
+
let stderrBuf = '';
|
|
2177
|
+
child.stderr?.on('data', (chunk) => {
|
|
2178
|
+
const s = chunk.toString();
|
|
2179
|
+
stderrBuf += s;
|
|
2180
|
+
process.stderr.write(s);
|
|
2181
|
+
});
|
|
2182
|
+
child.on('error', reject);
|
|
2183
|
+
child.on('exit', (code) => resolve({ status: code, stderr: stderrBuf }));
|
|
2184
|
+
});
|
|
2185
|
+
let lastStderr = '';
|
|
2186
|
+
let lastStatus;
|
|
2187
|
+
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
|
|
2188
|
+
const result = await runRollout();
|
|
2189
|
+
lastStatus = result.status;
|
|
2190
|
+
lastStderr = result.stderr;
|
|
2191
|
+
if (lastStatus === 0) return;
|
|
2192
|
+
if (!KUBECTL_TRANSIENT_PATTERN.test(lastStderr) || attempt === ATTEMPTS) break;
|
|
2193
|
+
const backoffMs = 2000 * attempt;
|
|
2194
|
+
console.log(
|
|
2195
|
+
`kubectl rollout status hit transient error on attempt ${attempt}/${ATTEMPTS}, retrying in ${backoffMs}ms`,
|
|
2196
|
+
);
|
|
2197
|
+
await delay(backoffMs);
|
|
2198
|
+
}
|
|
2199
|
+
throw new Error(
|
|
2200
|
+
`kubectl rollout status failed with exit ${lastStatus ?? '?'}: ${lastStderr.trim().slice(-500)}`,
|
|
2201
|
+
);
|
|
2202
|
+
});
|
|
2203
|
+
// 10. Force-restart traefik so it starts AFTER kube-router has installed
|
|
2204
|
+
// iptables rules for the NetworkPolicies. RCA from k8s-hetzner
|
|
2205
|
+
// 2026-04-28 deploy run (1di log): when traefik starts during
|
|
2206
|
+
// kustomize apply, kube-router's NetworkPolicy controller may not yet
|
|
2207
|
+
// have reconciled the new policies. traefik's first connection to
|
|
2208
|
+
// 10.43.0.1:443 races the rule install — it gets stuck in a retry
|
|
2209
|
+
// loop on `connect: connection refused`, never loads IngressRoutes,
|
|
2210
|
+
// and serves its default 404 on every external request. Bouncing
|
|
2211
|
+
// traefik live fixed every observed instance (k8s-ha verify-1df-1dg
|
|
2212
|
+
// and k8s-hetzner restore probe). Forcing a clean rollout-restart
|
|
2213
|
+
// here closes the race deterministically — by the time we restart
|
|
2214
|
+
// traefik, all NetworkPolicies + ClusterIssuers + IngressRoutes have
|
|
2215
|
+
// landed and kube-router has had time to reconcile.
|
|
2216
|
+
// ignoreError=true is intentional: traefik uses hostPort 80/443 with
|
|
2217
|
+
// RollingUpdate, so the new pod can't start until the old one
|
|
2218
|
+
// terminates. We force-delete the existing pod (graceful would block
|
|
2219
|
+
// on the hostPort race), then wait briefly for the replacement to
|
|
2220
|
+
// stabilize. If anything in this block fails, the deploy still
|
|
2221
|
+
// succeeds — the public probe will catch a real issue.
|
|
2222
|
+
try {
|
|
2223
|
+
await perfAsync(`deploy.${perfPrefix}.traefik.restart`, async () => {
|
|
2224
|
+
await runKubectlWithRetry(
|
|
2225
|
+
[
|
|
2226
|
+
'-n',
|
|
2227
|
+
'vibecarbon',
|
|
2228
|
+
'delete',
|
|
2229
|
+
'pod',
|
|
2230
|
+
'-l',
|
|
2231
|
+
'app=vibecarbon-traefik',
|
|
2232
|
+
'--force',
|
|
2233
|
+
'--grace-period=0',
|
|
2234
|
+
],
|
|
2235
|
+
{ env, description: 'applyK3sManifests: kubectl delete pod traefik' },
|
|
2236
|
+
);
|
|
2237
|
+
execFileSync(
|
|
2238
|
+
'kubectl',
|
|
2239
|
+
['-n', 'vibecarbon', 'rollout', 'status', 'deployment/traefik', '--timeout=120s'],
|
|
2240
|
+
{ stdio: 'inherit', env },
|
|
2241
|
+
);
|
|
2242
|
+
});
|
|
2243
|
+
} catch (err) {
|
|
2244
|
+
console.error(
|
|
2245
|
+
`[k3s] traefik post-apply restart warning: ${err.message?.split('\n')[0] || err}`,
|
|
2246
|
+
);
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
/**
|
|
2251
|
+
* Deploy a k3s cluster on Hetzner.
|
|
2252
|
+
*
|
|
2253
|
+
* Cold-path implementation through to "kubeconfig saved + cluster reachable."
|
|
2254
|
+
* App install + sideload land in a follow-up commit (Task 7).
|
|
2255
|
+
*
|
|
2256
|
+
* @param {K3sDeployOptions} options
|
|
2257
|
+
* @returns {Promise<{masterIp: string, floatingIp: string, supabaseIp: string, workerIps: string[], networkId: string|number, kubeconfig: string, sshKeyPath: string}>}
|
|
2258
|
+
*/
|
|
2259
|
+
export async function deployK3s(options) {
|
|
2260
|
+
const { state } = options;
|
|
2261
|
+
const spin = (options.tracker ?? p).spinner.bind(options.tracker ?? p);
|
|
2262
|
+
const s = spin();
|
|
2263
|
+
const projectDir = process.cwd();
|
|
2264
|
+
// perfPrefix tags all internal sub-stage perf timings. Default 'k3s' for
|
|
2265
|
+
// single-cluster (`deploy.k3s.iac.upStack` etc); HA passes 'k3s.primary' /
|
|
2266
|
+
// 'k3s.standby' so the perf_substep table can attribute primary/standby
|
|
2267
|
+
// deploy time per sub-stage. Without this, both HA clusters' timings
|
|
2268
|
+
// collide on identical sub-stage names and we can't tell which dominated.
|
|
2269
|
+
const perfPrefix = options.perfPrefix ?? 'k3s';
|
|
2270
|
+
|
|
2271
|
+
// 1. Preflight
|
|
2272
|
+
s.start('Preflight (docker + ssh + kubectl + helm)');
|
|
2273
|
+
for (const tool of ['docker', 'ssh', 'kubectl', 'helm']) {
|
|
2274
|
+
try {
|
|
2275
|
+
execFileSync('which', [tool], { stdio: 'ignore' });
|
|
2276
|
+
} catch {
|
|
2277
|
+
throw new Error(`Required tool '${tool}' not found on PATH`);
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
s.stop('Preflight OK');
|
|
2281
|
+
|
|
2282
|
+
// 2. SSH key
|
|
2283
|
+
const { privateKeyPath, publicKey } = options.sharedSshKeyPath
|
|
2284
|
+
? {
|
|
2285
|
+
privateKeyPath: options.sharedSshKeyPath,
|
|
2286
|
+
publicKey: options.sharedSshPublicKey ?? '',
|
|
2287
|
+
}
|
|
2288
|
+
: ensureSshKey(projectDir, options.environment);
|
|
2289
|
+
|
|
2290
|
+
// Per-env known_hosts file. host-keys.js: <projectDir>/.vibecarbon/known_hosts_<env>.
|
|
2291
|
+
// Used by every SSH/SCP call below so we never read or pollute the
|
|
2292
|
+
// operator's ~/.ssh/known_hosts (which would otherwise reject reconnects
|
|
2293
|
+
// when Hetzner recycles a public IP).
|
|
2294
|
+
const khPath = knownHostsPath(options.environment, projectDir);
|
|
2295
|
+
|
|
2296
|
+
// 3. Pulumi up — provisions network, firewall, master, supabase, workers,
|
|
2297
|
+
// floating IP. Cloud-init runs k3s install + hcloud-ccm/csi on each node.
|
|
2298
|
+
// Phase 5: Pulumi sees `minWorkers` (static floor) only; `maxWorkers`
|
|
2299
|
+
// is consumed by the cluster-autoscaler patch in applyK3sManifests
|
|
2300
|
+
// and intentionally NOT passed to Pulumi. Defaults match the JSDoc on
|
|
2301
|
+
// K8sStackConfig (1 / 3) — the orchestrator (Phase 8) populates from
|
|
2302
|
+
// CLI flags + envConfig.
|
|
2303
|
+
const minWorkers = options.minWorkers ?? 1;
|
|
2304
|
+
const maxWorkers = options.maxWorkers ?? 3;
|
|
2305
|
+
const infraInputs = {
|
|
2306
|
+
region: options.region,
|
|
2307
|
+
masterType: options.masterServerType,
|
|
2308
|
+
supabaseType: options.supabaseServerType,
|
|
2309
|
+
workerType: options.workerServerType,
|
|
2310
|
+
minWorkers,
|
|
2311
|
+
maxWorkers,
|
|
2312
|
+
k3sVersion: K3S_VERSION,
|
|
2313
|
+
};
|
|
2314
|
+
if (!state.shouldSkip('k3s-infra', infraInputs)) {
|
|
2315
|
+
state.startStep('k3s-infra', infraInputs);
|
|
2316
|
+
const { buildHetznerK8sProgram } = await import('../../iac/programs/hetzner-k8s.js');
|
|
2317
|
+
const { upStack } = await import('../../iac/index.js');
|
|
2318
|
+
|
|
2319
|
+
const allowedCidrs = (options.operatorCidrs ?? []).map((e) => e.cidr);
|
|
2320
|
+
const program = buildHetznerK8sProgram({
|
|
2321
|
+
projectName: options.projectName,
|
|
2322
|
+
environment: options.environment,
|
|
2323
|
+
sshPublicKey: publicKey,
|
|
2324
|
+
existingSshKeyId: options.sharedSshKeyId ? String(options.sharedSshKeyId) : undefined,
|
|
2325
|
+
location: options.region,
|
|
2326
|
+
networkZone: 'eu-central',
|
|
2327
|
+
masterServerType: options.masterServerType || options.serverType || 'cx23',
|
|
2328
|
+
supabaseServerType: options.supabaseServerType || options.serverType || 'cx23',
|
|
2329
|
+
workerServerType: options.workerServerType || options.serverType || 'cx23',
|
|
2330
|
+
minWorkers,
|
|
2331
|
+
maxWorkers,
|
|
2332
|
+
k3sVersion: K3S_VERSION,
|
|
2333
|
+
apiToken: options.apiToken,
|
|
2334
|
+
labels: { 'managed-by': 'vibecarbon', 'os-flavor': 'k3s' },
|
|
2335
|
+
allowedSshIps: allowedCidrs,
|
|
2336
|
+
allowedK8sApiIps: allowedCidrs,
|
|
2337
|
+
});
|
|
2338
|
+
|
|
2339
|
+
s.start('Provisioning k3s cluster (Pulumi up)');
|
|
2340
|
+
const { outputs } = await perfAsync(`deploy.${perfPrefix}.iac.upStack`, () =>
|
|
2341
|
+
upStack(options.environment, program, {
|
|
2342
|
+
hcloudToken: options.apiToken,
|
|
2343
|
+
s3Config: options.s3Config,
|
|
2344
|
+
}),
|
|
2345
|
+
);
|
|
2346
|
+
if (!outputs.masterIp || !outputs.floatingIp) {
|
|
2347
|
+
throw new Error('Pulumi outputs missing masterIp or floatingIp');
|
|
2348
|
+
}
|
|
2349
|
+
state.completeStep('k3s-infra', outputs);
|
|
2350
|
+
s.stop(`Cluster provisioned — master=${outputs.masterIp} floating=${outputs.floatingIp}`);
|
|
2351
|
+
}
|
|
2352
|
+
const infraOutputs = state.getStepResult('k3s-infra');
|
|
2353
|
+
const { masterIp, floatingIp, supabaseIp, workerIps, networkId } = infraOutputs;
|
|
2354
|
+
|
|
2355
|
+
// 4. Wait for cloud-init to finish + k3s to be Ready
|
|
2356
|
+
if (!state.shouldSkip('k3s-ready', { masterIp })) {
|
|
2357
|
+
state.startStep('k3s-ready', { masterIp });
|
|
2358
|
+
s.start('Waiting for k3s on master (cloud-init + Ready)');
|
|
2359
|
+
// 1200s (20 min) covers cold-init + apt-install + k3s-download + k3s-up.
|
|
2360
|
+
// Earlier 600s budget tripped on 2026-04-27 morning matrix when apt was
|
|
2361
|
+
// mid-install at the timeout (cloud-init-output.log diagnostics
|
|
2362
|
+
// confirmed). The diagnostic capture inside waitForK3sReady means a
|
|
2363
|
+
// genuine bug surfaces with cloud-init log content, so a longer budget
|
|
2364
|
+
// costs us nothing — we still get the same actionable signal on real
|
|
2365
|
+
// failures, just don't false-positive on slow apt mornings.
|
|
2366
|
+
await perfAsync(`deploy.${perfPrefix}.waitForReady`, () =>
|
|
2367
|
+
waitForK3sReady(masterIp, privateKeyPath, khPath, 1200),
|
|
2368
|
+
);
|
|
2369
|
+
state.completeStep('k3s-ready');
|
|
2370
|
+
s.stop('k3s reachable');
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
// 5. Fetch + patch kubeconfig
|
|
2374
|
+
let kubeconfig;
|
|
2375
|
+
if (!state.shouldSkip('k3s-kubeconfig', { masterIp })) {
|
|
2376
|
+
state.startStep('k3s-kubeconfig', { masterIp });
|
|
2377
|
+
s.start('Retrieving kubeconfig');
|
|
2378
|
+
kubeconfig = fetchKubeconfig(masterIp, privateKeyPath, khPath, projectDir, options.environment);
|
|
2379
|
+
state.completeStep('k3s-kubeconfig', { kubeconfig });
|
|
2380
|
+
s.stop('Kubeconfig saved');
|
|
2381
|
+
} else {
|
|
2382
|
+
kubeconfig = state.getStepResult('k3s-kubeconfig').kubeconfig;
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
// 6. Build the app image locally
|
|
2386
|
+
const buildInputs = { projectName: options.projectName };
|
|
2387
|
+
if (!state.shouldSkip('k3s-build', buildInputs)) {
|
|
2388
|
+
state.startStep('k3s-build', buildInputs);
|
|
2389
|
+
s.start('Building app image (10.0.1.1:5000/...)');
|
|
2390
|
+
const built = await buildAppImage(projectDir, options.projectName);
|
|
2391
|
+
state.completeStep('k3s-build', {
|
|
2392
|
+
tag: built.tag,
|
|
2393
|
+
gitSha: built.gitSha,
|
|
2394
|
+
isDirty: built.isDirty,
|
|
2395
|
+
});
|
|
2396
|
+
s.stop(`Image built: ${built.tag}`);
|
|
2397
|
+
}
|
|
2398
|
+
const { tag: imageTag } = state.getStepResult('k3s-build');
|
|
2399
|
+
|
|
2400
|
+
// 7. Build the backup image up-front so we can fan out BOTH sideloads in
|
|
2401
|
+
// parallel. base/'s CronJob ships image=ghcr.io/<owner>/<project>-backup
|
|
2402
|
+
// — unreachable in direct/local mode (no GHCR push). Build the
|
|
2403
|
+
// <projectDir>/backup context locally, then later patch the CronJob
|
|
2404
|
+
// image in applyK3sManifests (step 5c).
|
|
2405
|
+
const backupBuildInputs = { projectName: `${options.projectName}-backup` };
|
|
2406
|
+
if (!state.shouldSkip('k3s-backup-build', backupBuildInputs)) {
|
|
2407
|
+
state.startStep('k3s-backup-build', backupBuildInputs);
|
|
2408
|
+
s.start('Building backup image');
|
|
2409
|
+
const built = await buildAppImage(join(projectDir, 'backup'), `${options.projectName}-backup`);
|
|
2410
|
+
state.completeStep('k3s-backup-build', {
|
|
2411
|
+
tag: built.tag,
|
|
2412
|
+
gitSha: built.gitSha,
|
|
2413
|
+
isDirty: built.isDirty,
|
|
2414
|
+
});
|
|
2415
|
+
s.stop(`Backup image built: ${built.tag}`);
|
|
2416
|
+
}
|
|
2417
|
+
const { tag: backupImageTag } = state.getStepResult('k3s-backup-build');
|
|
2418
|
+
|
|
2419
|
+
// 7b. Sideload to every node (parallel SSH within sideloadK3s) AND fan out
|
|
2420
|
+
// the app+backup sideloads against each other. Previously sequential:
|
|
2421
|
+
// sideload.app then sideload.backup ≈ 1m4s + 57s on k8s-ha primary.
|
|
2422
|
+
// Both writes go through `docker save | ssh ... gunzip | k3s ctr import`
|
|
2423
|
+
// against disjoint image refs on the same target nodes — kernel disk
|
|
2424
|
+
// buffer + sshd are happy to multiplex two parallel streams. Cuts the
|
|
2425
|
+
// critical path to max(app, backup) instead of sum.
|
|
2426
|
+
const sshTargets = [`root@${masterIp}`];
|
|
2427
|
+
if (supabaseIp) sshTargets.push(`root@${supabaseIp}`);
|
|
2428
|
+
for (const wIp of workerIps ?? []) sshTargets.push(`root@${wIp}`);
|
|
2429
|
+
const sideloadInputs = { imageTag, targets: sshTargets.join(',') };
|
|
2430
|
+
const backupSideloadInputs = { backupImageTag, targets: sshTargets.join(',') };
|
|
2431
|
+
const sideloadTasks = [];
|
|
2432
|
+
if (!state.shouldSkip('k3s-sideload', sideloadInputs)) {
|
|
2433
|
+
state.startStep('k3s-sideload', sideloadInputs);
|
|
2434
|
+
sideloadTasks.push(
|
|
2435
|
+
perfAsync(`deploy.${perfPrefix}.sideload.app`, () =>
|
|
2436
|
+
sideloadK3s({ tag: imageTag, sshTargets, sshKey: privateKeyPath, khPath }),
|
|
2437
|
+
).then(() => state.completeStep('k3s-sideload')),
|
|
2438
|
+
);
|
|
2439
|
+
}
|
|
2440
|
+
if (!state.shouldSkip('k3s-backup-sideload', backupSideloadInputs)) {
|
|
2441
|
+
state.startStep('k3s-backup-sideload', backupSideloadInputs);
|
|
2442
|
+
sideloadTasks.push(
|
|
2443
|
+
perfAsync(`deploy.${perfPrefix}.sideload.backup`, () =>
|
|
2444
|
+
sideloadK3s({ tag: backupImageTag, sshTargets, sshKey: privateKeyPath, khPath }),
|
|
2445
|
+
).then(() => state.completeStep('k3s-backup-sideload')),
|
|
2446
|
+
);
|
|
2447
|
+
}
|
|
2448
|
+
if (sideloadTasks.length > 0) {
|
|
2449
|
+
s.start(`Sideloading app + backup to ${sshTargets.length} node(s) in parallel`);
|
|
2450
|
+
await Promise.all(sideloadTasks);
|
|
2451
|
+
s.stop('Sideload complete (app + backup)');
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
// 8. Apply manifests + supabase Helm release + roll out the app.
|
|
2455
|
+
if (!state.shouldSkip('k3s-apply', { imageTag, backupImageTag })) {
|
|
2456
|
+
state.startStep('k3s-apply', { imageTag, backupImageTag });
|
|
2457
|
+
s.start('Applying k8s manifests + installing Supabase + rolling out app');
|
|
2458
|
+
const envLocal = loadEnvLocal(join(projectDir, '.env.local'));
|
|
2459
|
+
await perfAsync(`deploy.${perfPrefix}.applyManifests`, () =>
|
|
2460
|
+
applyK3sManifests({
|
|
2461
|
+
perfPrefix,
|
|
2462
|
+
kubeconfig,
|
|
2463
|
+
projectDir,
|
|
2464
|
+
projectName: options.projectName,
|
|
2465
|
+
imageTag,
|
|
2466
|
+
backupImageTag,
|
|
2467
|
+
envLocal,
|
|
2468
|
+
domain: options.domain || 'localhost',
|
|
2469
|
+
s3Config: options.s3Config,
|
|
2470
|
+
backupBucketName: options.backupBucketName,
|
|
2471
|
+
dnsProvider: options.dnsProvider,
|
|
2472
|
+
cloudflareApiToken: options.cloudflareApiToken,
|
|
2473
|
+
// Hetzner DNS-01 uses the SAME Cloud API token (api.hetzner.cloud)
|
|
2474
|
+
// — the separate dns.hetzner.com console + token were retired in
|
|
2475
|
+
// May 2026 (zone management folded into the main Cloud Console).
|
|
2476
|
+
hetznerApiToken: options.apiToken,
|
|
2477
|
+
// Phase 5: cluster-autoscaler wiring. apiToken (the Hetzner Cloud
|
|
2478
|
+
// token CCM/CSI/Pulumi already use) flows in here under its own
|
|
2479
|
+
// name to make the CA Secret apply unambiguous; the k3sToken
|
|
2480
|
+
// came back from Pulumi outputs in `k3s-infra` step.
|
|
2481
|
+
apiToken: options.apiToken,
|
|
2482
|
+
region: options.region,
|
|
2483
|
+
environment: options.environment,
|
|
2484
|
+
minWorkers,
|
|
2485
|
+
maxWorkers,
|
|
2486
|
+
workerServerType: options.workerServerType || options.serverType || 'cx23',
|
|
2487
|
+
k3sToken: infraOutputs.k3sToken,
|
|
2488
|
+
// Phase 6: registry-push wiring. operator opens an SSH tunnel
|
|
2489
|
+
// to master:5000 inside applyK3sManifests; the same SSH coords
|
|
2490
|
+
// sideloadK3s already used (privateKeyPath + per-env khPath).
|
|
2491
|
+
masterIp,
|
|
2492
|
+
sshKeyPath: privateKeyPath,
|
|
2493
|
+
khPath,
|
|
2494
|
+
// HA passes a per-cluster port so primary + standby don't race
|
|
2495
|
+
// for `localhost:5000` on the operator host. Single-cluster
|
|
2496
|
+
// deploys leave it undefined → applyK3sManifests defaults to 5000.
|
|
2497
|
+
localTunnelPort: options.localTunnelPort,
|
|
2498
|
+
}),
|
|
2499
|
+
);
|
|
2500
|
+
state.completeStep('k3s-apply');
|
|
2501
|
+
s.stop('App rolled out');
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
// 9. Cluster details
|
|
2505
|
+
return {
|
|
2506
|
+
masterIp,
|
|
2507
|
+
floatingIp,
|
|
2508
|
+
supabaseIp,
|
|
2509
|
+
workerIps,
|
|
2510
|
+
networkId,
|
|
2511
|
+
kubeconfig,
|
|
2512
|
+
sshKeyPath: privateKeyPath,
|
|
2513
|
+
imageTag,
|
|
2514
|
+
};
|
|
2515
|
+
}
|