vibecarbon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +663 -0
- package/README.md +188 -0
- package/carbon/.claude/hooks/task-completed-gate.sh +19 -0
- package/carbon/.claude/hooks/teammate-idle-gate.sh +29 -0
- package/carbon/.claude/settings.json +29 -0
- package/carbon/.cursor/rules/vibecarbon.mdc +6 -0
- package/carbon/.dockerignore +57 -0
- package/carbon/.env.example +219 -0
- package/carbon/.github/copilot-instructions.md +3 -0
- package/carbon/.github/workflows/deploy.yml +346 -0
- package/carbon/.github/workflows/vibecarbon-build.yml +81 -0
- package/carbon/.windsurfrules +74 -0
- package/carbon/AGENTS.md +422 -0
- package/carbon/CLAUDE.md +3 -0
- package/carbon/DEVELOPMENT.md +187 -0
- package/carbon/Dockerfile +98 -0
- package/carbon/LICENSE +21 -0
- package/carbon/PRODUCTION.md +364 -0
- package/carbon/README.md +193 -0
- package/carbon/TESTING.md +138 -0
- package/carbon/backup/Dockerfile +75 -0
- package/carbon/backup/backup.sh +95 -0
- package/carbon/backup/compose-backup.sh +140 -0
- package/carbon/biome.json +83 -0
- package/carbon/cloud-init/docker-ce-setup.yaml +55 -0
- package/carbon/cloud-init/k3s/master-init.sh +215 -0
- package/carbon/cloud-init/k3s/supabase-init.sh +167 -0
- package/carbon/cloud-init/k3s/worker-init.sh +147 -0
- package/carbon/components.json +24 -0
- package/carbon/content/blog/authentication-guide.mdx +34 -0
- package/carbon/content/blog/getting-started.mdx +41 -0
- package/carbon/content/changelog/v0-1-0.mdx +40 -0
- package/carbon/content/docs/analytics.mdx +90 -0
- package/carbon/content/docs/authentication.mdx +231 -0
- package/carbon/content/docs/background-jobs.mdx +116 -0
- package/carbon/content/docs/cli.mdx +630 -0
- package/carbon/content/docs/database.mdx +236 -0
- package/carbon/content/docs/deployment.mdx +227 -0
- package/carbon/content/docs/development.mdx +238 -0
- package/carbon/content/docs/environments.mdx +84 -0
- package/carbon/content/docs/getting-started.mdx +112 -0
- package/carbon/content/docs/legal/privacy-policy.mdx +89 -0
- package/carbon/content/docs/legal/terms-of-service.mdx +99 -0
- package/carbon/content/docs/optional-services.mdx +160 -0
- package/carbon/db/Dockerfile +23 -0
- package/carbon/docker-compose.dev-init.yml +5 -0
- package/carbon/docker-compose.metabase.override.yml +14 -0
- package/carbon/docker-compose.metabase.prod.yml +28 -0
- package/carbon/docker-compose.metabase.yml +84 -0
- package/carbon/docker-compose.n8n.override.yml +14 -0
- package/carbon/docker-compose.n8n.prod.yml +31 -0
- package/carbon/docker-compose.n8n.yml +97 -0
- package/carbon/docker-compose.observability.override.yml +18 -0
- package/carbon/docker-compose.observability.prod.yml +28 -0
- package/carbon/docker-compose.observability.yml +125 -0
- package/carbon/docker-compose.override.yml +28 -0
- package/carbon/docker-compose.prod.yml +294 -0
- package/carbon/docker-compose.yml +508 -0
- package/carbon/docker-entrypoint.sh +12 -0
- package/carbon/ha/activate-standby.sh +52 -0
- package/carbon/ha/primary-init.sql +36 -0
- package/carbon/ha/standby-init.sh +74 -0
- package/carbon/k8s/LICENSE +99 -0
- package/carbon/k8s/README.md +272 -0
- package/carbon/k8s/base/app/deployment.yaml +130 -0
- package/carbon/k8s/base/app/hpa.yaml +44 -0
- package/carbon/k8s/base/app/kustomization.yaml +10 -0
- package/carbon/k8s/base/app/network-policy.yaml +124 -0
- package/carbon/k8s/base/app/pdb.yaml +14 -0
- package/carbon/k8s/base/app/rbac.yaml +36 -0
- package/carbon/k8s/base/app/service.yaml +16 -0
- package/carbon/k8s/base/backup/cronjob.yaml +120 -0
- package/carbon/k8s/base/backup/kustomization.yaml +7 -0
- package/carbon/k8s/base/backup/network-policy.yaml +31 -0
- package/carbon/k8s/base/backup/rbac.yaml +7 -0
- package/carbon/k8s/base/cluster-autoscaler/deployment.yaml +103 -0
- package/carbon/k8s/base/cluster-autoscaler/kustomization.yaml +17 -0
- package/carbon/k8s/base/cluster-autoscaler/rbac.yaml +109 -0
- package/carbon/k8s/base/config/configmap.yaml +48 -0
- package/carbon/k8s/base/config/kustomization.yaml +8 -0
- package/carbon/k8s/base/hetzner-ccm/kustomization.yaml +9 -0
- package/carbon/k8s/base/hetzner-csi/kustomization.yaml +9 -0
- package/carbon/k8s/base/kustomization.yaml +43 -0
- package/carbon/k8s/base/namespace.yaml +7 -0
- package/carbon/k8s/base/network-policies.yaml +95 -0
- package/carbon/k8s/base/registry/kustomization.yaml +5 -0
- package/carbon/k8s/base/registry/local-registry.yaml +193 -0
- package/carbon/k8s/base/traefik/certificate.yaml +21 -0
- package/carbon/k8s/base/traefik/configmap.yaml +13 -0
- package/carbon/k8s/base/traefik/deployment.yaml +165 -0
- package/carbon/k8s/base/traefik/ingressroute.yaml +141 -0
- package/carbon/k8s/base/traefik/kustomization.yaml +11 -0
- package/carbon/k8s/base/traefik/middleware.yaml +109 -0
- package/carbon/k8s/base/traefik/network-policy.yaml +92 -0
- package/carbon/k8s/base/traefik/service.yaml +38 -0
- package/carbon/k8s/flux/README.md +49 -0
- package/carbon/k8s/flux/clusters/primary/vibecarbon.yaml +128 -0
- package/carbon/k8s/flux/clusters/standby/vibecarbon.yaml +83 -0
- package/carbon/k8s/gitops/cert-manager-webhook-hetzner/helm-release.yaml +38 -0
- package/carbon/k8s/gitops/cert-manager-webhook-hetzner/helm-repository.yaml +16 -0
- package/carbon/k8s/gitops/cert-manager-webhook-hetzner/kustomization.yaml +10 -0
- package/carbon/k8s/gitops/supabase/helm-release.yaml +66 -0
- package/carbon/k8s/gitops/supabase/helm-repository.yaml +18 -0
- package/carbon/k8s/gitops/supabase/kustomization.yaml +33 -0
- package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-cloudflare.yaml +51 -0
- package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-hetzner.yaml +59 -0
- package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-manual.yaml +43 -0
- package/carbon/k8s/infra/cert-manager-resources/kustomization.yaml +15 -0
- package/carbon/k8s/infra/kustomization.yaml +21 -0
- package/carbon/k8s/infra/traefik-crds/kustomization.yaml +10 -0
- package/carbon/k8s/overlays/local/configmap.yaml +23 -0
- package/carbon/k8s/overlays/local/ingressroute-studio.yaml +104 -0
- package/carbon/k8s/overlays/local/kustomization.yaml +215 -0
- package/carbon/k8s/overlays/local/namespace.yaml +8 -0
- package/carbon/k8s/overlays/local/secrets.yaml +32 -0
- package/carbon/k8s/overlays/production/kustomization.yaml +34 -0
- package/carbon/k8s/test-local.sh +318 -0
- package/carbon/k8s/values/supabase.values.yaml +133 -0
- package/carbon/package.json +154 -0
- package/carbon/runtime.Dockerfile +17 -0
- package/carbon/scripts/_dev-jwt.mjs +45 -0
- package/carbon/scripts/check-rls.ts +53 -0
- package/carbon/scripts/dev-init.js +191 -0
- package/carbon/scripts/dev.js +191 -0
- package/carbon/scripts/docker-down.js +63 -0
- package/carbon/scripts/docker-logs.js +59 -0
- package/carbon/scripts/docker-up.js +222 -0
- package/carbon/scripts/generate-dev-configs.sh +131 -0
- package/carbon/scripts/generate-rss.ts +116 -0
- package/carbon/scripts/generate-sitemap.ts +102 -0
- package/carbon/scripts/k8s-apply.js +71 -0
- package/carbon/scripts/k8s-delete.js +75 -0
- package/carbon/scripts/lib/manifest.js +176 -0
- package/carbon/scripts/secret-scan.mjs +278 -0
- package/carbon/scripts/validate-dev-configs.sh +101 -0
- package/carbon/src/client/App.tsx +202 -0
- package/carbon/src/client/assets/hyperformant-dark.svg +29 -0
- package/carbon/src/client/assets/hyperformant-light.svg +29 -0
- package/carbon/src/client/assets/logos/aider.svg +1 -0
- package/carbon/src/client/assets/logos/antigravity.svg +1 -0
- package/carbon/src/client/assets/logos/bolt.svg +1 -0
- package/carbon/src/client/assets/logos/claude-code.svg +1 -0
- package/carbon/src/client/assets/logos/copilot.svg +1 -0
- package/carbon/src/client/assets/logos/cursor.svg +1 -0
- package/carbon/src/client/assets/logos/gemini-cli.svg +1 -0
- package/carbon/src/client/assets/logos/lovable.svg +1 -0
- package/carbon/src/client/assets/logos/openai-codex.svg +1 -0
- package/carbon/src/client/assets/logos/v0.svg +1 -0
- package/carbon/src/client/assets/logos/windsurf.svg +1 -0
- package/carbon/src/client/assets/vibecarbon-icon.svg +19 -0
- package/carbon/src/client/assets/vibecarbon-logo-dark.svg +29 -0
- package/carbon/src/client/assets/vibecarbon-logo-light.svg +29 -0
- package/carbon/src/client/components/AIIntegrationSection.tsx +120 -0
- package/carbon/src/client/components/AppSidebar.tsx +760 -0
- package/carbon/src/client/components/ArchitectureSection.tsx +46 -0
- package/carbon/src/client/components/CTAFooter.tsx +59 -0
- package/carbon/src/client/components/ComparisonSection.tsx +132 -0
- package/carbon/src/client/components/ContentPanel.tsx +66 -0
- package/carbon/src/client/components/ContentSkeleton.tsx +21 -0
- package/carbon/src/client/components/ErrorBoundary.tsx +46 -0
- package/carbon/src/client/components/FAQSection.tsx +76 -0
- package/carbon/src/client/components/FileUpload.tsx +210 -0
- package/carbon/src/client/components/HeaderActions.tsx +17 -0
- package/carbon/src/client/components/Hero.tsx +608 -0
- package/carbon/src/client/components/ImpersonationBanner.tsx +31 -0
- package/carbon/src/client/components/LanguageSwitcher.tsx +67 -0
- package/carbon/src/client/components/Logo.tsx +87 -0
- package/carbon/src/client/components/LogoStrip.tsx +76 -0
- package/carbon/src/client/components/MetricsStrip.tsx +48 -0
- package/carbon/src/client/components/Nav.tsx +195 -0
- package/carbon/src/client/components/NewsletterSignup.tsx +147 -0
- package/carbon/src/client/components/NotificationDrawer.tsx +171 -0
- package/carbon/src/client/components/PageHeader.tsx +24 -0
- package/carbon/src/client/components/PlanGate.tsx +36 -0
- package/carbon/src/client/components/PricingSection.tsx +371 -0
- package/carbon/src/client/components/SEO.tsx +34 -0
- package/carbon/src/client/components/SmoothScroll.tsx +45 -0
- package/carbon/src/client/components/TechStackSection.tsx +165 -0
- package/carbon/src/client/components/WorkflowSection.tsx +604 -0
- package/carbon/src/client/components/admin/DockerLogs.tsx +276 -0
- package/carbon/src/client/components/admin/PerformanceMetrics.tsx +230 -0
- package/carbon/src/client/components/admin/ServiceCard.tsx +45 -0
- package/carbon/src/client/components/admin/ServicesStatus.tsx +296 -0
- package/carbon/src/client/components/architecture/ArchitectureDiagram.tsx +413 -0
- package/carbon/src/client/components/auth/AuthProvider.tsx +466 -0
- package/carbon/src/client/components/effects/FilmGrainOverlay.tsx +35 -0
- package/carbon/src/client/components/effects/FresnelEdge.tsx +49 -0
- package/carbon/src/client/components/effects/GlowTracker.tsx +46 -0
- package/carbon/src/client/components/effects/SparkBurst.tsx +145 -0
- package/carbon/src/client/components/effects/VGlowEffect.tsx +83 -0
- package/carbon/src/client/components/effects/index.ts +2 -0
- package/carbon/src/client/components/layouts/SidebarLayout.tsx +20 -0
- package/carbon/src/client/components/scroll/ScrollSection.tsx +76 -0
- package/carbon/src/client/components/scroll/ScrollytellingProvider.tsx +81 -0
- package/carbon/src/client/components/scroll/index.ts +2 -0
- package/carbon/src/client/components/ui/accordion.tsx +71 -0
- package/carbon/src/client/components/ui/alert-dialog.tsx +162 -0
- package/carbon/src/client/components/ui/alert.tsx +73 -0
- package/carbon/src/client/components/ui/aspect-ratio.tsx +22 -0
- package/carbon/src/client/components/ui/avatar.tsx +91 -0
- package/carbon/src/client/components/ui/badge.tsx +50 -0
- package/carbon/src/client/components/ui/breadcrumb.tsx +100 -0
- package/carbon/src/client/components/ui/button-group.tsx +78 -0
- package/carbon/src/client/components/ui/button.tsx +120 -0
- package/carbon/src/client/components/ui/calendar.tsx +182 -0
- package/carbon/src/client/components/ui/card.tsx +85 -0
- package/carbon/src/client/components/ui/carousel.tsx +227 -0
- package/carbon/src/client/components/ui/chart.tsx +357 -0
- package/carbon/src/client/components/ui/checkbox.tsx +27 -0
- package/carbon/src/client/components/ui/collapsible.tsx +15 -0
- package/carbon/src/client/components/ui/command.tsx +178 -0
- package/carbon/src/client/components/ui/context-menu.tsx +233 -0
- package/carbon/src/client/components/ui/dialog.tsx +132 -0
- package/carbon/src/client/components/ui/drawer.tsx +118 -0
- package/carbon/src/client/components/ui/dropdown-menu.tsx +242 -0
- package/carbon/src/client/components/ui/empty.tsx +94 -0
- package/carbon/src/client/components/ui/field.tsx +226 -0
- package/carbon/src/client/components/ui/hover-card.tsx +44 -0
- package/carbon/src/client/components/ui/input-group.tsx +146 -0
- package/carbon/src/client/components/ui/input-otp.tsx +83 -0
- package/carbon/src/client/components/ui/input.tsx +20 -0
- package/carbon/src/client/components/ui/item.tsx +188 -0
- package/carbon/src/client/components/ui/kbd.tsx +26 -0
- package/carbon/src/client/components/ui/label.tsx +21 -0
- package/carbon/src/client/components/ui/menubar.tsx +250 -0
- package/carbon/src/client/components/ui/navigation-menu.tsx +155 -0
- package/carbon/src/client/components/ui/pagination.tsx +102 -0
- package/carbon/src/client/components/ui/popover.tsx +75 -0
- package/carbon/src/client/components/ui/progress.tsx +66 -0
- package/carbon/src/client/components/ui/radio-group.tsx +36 -0
- package/carbon/src/client/components/ui/resizable.tsx +46 -0
- package/carbon/src/client/components/ui/scroll-area.tsx +48 -0
- package/carbon/src/client/components/ui/select.tsx +186 -0
- package/carbon/src/client/components/ui/separator.tsx +21 -0
- package/carbon/src/client/components/ui/sheet.tsx +124 -0
- package/carbon/src/client/components/ui/sidebar.tsx +702 -0
- package/carbon/src/client/components/ui/skeleton.tsx +13 -0
- package/carbon/src/client/components/ui/slider.tsx +57 -0
- package/carbon/src/client/components/ui/sonner.tsx +45 -0
- package/carbon/src/client/components/ui/spinner.tsx +15 -0
- package/carbon/src/client/components/ui/switch.tsx +30 -0
- package/carbon/src/client/components/ui/table.tsx +89 -0
- package/carbon/src/client/components/ui/tabs.tsx +73 -0
- package/carbon/src/client/components/ui/textarea.tsx +18 -0
- package/carbon/src/client/components/ui/toggle-group.tsx +87 -0
- package/carbon/src/client/components/ui/toggle.tsx +44 -0
- package/carbon/src/client/components/ui/tooltip.tsx +56 -0
- package/carbon/src/client/hooks/api/index.ts +14 -0
- package/carbon/src/client/hooks/api/useAuthSettings.ts +80 -0
- package/carbon/src/client/hooks/api/useSubscription.ts +87 -0
- package/carbon/src/client/hooks/use-mobile.ts +21 -0
- package/carbon/src/client/hooks/useMousePosition.ts +91 -0
- package/carbon/src/client/hooks/useNotifications.tsx +124 -0
- package/carbon/src/client/hooks/useOrganizationMembers.ts +127 -0
- package/carbon/src/client/hooks/useOrganizations.tsx +230 -0
- package/carbon/src/client/hooks/usePackageManager.ts +69 -0
- package/carbon/src/client/hooks/useReducedMotion.ts +20 -0
- package/carbon/src/client/hooks/useRunningServices.ts +114 -0
- package/carbon/src/client/hooks/useScrollProgress.ts +56 -0
- package/carbon/src/client/index.css +467 -0
- package/carbon/src/client/index.html +56 -0
- package/carbon/src/client/lib/admin-services.ts +151 -0
- package/carbon/src/client/lib/api.ts +32 -0
- package/carbon/src/client/lib/blog.ts +35 -0
- package/carbon/src/client/lib/changelog.ts +33 -0
- package/carbon/src/client/lib/docs-search.ts +101 -0
- package/carbon/src/client/lib/docs.ts +37 -0
- package/carbon/src/client/lib/i18n.ts +32 -0
- package/carbon/src/client/lib/supabase.ts +72 -0
- package/carbon/src/client/lib/tailwind-colors.ts +357 -0
- package/carbon/src/client/lib/theme.ts +117 -0
- package/carbon/src/client/lib/utils.ts +22 -0
- package/carbon/src/client/locales/de.json +529 -0
- package/carbon/src/client/locales/en.json +461 -0
- package/carbon/src/client/locales/es.json +529 -0
- package/carbon/src/client/locales/fr.json +529 -0
- package/carbon/src/client/locales/pt.json +529 -0
- package/carbon/src/client/main.tsx +56 -0
- package/carbon/src/client/mdx.d.ts +13 -0
- package/carbon/src/client/pages/ApiDocs.tsx +76 -0
- package/carbon/src/client/pages/AuthCallback.tsx +34 -0
- package/carbon/src/client/pages/Blog.tsx +167 -0
- package/carbon/src/client/pages/Changelog.tsx +171 -0
- package/carbon/src/client/pages/Charts.tsx +388 -0
- package/carbon/src/client/pages/Checkout.tsx +227 -0
- package/carbon/src/client/pages/Contact.tsx +174 -0
- package/carbon/src/client/pages/Dashboard.tsx +368 -0
- package/carbon/src/client/pages/Docs.tsx +372 -0
- package/carbon/src/client/pages/ForgotPassword.tsx +111 -0
- package/carbon/src/client/pages/Home.tsx +187 -0
- package/carbon/src/client/pages/Legal.tsx +100 -0
- package/carbon/src/client/pages/Login.tsx +408 -0
- package/carbon/src/client/pages/MFAVerify.tsx +156 -0
- package/carbon/src/client/pages/NotFound.tsx +21 -0
- package/carbon/src/client/pages/Onboarding.tsx +246 -0
- package/carbon/src/client/pages/ResetPassword.tsx +200 -0
- package/carbon/src/client/pages/UIComponents.tsx +390 -0
- package/carbon/src/client/pages/admin/ContactSubmissions.tsx +220 -0
- package/carbon/src/client/pages/admin/Dashboard.tsx +24 -0
- package/carbon/src/client/pages/admin/Infrastructure.tsx +257 -0
- package/carbon/src/client/pages/admin/Jobs.tsx +225 -0
- package/carbon/src/client/pages/admin/Logs.tsx +18 -0
- package/carbon/src/client/pages/admin/Newsletter.tsx +300 -0
- package/carbon/src/client/pages/admin/Notifications.tsx +603 -0
- package/carbon/src/client/pages/admin/Organizations.tsx +306 -0
- package/carbon/src/client/pages/admin/Settings.tsx +314 -0
- package/carbon/src/client/pages/admin/Theme.tsx +465 -0
- package/carbon/src/client/pages/admin/Users.tsx +365 -0
- package/carbon/src/client/pages/api-docs.css +156 -0
- package/carbon/src/client/pages/organizations/Details.tsx +200 -0
- package/carbon/src/client/pages/organizations/Members.tsx +402 -0
- package/carbon/src/client/pages/settings/Billing.tsx +473 -0
- package/carbon/src/client/pages/settings/Profile.tsx +160 -0
- package/carbon/src/client/pages/settings/Security.tsx +341 -0
- package/carbon/src/client/public/favicon.svg +19 -0
- package/carbon/src/client/public/robots.txt +8 -0
- package/carbon/src/server/billing/index.ts +104 -0
- package/carbon/src/server/billing/provider.ts +81 -0
- package/carbon/src/server/billing/providers/paddle.ts +314 -0
- package/carbon/src/server/billing/providers/polar.ts +325 -0
- package/carbon/src/server/billing/providers/stripe.ts +233 -0
- package/carbon/src/server/emails/templates.ts +116 -0
- package/carbon/src/server/index.ts +554 -0
- package/carbon/src/server/lib/auth.ts +6 -0
- package/carbon/src/server/lib/email.ts +64 -0
- package/carbon/src/server/lib/env.ts +112 -0
- package/carbon/src/server/lib/errors.ts +21 -0
- package/carbon/src/server/lib/logger.ts +17 -0
- package/carbon/src/server/lib/rate-limiter.ts +288 -0
- package/carbon/src/server/lib/request.ts +34 -0
- package/carbon/src/server/lib/stripe.ts +42 -0
- package/carbon/src/server/lib/supabase.ts +65 -0
- package/carbon/src/server/middleware/requirePlan.ts +80 -0
- package/carbon/src/server/routes/_internal/services-status.ts +958 -0
- package/carbon/src/server/routes/_internal/verify-role.ts +185 -0
- package/carbon/src/server/routes/health.ts +48 -0
- package/carbon/src/server/routes/v1/admin/contact.ts +128 -0
- package/carbon/src/server/routes/v1/admin/jobs.ts +171 -0
- package/carbon/src/server/routes/v1/admin/newsletter.ts +237 -0
- package/carbon/src/server/routes/v1/auth.ts +390 -0
- package/carbon/src/server/routes/v1/billing.ts +718 -0
- package/carbon/src/server/routes/v1/contact.ts +93 -0
- package/carbon/src/server/routes/v1/index.ts +1333 -0
- package/carbon/src/server/routes/v1/newsletter.ts +181 -0
- package/carbon/src/server/routes/v1/performance.ts +157 -0
- package/carbon/src/server/routes/v1/stats.ts +170 -0
- package/carbon/src/server/routes/v1/theme.ts +106 -0
- package/carbon/src/server/routes/webhooks/billing.ts +376 -0
- package/carbon/src/server/routes/webhooks/stripe.ts +276 -0
- package/carbon/src/server/types.ts +11 -0
- package/carbon/src/shared/pricing.ts +155 -0
- package/carbon/src/shared/types.ts +338 -0
- package/carbon/supabase/migrations/00001_init.sql +717 -0
- package/carbon/supabase/migrations/00002_theme_settings.sql +13 -0
- package/carbon/supabase/migrations/00003_pg_cron.sql +121 -0
- package/carbon/supabase/migrations/00004_contact_newsletter.sql +81 -0
- package/carbon/supabase/migrations/00005_localization_languages.sql +22 -0
- package/carbon/supabase/seed.sql +16 -0
- package/carbon/tests/_helpers/app.ts +45 -0
- package/carbon/tests/_helpers/env.ts +37 -0
- package/carbon/tests/_helpers/factories.ts +69 -0
- package/carbon/tests/_helpers/jwt.ts +23 -0
- package/carbon/tests/_helpers/setup-integration.ts +20 -0
- package/carbon/tests/_helpers/setup-rtl.ts +12 -0
- package/carbon/tests/component/ErrorBoundary.test.tsx +53 -0
- package/carbon/tests/component/use-auth-settings.test.tsx +119 -0
- package/carbon/tests/integration/server/routes/contact.test.ts +162 -0
- package/carbon/tests/integration/server/routes/health.test.ts +61 -0
- package/carbon/tests/structural/i18n-parity.test.ts +42 -0
- package/carbon/tests/unit/client/utils.test.ts +49 -0
- package/carbon/tests/unit/shared/pricing.test.ts +93 -0
- package/carbon/tsconfig.json +27 -0
- package/carbon/tsconfig.server.json +27 -0
- package/carbon/tsconfig.test.json +9 -0
- package/carbon/vite.config.ts +110 -0
- package/carbon/vitest.config.ts +74 -0
- package/carbon/volumes/db/jwt.sql +57 -0
- package/carbon/volumes/db/metabase-init.sh +29 -0
- package/carbon/volumes/db/n8n-init.sh +25 -0
- package/carbon/volumes/db/realtime.sql +33 -0
- package/carbon/volumes/db/roles.sql +93 -0
- package/carbon/volumes/db/set-passwords.sh +12 -0
- package/carbon/volumes/db/super-admin.dev.sql +113 -0
- package/carbon/volumes/db/super-admin.generated.sql +113 -0
- package/carbon/volumes/db/super-admin.sql +114 -0
- package/carbon/volumes/grafana/dashboards/logs.json +179 -0
- package/carbon/volumes/grafana/dashboards/overview.json +523 -0
- package/carbon/volumes/grafana/dashboards/postgresql.json +337 -0
- package/carbon/volumes/grafana/dashboards.dev/logs.json +179 -0
- package/carbon/volumes/grafana/dashboards.dev/overview.json +156 -0
- package/carbon/volumes/grafana/dashboards.dev/postgresql.json +337 -0
- package/carbon/volumes/grafana/provisioning/dashboards/dashboards.dev.yml +16 -0
- package/carbon/volumes/grafana/provisioning/dashboards/dashboards.yml +16 -0
- package/carbon/volumes/grafana/provisioning/datasources/datasources.yml +43 -0
- package/carbon/volumes/kong/docker-entrypoint.sh +9 -0
- package/carbon/volumes/kong/kong.yml +208 -0
- package/carbon/volumes/loki/loki-config.yml +58 -0
- package/carbon/volumes/n8n/hooks.js +131 -0
- package/carbon/volumes/n8n/scripts/setup.sh +66 -0
- package/carbon/volumes/prometheus/prometheus.yml +43 -0
- package/carbon/volumes/promtail/promtail-config.yml +64 -0
- package/carbon/volumes/traefik/middlewares.dev.yml +83 -0
- package/carbon/volumes/traefik/middlewares.yml +95 -0
- package/carbon/volumes/traefik/vite-dev.yml +20 -0
- package/package.json +95 -0
- package/src/access.js +354 -0
- package/src/activate.js +187 -0
- package/src/add.js +718 -0
- package/src/backup.js +786 -0
- package/src/cli.js +350 -0
- package/src/configure.js +967 -0
- package/src/console.js +155 -0
- package/src/create.js +1499 -0
- package/src/deploy.js +311 -0
- package/src/destroy.js +2033 -0
- package/src/diagnose.js +735 -0
- package/src/down.js +80 -0
- package/src/failover.js +1032 -0
- package/src/lib/backup-s3.js +179 -0
- package/src/lib/build.js +33 -0
- package/src/lib/checksum.js +28 -0
- package/src/lib/ci-setup.js +666 -0
- package/src/lib/cli/help.js +129 -0
- package/src/lib/cli/parse-flags.js +160 -0
- package/src/lib/cli/select-action.js +65 -0
- package/src/lib/cli/select-environment.js +108 -0
- package/src/lib/cli/tty-guard.js +75 -0
- package/src/lib/cloudflare.js +447 -0
- package/src/lib/colors.js +52 -0
- package/src/lib/command.js +361 -0
- package/src/lib/config.js +359 -0
- package/src/lib/cost.js +103 -0
- package/src/lib/deploy/bundle.js +231 -0
- package/src/lib/deploy/compose/acme-verify.js +121 -0
- package/src/lib/deploy/compose/build-args.js +78 -0
- package/src/lib/deploy/compose/ha.js +1874 -0
- package/src/lib/deploy/compose/index.js +1294 -0
- package/src/lib/deploy/compose/reconcile.js +74 -0
- package/src/lib/deploy/github.js +382 -0
- package/src/lib/deploy/image.js +191 -0
- package/src/lib/deploy/index.js +119 -0
- package/src/lib/deploy/k8s/LICENSE +99 -0
- package/src/lib/deploy/k8s/gitops-deploy.js +298 -0
- package/src/lib/deploy/k8s/ha/index.js +1000 -0
- package/src/lib/deploy/k8s/index.js +62 -0
- package/src/lib/deploy/k8s/k3s.js +2515 -0
- package/src/lib/deploy/orchestrator.js +1401 -0
- package/src/lib/deploy/prompts.js +545 -0
- package/src/lib/deploy/remote-build.js +130 -0
- package/src/lib/deploy/state.js +120 -0
- package/src/lib/deploy/utils.js +328 -0
- package/src/lib/deploy-logger.js +93 -0
- package/src/lib/dns-propagation.js +48 -0
- package/src/lib/environment.js +58 -0
- package/src/lib/fetch-retry.js +103 -0
- package/src/lib/github-environments.js +335 -0
- package/src/lib/hetzner-dns.js +377 -0
- package/src/lib/hetzner-guided-setup.js +275 -0
- package/src/lib/host-keys.js +37 -0
- package/src/lib/iac/cloud-init.js +52 -0
- package/src/lib/iac/index.js +325 -0
- package/src/lib/iac/programs/hetzner-compose.js +130 -0
- package/src/lib/iac/programs/hetzner-k8s.js +320 -0
- package/src/lib/kubectl.js +81 -0
- package/src/lib/licensing/index.js +259 -0
- package/src/lib/licensing/tiers.js +181 -0
- package/src/lib/licensing/validator.js +171 -0
- package/src/lib/merge-package-json.js +90 -0
- package/src/lib/operator-ip.js +381 -0
- package/src/lib/package-manager.js +111 -0
- package/src/lib/perf.js +71 -0
- package/src/lib/project-guard.js +39 -0
- package/src/lib/project.js +334 -0
- package/src/lib/providers/base.js +276 -0
- package/src/lib/providers/hetzner-s3.js +656 -0
- package/src/lib/providers/hetzner.js +755 -0
- package/src/lib/providers/index.js +164 -0
- package/src/lib/s3.js +510 -0
- package/src/lib/secret-scan.js +583 -0
- package/src/lib/secrets.js +63 -0
- package/src/lib/server-types.js +195 -0
- package/src/lib/shell.js +91 -0
- package/src/lib/ssh.js +241 -0
- package/src/lib/tracker.js +242 -0
- package/src/lib/upgrade-policy.js +170 -0
- package/src/lib/validators.js +105 -0
- package/src/lib/version.js +5 -0
- package/src/remove.js +292 -0
- package/src/reset.js +97 -0
- package/src/restore.js +871 -0
- package/src/scale.js +1734 -0
- package/src/shell.js +222 -0
- package/src/status.js +981 -0
- package/src/up.js +264 -0
- package/src/upgrade.js +721 -0
|
@@ -0,0 +1,1294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker Compose Production Deployment Module
|
|
3
|
+
*
|
|
4
|
+
* Deploys Vibecarbon projects to a single VPS using Docker Compose.
|
|
5
|
+
* Available to all tiers (Fast + Pro).
|
|
6
|
+
*
|
|
7
|
+
* Two provisioning modes:
|
|
8
|
+
* 1. Hetzner (automated): Uses HetznerProvider API to create VPS
|
|
9
|
+
* 2. Any VPS via SSH (--ssh user@ip): Skip provisioning, deploy to existing server
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { randomBytes } from 'node:crypto';
|
|
14
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import * as p from '@clack/prompts';
|
|
19
|
+
import { uploadS3Backup } from '../../backup-s3.js';
|
|
20
|
+
import { runCommand, runCommandAsync } from '../../command.js';
|
|
21
|
+
import { perfAsync, perfTimer } from '../../perf.js';
|
|
22
|
+
import { shEscape } from '../../shell.js';
|
|
23
|
+
import { scpDownload } from '../../ssh.js';
|
|
24
|
+
|
|
25
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
26
|
+
const __dirname = dirname(__filename);
|
|
27
|
+
|
|
28
|
+
// Path to the cloud-init YAML shipped with the CLI package. Referenced from
|
|
29
|
+
// carbon/cloud-init/ (one level up from src/ in the installed tree).
|
|
30
|
+
const CLOUD_INIT_PATH = join(__dirname, '../../../../carbon/cloud-init/docker-ce-setup.yaml');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read the cloud-init user-data YAML that runs at VPS boot to front-load
|
|
34
|
+
* ufw + unattended-upgrades installation. Callers pass the returned string
|
|
35
|
+
* as `userData` to HetznerProvider.createServer().
|
|
36
|
+
*
|
|
37
|
+
* Kept as a thin wrapper so tests can stub the read and so `vibecarbon
|
|
38
|
+
* upgrade` has a single place to notice layout changes.
|
|
39
|
+
*/
|
|
40
|
+
export function loadCloudInitScript() {
|
|
41
|
+
return readFileSync(CLOUD_INIT_PATH, 'utf-8');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ServerAliveInterval+CountMax: force ssh to give up after 60s of no
|
|
45
|
+
// SSH-protocol traffic, so a freshly-provisioned VPS that accepts TCP on 22
|
|
46
|
+
// but never sends the SSH banner ("Connection timed out during banner
|
|
47
|
+
// exchange") fails fast instead of hanging the caller indefinitely. Same
|
|
48
|
+
// fix applied in lib/ssh.js — keep them in lockstep.
|
|
49
|
+
const SSH_OPTS =
|
|
50
|
+
'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=4';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run a command on a remote server via SSH
|
|
54
|
+
*/
|
|
55
|
+
function sshRun(ip, sshKeyPath, command, options = {}) {
|
|
56
|
+
const { timeout = 120_000 } = options;
|
|
57
|
+
return runCommand(['ssh', ...SSH_OPTS.split(' '), '-i', sshKeyPath, `root@${ip}`, command], {
|
|
58
|
+
stdio: 'pipe',
|
|
59
|
+
timeout,
|
|
60
|
+
encoding: 'utf-8',
|
|
61
|
+
...options,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Async SSH runner — uses spawn (non-blocking) so spinners can animate.
|
|
67
|
+
* Retries on transient SSH connection errors (common on freshly provisioned servers
|
|
68
|
+
* where cloud-init may restart sshd).
|
|
69
|
+
*/
|
|
70
|
+
export async function sshRunAsync(ip, sshKeyPath, command, options = {}) {
|
|
71
|
+
const { timeout = 120_000, retries = 3, ignoreError, ...rest } = options;
|
|
72
|
+
const sshArgs = ['ssh', ...SSH_OPTS.split(' '), '-i', sshKeyPath, `root@${ip}`, command];
|
|
73
|
+
// Strip ignoreError from inner call so connection errors always throw and
|
|
74
|
+
// the retry loop can catch them. We apply ignoreError ourselves after retries.
|
|
75
|
+
const runOpts = { silent: true, timeout, ...rest };
|
|
76
|
+
|
|
77
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
78
|
+
try {
|
|
79
|
+
return await runCommandAsync(sshArgs, runOpts);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
const msg = err.message || '';
|
|
82
|
+
const isConnectionError =
|
|
83
|
+
msg.includes('Connection reset') ||
|
|
84
|
+
msg.includes('Connection refused') ||
|
|
85
|
+
msg.includes('Connection closed') ||
|
|
86
|
+
msg.includes('kex_exchange_identification') ||
|
|
87
|
+
msg.includes('ssh_exchange_identification') ||
|
|
88
|
+
msg.includes('No route to host');
|
|
89
|
+
// Timeouts are transient too: a freshly-provisioned Hetzner VPS can
|
|
90
|
+
// have unattended-upgrades chewing CPU/disk in the background after
|
|
91
|
+
// cloud-init's marker file lands — SSH responds, but each command
|
|
92
|
+
// takes longer. Observed in iter-validate4 compose-ha restore where
|
|
93
|
+
// dockerLogin (a tiny stdin-script SSH call with a 30s timeout)
|
|
94
|
+
// hit the timeout twice in a row on the primary VPS without
|
|
95
|
+
// retry. The retry budget here (3 attempts × 5s backoff) is small
|
|
96
|
+
// enough that real hangs still surface fast, but absorbs the
|
|
97
|
+
// background-work blip on a healthy server.
|
|
98
|
+
const isTimeout =
|
|
99
|
+
msg.includes('timed out') ||
|
|
100
|
+
msg.includes('Command was killed with SIGTERM') ||
|
|
101
|
+
msg.includes('ETIMEDOUT');
|
|
102
|
+
const isTransient = isConnectionError || isTimeout;
|
|
103
|
+
|
|
104
|
+
if (isTransient && attempt < retries - 1) {
|
|
105
|
+
console.error(
|
|
106
|
+
`[retry] ssh ${ip}: attempt ${attempt + 1}/${retries} failed (${msg.slice(0, 80).split('\n')[0]}); retrying in 5s`,
|
|
107
|
+
);
|
|
108
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// All retries exhausted — respect ignoreError if set
|
|
113
|
+
if (ignoreError) return null;
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Wait for SSH to become available on a newly provisioned server.
|
|
121
|
+
*
|
|
122
|
+
* Hetzner VPS typically accepts SSH ~20–35s after the create API returns.
|
|
123
|
+
* Early attempts use a 2s interval so we catch the edge quickly; after
|
|
124
|
+
* 10 tries (~20s) we fall back to 5s so we're not hammering the sshd
|
|
125
|
+
* during slow boots.
|
|
126
|
+
*/
|
|
127
|
+
export async function waitForSSH(host, sshKeyPath, maxAttempts = 30) {
|
|
128
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
129
|
+
try {
|
|
130
|
+
runCommand(
|
|
131
|
+
[
|
|
132
|
+
'ssh',
|
|
133
|
+
...SSH_OPTS.split(' '),
|
|
134
|
+
'-i',
|
|
135
|
+
sshKeyPath,
|
|
136
|
+
'-o',
|
|
137
|
+
'ConnectTimeout=5',
|
|
138
|
+
`root@${host}`,
|
|
139
|
+
'echo',
|
|
140
|
+
'ok',
|
|
141
|
+
],
|
|
142
|
+
{ stdio: 'pipe', timeout: 10_000 },
|
|
143
|
+
);
|
|
144
|
+
return true;
|
|
145
|
+
} catch {
|
|
146
|
+
if (i === maxAttempts - 1) return false;
|
|
147
|
+
// Tighter than the previous [2s ×10, 5s ×20]: SSH is usually live by
|
|
148
|
+
// attempt 3-5, so the fast leg pays off most often. Tail keeps 5s
|
|
149
|
+
// pacing for the genuinely slow boots.
|
|
150
|
+
const interval = i < 5 ? 1000 : i < 10 ? 2000 : 5000;
|
|
151
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Wait for the boot-time cloud-init script to finish.
|
|
159
|
+
*
|
|
160
|
+
* The Hetzner VPS is created with `user_data` that installs ufw + unattended-
|
|
161
|
+
* upgrades and touches `/var/lib/vibecarbon/ready`. `waitForSSH` only tells us
|
|
162
|
+
* sshd is accepting connections — cloud-init may still be mid-way through its
|
|
163
|
+
* runcmd list. Polling for the marker keeps deploy's critical path tight:
|
|
164
|
+
* by the time SSH is up, cloud-init is usually 10-30s away from finishing, and
|
|
165
|
+
* this returns without any apt-get work on our side.
|
|
166
|
+
*
|
|
167
|
+
* If cloud-init fails (marker never appears within the deadline), dump the
|
|
168
|
+
* tail of /var/log/cloud-init-output.log into the error so diagnosis is a
|
|
169
|
+
* one-line log grep for the operator.
|
|
170
|
+
*/
|
|
171
|
+
export async function setupServer(ip, sshKeyPath) {
|
|
172
|
+
const deadline = Date.now() + 180_000; // 3 min — generous ceiling vs typical ~20-40s
|
|
173
|
+
let attempt = 0;
|
|
174
|
+
while (Date.now() < deadline) {
|
|
175
|
+
const result = await sshRunAsync(
|
|
176
|
+
ip,
|
|
177
|
+
sshKeyPath,
|
|
178
|
+
'test -f /var/lib/vibecarbon/ready && echo READY || echo NOT_READY',
|
|
179
|
+
{ timeout: 10_000, ignoreError: true },
|
|
180
|
+
);
|
|
181
|
+
if (result?.trim()?.endsWith('READY') && !result.includes('NOT_READY')) return;
|
|
182
|
+
// Early attempts poll fast — cloud-init typically finishes in 20-40s.
|
|
183
|
+
// First 15 tries at 750ms catch the typical case ~10s sooner; back off
|
|
184
|
+
// to 5s afterward to avoid hammering an actually-slow boot.
|
|
185
|
+
const interval = attempt < 15 ? 750 : 5000;
|
|
186
|
+
attempt++;
|
|
187
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Marker never appeared — surface cloud-init output so the operator can see why.
|
|
191
|
+
let tail = '';
|
|
192
|
+
try {
|
|
193
|
+
tail =
|
|
194
|
+
(await sshRunAsync(
|
|
195
|
+
ip,
|
|
196
|
+
sshKeyPath,
|
|
197
|
+
'tail -n 50 /var/log/cloud-init-output.log 2>/dev/null || echo "(cloud-init log unavailable)"',
|
|
198
|
+
{ timeout: 15_000, ignoreError: true },
|
|
199
|
+
)) || '';
|
|
200
|
+
} catch {
|
|
201
|
+
// Best effort — don't mask the outer failure.
|
|
202
|
+
}
|
|
203
|
+
throw new Error(
|
|
204
|
+
`cloud-init never reached the ready marker on ${ip} within 180s.\n` +
|
|
205
|
+
`--- last 50 lines of /var/log/cloud-init-output.log ---\n${tail.trim()}\n` +
|
|
206
|
+
'--- end cloud-init tail ---',
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Authenticate with a container registry on a remote server.
|
|
212
|
+
*
|
|
213
|
+
* Writes credentials directly into `/root/.docker/config.json` rather than
|
|
214
|
+
* invoking `docker login --password-stdin`. `docker login` has two moving
|
|
215
|
+
* parts that have caused silent failures in e2e:
|
|
216
|
+
*
|
|
217
|
+
* 1. SSH stdin-input race — on a freshly-booted VM, the shell can start
|
|
218
|
+
* reading `docker login`'s prompt before our piped token arrives,
|
|
219
|
+
* causing docker to hang 30s then exit non-zero.
|
|
220
|
+
* 2. Any `credsStore` / `credsHelpers` entry in `/root/.docker/config.json`
|
|
221
|
+
* makes `docker login` try to delegate to a helper binary that doesn't
|
|
222
|
+
* exist on a bare Hetzner VM — login exits 0 but config.json never
|
|
223
|
+
* gets the new auth entry.
|
|
224
|
+
*
|
|
225
|
+
* The `auths` entry we write here is exactly what `docker login` produces
|
|
226
|
+
* internally (base64 of `username:token`, no encryption). Docker reads it
|
|
227
|
+
* on every pull/manifest-HEAD.
|
|
228
|
+
*
|
|
229
|
+
* Fails loud: if the write fails (unreachable server, permission denied,
|
|
230
|
+
* malformed JSON) we throw. A silent fallback here has burned us on
|
|
231
|
+
* standby GHCR 401s — a loud failure during login is much easier to debug
|
|
232
|
+
* than an `unauthorized` 20 minutes into reconcile.
|
|
233
|
+
*/
|
|
234
|
+
export async function dockerLoginOnServer(ip, sshKeyPath, creds) {
|
|
235
|
+
if (!creds?.username || !creds?.token) return;
|
|
236
|
+
const { username, token, registry } = creds;
|
|
237
|
+
const registryHost = registry || 'https://index.docker.io/v1/';
|
|
238
|
+
const authB64 = Buffer.from(`${username}:${token}`).toString('base64');
|
|
239
|
+
|
|
240
|
+
// Merge into any existing config.json via jq (preserves other registries'
|
|
241
|
+
// auth when we log in to multiple). jq is guaranteed on Hetzner docker-ce
|
|
242
|
+
// images; if not present we fall back to writing a minimal file.
|
|
243
|
+
// Bash here-doc writes a minimal config.json directly. No jq dependency:
|
|
244
|
+
// bare Hetzner images don't ship with it, and installing via apt-get races
|
|
245
|
+
// cloud-init's unattended-upgrades apt lock (observed 04:08 UTC —
|
|
246
|
+
// "Could not get lock /var/lib/apt/lists/lock").
|
|
247
|
+
//
|
|
248
|
+
// Python3 is preinstalled on Ubuntu's docker-ce cloud image, so we use
|
|
249
|
+
// it to merge cleanly into any existing config.json (preserves other
|
|
250
|
+
// registries' auth when logging into Docker Hub + GHCR sequentially).
|
|
251
|
+
// Falls back to overwriting config.json if python3 is absent.
|
|
252
|
+
const script = `
|
|
253
|
+
set -e
|
|
254
|
+
mkdir -p /root/.docker
|
|
255
|
+
cfg=/root/.docker/config.json
|
|
256
|
+
if [ ! -s "$cfg" ]; then echo '{}' > "$cfg"; fi
|
|
257
|
+
|
|
258
|
+
if command -v python3 >/dev/null 2>&1; then
|
|
259
|
+
HOST=${shEscape(registryHost)} AUTH=${shEscape(authB64)} python3 - <<'PY'
|
|
260
|
+
import json, os
|
|
261
|
+
cfg_path = '/root/.docker/config.json'
|
|
262
|
+
try:
|
|
263
|
+
with open(cfg_path) as f:
|
|
264
|
+
cfg = json.load(f)
|
|
265
|
+
except Exception:
|
|
266
|
+
cfg = {}
|
|
267
|
+
if not isinstance(cfg, dict):
|
|
268
|
+
cfg = {}
|
|
269
|
+
cfg.setdefault('auths', {})[os.environ['HOST']] = {'auth': os.environ['AUTH']}
|
|
270
|
+
cfg.pop('credsStore', None)
|
|
271
|
+
cfg.pop('credHelpers', None)
|
|
272
|
+
with open(cfg_path, 'w') as f:
|
|
273
|
+
json.dump(cfg, f)
|
|
274
|
+
PY
|
|
275
|
+
else
|
|
276
|
+
# Minimal fallback: overwrite with single-registry config. Acceptable
|
|
277
|
+
# for bare images where we're the only thing writing this file.
|
|
278
|
+
printf '{"auths":{"%s":{"auth":"%s"}}}' ${shEscape(registryHost)} ${shEscape(authB64)} > "$cfg"
|
|
279
|
+
fi
|
|
280
|
+
|
|
281
|
+
chmod 600 "$cfg"
|
|
282
|
+
# Verify the auth entry landed. Deploys that silently lose auth here
|
|
283
|
+
# surface as 401 during reconcile 15+ min later — this grep catches it
|
|
284
|
+
# at login time so the failure is actionable.
|
|
285
|
+
if ! grep -q ${shEscape(authB64)} "$cfg"; then
|
|
286
|
+
echo "docker auth write verification failed — config.json:" >&2
|
|
287
|
+
cat "$cfg" >&2
|
|
288
|
+
exit 1
|
|
289
|
+
fi
|
|
290
|
+
`;
|
|
291
|
+
await sshRunAsync(ip, sshKeyPath, `/bin/bash -s`, {
|
|
292
|
+
silent: true,
|
|
293
|
+
timeout: 30_000,
|
|
294
|
+
input: script,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Collect deploy files and sync them to the server.
|
|
300
|
+
* Supports pre-rendered bundlePath (Phase 1 Optimization).
|
|
301
|
+
*/
|
|
302
|
+
export async function setupServerFiles(ip, sshKeyPath, projectName, options = {}) {
|
|
303
|
+
const remoteDir = `/opt/${projectName}`;
|
|
304
|
+
const stageDir = options.bundlePath || mkdtempSync(join(tmpdir(), 'vc-deploy-'));
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
// If not using a pre-rendered bundle, perform ad-hoc rendering (legacy path)
|
|
308
|
+
if (!options.bundlePath) {
|
|
309
|
+
// ... (rest of the manual rendering logic if needed, but we now use bundle.js)
|
|
310
|
+
// Actually, to keep it clean, we just import renderBundle if bundlePath is missing
|
|
311
|
+
const { renderBundle } = await import('../bundle.js');
|
|
312
|
+
const adhocBundle = renderBundle(projectName, options);
|
|
313
|
+
// Copy from adhoc to stageDir or just use adhoc
|
|
314
|
+
// For simplicity, let's just use the renderBundle result
|
|
315
|
+
options.bundlePath = adhocBundle;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// -- Single tar + SSH pipe to extract on server --
|
|
319
|
+
// Per-arm filename. HA-scale and HA-deploy fan two arms in parallel
|
|
320
|
+
// through this function, both with the same projectName. A shared
|
|
321
|
+
// /tmp path was racing — Arm A's stream of the bundle could mid-flight
|
|
322
|
+
// read a file Arm B had already begun overwriting, surfacing on the
|
|
323
|
+
// remote side as "gzip: stdin: unexpected end of file". The ip + pid +
|
|
324
|
+
// random suffix gives each arm its own file so the cleanup rmSync below
|
|
325
|
+
// can't yank Arm A's stream out from under it.
|
|
326
|
+
const safeIp = ip.replace(/[^0-9a-zA-Z]/g, '_');
|
|
327
|
+
const tarPath = join(
|
|
328
|
+
tmpdir(),
|
|
329
|
+
`vc-deploy-${projectName}-${safeIp}-${process.pid}-${randomBytes(6).toString('hex')}.tar.gz`,
|
|
330
|
+
);
|
|
331
|
+
// Inner pack vs upload+extract split — pack is local CPU-bound, upload
|
|
332
|
+
// is bandwidth-bound. Splitting them isolates whether a slow deploy is
|
|
333
|
+
// dev-laptop or network/server side. Clack spinners replace the older
|
|
334
|
+
// `console.log('[sync] ...')` triplet so the operator sees animation
|
|
335
|
+
// during the perfAsync wait instead of dead silence.
|
|
336
|
+
const packSpinner = p.spinner();
|
|
337
|
+
packSpinner.start(`Packing bundle from ${options.bundlePath}`);
|
|
338
|
+
try {
|
|
339
|
+
await perfAsync('deploy.bundle.tarPack', () =>
|
|
340
|
+
runCommandAsync(['tar', '-czf', tarPath, '-C', options.bundlePath, '.'], {
|
|
341
|
+
silent: true,
|
|
342
|
+
timeout: 30_000,
|
|
343
|
+
}),
|
|
344
|
+
);
|
|
345
|
+
packSpinner.stop('Bundle packed');
|
|
346
|
+
} catch (err) {
|
|
347
|
+
packSpinner.stop('Bundle pack failed', 1);
|
|
348
|
+
throw err;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const uploadSpinner = p.spinner();
|
|
352
|
+
uploadSpinner.start(`Uploading + extracting bundle on ${ip}`);
|
|
353
|
+
try {
|
|
354
|
+
await perfAsync('deploy.bundle.extract', () =>
|
|
355
|
+
runCommandAsync(
|
|
356
|
+
[
|
|
357
|
+
'bash',
|
|
358
|
+
'-c',
|
|
359
|
+
'cat "$1" | ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -i "$2" "root@$3" "mkdir -p $4/backups && tar --no-xattrs --no-same-owner -xzf - -C $4 && cp $4/$5.service /etc/systemd/system/ && systemctl daemon-reload"',
|
|
360
|
+
'--',
|
|
361
|
+
tarPath,
|
|
362
|
+
sshKeyPath,
|
|
363
|
+
ip,
|
|
364
|
+
remoteDir,
|
|
365
|
+
projectName,
|
|
366
|
+
],
|
|
367
|
+
{ silent: true, timeout: 300_000 },
|
|
368
|
+
),
|
|
369
|
+
);
|
|
370
|
+
uploadSpinner.stop('Bundle synced');
|
|
371
|
+
} catch (err) {
|
|
372
|
+
uploadSpinner.stop('Bundle upload failed', 1);
|
|
373
|
+
throw err;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Clean up local tar
|
|
377
|
+
try {
|
|
378
|
+
rmSync(tarPath, { force: true });
|
|
379
|
+
} catch {
|
|
380
|
+
// Ignore cleanup errors
|
|
381
|
+
}
|
|
382
|
+
} finally {
|
|
383
|
+
// Clean up staging dir only if we created it (adhoc)
|
|
384
|
+
if (!options.bundlePath) {
|
|
385
|
+
try {
|
|
386
|
+
rmSync(stageDir, { recursive: true, force: true });
|
|
387
|
+
} catch {
|
|
388
|
+
// Ignore cleanup errors
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Heuristic: does this image tag refer to a registry path or a local-only tag?
|
|
396
|
+
*
|
|
397
|
+
* A registry path always contains either a `/` (e.g. `ghcr.io/owner/repo:tag`,
|
|
398
|
+
* `library/postgres`) or a host:port prefix. Local tags built by direct mode
|
|
399
|
+
* are bare names like `<project>-app:local`. Distinguishing the two lets
|
|
400
|
+
* callers decide whether to ship the image to peer servers (local-only)
|
|
401
|
+
* or rely on `docker pull` (registry path).
|
|
402
|
+
*/
|
|
403
|
+
export function isLocalOnlyImageTag(imageRef) {
|
|
404
|
+
if (!imageRef || typeof imageRef !== 'string') return false;
|
|
405
|
+
const [namePart] = imageRef.split(':');
|
|
406
|
+
// A '/' anywhere in the name part means the tag carries a registry path
|
|
407
|
+
// (e.g. ghcr.io/owner/repo or owner/repo). Local docker tags built via
|
|
408
|
+
// `docker build -t <project>-app:local` have no slash.
|
|
409
|
+
return !namePart.includes('/');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Distribute a locally-built Docker image from one server's daemon to another
|
|
414
|
+
* via an SSH-piped `docker save | docker load`.
|
|
415
|
+
*
|
|
416
|
+
* Direct-mode deploys build the image into a single server's local Docker
|
|
417
|
+
* daemon (via `DOCKER_HOST=ssh://<source>`). Multi-server scenarios (HA
|
|
418
|
+
* standby, compose-scale's blue-green new VM) need that image on the other
|
|
419
|
+
* servers before reconcile.sh runs `docker compose up -d` — otherwise compose
|
|
420
|
+
* tries to `docker pull <local-tag>`, which is not a registry path, and fails
|
|
421
|
+
* with `pull access denied`.
|
|
422
|
+
*
|
|
423
|
+
* The pure SSH-pipe approach avoids any temp-file or scp intermediate: stdout
|
|
424
|
+
* of `docker save` on the source streams directly into `docker load` on the
|
|
425
|
+
* destination. For a 600MB image across hel1↔nbg1 this typically lands in
|
|
426
|
+
* 30-60s; we budget 10 minutes (large images + slow inter-DC links).
|
|
427
|
+
*
|
|
428
|
+
* No-op (returns without erroring) when imageRef looks like a registry path,
|
|
429
|
+
* since `docker compose pull` will already handle those.
|
|
430
|
+
*
|
|
431
|
+
* @param {string} sourceIp - Server that already has the image in its daemon
|
|
432
|
+
* @param {string} destIp - Server that needs the image
|
|
433
|
+
* @param {string} sshKeyPath
|
|
434
|
+
* @param {string} imageRef - Tag to transfer (e.g. `<project>-app:local`)
|
|
435
|
+
* @returns {Promise<void>}
|
|
436
|
+
*/
|
|
437
|
+
export async function transferImageBetweenServers(sourceIp, destIp, sshKeyPath, imageRef) {
|
|
438
|
+
if (!isLocalOnlyImageTag(imageRef)) {
|
|
439
|
+
// Registry-path image — let `docker compose pull` handle it.
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (sourceIp === destIp) return;
|
|
443
|
+
// shEscape the imageRef so a future tag with shell metacharacters (':',
|
|
444
|
+
// '@', '/' are all fine; we still escape for defense-in-depth) can't
|
|
445
|
+
// break out of `docker save`. The other tokens (ip, sshKeyPath) are
|
|
446
|
+
// controlled by the deploy code, not user input.
|
|
447
|
+
// gzip on the wire — same trick as sideloadCompose / sideloadK3s. Node
|
|
448
|
+
// images compress 3-5x; without gzip a ~600MB save streams uncompressed
|
|
449
|
+
// across two regions on top of the operator's residential upload, which
|
|
450
|
+
// historically tripped the 600s timeout under load.
|
|
451
|
+
const saveCmd = `ssh ${SSH_OPTS} -i ${shEscape(sshKeyPath)} root@${sourceIp} docker save ${shEscape(imageRef)}`;
|
|
452
|
+
const loadCmd = `ssh ${SSH_OPTS} -i ${shEscape(sshKeyPath)} root@${destIp} 'gunzip | docker load'`;
|
|
453
|
+
await runCommandAsync(['bash', '-c', `set -o pipefail && ${saveCmd} | gzip -1 | ${loadCmd}`], {
|
|
454
|
+
silent: true,
|
|
455
|
+
timeout: 600_000,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Wait for Docker daemon to be ready on a remote server.
|
|
461
|
+
*
|
|
462
|
+
* On Hetzner docker-ce VPSes Docker is already running once cloud-init
|
|
463
|
+
* releases the dpkg lock, so the first check usually succeeds immediately.
|
|
464
|
+
* Short initial interval keeps the latency tight on the rare miss.
|
|
465
|
+
*/
|
|
466
|
+
export async function waitForDockerReady(ip, sshKeyPath, maxAttempts = 12) {
|
|
467
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
468
|
+
const result = await sshRunAsync(ip, sshKeyPath, 'docker info >/dev/null 2>&1 && echo READY', {
|
|
469
|
+
timeout: 10_000,
|
|
470
|
+
ignoreError: true,
|
|
471
|
+
});
|
|
472
|
+
if (result?.trim() === 'READY') return true;
|
|
473
|
+
if (i < maxAttempts - 1) {
|
|
474
|
+
const interval = i < 3 ? 1500 : 5000;
|
|
475
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Build the compose files flags string for a given set of service options.
|
|
483
|
+
*/
|
|
484
|
+
function composeFileFlags(options = {}) {
|
|
485
|
+
const files = ['-f', 'docker-compose.yml', '-f', 'docker-compose.prod.yml'];
|
|
486
|
+
if (options.observability) {
|
|
487
|
+
files.push('-f', 'docker-compose.observability.yml');
|
|
488
|
+
files.push('-f', 'docker-compose.observability.prod.yml');
|
|
489
|
+
}
|
|
490
|
+
if (options.n8n) {
|
|
491
|
+
files.push('-f', 'docker-compose.n8n.yml', '-f', 'docker-compose.n8n.prod.yml');
|
|
492
|
+
}
|
|
493
|
+
if (options.metabase) {
|
|
494
|
+
files.push('-f', 'docker-compose.metabase.yml', '-f', 'docker-compose.metabase.prod.yml');
|
|
495
|
+
}
|
|
496
|
+
if (options.redis) files.push('-f', 'docker-compose.redis.yml');
|
|
497
|
+
return files.join(' ');
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Start the Docker Compose stack on the remote server
|
|
502
|
+
*/
|
|
503
|
+
export async function startComposeStack(ip, sshKeyPath, projectName, _options = {}) {
|
|
504
|
+
const remoteDir = `/opt/${projectName}`;
|
|
505
|
+
// Spinner over the long silent compose.up — typical 60-130s warm, longer
|
|
506
|
+
// cold while Supabase services pull. Replaces the previous
|
|
507
|
+
// `console.log('[reconcile] Running...')` + silence + `Reconcile
|
|
508
|
+
// complete.` pair.
|
|
509
|
+
const reconcileSpinner = p.spinner();
|
|
510
|
+
reconcileSpinner.start(`Reconciling stack on ${ip} (docker compose up)`);
|
|
511
|
+
try {
|
|
512
|
+
// reconcile.sh combines `docker compose pull` + `docker compose up -d`
|
|
513
|
+
// on the server. The orchestrator-level `deploy.reconcile.run` wraps the
|
|
514
|
+
// outer call; this inner timer captures the SSH-side cost separately so
|
|
515
|
+
// we can attribute slow deploys to the remote-bash phase vs. the SSH
|
|
516
|
+
// round-trip overhead.
|
|
517
|
+
//
|
|
518
|
+
// 1800s (30 min) timeout: iter-banner 2026-05-02 had compose-ha standby
|
|
519
|
+
// reconcile run past the previous 900s budget while Docker Hub / Hetzner
|
|
520
|
+
// egress was slow — SSH was making progress (cluster came up healthy
|
|
521
|
+
// after the wrapper timed out) but the timer ate the run. 30 min covers
|
|
522
|
+
// a slow-pull cold start on any of the three S3 regions plus margin
|
|
523
|
+
// without masking a true regression (a real hang would still surface
|
|
524
|
+
// via the 60s ServerAlive keepalives in lib/ssh.js + compose SSH_OPTS).
|
|
525
|
+
await perfAsync('deploy.compose.up', () =>
|
|
526
|
+
sshRunAsync(ip, sshKeyPath, `/bin/bash ${remoteDir}/reconcile.sh`, { timeout: 1_800_000 }),
|
|
527
|
+
);
|
|
528
|
+
reconcileSpinner.stop('Stack reconciled');
|
|
529
|
+
} catch (err) {
|
|
530
|
+
reconcileSpinner.stop('Stack reconcile failed', 1);
|
|
531
|
+
const stdout = err.stdout?.trim?.();
|
|
532
|
+
if (stdout) {
|
|
533
|
+
const lines = stdout.split('\n');
|
|
534
|
+
const tail = lines.slice(-30).join('\n');
|
|
535
|
+
err.message = `${err.message}\n--- reconcile output (last 30 lines) ---\n${tail}`;
|
|
536
|
+
}
|
|
537
|
+
// Capture the db container's actual stderr — `docker compose up` only
|
|
538
|
+
// surfaces "container is unhealthy" / "Error", not the postgres exit
|
|
539
|
+
// reason. Without these logs, debugging a failed re-deploy means
|
|
540
|
+
// "guess and re-run". Mirrors what compose/ha.js already does on
|
|
541
|
+
// failover. Best-effort — we re-throw the original failure regardless.
|
|
542
|
+
try {
|
|
543
|
+
const dbLogs = await sshRunAsync(
|
|
544
|
+
ip,
|
|
545
|
+
sshKeyPath,
|
|
546
|
+
`cd ${remoteDir} && docker compose logs db --tail=60 2>&1 || true`,
|
|
547
|
+
{ timeout: 30_000 },
|
|
548
|
+
);
|
|
549
|
+
if (typeof dbLogs === 'string' && dbLogs.trim()) {
|
|
550
|
+
err.message = `${err.message}\n--- db container logs (last 60 lines) ---\n${dbLogs.trim()}`;
|
|
551
|
+
}
|
|
552
|
+
} catch (logErr) {
|
|
553
|
+
err.message = `${err.message}\n(failed to capture db logs: ${logErr.message})`;
|
|
554
|
+
}
|
|
555
|
+
throw err;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Wait for all compose services with healthchecks to reach "healthy".
|
|
561
|
+
*
|
|
562
|
+
* `docker compose up -d` returns as soon as containers are *created*, not
|
|
563
|
+
* when their healthchecks pass. For Supabase, auth/storage/rest all need
|
|
564
|
+
* 10-60s after container-start to become healthy — verify-deploy's
|
|
565
|
+
* storage_upload / rest_api checks 502 when they run against a starting
|
|
566
|
+
* service. Polls every 5s up to 3min; returns early once every service
|
|
567
|
+
* with a declared healthcheck reports "healthy".
|
|
568
|
+
*
|
|
569
|
+
* Non-critical — on timeout or error we log but don't fail, since the
|
|
570
|
+
* caller's primary gate is the app-container probe which already passed.
|
|
571
|
+
*/
|
|
572
|
+
async function waitForServicesHealthy(ip, sshKeyPath, projectName, maxWaitMs = 180_000) {
|
|
573
|
+
const remoteDir = `/opt/${projectName}`;
|
|
574
|
+
const cmd = `cd ${remoteDir} && docker compose ps --format '{{.Service}} {{.Health}}' 2>&1`;
|
|
575
|
+
const deadline = Date.now() + maxWaitMs;
|
|
576
|
+
let lastState = '';
|
|
577
|
+
while (Date.now() < deadline) {
|
|
578
|
+
try {
|
|
579
|
+
const out = await sshRunAsync(ip, sshKeyPath, cmd, {
|
|
580
|
+
timeout: 15_000,
|
|
581
|
+
ignoreError: true,
|
|
582
|
+
});
|
|
583
|
+
const lines = (out || '')
|
|
584
|
+
.trim()
|
|
585
|
+
.split('\n')
|
|
586
|
+
.map((l) => l.trim())
|
|
587
|
+
.filter(Boolean);
|
|
588
|
+
// Partition by health state. Services without a declared healthcheck
|
|
589
|
+
// report empty/no-health — skip those, they don't block readiness.
|
|
590
|
+
const stillWarming = lines.filter((l) => l.endsWith(' starting') || l.endsWith(' unhealthy'));
|
|
591
|
+
lastState = stillWarming.join(', ') || 'all healthy';
|
|
592
|
+
if (stillWarming.length === 0) return true;
|
|
593
|
+
} catch {
|
|
594
|
+
/* retry */
|
|
595
|
+
}
|
|
596
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
597
|
+
}
|
|
598
|
+
// Best-effort; don't fail the deploy here — the app probe already passed.
|
|
599
|
+
console.error(
|
|
600
|
+
`[verifyAppHealth] services still warming after ${maxWaitMs / 1000}s: ${lastState}`,
|
|
601
|
+
);
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Verify the deployed app is actually serving requests.
|
|
607
|
+
*
|
|
608
|
+
* Probes `http://localhost/api/health` on the server itself with a Host header
|
|
609
|
+
* (if a domain is configured), bypassing public DNS and TLS to isolate app
|
|
610
|
+
* health from DNS propagation / cert issuance races. Polls up to `attempts`
|
|
611
|
+
* times with 3s between attempts.
|
|
612
|
+
*
|
|
613
|
+
* On failure, captures `docker compose ps` + the last 40 lines of the `app`
|
|
614
|
+
* container log so operators see WHY the probe failed, not just THAT it did.
|
|
615
|
+
*
|
|
616
|
+
* Returns `{ healthy, status, details }`:
|
|
617
|
+
* - healthy — true iff the probe returned HTTP 2xx within the budget
|
|
618
|
+
* - status — HTTP status of the last probe ('none' if we never got one)
|
|
619
|
+
* - details — diagnostic string (empty when healthy)
|
|
620
|
+
*/
|
|
621
|
+
export async function verifyAppHealth(ip, sshKeyPath, projectName, options = {}) {
|
|
622
|
+
// Probe the app container directly on its internal port (3000), bypassing
|
|
623
|
+
// Traefik + TLS. Traefik's port 80 listener redirects to HTTPS (301) and
|
|
624
|
+
// HTTPS requires a valid cert which may still be issuing on a fresh
|
|
625
|
+
// deploy. Hitting the container directly tests app-layer health without
|
|
626
|
+
// entangling the ingress path — the e2e harness's own
|
|
627
|
+
// waitForHealthy check (which hits the public URL) verifies end-to-end.
|
|
628
|
+
//
|
|
629
|
+
// First-boot route registration takes longer than the initial `docker
|
|
630
|
+
// compose up -d` return: the app has to start + bind port 3000.
|
|
631
|
+
// Empirically 10-30s. 3 min tolerates worst-case cold starts.
|
|
632
|
+
const { path = '/api/health', attempts = 18, delayMs = 10_000 } = options;
|
|
633
|
+
const remoteDir = `/opt/${projectName}`;
|
|
634
|
+
// Use `node` for the probe — the app's runtime image is guaranteed to have
|
|
635
|
+
// it (it runs the server), whereas curl/wget may not be present in slim
|
|
636
|
+
// node base images. One-liner: HEAD the local port 3000 path; exit 0 on
|
|
637
|
+
// 2xx, 1 otherwise. The outer shell echoes "2xx" or "fail" so the caller
|
|
638
|
+
// sees a deterministic token on stdout regardless of node's exit code.
|
|
639
|
+
const nodeScript = `const r=require('http').request({host:'localhost',port:3000,path:${JSON.stringify(path)},method:'GET',timeout:4000},res=>{process.exit(res.statusCode<300?0:1)});r.on('error',()=>process.exit(2));r.on('timeout',()=>process.exit(3));r.end();`;
|
|
640
|
+
const nodeScriptB64 = Buffer.from(nodeScript).toString('base64');
|
|
641
|
+
const probeCmd = `cd ${remoteDir} && (echo ${nodeScriptB64} | base64 -d | docker compose exec -T app node - && echo 2xx || echo fail) 2>&1`;
|
|
642
|
+
|
|
643
|
+
let lastStatus = 'none';
|
|
644
|
+
for (let i = 0; i < attempts; i++) {
|
|
645
|
+
if (i > 0) await new Promise((r) => setTimeout(r, delayMs));
|
|
646
|
+
try {
|
|
647
|
+
const result = await sshRunAsync(ip, sshKeyPath, probeCmd, {
|
|
648
|
+
timeout: 15_000,
|
|
649
|
+
ignoreError: true,
|
|
650
|
+
});
|
|
651
|
+
const out = (result || '').trim();
|
|
652
|
+
lastStatus = out.slice(-8) || 'none';
|
|
653
|
+
if (out.endsWith('2xx')) {
|
|
654
|
+
// App is serving, but Supabase services may still be warming up
|
|
655
|
+
// their healthchecks. The e2e harness's verify-deploy probes
|
|
656
|
+
// auth/storage/rest endpoints immediately after deploy returns —
|
|
657
|
+
// a 502 from storage that takes 60s to become healthy would fail
|
|
658
|
+
// otherwise-valid deploys. Wait for all compose-managed services
|
|
659
|
+
// with healthchecks to reach "healthy". Bounded: 3 min budget
|
|
660
|
+
// (wasn't spent during the app probe since that succeeded quickly).
|
|
661
|
+
await waitForServicesHealthy(ip, sshKeyPath, projectName);
|
|
662
|
+
return { healthy: true, status: '200', details: '' };
|
|
663
|
+
}
|
|
664
|
+
} catch (err) {
|
|
665
|
+
lastStatus = err.message?.slice(0, 80) || 'ssh-error';
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Unhealthy — collect diagnostics.
|
|
670
|
+
let ps = '';
|
|
671
|
+
let logs = '';
|
|
672
|
+
try {
|
|
673
|
+
ps =
|
|
674
|
+
(await sshRunAsync(
|
|
675
|
+
ip,
|
|
676
|
+
sshKeyPath,
|
|
677
|
+
`cd ${remoteDir} && docker compose ps --format '{{.Name}}\\t{{.State}}\\t{{.Status}}' 2>&1 | head -20`,
|
|
678
|
+
{ timeout: 15_000, ignoreError: true },
|
|
679
|
+
)) || '';
|
|
680
|
+
} catch {
|
|
681
|
+
/* best effort */
|
|
682
|
+
}
|
|
683
|
+
try {
|
|
684
|
+
logs =
|
|
685
|
+
(await sshRunAsync(
|
|
686
|
+
ip,
|
|
687
|
+
sshKeyPath,
|
|
688
|
+
`cd ${remoteDir} && docker compose logs --tail=40 app 2>&1 | tail -40`,
|
|
689
|
+
{ timeout: 15_000, ignoreError: true },
|
|
690
|
+
)) || '';
|
|
691
|
+
} catch {
|
|
692
|
+
/* best effort */
|
|
693
|
+
}
|
|
694
|
+
const details =
|
|
695
|
+
`Health probe failed after ${attempts} attempts (last status: ${lastStatus})\n` +
|
|
696
|
+
`--- docker compose ps ---\n${ps.trim()}\n` +
|
|
697
|
+
`--- app container log tail ---\n${logs.trim()}`;
|
|
698
|
+
return { healthy: false, status: lastStatus, details };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Stop the Docker Compose stack on the remote server
|
|
703
|
+
*/
|
|
704
|
+
export async function stopComposeStack(ip, sshKeyPath, projectName, options = {}) {
|
|
705
|
+
const remoteDir = `/opt/${projectName}`;
|
|
706
|
+
const flags = composeFileFlags(options);
|
|
707
|
+
const composeCmd = `cd ${remoteDir} && docker compose ${flags} down --timeout 30`;
|
|
708
|
+
await sshRunAsync(ip, sshKeyPath, composeCmd, { timeout: 120_000 });
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Pre-pull container images on the remote server.
|
|
713
|
+
* Run this in parallel with the local Docker build to overlap I/O.
|
|
714
|
+
*/
|
|
715
|
+
export async function pullComposeImages(ip, sshKeyPath, projectName, options = {}) {
|
|
716
|
+
const remoteDir = `/opt/${projectName}`;
|
|
717
|
+
const flags = composeFileFlags(options);
|
|
718
|
+
// `docker compose pull` over SSH — first deploys pull ~2GB of Supabase
|
|
719
|
+
// images and Hetzner ingress can spike to several minutes. Warm pulls
|
|
720
|
+
// (everything cached) finish in <5s.
|
|
721
|
+
await perfAsync('deploy.compose.imagesPull', () =>
|
|
722
|
+
sshRunAsync(
|
|
723
|
+
ip,
|
|
724
|
+
sshKeyPath,
|
|
725
|
+
`cd ${remoteDir} && (docker compose ${flags} pull --policy missing --ignore-buildable || docker compose ${flags} pull --ignore-buildable || docker compose ${flags} pull 2>&1 || true)`,
|
|
726
|
+
{ timeout: 600_000, ignoreError: true },
|
|
727
|
+
),
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Run database migrations on the remote server
|
|
733
|
+
*/
|
|
734
|
+
export async function runMigrations(ip, sshKeyPath, projectName) {
|
|
735
|
+
const remoteDir = `/opt/${projectName}`;
|
|
736
|
+
|
|
737
|
+
// Wait for PostgreSQL to be ready, then run every migration — all in one
|
|
738
|
+
// SSH round-trip. A `for f in *.sql; do ...; done` on the remote shell
|
|
739
|
+
// executes the migrations sequentially without N+1 SSH handshakes (one
|
|
740
|
+
// ls, one psql per file = N+1 trips before). `|| true` per-file keeps a
|
|
741
|
+
// bad migration from halting the rest (matches prior behaviour).
|
|
742
|
+
await sshRunAsync(
|
|
743
|
+
ip,
|
|
744
|
+
sshKeyPath,
|
|
745
|
+
`cd ${remoteDir} && docker compose exec -T db pg_isready -U postgres && ` +
|
|
746
|
+
`for f in $(ls supabase/migrations/ 2>/dev/null | sort); do ` +
|
|
747
|
+
`cat "supabase/migrations/$f" | docker compose exec -T db psql -U supabase_admin -d postgres 2>/dev/null || true; ` +
|
|
748
|
+
`done`,
|
|
749
|
+
{ timeout: 300_000 },
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Main Compose deployment orchestrator
|
|
755
|
+
*
|
|
756
|
+
* @param {object} options
|
|
757
|
+
* @param {string} options.projectName - Project name
|
|
758
|
+
* @param {string} options.environment - Environment name (prod, staging, etc.)
|
|
759
|
+
* @param {string} options.ip - Server IP address
|
|
760
|
+
* @param {string} options.sshKeyPath - Path to SSH private key
|
|
761
|
+
* @param {string} [options.domain] - Custom domain
|
|
762
|
+
* @param {boolean} [options.isNewServer] - Whether server was just provisioned
|
|
763
|
+
* @param {object} [options.services] - Enabled services (observability, n8n, etc.)
|
|
764
|
+
* @param {Function} [options.onProgress] - Progress callback
|
|
765
|
+
* @returns {object} Deployment result
|
|
766
|
+
*/
|
|
767
|
+
export async function deployCompose(options) {
|
|
768
|
+
const {
|
|
769
|
+
projectName,
|
|
770
|
+
ip,
|
|
771
|
+
sshKeyPath,
|
|
772
|
+
domain,
|
|
773
|
+
isNewServer = false,
|
|
774
|
+
services = {},
|
|
775
|
+
imageRef,
|
|
776
|
+
onProgress = () => {},
|
|
777
|
+
} = options;
|
|
778
|
+
|
|
779
|
+
if (!imageRef) {
|
|
780
|
+
throw new Error('deployCompose requires options.imageRef (ghcr.io/<owner>/<repo>:<tag>)');
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Step 1: Install Docker on new servers
|
|
784
|
+
if (isNewServer) {
|
|
785
|
+
onProgress('Configuring server...');
|
|
786
|
+
await setupServer(ip, sshKeyPath);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const serviceOpts = {
|
|
790
|
+
observability: services.observability,
|
|
791
|
+
n8n: services.n8n,
|
|
792
|
+
metabase: services.metabase,
|
|
793
|
+
redis: services.redis,
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
// Step 2: Copy project files + point APP_IMAGE at the CI-built ghcr.io tag
|
|
797
|
+
onProgress('Copying project files to server...');
|
|
798
|
+
await setupServerFiles(ip, sshKeyPath, projectName, {
|
|
799
|
+
domain,
|
|
800
|
+
image: imageRef,
|
|
801
|
+
...serviceOpts,
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
// Step 3: Pull all images (app + Supabase) — docker compose pull handles it.
|
|
805
|
+
onProgress('Pulling images...');
|
|
806
|
+
await pullComposeImages(ip, sshKeyPath, projectName, serviceOpts);
|
|
807
|
+
|
|
808
|
+
// Step 4: Start Docker Compose stack
|
|
809
|
+
onProgress('Starting services...');
|
|
810
|
+
await startComposeStack(ip, sshKeyPath, projectName, serviceOpts);
|
|
811
|
+
|
|
812
|
+
// Step 5: Run migrations
|
|
813
|
+
onProgress('Running database migrations...');
|
|
814
|
+
const remoteDir = `/opt/${projectName}`;
|
|
815
|
+
try {
|
|
816
|
+
await runMigrations(ip, sshKeyPath, projectName);
|
|
817
|
+
// Reload PostgREST schema cache so it sees newly created tables
|
|
818
|
+
await sshRunAsync(
|
|
819
|
+
ip,
|
|
820
|
+
sshKeyPath,
|
|
821
|
+
`cd ${remoteDir} && docker compose exec -T rest kill -s SIGUSR1 1 2>/dev/null || true`,
|
|
822
|
+
);
|
|
823
|
+
} catch {
|
|
824
|
+
// Migrations may partially fail on first run, non-fatal
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return {
|
|
828
|
+
success: true,
|
|
829
|
+
ip,
|
|
830
|
+
domain,
|
|
831
|
+
deployMode: 'compose',
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Fast re-deploy: sync new config and reconcile.
|
|
837
|
+
*/
|
|
838
|
+
export async function redeployCompose(options) {
|
|
839
|
+
const { projectName, ip, sshKeyPath, services = {}, imageRef, onProgress = () => {} } = options;
|
|
840
|
+
|
|
841
|
+
if (!imageRef) {
|
|
842
|
+
throw new Error('redeployCompose requires options.imageRef');
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Step 1: Sync new files (including .env with new image tag)
|
|
846
|
+
onProgress('Syncing configuration...');
|
|
847
|
+
await setupServerFiles(ip, sshKeyPath, projectName, {
|
|
848
|
+
image: imageRef,
|
|
849
|
+
observability: services.observability,
|
|
850
|
+
n8n: services.n8n,
|
|
851
|
+
metabase: services.metabase,
|
|
852
|
+
redis: services.redis,
|
|
853
|
+
domain: options.domain,
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
// Step 2: Reconcile (pulls images if needed and restarts)
|
|
857
|
+
onProgress('Reconciling services...');
|
|
858
|
+
await startComposeStack(ip, sshKeyPath, projectName, {
|
|
859
|
+
observability: services.observability,
|
|
860
|
+
n8n: services.n8n,
|
|
861
|
+
metabase: services.metabase,
|
|
862
|
+
redis: services.redis,
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
return { success: true };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Get status of Docker Compose services on the remote server
|
|
870
|
+
*/
|
|
871
|
+
export function getComposeStatus(ip, sshKeyPath, projectName) {
|
|
872
|
+
const remoteDir = `/opt/${projectName}`;
|
|
873
|
+
|
|
874
|
+
try {
|
|
875
|
+
const output = sshRun(ip, sshKeyPath, `cd ${remoteDir} && docker compose ps --format json`, {
|
|
876
|
+
timeout: 15_000,
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
if (!output?.trim()) {
|
|
880
|
+
return { running: false, services: [] };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// docker compose ps --format json outputs one JSON object per line
|
|
884
|
+
const services = output
|
|
885
|
+
.trim()
|
|
886
|
+
.split('\n')
|
|
887
|
+
.filter(Boolean)
|
|
888
|
+
.map((line) => {
|
|
889
|
+
try {
|
|
890
|
+
return JSON.parse(line);
|
|
891
|
+
} catch {
|
|
892
|
+
return null;
|
|
893
|
+
}
|
|
894
|
+
})
|
|
895
|
+
.filter(Boolean);
|
|
896
|
+
|
|
897
|
+
return {
|
|
898
|
+
running: services.some((s) => s.State === 'running'),
|
|
899
|
+
services,
|
|
900
|
+
};
|
|
901
|
+
} catch {
|
|
902
|
+
return { running: false, services: [] };
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* Destroy a Docker Compose deployment
|
|
908
|
+
*/
|
|
909
|
+
export function destroyCompose(ip, sshKeyPath, projectName) {
|
|
910
|
+
const remoteDir = `/opt/${projectName}`;
|
|
911
|
+
|
|
912
|
+
// Stop and remove containers + volumes
|
|
913
|
+
sshRun(
|
|
914
|
+
ip,
|
|
915
|
+
sshKeyPath,
|
|
916
|
+
`cd ${remoteDir} && docker compose down -v --remove-orphans 2>/dev/null; rm -rf ${remoteDir}`,
|
|
917
|
+
{
|
|
918
|
+
timeout: 120_000,
|
|
919
|
+
},
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Create a full backup via Docker Compose.
|
|
925
|
+
* Produces a .tar.gz archive containing:
|
|
926
|
+
* - postgres.sql.gz (main Supabase database, always)
|
|
927
|
+
* - n8n.sql.gz (if n8n database exists)
|
|
928
|
+
* - metabase.sql.gz (if metabase database exists)
|
|
929
|
+
* - storage.tar.gz (if file-backend storage files exist)
|
|
930
|
+
*/
|
|
931
|
+
export function backupCompose(ip, sshKeyPath, projectName, options = {}) {
|
|
932
|
+
const { clean = false } = options;
|
|
933
|
+
const remoteDir = `/opt/${projectName}`;
|
|
934
|
+
const timestamp = new Date().toISOString().replace(/T/, '_').replace(/[:.]/g, '').slice(0, 15);
|
|
935
|
+
const archiveName = `${projectName}_${timestamp}_full.tar.gz`;
|
|
936
|
+
const cleanFlags = clean ? '--clean --if-exists' : '';
|
|
937
|
+
|
|
938
|
+
// Step 1: Dump postgres database
|
|
939
|
+
const t1 = perfTimer('backup.pgDump.main');
|
|
940
|
+
sshRun(
|
|
941
|
+
ip,
|
|
942
|
+
sshKeyPath,
|
|
943
|
+
`cd ${remoteDir} && docker compose exec -T db pg_dump -U postgres ${cleanFlags} postgres | gzip > backups/_postgres.sql.gz`,
|
|
944
|
+
{ timeout: 300_000 },
|
|
945
|
+
);
|
|
946
|
+
t1.end();
|
|
947
|
+
|
|
948
|
+
// Step 2: Dump optional service databases (n8n, metabase) if they exist
|
|
949
|
+
const t2 = perfTimer('backup.pgDump.features');
|
|
950
|
+
sshRun(
|
|
951
|
+
ip,
|
|
952
|
+
sshKeyPath,
|
|
953
|
+
`cd ${remoteDir} && for db in n8n metabase; do docker compose exec -T db psql -U postgres -tAc "SELECT 1 FROM pg_database WHERE datname='\${db}'" 2>/dev/null | grep -q 1 && docker compose exec -T db pg_dump -U postgres "\${db}" | gzip > "backups/_\${db}.sql.gz" || true; done`,
|
|
954
|
+
{ timeout: 300_000 },
|
|
955
|
+
);
|
|
956
|
+
t2.end();
|
|
957
|
+
|
|
958
|
+
// Step 3: Backup storage files if using file backend
|
|
959
|
+
const t3 = perfTimer('backup.storageFiles');
|
|
960
|
+
sshRun(
|
|
961
|
+
ip,
|
|
962
|
+
sshKeyPath,
|
|
963
|
+
`cd ${remoteDir} && FCOUNT=$(docker compose exec -T storage sh -c 'find /var/lib/storage -type f 2>/dev/null | wc -l' 2>/dev/null || echo 0) && [ "$FCOUNT" -gt 0 ] 2>/dev/null && docker compose exec -T storage tar czf - -C / var/lib/storage > backups/_storage.tar.gz || true`,
|
|
964
|
+
{ timeout: 300_000 },
|
|
965
|
+
);
|
|
966
|
+
t3.end();
|
|
967
|
+
|
|
968
|
+
// Step 4: Create combined archive from work files (prefixed with _)
|
|
969
|
+
const t4 = perfTimer('backup.combineArchive');
|
|
970
|
+
sshRun(
|
|
971
|
+
ip,
|
|
972
|
+
sshKeyPath,
|
|
973
|
+
`cd ${remoteDir}/backups && tar czf ${archiveName} $(ls _postgres.sql.gz _n8n.sql.gz _metabase.sql.gz _storage.tar.gz 2>/dev/null) && rm -f _postgres.sql.gz _n8n.sql.gz _metabase.sql.gz _storage.tar.gz`,
|
|
974
|
+
{ timeout: 120_000 },
|
|
975
|
+
);
|
|
976
|
+
t4.end();
|
|
977
|
+
|
|
978
|
+
return archiveName;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* Upload a compose backup to S3.
|
|
983
|
+
*
|
|
984
|
+
* Downloads the archive from the VPS to a local temp file via SCP, then uploads
|
|
985
|
+
* to S3 using the Node.js SDK. This replaces the previous approach of installing
|
|
986
|
+
* awscli on the VPS (which failed silently on many server images).
|
|
987
|
+
*/
|
|
988
|
+
export async function uploadComposeBackupToS3(
|
|
989
|
+
ip,
|
|
990
|
+
sshKeyPath,
|
|
991
|
+
projectName,
|
|
992
|
+
archiveName,
|
|
993
|
+
backupS3Config,
|
|
994
|
+
) {
|
|
995
|
+
const localTmpPath = join(tmpdir(), `vc-backup-${archiveName}`);
|
|
996
|
+
try {
|
|
997
|
+
await perfAsync('backup.scpFromServer', async () =>
|
|
998
|
+
scpDownload(ip, sshKeyPath, `/opt/${projectName}/backups/${archiveName}`, localTmpPath),
|
|
999
|
+
);
|
|
1000
|
+
await perfAsync('backup.uploadS3', async () =>
|
|
1001
|
+
uploadS3Backup(backupS3Config, localTmpPath, `backups/${archiveName}`),
|
|
1002
|
+
);
|
|
1003
|
+
} finally {
|
|
1004
|
+
try {
|
|
1005
|
+
unlinkSync(localTmpPath);
|
|
1006
|
+
} catch {
|
|
1007
|
+
// Ignore cleanup errors
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Set up automated backup cron on the VPS.
|
|
1014
|
+
*/
|
|
1015
|
+
export function setupComposeBackupCron(ip, sshKeyPath, projectName, backupS3Config, backupConfig) {
|
|
1016
|
+
const remoteDir = `/opt/${projectName}`;
|
|
1017
|
+
const schedule = backupConfig?.schedule || '0 */6 * * *';
|
|
1018
|
+
const retentionDays = backupConfig?.retentionDays || 30;
|
|
1019
|
+
|
|
1020
|
+
// Build environment file for cron
|
|
1021
|
+
const envLines = [`PROJECT_NAME=${projectName}`, `BACKUP_RETENTION_DAYS=${retentionDays}`];
|
|
1022
|
+
if (backupS3Config) {
|
|
1023
|
+
envLines.push(
|
|
1024
|
+
`S3_BACKUP_BUCKET=${backupS3Config.bucket}`,
|
|
1025
|
+
`S3_ACCESS_KEY=${backupS3Config.accessKey}`,
|
|
1026
|
+
`S3_SECRET_KEY=${backupS3Config.secretKey}`,
|
|
1027
|
+
`S3_ENDPOINT=${backupS3Config.endpoint}`,
|
|
1028
|
+
`S3_REGION=${backupS3Config.region}`,
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Install awscli for automated S3 uploads (fail loudly if it can't be installed)
|
|
1033
|
+
if (backupS3Config) {
|
|
1034
|
+
sshRun(
|
|
1035
|
+
ip,
|
|
1036
|
+
sshKeyPath,
|
|
1037
|
+
'command -v aws >/dev/null 2>&1 || (apt-get update -qq && apt-get install -y awscli || pip3 install awscli)',
|
|
1038
|
+
{ timeout: 120_000 },
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Write env file and install cron
|
|
1043
|
+
const envContent = envLines.join('\\n');
|
|
1044
|
+
sshRun(
|
|
1045
|
+
ip,
|
|
1046
|
+
sshKeyPath,
|
|
1047
|
+
[
|
|
1048
|
+
`printf '${envContent}\\n' > ${remoteDir}/backups/.backup-env`,
|
|
1049
|
+
`chmod 600 ${remoteDir}/backups/.backup-env`,
|
|
1050
|
+
`chmod +x ${remoteDir}/backup/compose-backup.sh`,
|
|
1051
|
+
`(crontab -l 2>/dev/null | grep -v 'compose-backup.sh' ; echo '${schedule} set -a && . ${remoteDir}/backups/.backup-env && set +a && ${remoteDir}/backup/compose-backup.sh >> ${remoteDir}/backups/backup.log 2>&1') | crontab -`,
|
|
1052
|
+
].join(' && '),
|
|
1053
|
+
{ timeout: 120_000 },
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Restore from a backup archive via Docker Compose.
|
|
1059
|
+
* Supports legacy .tar.gz (pg_dump) and new WAL-G point-in-time recovery.
|
|
1060
|
+
*/
|
|
1061
|
+
export async function restoreCompose(ip, sshKeyPath, projectName, backupFile, options = {}) {
|
|
1062
|
+
const remoteDir = `/opt/${projectName}`;
|
|
1063
|
+
const isWalg = backupFile === 'latest' || backupFile === 'walg';
|
|
1064
|
+
|
|
1065
|
+
// Stop the app container to prevent connections
|
|
1066
|
+
await sshRunAsync(
|
|
1067
|
+
ip,
|
|
1068
|
+
sshKeyPath,
|
|
1069
|
+
`cd ${remoteDir} && docker compose stop app 2>/dev/null || true`,
|
|
1070
|
+
);
|
|
1071
|
+
|
|
1072
|
+
if (isWalg) {
|
|
1073
|
+
// --- BLAZING FAST WAL-G RESTORE ---
|
|
1074
|
+
// 1. Stop DB
|
|
1075
|
+
await sshRunAsync(ip, sshKeyPath, `cd ${remoteDir} && docker compose stop db`);
|
|
1076
|
+
// 2. Clear data (WAL-G restore-fetch requires empty dir)
|
|
1077
|
+
await sshRunAsync(
|
|
1078
|
+
ip,
|
|
1079
|
+
sshKeyPath,
|
|
1080
|
+
`cd ${remoteDir} && docker compose run --rm -v db_data:/data alpine sh -c "rm -rf /data/*"`,
|
|
1081
|
+
);
|
|
1082
|
+
// 3. Run WAL-G restore
|
|
1083
|
+
const restoreCmd = `cd ${remoteDir} && docker compose run --rm \
|
|
1084
|
+
-e WALG_S3_PREFIX="${options.walgS3Prefix}" \
|
|
1085
|
+
-e AWS_ACCESS_KEY_ID="${options.s3AccessKey}" \
|
|
1086
|
+
-e AWS_SECRET_ACCESS_KEY="${options.s3SecretKey}" \
|
|
1087
|
+
-e AWS_ENDPOINT="${options.s3Endpoint}" \
|
|
1088
|
+
-e AWS_REGION="${options.s3Region}" \
|
|
1089
|
+
db wal-g backup-fetch /var/lib/postgresql/data LATEST`;
|
|
1090
|
+
await sshRunAsync(ip, sshKeyPath, restoreCmd, { timeout: 900_000 });
|
|
1091
|
+
// 4. Start DB
|
|
1092
|
+
await sshRunAsync(ip, sshKeyPath, `cd ${remoteDir} && docker compose start db`);
|
|
1093
|
+
} else {
|
|
1094
|
+
// --- LEGACY PG_DUMP RESTORE ---
|
|
1095
|
+
// Extract archive
|
|
1096
|
+
await sshRunAsync(
|
|
1097
|
+
ip,
|
|
1098
|
+
sshKeyPath,
|
|
1099
|
+
`mkdir -p /tmp/vb-restore && tar --no-xattrs --no-same-owner -xzf ${remoteDir}/backups/${backupFile} -C /tmp/vb-restore`,
|
|
1100
|
+
{ timeout: 120_000 },
|
|
1101
|
+
);
|
|
1102
|
+
|
|
1103
|
+
// Restore postgres database
|
|
1104
|
+
await sshRunAsync(
|
|
1105
|
+
ip,
|
|
1106
|
+
sshKeyPath,
|
|
1107
|
+
`cd ${remoteDir} && gunzip -c /tmp/vb-restore/postgres.sql.gz | docker compose exec -T db psql -U postgres -d postgres`,
|
|
1108
|
+
{ timeout: 300_000 },
|
|
1109
|
+
);
|
|
1110
|
+
|
|
1111
|
+
// Restore optional service databases if present
|
|
1112
|
+
for (const db of ['n8n', 'metabase']) {
|
|
1113
|
+
await sshRunAsync(
|
|
1114
|
+
ip,
|
|
1115
|
+
sshKeyPath,
|
|
1116
|
+
`test -f /tmp/vb-restore/${db}.sql.gz && cd ${remoteDir} && gunzip -c /tmp/vb-restore/${db}.sql.gz | docker compose exec -T db psql -U postgres -d ${db} || true`,
|
|
1117
|
+
{ timeout: 300_000 },
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// Restore storage files if present
|
|
1122
|
+
await sshRunAsync(
|
|
1123
|
+
ip,
|
|
1124
|
+
sshKeyPath,
|
|
1125
|
+
`test -f /tmp/vb-restore/storage.tar.gz && cd ${remoteDir} && docker compose exec -T storage sh -c 'tar --no-xattrs --no-same-owner -xzf - -C /' < /tmp/vb-restore/storage.tar.gz || true`,
|
|
1126
|
+
{ timeout: 300_000 },
|
|
1127
|
+
);
|
|
1128
|
+
|
|
1129
|
+
// Cleanup
|
|
1130
|
+
await sshRunAsync(ip, sshKeyPath, 'rm -rf /tmp/vb-restore');
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Restart app
|
|
1134
|
+
await sshRunAsync(ip, sshKeyPath, `cd ${remoteDir} && docker compose start app`);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Wait for an HTTPS endpoint to respond with a valid certificate.
|
|
1139
|
+
* Polls until a successful response (2xx/3xx) or timeout.
|
|
1140
|
+
*
|
|
1141
|
+
* @param {string} url - Full HTTPS URL to check
|
|
1142
|
+
* @param {object} [options]
|
|
1143
|
+
* @param {number} [options.timeout=90000] - Max wait time in ms
|
|
1144
|
+
* @param {number} [options.interval=5000] - Poll interval in ms
|
|
1145
|
+
* @returns {Promise<boolean>} true if SSL is ready, false if timed out
|
|
1146
|
+
*/
|
|
1147
|
+
export async function waitForSSL(url, { timeout = 90_000, interval = 5_000 } = {}) {
|
|
1148
|
+
const deadline = Date.now() + timeout;
|
|
1149
|
+
while (Date.now() < deadline) {
|
|
1150
|
+
try {
|
|
1151
|
+
const result = runCommand(
|
|
1152
|
+
['curl', '-sf', '--max-time', '5', '-o', '/dev/null', '-w', '%{http_code}', url],
|
|
1153
|
+
{
|
|
1154
|
+
stdio: 'pipe',
|
|
1155
|
+
encoding: 'utf-8',
|
|
1156
|
+
timeout: 10_000,
|
|
1157
|
+
},
|
|
1158
|
+
);
|
|
1159
|
+
if (result?.trim().match(/^[23]\d\d$/)) return true;
|
|
1160
|
+
} catch {
|
|
1161
|
+
// Not ready yet
|
|
1162
|
+
}
|
|
1163
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
1164
|
+
}
|
|
1165
|
+
return false;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* Create the admin user in production Supabase via the GoTrue admin API.
|
|
1170
|
+
*
|
|
1171
|
+
* The user created during `vibecarbon create` only exists in the local Docker
|
|
1172
|
+
* Compose Supabase. Production needs its own admin user. The API returns 422
|
|
1173
|
+
* if the user already exists, making this idempotent.
|
|
1174
|
+
*
|
|
1175
|
+
* @param {string} serverIp - IP of the server running Supabase
|
|
1176
|
+
* @param {string} sshKeyPath - Path to SSH private key
|
|
1177
|
+
* @param {string} projectName - Project name (for remote dir)
|
|
1178
|
+
* @returns {Promise<{success: boolean, message: string}>}
|
|
1179
|
+
*/
|
|
1180
|
+
export async function createAdminUser(serverIp, sshKeyPath, projectName) {
|
|
1181
|
+
const envPath = join(process.cwd(), '.env');
|
|
1182
|
+
if (!existsSync(envPath)) {
|
|
1183
|
+
return { success: false, message: 'No .env file found' };
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const envContent = readFileSync(envPath, 'utf-8');
|
|
1187
|
+
const adminEmail = envContent.match(/^ADMIN_EMAIL="?([^"\n]+)"?/m)?.[1];
|
|
1188
|
+
const adminPassword = envContent.match(/^ADMIN_PASSWORD="?([^"\n]+)"?/m)?.[1];
|
|
1189
|
+
const serviceRoleKey = envContent.match(/^SUPABASE_SERVICE_ROLE_KEY="?([^"\n]+)"?/m)?.[1];
|
|
1190
|
+
|
|
1191
|
+
if (!adminEmail || !adminPassword || !serviceRoleKey) {
|
|
1192
|
+
return { success: false, message: 'Admin credentials not found in .env' };
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const remoteDir = `/opt/${projectName}`;
|
|
1196
|
+
|
|
1197
|
+
// Wait for auth service to become healthy. Once the stack starts, GoTrue
|
|
1198
|
+
// typically answers /health within 15-25s. Short initial interval catches
|
|
1199
|
+
// the fast path without penalising slower boots.
|
|
1200
|
+
let authReady = false;
|
|
1201
|
+
for (let i = 0; i < 30; i++) {
|
|
1202
|
+
const health = await sshRunAsync(
|
|
1203
|
+
serverIp,
|
|
1204
|
+
sshKeyPath,
|
|
1205
|
+
`cd ${remoteDir} && docker compose exec -T auth wget -qO- http://localhost:9999/health 2>/dev/null || true`,
|
|
1206
|
+
{ timeout: 10_000, ignoreError: true },
|
|
1207
|
+
);
|
|
1208
|
+
if (health?.includes('GoTrue')) {
|
|
1209
|
+
authReady = true;
|
|
1210
|
+
break;
|
|
1211
|
+
}
|
|
1212
|
+
const interval = i < 5 ? 500 : 2000;
|
|
1213
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
if (!authReady) {
|
|
1217
|
+
return { success: false, message: 'Auth service not ready' };
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// Create admin user via GoTrue admin API through an SSH port-forward.
|
|
1221
|
+
// Uses native fetch to avoid shell escaping issues with special chars in passwords.
|
|
1222
|
+
const localPort = 19876;
|
|
1223
|
+
const pf = spawn(
|
|
1224
|
+
'ssh',
|
|
1225
|
+
[
|
|
1226
|
+
'-o',
|
|
1227
|
+
'StrictHostKeyChecking=no',
|
|
1228
|
+
'-o',
|
|
1229
|
+
'UserKnownHostsFile=/dev/null',
|
|
1230
|
+
'-o',
|
|
1231
|
+
'BatchMode=yes',
|
|
1232
|
+
'-i',
|
|
1233
|
+
sshKeyPath,
|
|
1234
|
+
'-N',
|
|
1235
|
+
'-L',
|
|
1236
|
+
`${localPort}:localhost:8000`,
|
|
1237
|
+
`root@${serverIp}`,
|
|
1238
|
+
],
|
|
1239
|
+
{ stdio: 'ignore' },
|
|
1240
|
+
);
|
|
1241
|
+
|
|
1242
|
+
try {
|
|
1243
|
+
// Wait for port-forward to be ready. ssh -L is usually listening on
|
|
1244
|
+
// localhost within 200-500ms; first few retries are at 200ms before
|
|
1245
|
+
// falling back to 1s.
|
|
1246
|
+
let connected = false;
|
|
1247
|
+
for (let i = 0; i < 15; i++) {
|
|
1248
|
+
try {
|
|
1249
|
+
await fetch(`http://localhost:${localPort}/auth/v1/health`, {
|
|
1250
|
+
signal: AbortSignal.timeout(2000),
|
|
1251
|
+
});
|
|
1252
|
+
connected = true;
|
|
1253
|
+
break;
|
|
1254
|
+
} catch {
|
|
1255
|
+
const interval = i < 5 ? 200 : 1000;
|
|
1256
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
if (!connected) {
|
|
1261
|
+
return { success: false, message: 'Could not reach auth service via SSH tunnel' };
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const response = await fetch(`http://localhost:${localPort}/auth/v1/admin/users`, {
|
|
1265
|
+
method: 'POST',
|
|
1266
|
+
headers: {
|
|
1267
|
+
Authorization: `Bearer ${serviceRoleKey}`,
|
|
1268
|
+
apikey: serviceRoleKey,
|
|
1269
|
+
'Content-Type': 'application/json',
|
|
1270
|
+
},
|
|
1271
|
+
body: JSON.stringify({
|
|
1272
|
+
email: adminEmail,
|
|
1273
|
+
password: adminPassword,
|
|
1274
|
+
email_confirm: true,
|
|
1275
|
+
app_metadata: { role: 'super_admin' },
|
|
1276
|
+
}),
|
|
1277
|
+
signal: AbortSignal.timeout(15000),
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
if (response.ok) {
|
|
1281
|
+
return { success: true, message: `Admin user created: ${adminEmail}` };
|
|
1282
|
+
}
|
|
1283
|
+
if (response.status === 422) {
|
|
1284
|
+
return { success: true, message: `Admin user already exists: ${adminEmail}` };
|
|
1285
|
+
}
|
|
1286
|
+
const body = await response.text().catch(() => '');
|
|
1287
|
+
return {
|
|
1288
|
+
success: false,
|
|
1289
|
+
message: `Auth returned ${response.status}${body ? `: ${body}` : ''}`,
|
|
1290
|
+
};
|
|
1291
|
+
} finally {
|
|
1292
|
+
pf.kill();
|
|
1293
|
+
}
|
|
1294
|
+
}
|