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.
Files changed (495) hide show
  1. package/LICENSE +663 -0
  2. package/README.md +188 -0
  3. package/carbon/.claude/hooks/task-completed-gate.sh +19 -0
  4. package/carbon/.claude/hooks/teammate-idle-gate.sh +29 -0
  5. package/carbon/.claude/settings.json +29 -0
  6. package/carbon/.cursor/rules/vibecarbon.mdc +6 -0
  7. package/carbon/.dockerignore +57 -0
  8. package/carbon/.env.example +219 -0
  9. package/carbon/.github/copilot-instructions.md +3 -0
  10. package/carbon/.github/workflows/deploy.yml +346 -0
  11. package/carbon/.github/workflows/vibecarbon-build.yml +81 -0
  12. package/carbon/.windsurfrules +74 -0
  13. package/carbon/AGENTS.md +422 -0
  14. package/carbon/CLAUDE.md +3 -0
  15. package/carbon/DEVELOPMENT.md +187 -0
  16. package/carbon/Dockerfile +98 -0
  17. package/carbon/LICENSE +21 -0
  18. package/carbon/PRODUCTION.md +364 -0
  19. package/carbon/README.md +193 -0
  20. package/carbon/TESTING.md +138 -0
  21. package/carbon/backup/Dockerfile +75 -0
  22. package/carbon/backup/backup.sh +95 -0
  23. package/carbon/backup/compose-backup.sh +140 -0
  24. package/carbon/biome.json +83 -0
  25. package/carbon/cloud-init/docker-ce-setup.yaml +55 -0
  26. package/carbon/cloud-init/k3s/master-init.sh +215 -0
  27. package/carbon/cloud-init/k3s/supabase-init.sh +167 -0
  28. package/carbon/cloud-init/k3s/worker-init.sh +147 -0
  29. package/carbon/components.json +24 -0
  30. package/carbon/content/blog/authentication-guide.mdx +34 -0
  31. package/carbon/content/blog/getting-started.mdx +41 -0
  32. package/carbon/content/changelog/v0-1-0.mdx +40 -0
  33. package/carbon/content/docs/analytics.mdx +90 -0
  34. package/carbon/content/docs/authentication.mdx +231 -0
  35. package/carbon/content/docs/background-jobs.mdx +116 -0
  36. package/carbon/content/docs/cli.mdx +630 -0
  37. package/carbon/content/docs/database.mdx +236 -0
  38. package/carbon/content/docs/deployment.mdx +227 -0
  39. package/carbon/content/docs/development.mdx +238 -0
  40. package/carbon/content/docs/environments.mdx +84 -0
  41. package/carbon/content/docs/getting-started.mdx +112 -0
  42. package/carbon/content/docs/legal/privacy-policy.mdx +89 -0
  43. package/carbon/content/docs/legal/terms-of-service.mdx +99 -0
  44. package/carbon/content/docs/optional-services.mdx +160 -0
  45. package/carbon/db/Dockerfile +23 -0
  46. package/carbon/docker-compose.dev-init.yml +5 -0
  47. package/carbon/docker-compose.metabase.override.yml +14 -0
  48. package/carbon/docker-compose.metabase.prod.yml +28 -0
  49. package/carbon/docker-compose.metabase.yml +84 -0
  50. package/carbon/docker-compose.n8n.override.yml +14 -0
  51. package/carbon/docker-compose.n8n.prod.yml +31 -0
  52. package/carbon/docker-compose.n8n.yml +97 -0
  53. package/carbon/docker-compose.observability.override.yml +18 -0
  54. package/carbon/docker-compose.observability.prod.yml +28 -0
  55. package/carbon/docker-compose.observability.yml +125 -0
  56. package/carbon/docker-compose.override.yml +28 -0
  57. package/carbon/docker-compose.prod.yml +294 -0
  58. package/carbon/docker-compose.yml +508 -0
  59. package/carbon/docker-entrypoint.sh +12 -0
  60. package/carbon/ha/activate-standby.sh +52 -0
  61. package/carbon/ha/primary-init.sql +36 -0
  62. package/carbon/ha/standby-init.sh +74 -0
  63. package/carbon/k8s/LICENSE +99 -0
  64. package/carbon/k8s/README.md +272 -0
  65. package/carbon/k8s/base/app/deployment.yaml +130 -0
  66. package/carbon/k8s/base/app/hpa.yaml +44 -0
  67. package/carbon/k8s/base/app/kustomization.yaml +10 -0
  68. package/carbon/k8s/base/app/network-policy.yaml +124 -0
  69. package/carbon/k8s/base/app/pdb.yaml +14 -0
  70. package/carbon/k8s/base/app/rbac.yaml +36 -0
  71. package/carbon/k8s/base/app/service.yaml +16 -0
  72. package/carbon/k8s/base/backup/cronjob.yaml +120 -0
  73. package/carbon/k8s/base/backup/kustomization.yaml +7 -0
  74. package/carbon/k8s/base/backup/network-policy.yaml +31 -0
  75. package/carbon/k8s/base/backup/rbac.yaml +7 -0
  76. package/carbon/k8s/base/cluster-autoscaler/deployment.yaml +103 -0
  77. package/carbon/k8s/base/cluster-autoscaler/kustomization.yaml +17 -0
  78. package/carbon/k8s/base/cluster-autoscaler/rbac.yaml +109 -0
  79. package/carbon/k8s/base/config/configmap.yaml +48 -0
  80. package/carbon/k8s/base/config/kustomization.yaml +8 -0
  81. package/carbon/k8s/base/hetzner-ccm/kustomization.yaml +9 -0
  82. package/carbon/k8s/base/hetzner-csi/kustomization.yaml +9 -0
  83. package/carbon/k8s/base/kustomization.yaml +43 -0
  84. package/carbon/k8s/base/namespace.yaml +7 -0
  85. package/carbon/k8s/base/network-policies.yaml +95 -0
  86. package/carbon/k8s/base/registry/kustomization.yaml +5 -0
  87. package/carbon/k8s/base/registry/local-registry.yaml +193 -0
  88. package/carbon/k8s/base/traefik/certificate.yaml +21 -0
  89. package/carbon/k8s/base/traefik/configmap.yaml +13 -0
  90. package/carbon/k8s/base/traefik/deployment.yaml +165 -0
  91. package/carbon/k8s/base/traefik/ingressroute.yaml +141 -0
  92. package/carbon/k8s/base/traefik/kustomization.yaml +11 -0
  93. package/carbon/k8s/base/traefik/middleware.yaml +109 -0
  94. package/carbon/k8s/base/traefik/network-policy.yaml +92 -0
  95. package/carbon/k8s/base/traefik/service.yaml +38 -0
  96. package/carbon/k8s/flux/README.md +49 -0
  97. package/carbon/k8s/flux/clusters/primary/vibecarbon.yaml +128 -0
  98. package/carbon/k8s/flux/clusters/standby/vibecarbon.yaml +83 -0
  99. package/carbon/k8s/gitops/cert-manager-webhook-hetzner/helm-release.yaml +38 -0
  100. package/carbon/k8s/gitops/cert-manager-webhook-hetzner/helm-repository.yaml +16 -0
  101. package/carbon/k8s/gitops/cert-manager-webhook-hetzner/kustomization.yaml +10 -0
  102. package/carbon/k8s/gitops/supabase/helm-release.yaml +66 -0
  103. package/carbon/k8s/gitops/supabase/helm-repository.yaml +18 -0
  104. package/carbon/k8s/gitops/supabase/kustomization.yaml +33 -0
  105. package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-cloudflare.yaml +51 -0
  106. package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-hetzner.yaml +59 -0
  107. package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-manual.yaml +43 -0
  108. package/carbon/k8s/infra/cert-manager-resources/kustomization.yaml +15 -0
  109. package/carbon/k8s/infra/kustomization.yaml +21 -0
  110. package/carbon/k8s/infra/traefik-crds/kustomization.yaml +10 -0
  111. package/carbon/k8s/overlays/local/configmap.yaml +23 -0
  112. package/carbon/k8s/overlays/local/ingressroute-studio.yaml +104 -0
  113. package/carbon/k8s/overlays/local/kustomization.yaml +215 -0
  114. package/carbon/k8s/overlays/local/namespace.yaml +8 -0
  115. package/carbon/k8s/overlays/local/secrets.yaml +32 -0
  116. package/carbon/k8s/overlays/production/kustomization.yaml +34 -0
  117. package/carbon/k8s/test-local.sh +318 -0
  118. package/carbon/k8s/values/supabase.values.yaml +133 -0
  119. package/carbon/package.json +154 -0
  120. package/carbon/runtime.Dockerfile +17 -0
  121. package/carbon/scripts/_dev-jwt.mjs +45 -0
  122. package/carbon/scripts/check-rls.ts +53 -0
  123. package/carbon/scripts/dev-init.js +191 -0
  124. package/carbon/scripts/dev.js +191 -0
  125. package/carbon/scripts/docker-down.js +63 -0
  126. package/carbon/scripts/docker-logs.js +59 -0
  127. package/carbon/scripts/docker-up.js +222 -0
  128. package/carbon/scripts/generate-dev-configs.sh +131 -0
  129. package/carbon/scripts/generate-rss.ts +116 -0
  130. package/carbon/scripts/generate-sitemap.ts +102 -0
  131. package/carbon/scripts/k8s-apply.js +71 -0
  132. package/carbon/scripts/k8s-delete.js +75 -0
  133. package/carbon/scripts/lib/manifest.js +176 -0
  134. package/carbon/scripts/secret-scan.mjs +278 -0
  135. package/carbon/scripts/validate-dev-configs.sh +101 -0
  136. package/carbon/src/client/App.tsx +202 -0
  137. package/carbon/src/client/assets/hyperformant-dark.svg +29 -0
  138. package/carbon/src/client/assets/hyperformant-light.svg +29 -0
  139. package/carbon/src/client/assets/logos/aider.svg +1 -0
  140. package/carbon/src/client/assets/logos/antigravity.svg +1 -0
  141. package/carbon/src/client/assets/logos/bolt.svg +1 -0
  142. package/carbon/src/client/assets/logos/claude-code.svg +1 -0
  143. package/carbon/src/client/assets/logos/copilot.svg +1 -0
  144. package/carbon/src/client/assets/logos/cursor.svg +1 -0
  145. package/carbon/src/client/assets/logos/gemini-cli.svg +1 -0
  146. package/carbon/src/client/assets/logos/lovable.svg +1 -0
  147. package/carbon/src/client/assets/logos/openai-codex.svg +1 -0
  148. package/carbon/src/client/assets/logos/v0.svg +1 -0
  149. package/carbon/src/client/assets/logos/windsurf.svg +1 -0
  150. package/carbon/src/client/assets/vibecarbon-icon.svg +19 -0
  151. package/carbon/src/client/assets/vibecarbon-logo-dark.svg +29 -0
  152. package/carbon/src/client/assets/vibecarbon-logo-light.svg +29 -0
  153. package/carbon/src/client/components/AIIntegrationSection.tsx +120 -0
  154. package/carbon/src/client/components/AppSidebar.tsx +760 -0
  155. package/carbon/src/client/components/ArchitectureSection.tsx +46 -0
  156. package/carbon/src/client/components/CTAFooter.tsx +59 -0
  157. package/carbon/src/client/components/ComparisonSection.tsx +132 -0
  158. package/carbon/src/client/components/ContentPanel.tsx +66 -0
  159. package/carbon/src/client/components/ContentSkeleton.tsx +21 -0
  160. package/carbon/src/client/components/ErrorBoundary.tsx +46 -0
  161. package/carbon/src/client/components/FAQSection.tsx +76 -0
  162. package/carbon/src/client/components/FileUpload.tsx +210 -0
  163. package/carbon/src/client/components/HeaderActions.tsx +17 -0
  164. package/carbon/src/client/components/Hero.tsx +608 -0
  165. package/carbon/src/client/components/ImpersonationBanner.tsx +31 -0
  166. package/carbon/src/client/components/LanguageSwitcher.tsx +67 -0
  167. package/carbon/src/client/components/Logo.tsx +87 -0
  168. package/carbon/src/client/components/LogoStrip.tsx +76 -0
  169. package/carbon/src/client/components/MetricsStrip.tsx +48 -0
  170. package/carbon/src/client/components/Nav.tsx +195 -0
  171. package/carbon/src/client/components/NewsletterSignup.tsx +147 -0
  172. package/carbon/src/client/components/NotificationDrawer.tsx +171 -0
  173. package/carbon/src/client/components/PageHeader.tsx +24 -0
  174. package/carbon/src/client/components/PlanGate.tsx +36 -0
  175. package/carbon/src/client/components/PricingSection.tsx +371 -0
  176. package/carbon/src/client/components/SEO.tsx +34 -0
  177. package/carbon/src/client/components/SmoothScroll.tsx +45 -0
  178. package/carbon/src/client/components/TechStackSection.tsx +165 -0
  179. package/carbon/src/client/components/WorkflowSection.tsx +604 -0
  180. package/carbon/src/client/components/admin/DockerLogs.tsx +276 -0
  181. package/carbon/src/client/components/admin/PerformanceMetrics.tsx +230 -0
  182. package/carbon/src/client/components/admin/ServiceCard.tsx +45 -0
  183. package/carbon/src/client/components/admin/ServicesStatus.tsx +296 -0
  184. package/carbon/src/client/components/architecture/ArchitectureDiagram.tsx +413 -0
  185. package/carbon/src/client/components/auth/AuthProvider.tsx +466 -0
  186. package/carbon/src/client/components/effects/FilmGrainOverlay.tsx +35 -0
  187. package/carbon/src/client/components/effects/FresnelEdge.tsx +49 -0
  188. package/carbon/src/client/components/effects/GlowTracker.tsx +46 -0
  189. package/carbon/src/client/components/effects/SparkBurst.tsx +145 -0
  190. package/carbon/src/client/components/effects/VGlowEffect.tsx +83 -0
  191. package/carbon/src/client/components/effects/index.ts +2 -0
  192. package/carbon/src/client/components/layouts/SidebarLayout.tsx +20 -0
  193. package/carbon/src/client/components/scroll/ScrollSection.tsx +76 -0
  194. package/carbon/src/client/components/scroll/ScrollytellingProvider.tsx +81 -0
  195. package/carbon/src/client/components/scroll/index.ts +2 -0
  196. package/carbon/src/client/components/ui/accordion.tsx +71 -0
  197. package/carbon/src/client/components/ui/alert-dialog.tsx +162 -0
  198. package/carbon/src/client/components/ui/alert.tsx +73 -0
  199. package/carbon/src/client/components/ui/aspect-ratio.tsx +22 -0
  200. package/carbon/src/client/components/ui/avatar.tsx +91 -0
  201. package/carbon/src/client/components/ui/badge.tsx +50 -0
  202. package/carbon/src/client/components/ui/breadcrumb.tsx +100 -0
  203. package/carbon/src/client/components/ui/button-group.tsx +78 -0
  204. package/carbon/src/client/components/ui/button.tsx +120 -0
  205. package/carbon/src/client/components/ui/calendar.tsx +182 -0
  206. package/carbon/src/client/components/ui/card.tsx +85 -0
  207. package/carbon/src/client/components/ui/carousel.tsx +227 -0
  208. package/carbon/src/client/components/ui/chart.tsx +357 -0
  209. package/carbon/src/client/components/ui/checkbox.tsx +27 -0
  210. package/carbon/src/client/components/ui/collapsible.tsx +15 -0
  211. package/carbon/src/client/components/ui/command.tsx +178 -0
  212. package/carbon/src/client/components/ui/context-menu.tsx +233 -0
  213. package/carbon/src/client/components/ui/dialog.tsx +132 -0
  214. package/carbon/src/client/components/ui/drawer.tsx +118 -0
  215. package/carbon/src/client/components/ui/dropdown-menu.tsx +242 -0
  216. package/carbon/src/client/components/ui/empty.tsx +94 -0
  217. package/carbon/src/client/components/ui/field.tsx +226 -0
  218. package/carbon/src/client/components/ui/hover-card.tsx +44 -0
  219. package/carbon/src/client/components/ui/input-group.tsx +146 -0
  220. package/carbon/src/client/components/ui/input-otp.tsx +83 -0
  221. package/carbon/src/client/components/ui/input.tsx +20 -0
  222. package/carbon/src/client/components/ui/item.tsx +188 -0
  223. package/carbon/src/client/components/ui/kbd.tsx +26 -0
  224. package/carbon/src/client/components/ui/label.tsx +21 -0
  225. package/carbon/src/client/components/ui/menubar.tsx +250 -0
  226. package/carbon/src/client/components/ui/navigation-menu.tsx +155 -0
  227. package/carbon/src/client/components/ui/pagination.tsx +102 -0
  228. package/carbon/src/client/components/ui/popover.tsx +75 -0
  229. package/carbon/src/client/components/ui/progress.tsx +66 -0
  230. package/carbon/src/client/components/ui/radio-group.tsx +36 -0
  231. package/carbon/src/client/components/ui/resizable.tsx +46 -0
  232. package/carbon/src/client/components/ui/scroll-area.tsx +48 -0
  233. package/carbon/src/client/components/ui/select.tsx +186 -0
  234. package/carbon/src/client/components/ui/separator.tsx +21 -0
  235. package/carbon/src/client/components/ui/sheet.tsx +124 -0
  236. package/carbon/src/client/components/ui/sidebar.tsx +702 -0
  237. package/carbon/src/client/components/ui/skeleton.tsx +13 -0
  238. package/carbon/src/client/components/ui/slider.tsx +57 -0
  239. package/carbon/src/client/components/ui/sonner.tsx +45 -0
  240. package/carbon/src/client/components/ui/spinner.tsx +15 -0
  241. package/carbon/src/client/components/ui/switch.tsx +30 -0
  242. package/carbon/src/client/components/ui/table.tsx +89 -0
  243. package/carbon/src/client/components/ui/tabs.tsx +73 -0
  244. package/carbon/src/client/components/ui/textarea.tsx +18 -0
  245. package/carbon/src/client/components/ui/toggle-group.tsx +87 -0
  246. package/carbon/src/client/components/ui/toggle.tsx +44 -0
  247. package/carbon/src/client/components/ui/tooltip.tsx +56 -0
  248. package/carbon/src/client/hooks/api/index.ts +14 -0
  249. package/carbon/src/client/hooks/api/useAuthSettings.ts +80 -0
  250. package/carbon/src/client/hooks/api/useSubscription.ts +87 -0
  251. package/carbon/src/client/hooks/use-mobile.ts +21 -0
  252. package/carbon/src/client/hooks/useMousePosition.ts +91 -0
  253. package/carbon/src/client/hooks/useNotifications.tsx +124 -0
  254. package/carbon/src/client/hooks/useOrganizationMembers.ts +127 -0
  255. package/carbon/src/client/hooks/useOrganizations.tsx +230 -0
  256. package/carbon/src/client/hooks/usePackageManager.ts +69 -0
  257. package/carbon/src/client/hooks/useReducedMotion.ts +20 -0
  258. package/carbon/src/client/hooks/useRunningServices.ts +114 -0
  259. package/carbon/src/client/hooks/useScrollProgress.ts +56 -0
  260. package/carbon/src/client/index.css +467 -0
  261. package/carbon/src/client/index.html +56 -0
  262. package/carbon/src/client/lib/admin-services.ts +151 -0
  263. package/carbon/src/client/lib/api.ts +32 -0
  264. package/carbon/src/client/lib/blog.ts +35 -0
  265. package/carbon/src/client/lib/changelog.ts +33 -0
  266. package/carbon/src/client/lib/docs-search.ts +101 -0
  267. package/carbon/src/client/lib/docs.ts +37 -0
  268. package/carbon/src/client/lib/i18n.ts +32 -0
  269. package/carbon/src/client/lib/supabase.ts +72 -0
  270. package/carbon/src/client/lib/tailwind-colors.ts +357 -0
  271. package/carbon/src/client/lib/theme.ts +117 -0
  272. package/carbon/src/client/lib/utils.ts +22 -0
  273. package/carbon/src/client/locales/de.json +529 -0
  274. package/carbon/src/client/locales/en.json +461 -0
  275. package/carbon/src/client/locales/es.json +529 -0
  276. package/carbon/src/client/locales/fr.json +529 -0
  277. package/carbon/src/client/locales/pt.json +529 -0
  278. package/carbon/src/client/main.tsx +56 -0
  279. package/carbon/src/client/mdx.d.ts +13 -0
  280. package/carbon/src/client/pages/ApiDocs.tsx +76 -0
  281. package/carbon/src/client/pages/AuthCallback.tsx +34 -0
  282. package/carbon/src/client/pages/Blog.tsx +167 -0
  283. package/carbon/src/client/pages/Changelog.tsx +171 -0
  284. package/carbon/src/client/pages/Charts.tsx +388 -0
  285. package/carbon/src/client/pages/Checkout.tsx +227 -0
  286. package/carbon/src/client/pages/Contact.tsx +174 -0
  287. package/carbon/src/client/pages/Dashboard.tsx +368 -0
  288. package/carbon/src/client/pages/Docs.tsx +372 -0
  289. package/carbon/src/client/pages/ForgotPassword.tsx +111 -0
  290. package/carbon/src/client/pages/Home.tsx +187 -0
  291. package/carbon/src/client/pages/Legal.tsx +100 -0
  292. package/carbon/src/client/pages/Login.tsx +408 -0
  293. package/carbon/src/client/pages/MFAVerify.tsx +156 -0
  294. package/carbon/src/client/pages/NotFound.tsx +21 -0
  295. package/carbon/src/client/pages/Onboarding.tsx +246 -0
  296. package/carbon/src/client/pages/ResetPassword.tsx +200 -0
  297. package/carbon/src/client/pages/UIComponents.tsx +390 -0
  298. package/carbon/src/client/pages/admin/ContactSubmissions.tsx +220 -0
  299. package/carbon/src/client/pages/admin/Dashboard.tsx +24 -0
  300. package/carbon/src/client/pages/admin/Infrastructure.tsx +257 -0
  301. package/carbon/src/client/pages/admin/Jobs.tsx +225 -0
  302. package/carbon/src/client/pages/admin/Logs.tsx +18 -0
  303. package/carbon/src/client/pages/admin/Newsletter.tsx +300 -0
  304. package/carbon/src/client/pages/admin/Notifications.tsx +603 -0
  305. package/carbon/src/client/pages/admin/Organizations.tsx +306 -0
  306. package/carbon/src/client/pages/admin/Settings.tsx +314 -0
  307. package/carbon/src/client/pages/admin/Theme.tsx +465 -0
  308. package/carbon/src/client/pages/admin/Users.tsx +365 -0
  309. package/carbon/src/client/pages/api-docs.css +156 -0
  310. package/carbon/src/client/pages/organizations/Details.tsx +200 -0
  311. package/carbon/src/client/pages/organizations/Members.tsx +402 -0
  312. package/carbon/src/client/pages/settings/Billing.tsx +473 -0
  313. package/carbon/src/client/pages/settings/Profile.tsx +160 -0
  314. package/carbon/src/client/pages/settings/Security.tsx +341 -0
  315. package/carbon/src/client/public/favicon.svg +19 -0
  316. package/carbon/src/client/public/robots.txt +8 -0
  317. package/carbon/src/server/billing/index.ts +104 -0
  318. package/carbon/src/server/billing/provider.ts +81 -0
  319. package/carbon/src/server/billing/providers/paddle.ts +314 -0
  320. package/carbon/src/server/billing/providers/polar.ts +325 -0
  321. package/carbon/src/server/billing/providers/stripe.ts +233 -0
  322. package/carbon/src/server/emails/templates.ts +116 -0
  323. package/carbon/src/server/index.ts +554 -0
  324. package/carbon/src/server/lib/auth.ts +6 -0
  325. package/carbon/src/server/lib/email.ts +64 -0
  326. package/carbon/src/server/lib/env.ts +112 -0
  327. package/carbon/src/server/lib/errors.ts +21 -0
  328. package/carbon/src/server/lib/logger.ts +17 -0
  329. package/carbon/src/server/lib/rate-limiter.ts +288 -0
  330. package/carbon/src/server/lib/request.ts +34 -0
  331. package/carbon/src/server/lib/stripe.ts +42 -0
  332. package/carbon/src/server/lib/supabase.ts +65 -0
  333. package/carbon/src/server/middleware/requirePlan.ts +80 -0
  334. package/carbon/src/server/routes/_internal/services-status.ts +958 -0
  335. package/carbon/src/server/routes/_internal/verify-role.ts +185 -0
  336. package/carbon/src/server/routes/health.ts +48 -0
  337. package/carbon/src/server/routes/v1/admin/contact.ts +128 -0
  338. package/carbon/src/server/routes/v1/admin/jobs.ts +171 -0
  339. package/carbon/src/server/routes/v1/admin/newsletter.ts +237 -0
  340. package/carbon/src/server/routes/v1/auth.ts +390 -0
  341. package/carbon/src/server/routes/v1/billing.ts +718 -0
  342. package/carbon/src/server/routes/v1/contact.ts +93 -0
  343. package/carbon/src/server/routes/v1/index.ts +1333 -0
  344. package/carbon/src/server/routes/v1/newsletter.ts +181 -0
  345. package/carbon/src/server/routes/v1/performance.ts +157 -0
  346. package/carbon/src/server/routes/v1/stats.ts +170 -0
  347. package/carbon/src/server/routes/v1/theme.ts +106 -0
  348. package/carbon/src/server/routes/webhooks/billing.ts +376 -0
  349. package/carbon/src/server/routes/webhooks/stripe.ts +276 -0
  350. package/carbon/src/server/types.ts +11 -0
  351. package/carbon/src/shared/pricing.ts +155 -0
  352. package/carbon/src/shared/types.ts +338 -0
  353. package/carbon/supabase/migrations/00001_init.sql +717 -0
  354. package/carbon/supabase/migrations/00002_theme_settings.sql +13 -0
  355. package/carbon/supabase/migrations/00003_pg_cron.sql +121 -0
  356. package/carbon/supabase/migrations/00004_contact_newsletter.sql +81 -0
  357. package/carbon/supabase/migrations/00005_localization_languages.sql +22 -0
  358. package/carbon/supabase/seed.sql +16 -0
  359. package/carbon/tests/_helpers/app.ts +45 -0
  360. package/carbon/tests/_helpers/env.ts +37 -0
  361. package/carbon/tests/_helpers/factories.ts +69 -0
  362. package/carbon/tests/_helpers/jwt.ts +23 -0
  363. package/carbon/tests/_helpers/setup-integration.ts +20 -0
  364. package/carbon/tests/_helpers/setup-rtl.ts +12 -0
  365. package/carbon/tests/component/ErrorBoundary.test.tsx +53 -0
  366. package/carbon/tests/component/use-auth-settings.test.tsx +119 -0
  367. package/carbon/tests/integration/server/routes/contact.test.ts +162 -0
  368. package/carbon/tests/integration/server/routes/health.test.ts +61 -0
  369. package/carbon/tests/structural/i18n-parity.test.ts +42 -0
  370. package/carbon/tests/unit/client/utils.test.ts +49 -0
  371. package/carbon/tests/unit/shared/pricing.test.ts +93 -0
  372. package/carbon/tsconfig.json +27 -0
  373. package/carbon/tsconfig.server.json +27 -0
  374. package/carbon/tsconfig.test.json +9 -0
  375. package/carbon/vite.config.ts +110 -0
  376. package/carbon/vitest.config.ts +74 -0
  377. package/carbon/volumes/db/jwt.sql +57 -0
  378. package/carbon/volumes/db/metabase-init.sh +29 -0
  379. package/carbon/volumes/db/n8n-init.sh +25 -0
  380. package/carbon/volumes/db/realtime.sql +33 -0
  381. package/carbon/volumes/db/roles.sql +93 -0
  382. package/carbon/volumes/db/set-passwords.sh +12 -0
  383. package/carbon/volumes/db/super-admin.dev.sql +113 -0
  384. package/carbon/volumes/db/super-admin.generated.sql +113 -0
  385. package/carbon/volumes/db/super-admin.sql +114 -0
  386. package/carbon/volumes/grafana/dashboards/logs.json +179 -0
  387. package/carbon/volumes/grafana/dashboards/overview.json +523 -0
  388. package/carbon/volumes/grafana/dashboards/postgresql.json +337 -0
  389. package/carbon/volumes/grafana/dashboards.dev/logs.json +179 -0
  390. package/carbon/volumes/grafana/dashboards.dev/overview.json +156 -0
  391. package/carbon/volumes/grafana/dashboards.dev/postgresql.json +337 -0
  392. package/carbon/volumes/grafana/provisioning/dashboards/dashboards.dev.yml +16 -0
  393. package/carbon/volumes/grafana/provisioning/dashboards/dashboards.yml +16 -0
  394. package/carbon/volumes/grafana/provisioning/datasources/datasources.yml +43 -0
  395. package/carbon/volumes/kong/docker-entrypoint.sh +9 -0
  396. package/carbon/volumes/kong/kong.yml +208 -0
  397. package/carbon/volumes/loki/loki-config.yml +58 -0
  398. package/carbon/volumes/n8n/hooks.js +131 -0
  399. package/carbon/volumes/n8n/scripts/setup.sh +66 -0
  400. package/carbon/volumes/prometheus/prometheus.yml +43 -0
  401. package/carbon/volumes/promtail/promtail-config.yml +64 -0
  402. package/carbon/volumes/traefik/middlewares.dev.yml +83 -0
  403. package/carbon/volumes/traefik/middlewares.yml +95 -0
  404. package/carbon/volumes/traefik/vite-dev.yml +20 -0
  405. package/package.json +95 -0
  406. package/src/access.js +354 -0
  407. package/src/activate.js +187 -0
  408. package/src/add.js +718 -0
  409. package/src/backup.js +786 -0
  410. package/src/cli.js +350 -0
  411. package/src/configure.js +967 -0
  412. package/src/console.js +155 -0
  413. package/src/create.js +1499 -0
  414. package/src/deploy.js +311 -0
  415. package/src/destroy.js +2033 -0
  416. package/src/diagnose.js +735 -0
  417. package/src/down.js +80 -0
  418. package/src/failover.js +1032 -0
  419. package/src/lib/backup-s3.js +179 -0
  420. package/src/lib/build.js +33 -0
  421. package/src/lib/checksum.js +28 -0
  422. package/src/lib/ci-setup.js +666 -0
  423. package/src/lib/cli/help.js +129 -0
  424. package/src/lib/cli/parse-flags.js +160 -0
  425. package/src/lib/cli/select-action.js +65 -0
  426. package/src/lib/cli/select-environment.js +108 -0
  427. package/src/lib/cli/tty-guard.js +75 -0
  428. package/src/lib/cloudflare.js +447 -0
  429. package/src/lib/colors.js +52 -0
  430. package/src/lib/command.js +361 -0
  431. package/src/lib/config.js +359 -0
  432. package/src/lib/cost.js +103 -0
  433. package/src/lib/deploy/bundle.js +231 -0
  434. package/src/lib/deploy/compose/acme-verify.js +121 -0
  435. package/src/lib/deploy/compose/build-args.js +78 -0
  436. package/src/lib/deploy/compose/ha.js +1874 -0
  437. package/src/lib/deploy/compose/index.js +1294 -0
  438. package/src/lib/deploy/compose/reconcile.js +74 -0
  439. package/src/lib/deploy/github.js +382 -0
  440. package/src/lib/deploy/image.js +191 -0
  441. package/src/lib/deploy/index.js +119 -0
  442. package/src/lib/deploy/k8s/LICENSE +99 -0
  443. package/src/lib/deploy/k8s/gitops-deploy.js +298 -0
  444. package/src/lib/deploy/k8s/ha/index.js +1000 -0
  445. package/src/lib/deploy/k8s/index.js +62 -0
  446. package/src/lib/deploy/k8s/k3s.js +2515 -0
  447. package/src/lib/deploy/orchestrator.js +1401 -0
  448. package/src/lib/deploy/prompts.js +545 -0
  449. package/src/lib/deploy/remote-build.js +130 -0
  450. package/src/lib/deploy/state.js +120 -0
  451. package/src/lib/deploy/utils.js +328 -0
  452. package/src/lib/deploy-logger.js +93 -0
  453. package/src/lib/dns-propagation.js +48 -0
  454. package/src/lib/environment.js +58 -0
  455. package/src/lib/fetch-retry.js +103 -0
  456. package/src/lib/github-environments.js +335 -0
  457. package/src/lib/hetzner-dns.js +377 -0
  458. package/src/lib/hetzner-guided-setup.js +275 -0
  459. package/src/lib/host-keys.js +37 -0
  460. package/src/lib/iac/cloud-init.js +52 -0
  461. package/src/lib/iac/index.js +325 -0
  462. package/src/lib/iac/programs/hetzner-compose.js +130 -0
  463. package/src/lib/iac/programs/hetzner-k8s.js +320 -0
  464. package/src/lib/kubectl.js +81 -0
  465. package/src/lib/licensing/index.js +259 -0
  466. package/src/lib/licensing/tiers.js +181 -0
  467. package/src/lib/licensing/validator.js +171 -0
  468. package/src/lib/merge-package-json.js +90 -0
  469. package/src/lib/operator-ip.js +381 -0
  470. package/src/lib/package-manager.js +111 -0
  471. package/src/lib/perf.js +71 -0
  472. package/src/lib/project-guard.js +39 -0
  473. package/src/lib/project.js +334 -0
  474. package/src/lib/providers/base.js +276 -0
  475. package/src/lib/providers/hetzner-s3.js +656 -0
  476. package/src/lib/providers/hetzner.js +755 -0
  477. package/src/lib/providers/index.js +164 -0
  478. package/src/lib/s3.js +510 -0
  479. package/src/lib/secret-scan.js +583 -0
  480. package/src/lib/secrets.js +63 -0
  481. package/src/lib/server-types.js +195 -0
  482. package/src/lib/shell.js +91 -0
  483. package/src/lib/ssh.js +241 -0
  484. package/src/lib/tracker.js +242 -0
  485. package/src/lib/upgrade-policy.js +170 -0
  486. package/src/lib/validators.js +105 -0
  487. package/src/lib/version.js +5 -0
  488. package/src/remove.js +292 -0
  489. package/src/reset.js +97 -0
  490. package/src/restore.js +871 -0
  491. package/src/scale.js +1734 -0
  492. package/src/shell.js +222 -0
  493. package/src/status.js +981 -0
  494. package/src/up.js +264 -0
  495. 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
+ }