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
package/src/destroy.js
ADDED
|
@@ -0,0 +1,2033 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vibecarbon Destroy Command
|
|
3
|
+
* Safely tears down cloud resources created by deploy command
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* vibecarbon destroy # Interactive destruction
|
|
7
|
+
* vibecarbon destroy --env prod # Destroy specific environment
|
|
8
|
+
* vibecarbon destroy --env prod --yes # Skip confirmation
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, rmSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import * as p from '@clack/prompts';
|
|
14
|
+
import { runCommand } from './lib/command.js';
|
|
15
|
+
|
|
16
|
+
// Handle Ctrl+C — without this, SIGINT is swallowed during subprocess/fetch
|
|
17
|
+
// calls and the process continues to the next cleanup step instead of exiting.
|
|
18
|
+
process.on('SIGINT', () => {
|
|
19
|
+
p.cancel('Operation cancelled.');
|
|
20
|
+
process.exit(130);
|
|
21
|
+
});
|
|
22
|
+
process.on('SIGTERM', () => {
|
|
23
|
+
process.exit(143);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
import { renderHelp } from './lib/cli/help.js';
|
|
27
|
+
import { parseFlags } from './lib/cli/parse-flags.js';
|
|
28
|
+
import { requireTTYOrFlags } from './lib/cli/tty-guard.js';
|
|
29
|
+
import {
|
|
30
|
+
deleteDNSRecord as cloudflareDeleteDNSRecord,
|
|
31
|
+
deleteHealthCheck as cloudflareDeleteHealthCheck,
|
|
32
|
+
} from './lib/cloudflare.js';
|
|
33
|
+
import { c, printBanner } from './lib/colors.js';
|
|
34
|
+
import { loadCredentials, loadProjectConfig, saveProjectConfig } from './lib/config.js';
|
|
35
|
+
import { fetchWithRetry } from './lib/fetch-retry.js';
|
|
36
|
+
import {
|
|
37
|
+
deleteDNSRecord as hetznerDnsDeleteRecord,
|
|
38
|
+
getZone as hetznerDnsGetZone,
|
|
39
|
+
} from './lib/hetzner-dns.js';
|
|
40
|
+
import { getApiToken, getS3Credentials } from './lib/hetzner-guided-setup.js';
|
|
41
|
+
import { perfAsync } from './lib/perf.js';
|
|
42
|
+
import { assertInProjectDir } from './lib/project-guard.js';
|
|
43
|
+
import { HetznerS3Provider, sanitizeBucketName } from './lib/providers/hetzner-s3.js';
|
|
44
|
+
import { createTracker } from './lib/tracker.js';
|
|
45
|
+
import { VERSION } from './lib/version.js';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns true for environment names that require a type-to-confirm prompt
|
|
49
|
+
* even when --yes is passed. Currently: prod and production (case-insensitive).
|
|
50
|
+
* Exported for testing.
|
|
51
|
+
*/
|
|
52
|
+
export function requiresProdTypeToConfirm(envName) {
|
|
53
|
+
if (!envName) return false;
|
|
54
|
+
return /^(prod|production)$/i.test(envName);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ============================================================================
|
|
58
|
+
// CLI ARGUMENT PARSING
|
|
59
|
+
// ============================================================================
|
|
60
|
+
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// COMMAND SPEC — single source of truth for argv parsing AND help output.
|
|
63
|
+
// ============================================================================
|
|
64
|
+
|
|
65
|
+
/** @type {import('./lib/cli/parse-flags.js').CommandSpec & { summary?: string, description?: string, examples?: Array<{ command: string, description?: string }> }} */
|
|
66
|
+
const SPEC = {
|
|
67
|
+
name: 'destroy',
|
|
68
|
+
summary: 'Tear down a cloud environment',
|
|
69
|
+
description: [
|
|
70
|
+
'WARNING: this is irreversible. The following are deleted:',
|
|
71
|
+
' • Hetzner servers and all data on them',
|
|
72
|
+
' • Hetzner volumes (orphaned PVCs from Kubernetes)',
|
|
73
|
+
' • Hetzner firewalls',
|
|
74
|
+
' • Hetzner SSH keys (deployment keys only)',
|
|
75
|
+
' • Cloudflare DNS records and health checks',
|
|
76
|
+
' • GitHub environment secrets',
|
|
77
|
+
].join('\n'),
|
|
78
|
+
positional: [
|
|
79
|
+
{
|
|
80
|
+
name: 'env',
|
|
81
|
+
optional: true,
|
|
82
|
+
description: 'Environment to destroy (skips the env prompt)',
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
flags: [
|
|
86
|
+
{ name: 'h', boolean: true, description: 'Show this help' },
|
|
87
|
+
{ name: 'v', boolean: true, description: 'Show version' },
|
|
88
|
+
{ name: 'y', boolean: true, description: 'Skip confirmation prompts' },
|
|
89
|
+
{ name: 'env', value: '<name>', description: 'Environment seed (alternative to positional)' },
|
|
90
|
+
{
|
|
91
|
+
name: 'orphans',
|
|
92
|
+
boolean: true,
|
|
93
|
+
description: 'Destroy stray Pulumi stacks not tracked in config',
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'purge',
|
|
97
|
+
boolean: true,
|
|
98
|
+
description: 'Also delete the backup S3 bucket (default: preserved)',
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
examples: [
|
|
102
|
+
{ command: 'vibecarbon destroy', description: 'prompts for env' },
|
|
103
|
+
{ command: 'vibecarbon destroy prod', description: 'env seeded' },
|
|
104
|
+
{
|
|
105
|
+
command: 'vibecarbon destroy prod -y',
|
|
106
|
+
description: 'destroy without confirmation (dangerous!)',
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
command: 'vibecarbon destroy prod -y -purge',
|
|
110
|
+
description: 'also delete the backup bucket',
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// ============================================================================
|
|
116
|
+
// ORPHAN PULUMI STACK DETECTION
|
|
117
|
+
// ============================================================================
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Find Pulumi stacks in the configured backend that aren't tracked in
|
|
121
|
+
* .vibecarbon.json. Pulumi state is authoritative — if a stack exists
|
|
122
|
+
* there but the env isn't in our project config (or its HA sibling), it's
|
|
123
|
+
* an orphan left behind by an interrupted deploy.
|
|
124
|
+
*/
|
|
125
|
+
async function findOrphanPulumiStacks(projectConfig) {
|
|
126
|
+
const { listStacks } = await import('./lib/iac/index.js');
|
|
127
|
+
const stackNames = await listStacks({ s3Config: projectConfig?.s3Config });
|
|
128
|
+
const trackedEnvs = new Set(
|
|
129
|
+
Object.keys(projectConfig?.environments ?? {}).flatMap((env) => [
|
|
130
|
+
env,
|
|
131
|
+
`${env}-primary`,
|
|
132
|
+
`${env}-standby`,
|
|
133
|
+
]),
|
|
134
|
+
);
|
|
135
|
+
return stackNames
|
|
136
|
+
.filter((name) => !trackedEnvs.has(name))
|
|
137
|
+
.map((name) => ({ name, path: `pulumi:${name}`, stateFile: `pulumi:${name}` }));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Remove local SSH keys, known_hosts, kubeconfigs, and deploy-state for the
|
|
142
|
+
* given environment. Idempotent — files that don't exist are skipped.
|
|
143
|
+
*
|
|
144
|
+
* Why: the deploy-state file marks `s3-setup` (and other steps) as completed,
|
|
145
|
+
* keyed by an inputs hash. After `destroy` deletes the project's S3 bucket,
|
|
146
|
+
* a subsequent `vibecarbon deploy` (e.g. e2e restore step's re-deploy)
|
|
147
|
+
* computes the same hash, shouldSkip returns true, and Pulumi tries to use
|
|
148
|
+
* the just-deleted bucket — failing with NoSuchBucket on `pulumi stack
|
|
149
|
+
* select`. The deploy-state file is on the cleanup list at the bottom of
|
|
150
|
+
* destroy.js, but the compose and compose-ha branches each `return;` early,
|
|
151
|
+
* bypassing the cleanup. Factor it out and call from each early return —
|
|
152
|
+
* observed in compose restore re-deploys hitting this exact NoSuchBucket
|
|
153
|
+
* error.
|
|
154
|
+
*/
|
|
155
|
+
/**
|
|
156
|
+
* Release all Hetzner CSI volumes via the cluster's own finalizers
|
|
157
|
+
* before Pulumi tears the cluster down, so the underlying volumes
|
|
158
|
+
* don't leak into the post-destroy orphan sweep.
|
|
159
|
+
*
|
|
160
|
+
* Approaches tried:
|
|
161
|
+
* - `kubectl delete pvc --all --wait=true`. Failed — PVCs have a
|
|
162
|
+
* kubernetes.io/pvc-protection finalizer that blocks delete while
|
|
163
|
+
* a Pod is bound, so the wait burned the full 180s while
|
|
164
|
+
* supabase-db-0 still held its PVC. 4 orphans.
|
|
165
|
+
* - Delete Pods first (--wait=false), then PVCs. Failed for the
|
|
166
|
+
* same end result — supabase-db is a StatefulSet, so its
|
|
167
|
+
* controller recreated the deleted Pod within seconds and
|
|
168
|
+
* re-bound the PVC. Same 4 orphans on the 2026-04-28 rerun.
|
|
169
|
+
* - Current: delete the entire vibecarbon namespace. The namespace
|
|
170
|
+
* controller handles dependency ordering automatically —
|
|
171
|
+
* workload controllers (StatefulSet,
|
|
172
|
+
* Deployment) get deleted FIRST so they stop recreating Pods,
|
|
173
|
+
* then Pods drain, then PVCs lose their pvc-protection
|
|
174
|
+
* finalizers, then PVs trigger the Hetzner CSI controller
|
|
175
|
+
* (still alive in kube-system; we only touch the vibecarbon
|
|
176
|
+
* namespace) to delete the underlying volumes via the PV's
|
|
177
|
+
* reclaim policy.
|
|
178
|
+
*
|
|
179
|
+
* Best-effort: any failure (cluster unreachable, kubectl missing,
|
|
180
|
+
* already-gone resources) is logged and swallowed so the destroy
|
|
181
|
+
* continues to Pulumi destroy + sweep.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} kubeconfigPath
|
|
184
|
+
*/
|
|
185
|
+
export async function cleanupClusterPVCs(kubeconfigPath) {
|
|
186
|
+
if (!existsSync(kubeconfigPath)) return;
|
|
187
|
+
try {
|
|
188
|
+
runCommand(
|
|
189
|
+
[
|
|
190
|
+
'kubectl',
|
|
191
|
+
'--kubeconfig',
|
|
192
|
+
kubeconfigPath,
|
|
193
|
+
'delete',
|
|
194
|
+
'namespace',
|
|
195
|
+
'vibecarbon',
|
|
196
|
+
'--wait=true',
|
|
197
|
+
'--timeout=240s',
|
|
198
|
+
'--ignore-not-found=true',
|
|
199
|
+
],
|
|
200
|
+
{ silent: true, ignoreError: true, returnOutput: false },
|
|
201
|
+
);
|
|
202
|
+
} catch {
|
|
203
|
+
// best-effort: post-destroy sweep is the safety net
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function cleanupLocalEnvFiles(cwd, envName) {
|
|
208
|
+
const cleanupPaths = [
|
|
209
|
+
join(cwd, '.vibecarbon', `deploy_key_${envName}`),
|
|
210
|
+
join(cwd, '.vibecarbon', `deploy_key_${envName}.pub`),
|
|
211
|
+
join(cwd, '.vibecarbon', `ssh-${envName}`),
|
|
212
|
+
join(cwd, '.vibecarbon', `ssh-${envName}.pub`),
|
|
213
|
+
join(cwd, '.vibecarbon', `known_hosts_${envName}`),
|
|
214
|
+
join(cwd, '.vibecarbon', `known_hosts_${envName}-primary`),
|
|
215
|
+
join(cwd, '.vibecarbon', `known_hosts_${envName}-standby`),
|
|
216
|
+
join(cwd, '.vibecarbon', `kubeconfig-${envName}`),
|
|
217
|
+
join(cwd, '.vibecarbon', `kubeconfig-${envName}-primary`),
|
|
218
|
+
join(cwd, '.vibecarbon', `kubeconfig-${envName}-standby`),
|
|
219
|
+
// k8s-HA uses per-cluster StateTrackers, so the deploy-state file
|
|
220
|
+
// splits across `<env>`, `<env>-primary`, `<env>-standby`. All three
|
|
221
|
+
// must be cleaned to prevent the next deploy's shouldSkip from matching
|
|
222
|
+
// a stale hash and reusing a deleted bucket.
|
|
223
|
+
join(cwd, '.vibecarbon', `deploy-state-${envName}.json`),
|
|
224
|
+
join(cwd, '.vibecarbon', `deploy-state-${envName}-primary.json`),
|
|
225
|
+
join(cwd, '.vibecarbon', `deploy-state-${envName}-standby.json`),
|
|
226
|
+
];
|
|
227
|
+
for (const path of cleanupPaths) {
|
|
228
|
+
try {
|
|
229
|
+
if (existsSync(path)) rmSync(path);
|
|
230
|
+
} catch {
|
|
231
|
+
// best-effort
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function destroyOrphanPulumiStack(orphan, { apiToken, s3Config }) {
|
|
237
|
+
// Destroy the orphan stack via the k8s destroy helper, which runs
|
|
238
|
+
// `pulumi destroy` + `removeStack`. Compose orphans also use the same
|
|
239
|
+
// underlying stack name → this works for both.
|
|
240
|
+
//
|
|
241
|
+
// apiToken + s3Config flow in from the caller (loaded from
|
|
242
|
+
// ~/.vibecarbon/credentials.json + projectConfig). Reading
|
|
243
|
+
// HCLOUD_TOKEN only from process.env left interactive runs failing
|
|
244
|
+
// with "Missing Hetzner Cloud API token" because operators don't
|
|
245
|
+
// export the token in their shell — it lives in credentials.json,
|
|
246
|
+
// and every other code path in destroy.js loads it the same way.
|
|
247
|
+
const { destroyK8s } = await import('./lib/deploy/k8s/index.js');
|
|
248
|
+
await destroyK8s({
|
|
249
|
+
projectName: 'vibecarbon',
|
|
250
|
+
environment: orphan.name,
|
|
251
|
+
apiToken,
|
|
252
|
+
s3Config,
|
|
253
|
+
region: 'nbg1',
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ============================================================================
|
|
258
|
+
// HETZNER CLEANUP
|
|
259
|
+
// ============================================================================
|
|
260
|
+
|
|
261
|
+
async function hetznerDeleteServer(apiToken, serverId) {
|
|
262
|
+
const response = await fetchWithRetry(`https://api.hetzner.cloud/v1/servers/${serverId}`, {
|
|
263
|
+
method: 'DELETE',
|
|
264
|
+
headers: { Authorization: `Bearer ${apiToken}` },
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
if (!response.ok && response.status !== 404) {
|
|
268
|
+
const error = await response.json();
|
|
269
|
+
throw new Error(`Failed to delete server: ${JSON.stringify(error)}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Hetzner's DELETE is async — it returns 202 and the actual VM teardown
|
|
273
|
+
// continues for several seconds. If a destroy → re-deploy cycle (compose
|
|
274
|
+
// restore step) creates a fresh server with the same name before the old
|
|
275
|
+
// one is gone, Pulumi 409s with 'server name is already used
|
|
276
|
+
// (uniqueness_error)'. Poll GET /servers/{id} until it returns 404, with
|
|
277
|
+
// a 90s deadline (empirical delete time on cx23 is well under that).
|
|
278
|
+
// Observed during compose restore. The same delete-then-poll fix is
|
|
279
|
+
// applied in destroyComposeHA.
|
|
280
|
+
if (response.status !== 404) {
|
|
281
|
+
const deadline = Date.now() + 90_000;
|
|
282
|
+
while (Date.now() < deadline) {
|
|
283
|
+
try {
|
|
284
|
+
const probe = await fetch(`https://api.hetzner.cloud/v1/servers/${serverId}`, {
|
|
285
|
+
headers: { Authorization: `Bearer ${apiToken}` },
|
|
286
|
+
});
|
|
287
|
+
if (probe.status === 404) break;
|
|
288
|
+
} catch {
|
|
289
|
+
// Transient — retry next loop iteration
|
|
290
|
+
}
|
|
291
|
+
await new Promise((r) => setTimeout(r, 2_000));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return response.status !== 404;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function hetznerDeleteFirewall(apiToken, name) {
|
|
299
|
+
// Use Hetzner's `?name=<exact>` filter, not a global list-then-find.
|
|
300
|
+
// Hetzner's default `per_page=25` plus an unfiltered list silently hides
|
|
301
|
+
// the project's firewalls when 25+ leaked firewalls accumulate, so the
|
|
302
|
+
// destroy no-ops and the next deploy collides on "name is already used"
|
|
303
|
+
// (also fixed identically in destroyComposeHA).
|
|
304
|
+
const headers = { Authorization: `Bearer ${apiToken}` };
|
|
305
|
+
const lookupUrl = `https://api.hetzner.cloud/v1/firewalls?name=${encodeURIComponent(name)}`;
|
|
306
|
+
const deadlineMs = 30_000;
|
|
307
|
+
const start = Date.now();
|
|
308
|
+
let everExisted = false;
|
|
309
|
+
while (Date.now() - start < deadlineMs) {
|
|
310
|
+
const listResp = await fetchWithRetry(lookupUrl, { headers });
|
|
311
|
+
const { firewalls } = await listResp.json();
|
|
312
|
+
const fw = firewalls?.[0];
|
|
313
|
+
if (!fw) return everExisted;
|
|
314
|
+
everExisted = true;
|
|
315
|
+
if (fw.applied_to?.length > 0) {
|
|
316
|
+
const serverResources = fw.applied_to
|
|
317
|
+
.filter((a) => a.type === 'server' && a.server?.id)
|
|
318
|
+
.map((a) => ({ type: 'server', server: { id: a.server.id } }));
|
|
319
|
+
if (serverResources.length > 0) {
|
|
320
|
+
try {
|
|
321
|
+
await fetchWithRetry(
|
|
322
|
+
`https://api.hetzner.cloud/v1/firewalls/${fw.id}/actions/remove_from_resources`,
|
|
323
|
+
{
|
|
324
|
+
method: 'POST',
|
|
325
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
326
|
+
body: JSON.stringify({ remove_from: serverResources }),
|
|
327
|
+
},
|
|
328
|
+
);
|
|
329
|
+
} catch {
|
|
330
|
+
// Servers may already be gone — proceed to DELETE anyway
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const delResp = await fetchWithRetry(`https://api.hetzner.cloud/v1/firewalls/${fw.id}`, {
|
|
335
|
+
method: 'DELETE',
|
|
336
|
+
headers,
|
|
337
|
+
});
|
|
338
|
+
if (delResp.ok || delResp.status === 404) return true;
|
|
339
|
+
// 409 = still attached; back off and re-look-up to refresh applied_to.
|
|
340
|
+
await new Promise((r) => setTimeout(r, 3_000));
|
|
341
|
+
}
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function hetznerDeleteSSHKey(apiToken, name) {
|
|
346
|
+
// Use Hetzner's `?name=<exact>` filter instead of listing everything and
|
|
347
|
+
// filtering client-side. Without this, when the project (or test matrix)
|
|
348
|
+
// accumulates 50+ SSH keys, the key we want falls off page 1 of the
|
|
349
|
+
// unpaginated list and destroy silently no-ops with "not found" — exactly
|
|
350
|
+
// the same bug that hit firewalls (also fixed there). Observed in
|
|
351
|
+
// 2026-04-27 morning matrix: compose destroy reported "SSH key not found",
|
|
352
|
+
// then the next deploy hit `409 SSH key not unique` because the key was
|
|
353
|
+
// actually still in Hetzner.
|
|
354
|
+
const listResponse = await fetchWithRetry(
|
|
355
|
+
`https://api.hetzner.cloud/v1/ssh_keys?name=${encodeURIComponent(name)}`,
|
|
356
|
+
{ headers: { Authorization: `Bearer ${apiToken}` } },
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const { ssh_keys } = await listResponse.json();
|
|
360
|
+
const sshKey = ssh_keys?.[0];
|
|
361
|
+
|
|
362
|
+
if (!sshKey) return false;
|
|
363
|
+
|
|
364
|
+
const response = await fetchWithRetry(`https://api.hetzner.cloud/v1/ssh_keys/${sshKey.id}`, {
|
|
365
|
+
method: 'DELETE',
|
|
366
|
+
headers: { Authorization: `Bearer ${apiToken}` },
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
return response.ok || response.status === 404;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function hetznerListServers(apiToken) {
|
|
373
|
+
const response = await fetchWithRetry('https://api.hetzner.cloud/v1/servers?per_page=50', {
|
|
374
|
+
headers: { Authorization: `Bearer ${apiToken}` },
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
if (!response.ok) {
|
|
378
|
+
return [];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const data = await response.json();
|
|
382
|
+
return data.servers || [];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function hetznerListNetworks(apiToken) {
|
|
386
|
+
const response = await fetchWithRetry('https://api.hetzner.cloud/v1/networks', {
|
|
387
|
+
headers: { Authorization: `Bearer ${apiToken}` },
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
if (!response.ok) {
|
|
391
|
+
return [];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const data = await response.json();
|
|
395
|
+
return data.networks || [];
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function hetznerListVolumes(apiToken) {
|
|
399
|
+
const response = await fetchWithRetry('https://api.hetzner.cloud/v1/volumes', {
|
|
400
|
+
headers: { Authorization: `Bearer ${apiToken}` },
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
if (!response.ok) {
|
|
404
|
+
return [];
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const data = await response.json();
|
|
408
|
+
return data.volumes || [];
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function hetznerDeleteVolume(apiToken, volumeId) {
|
|
412
|
+
const response = await fetchWithRetry(`https://api.hetzner.cloud/v1/volumes/${volumeId}`, {
|
|
413
|
+
method: 'DELETE',
|
|
414
|
+
headers: { Authorization: `Bearer ${apiToken}` },
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
return response.ok || response.status === 404;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function hetznerListLoadBalancers(apiToken) {
|
|
421
|
+
const response = await fetchWithRetry('https://api.hetzner.cloud/v1/load_balancers', {
|
|
422
|
+
headers: { Authorization: `Bearer ${apiToken}` },
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
if (!response.ok) {
|
|
426
|
+
return [];
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const data = await response.json();
|
|
430
|
+
return data.load_balancers || [];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function hetznerDeleteLoadBalancer(apiToken, lbId) {
|
|
434
|
+
const response = await fetchWithRetry(`https://api.hetzner.cloud/v1/load_balancers/${lbId}`, {
|
|
435
|
+
method: 'DELETE',
|
|
436
|
+
headers: { Authorization: `Bearer ${apiToken}` },
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
return response.ok || response.status === 404;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Find and delete load balancers created by Hetzner CCM (Kubernetes cloud controller manager).
|
|
444
|
+
* These are provisioned automatically when a LoadBalancer Service is created in k8s,
|
|
445
|
+
* not by the Pulumi program, so they aren't tracked in stack state and need
|
|
446
|
+
* direct API cleanup.
|
|
447
|
+
*/
|
|
448
|
+
async function hetznerCleanupLoadBalancers(apiToken, projectName, envName) {
|
|
449
|
+
const clusterName = `${projectName}-${envName}`;
|
|
450
|
+
|
|
451
|
+
// Look up the cluster's private network ID so we can match LBs by network attachment.
|
|
452
|
+
// This is more reliable than label/name matching because Hetzner CCM uses a random UID
|
|
453
|
+
// as the LB name by default (not the cluster name).
|
|
454
|
+
let clusterNetworkId = null;
|
|
455
|
+
try {
|
|
456
|
+
const networks = await hetznerListNetworks(apiToken);
|
|
457
|
+
const clusterNetwork = networks.find((n) => n.name === `${clusterName}-network`);
|
|
458
|
+
clusterNetworkId = clusterNetwork?.id ?? null;
|
|
459
|
+
} catch {
|
|
460
|
+
// Non-fatal; fall back to label/name matching only
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const loadBalancers = await hetznerListLoadBalancers(apiToken);
|
|
464
|
+
const deleted = [];
|
|
465
|
+
|
|
466
|
+
for (const lb of loadBalancers) {
|
|
467
|
+
const lbName = lb.name || '';
|
|
468
|
+
const lbLabels = lb.labels || {};
|
|
469
|
+
|
|
470
|
+
// CCM-created LBs have a label like: kubernetes.io/cluster/<cluster-name> = "owned"
|
|
471
|
+
// or the name contains the cluster identifier. Always match on the full cluster name
|
|
472
|
+
// (projectName + envName) to avoid deleting LBs belonging to other environments.
|
|
473
|
+
const clusterKey = Object.keys(lbLabels).find((k) => k.startsWith('kubernetes.io/cluster/'));
|
|
474
|
+
const matchesCluster = clusterKey?.includes(clusterName);
|
|
475
|
+
const matchesName = lbName.includes(clusterName);
|
|
476
|
+
// Also match by private network attachment — the CCM attaches LBs to the cluster network.
|
|
477
|
+
const matchesNetwork =
|
|
478
|
+
clusterNetworkId !== null && lb.private_net?.some((n) => n.network_id === clusterNetworkId);
|
|
479
|
+
// Traefik service annotation uses {{PROJECT_NAME}}-lb (no envName suffix).
|
|
480
|
+
// Match both the short form and the long form to cover all conventions.
|
|
481
|
+
const matchesStandardName =
|
|
482
|
+
lbName === `${projectName}-lb` || lbName === `${projectName}-${envName}-lb`;
|
|
483
|
+
|
|
484
|
+
if (matchesCluster || matchesName || matchesStandardName || matchesNetwork) {
|
|
485
|
+
try {
|
|
486
|
+
const ok = await hetznerDeleteLoadBalancer(apiToken, lb.id);
|
|
487
|
+
if (ok) deleted.push(lbName);
|
|
488
|
+
} catch {
|
|
489
|
+
// Continue with other LBs if one fails
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return deleted;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Sweep cluster-autoscaler-spawned Hetzner servers BEFORE `pulumi destroy`.
|
|
499
|
+
*
|
|
500
|
+
* CA-spawned servers exist outside Pulumi state. If they remain attached to
|
|
501
|
+
* the cluster network when `pulumi destroy` runs, the network/firewall
|
|
502
|
+
* destroy fails with "still in use". This function removes them via the
|
|
503
|
+
* Hetzner Cloud API directly (no SSH, no Pulumi state).
|
|
504
|
+
*
|
|
505
|
+
* Match strategy (in priority order):
|
|
506
|
+
* 1. Hetzner server label `cluster-autoscaler/node: worker-pool` —
|
|
507
|
+
* attached at server-create time by CA's hcloud provider, which reads
|
|
508
|
+
* it from `HCLOUD_CLUSTER_CONFIG.nodeConfigs.worker-pool.serverLabels`
|
|
509
|
+
* (Phase 4).
|
|
510
|
+
* 2. Fallback: server in the cluster network whose labels lack BOTH
|
|
511
|
+
* `cluster-autoscaler/node` AND `role` (the old role-based heuristic).
|
|
512
|
+
* Handles the upgrade case where pre-Phase-4 CA workers were spawned
|
|
513
|
+
* without the explicit label.
|
|
514
|
+
*
|
|
515
|
+
* Static (Pulumi-managed) workers carry `cluster-autoscaler/node: static`
|
|
516
|
+
* (Phase 3) so they're explicitly excluded by both matchers.
|
|
517
|
+
*
|
|
518
|
+
* Per feedback_orphan_auto_destroy_hazard.md: this is in-scope cleanup of
|
|
519
|
+
* an EXPLICIT destroy target (the user invoked `vibecarbon destroy <env>`),
|
|
520
|
+
* NOT orphan destruction across foreign Pulumi stacks. No
|
|
521
|
+
* `-orphans` requirement.
|
|
522
|
+
*
|
|
523
|
+
* @param {string} apiToken - Hetzner Cloud API token (write scope required).
|
|
524
|
+
* @param {string} clusterName - The cluster's Pulumi-naming prefix
|
|
525
|
+
* (`<projectName>-<environment>`). Used to
|
|
526
|
+
* find the cluster's private network by name
|
|
527
|
+
* `<clusterName>-network`.
|
|
528
|
+
* @returns {Promise<{deleted: string[], volumeIds: number[]}>}
|
|
529
|
+
*/
|
|
530
|
+
async function hetznerCleanupAutoscalerWorkers(apiToken, clusterName) {
|
|
531
|
+
// Find the cluster's private network by name (matches Pulumi resource name).
|
|
532
|
+
const networks = await hetznerListNetworks(apiToken);
|
|
533
|
+
const clusterNetwork = networks.find((n) => n.name === `${clusterName}-network`);
|
|
534
|
+
if (!clusterNetwork) {
|
|
535
|
+
return { deleted: [], volumeIds: [] };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const servers = await hetznerListServers(apiToken);
|
|
539
|
+
|
|
540
|
+
// Match strategy: label-primary, in-network-without-role-label fallback.
|
|
541
|
+
// Pulumi-managed roles for compatibility with pre-Phase-3 deploys.
|
|
542
|
+
const PULUMI_ROLES = new Set(['master', 'supabase', 'worker']);
|
|
543
|
+
const caServers = servers.filter((server) => {
|
|
544
|
+
const inClusterNetwork = server.private_net?.some((n) => n.network_id === clusterNetwork.id);
|
|
545
|
+
if (!inClusterNetwork) return false;
|
|
546
|
+
|
|
547
|
+
const labels = server.labels || {};
|
|
548
|
+
// PRIMARY: explicit CA-spawned label (Phase 4).
|
|
549
|
+
if (labels['cluster-autoscaler/node'] === 'worker-pool') return true;
|
|
550
|
+
// EXCLUDE: explicit static-worker label (Phase 3).
|
|
551
|
+
if (labels['cluster-autoscaler/node'] === 'static') return false;
|
|
552
|
+
// FALLBACK (upgrade path / missing labels): in-network without a
|
|
553
|
+
// recognized role label is treated as CA-spawned.
|
|
554
|
+
const role = labels.role;
|
|
555
|
+
return !role || !PULUMI_ROLES.has(role);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
if (caServers.length === 0) {
|
|
559
|
+
return { deleted: [], volumeIds: [] };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Collect attached volume IDs before deletion so the downstream volume
|
|
563
|
+
// sweep can reap dangling CSI PVCs even after the server records are gone.
|
|
564
|
+
const volumeIds = caServers.flatMap((server) => server.volumes || []);
|
|
565
|
+
|
|
566
|
+
const deleted = [];
|
|
567
|
+
for (const server of caServers) {
|
|
568
|
+
try {
|
|
569
|
+
const ok = await hetznerDeleteServer(apiToken, server.id);
|
|
570
|
+
if (ok) deleted.push(server.name);
|
|
571
|
+
} catch {
|
|
572
|
+
// Continue with remaining servers — best-effort cleanup. The
|
|
573
|
+
// post-Pulumi orphan sweep is the safety net for stragglers.
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return { deleted, volumeIds };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Find and delete orphaned volumes created by Kubernetes CSI driver.
|
|
582
|
+
* These volumes are created via PersistentVolumeClaims and persist after server deletion.
|
|
583
|
+
*
|
|
584
|
+
* @param {string[]} knownVolumeIds - Volume IDs we know belong to this cluster.
|
|
585
|
+
* These are deleted regardless of name matching.
|
|
586
|
+
*/
|
|
587
|
+
async function hetznerCleanupOrphanedVolumes(
|
|
588
|
+
apiToken,
|
|
589
|
+
projectName,
|
|
590
|
+
envName,
|
|
591
|
+
serverNames,
|
|
592
|
+
knownVolumeIds = [],
|
|
593
|
+
clusterLocations = [],
|
|
594
|
+
) {
|
|
595
|
+
const volumes = await hetznerListVolumes(apiToken);
|
|
596
|
+
const deletedVolumes = [];
|
|
597
|
+
const knownIdSet = new Set(knownVolumeIds);
|
|
598
|
+
|
|
599
|
+
for (const volume of volumes) {
|
|
600
|
+
// Check if volume is unattached (server is null or deleted)
|
|
601
|
+
if (volume.server !== null) continue;
|
|
602
|
+
|
|
603
|
+
const volumeName = volume.name || '';
|
|
604
|
+
const volumeLabels = volume.labels || {};
|
|
605
|
+
|
|
606
|
+
// Match by cluster name, CSI cluster label, or server name in the volume name.
|
|
607
|
+
// Always use the full cluster name (projectName + envName) to avoid matching volumes
|
|
608
|
+
// belonging to other environments of the same project.
|
|
609
|
+
const clusterName = `${projectName}-${envName}`;
|
|
610
|
+
const matchesProject =
|
|
611
|
+
volumeName.includes(clusterName) ||
|
|
612
|
+
volumeLabels['kubernetes.io/cluster'] === clusterName ||
|
|
613
|
+
serverNames.some((s) => volumeName.includes(s));
|
|
614
|
+
|
|
615
|
+
// Also match any volume IDs the caller explicitly told us belong to this cluster.
|
|
616
|
+
const isKnownClusterVolume = knownIdSet.has(volume.id);
|
|
617
|
+
|
|
618
|
+
// Unattached pvc-* volumes in the cluster's datacenter(s) are CSI-created for this cluster.
|
|
619
|
+
// Hetzner CSI names volumes pvc-<uuid>, so name/label matching won't find them.
|
|
620
|
+
const isPvcInClusterLocation =
|
|
621
|
+
clusterLocations.length > 0 &&
|
|
622
|
+
clusterLocations.includes(volume.location?.name) &&
|
|
623
|
+
volumeName.startsWith('pvc-');
|
|
624
|
+
|
|
625
|
+
// CSI namespace label (populated when --extra-create-metadata is enabled on the CSI node)
|
|
626
|
+
const matchesCsiNamespace =
|
|
627
|
+
volumeLabels['kubernetes.io/created-for/pvc/namespace'] === 'vibecarbon';
|
|
628
|
+
|
|
629
|
+
if (matchesProject || isKnownClusterVolume || isPvcInClusterLocation || matchesCsiNamespace) {
|
|
630
|
+
try {
|
|
631
|
+
const deleted = await hetznerDeleteVolume(apiToken, volume.id);
|
|
632
|
+
if (deleted) {
|
|
633
|
+
deletedVolumes.push(volume.name);
|
|
634
|
+
}
|
|
635
|
+
} catch {
|
|
636
|
+
// Continue with other volumes if one fails
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return deletedVolumes;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ============================================================================
|
|
645
|
+
// GITHUB CLEANUP
|
|
646
|
+
// ============================================================================
|
|
647
|
+
|
|
648
|
+
function deleteGitHubEnvironment(envName) {
|
|
649
|
+
try {
|
|
650
|
+
const repoInfo = runCommand(
|
|
651
|
+
['gh', 'repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'],
|
|
652
|
+
{
|
|
653
|
+
encoding: 'utf-8',
|
|
654
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
655
|
+
},
|
|
656
|
+
).trim();
|
|
657
|
+
|
|
658
|
+
runCommand(['gh', 'api', `repos/${repoInfo}/environments/${envName}`, '-X', 'DELETE'], {
|
|
659
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
return true;
|
|
663
|
+
} catch {
|
|
664
|
+
return false;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ============================================================================
|
|
669
|
+
// BACKUP BUCKET CLEANUP
|
|
670
|
+
// ============================================================================
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Handle the backup S3 bucket at the end of destroy. Default behavior is to
|
|
674
|
+
* preserve the bucket for recovery (production users); --purge-backups deletes
|
|
675
|
+
* it (used by e2e tests, which require a clean tear-down).
|
|
676
|
+
*/
|
|
677
|
+
async function handleBackupBucket(envConfig, projectConfig, args, spinner) {
|
|
678
|
+
if (!envConfig.backupS3?.bucket) return;
|
|
679
|
+
const bucketName = envConfig.backupS3.bucket;
|
|
680
|
+
|
|
681
|
+
if (!args.purgeBackups) {
|
|
682
|
+
p.log.info(`Backup bucket ${c.bold(bucketName)} preserved for recovery`);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const region = envConfig.backupS3.region || envConfig.s3?.region || 'fsn1';
|
|
687
|
+
spinner.start(`Purging backup bucket: ${bucketName}`);
|
|
688
|
+
try {
|
|
689
|
+
const s3Creds = await getS3Credentials(projectConfig.projectName, { save: false });
|
|
690
|
+
if (!s3Creds) {
|
|
691
|
+
spinner.stop('Backup bucket purge skipped (no credentials available)');
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const s3Provider = new HetznerS3Provider(s3Creds.accessKey, s3Creds.secretKey, region);
|
|
695
|
+
try {
|
|
696
|
+
const result = await s3Provider.emptyAndDeleteBucket(bucketName);
|
|
697
|
+
spinner.stop(
|
|
698
|
+
result.deleted
|
|
699
|
+
? result.objectsRemoved > 0
|
|
700
|
+
? `Backup bucket purged (${result.objectsRemoved} objects removed)`
|
|
701
|
+
: 'Backup bucket purged'
|
|
702
|
+
: 'Backup bucket not found',
|
|
703
|
+
);
|
|
704
|
+
} catch (bucketError) {
|
|
705
|
+
if (bucketError.name === 'NoSuchBucket' || bucketError.$metadata?.httpStatusCode === 404) {
|
|
706
|
+
spinner.stop('Backup bucket not found (already deleted or never created)');
|
|
707
|
+
} else {
|
|
708
|
+
throw bucketError;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
} catch (error) {
|
|
712
|
+
spinner.stop(`Backup bucket purge failed: ${error.message}`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Final outro for the destroy flow. If sub-steps left state behind (e.g. an
|
|
718
|
+
* S3 bucket that couldn't be emptied), surface them with hints and set a
|
|
719
|
+
* non-zero exit code so CI/scripts notice. Without this the destroy reports
|
|
720
|
+
* `Environment "X" destroyed.` even when work was left undone — the operator
|
|
721
|
+
* can miss the in-band warning and a stranded bucket continues billing.
|
|
722
|
+
*/
|
|
723
|
+
function destroyOutro(envName, timeStr, results) {
|
|
724
|
+
const issues = results?.issues ?? [];
|
|
725
|
+
if (issues.length === 0) {
|
|
726
|
+
p.log.success(`Environment "${envName}" destroyed.`);
|
|
727
|
+
p.outro(`Done ${c.dim(`(${timeStr})`)}`);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
p.log.warn(
|
|
731
|
+
`Environment "${envName}" destroyed with ${issues.length} issue${issues.length === 1 ? '' : 's'}:`,
|
|
732
|
+
);
|
|
733
|
+
for (const issue of issues) {
|
|
734
|
+
p.log.message(` ${c.error('•')} ${issue.detail}`);
|
|
735
|
+
if (issue.hint) p.log.message(` ${c.dim(issue.hint)}`);
|
|
736
|
+
}
|
|
737
|
+
p.outro(`Done with issues ${c.dim(`(${timeStr})`)}`);
|
|
738
|
+
process.exitCode = 1;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ============================================================================
|
|
742
|
+
// MAIN DESTRUCTION FLOW
|
|
743
|
+
// ============================================================================
|
|
744
|
+
|
|
745
|
+
async function main() {
|
|
746
|
+
const { values, positional, errors } = parseFlags(process.argv.slice(2), SPEC);
|
|
747
|
+
|
|
748
|
+
if (errors.length > 0) {
|
|
749
|
+
for (const e of errors) {
|
|
750
|
+
process.stderr.write(`${c.error('✗')} ${e}\n`);
|
|
751
|
+
}
|
|
752
|
+
process.stderr.write(`Run ${c.info('vibecarbon destroy -h')} for usage.\n`);
|
|
753
|
+
process.exit(1);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (values.h) {
|
|
757
|
+
process.stdout.write(renderHelp(SPEC));
|
|
758
|
+
process.exit(0);
|
|
759
|
+
}
|
|
760
|
+
if (values.v) {
|
|
761
|
+
console.log(`destroy-vibecarbon v${VERSION}`);
|
|
762
|
+
process.exit(0);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Build the legacy `args` struct that the orchestration code below
|
|
766
|
+
// reads (env / yes / destroyOrphans / purgeBackups). Field renames
|
|
767
|
+
// happen at the boundary so the orchestration stays untouched —
|
|
768
|
+
// each downstream branch is hundreds of lines and well-tested.
|
|
769
|
+
const envSeed =
|
|
770
|
+
/** @type {string|undefined} */ (positional.env) ||
|
|
771
|
+
/** @type {string|null} */ (values.env) ||
|
|
772
|
+
null;
|
|
773
|
+
const args = {
|
|
774
|
+
env: envSeed,
|
|
775
|
+
yes: !!values.y,
|
|
776
|
+
destroyOrphans: !!values.orphans,
|
|
777
|
+
purgeBackups: !!values.purge,
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
// Check if current working directory exists
|
|
781
|
+
let cwd;
|
|
782
|
+
try {
|
|
783
|
+
cwd = process.cwd();
|
|
784
|
+
} catch {
|
|
785
|
+
console.error(`\n${c.error('Error:')} Current working directory does not exist.`);
|
|
786
|
+
console.error(
|
|
787
|
+
`This can happen if the project directory was deleted while this command was running.`,
|
|
788
|
+
);
|
|
789
|
+
console.error(`\nPlease navigate to a valid directory and try again.`);
|
|
790
|
+
process.exit(1);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Project guard runs before banner so an accidental `vibecarbon
|
|
794
|
+
// destroy` from a parent directory emits the canonical message.
|
|
795
|
+
assertInProjectDir(cwd);
|
|
796
|
+
|
|
797
|
+
console.clear();
|
|
798
|
+
printBanner();
|
|
799
|
+
p.intro(`${c.bold('vibecarbon destroy')} ${c.dim(`v${VERSION}`)}`);
|
|
800
|
+
|
|
801
|
+
const projectConfig = loadProjectConfig(cwd);
|
|
802
|
+
|
|
803
|
+
// TTY guard: if the operator seeded an env, no env prompt fires;
|
|
804
|
+
// otherwise we'd open a picker that hangs off-TTY.
|
|
805
|
+
const envCount = Object.keys(projectConfig?.environments || {}).length;
|
|
806
|
+
requireTTYOrFlags({
|
|
807
|
+
requirements: [
|
|
808
|
+
{
|
|
809
|
+
flag: 'env',
|
|
810
|
+
description: 'name an environment to destroy',
|
|
811
|
+
satisfied: !!envSeed || envCount <= 1,
|
|
812
|
+
},
|
|
813
|
+
],
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
if (!projectConfig.environments || Object.keys(projectConfig.environments).length === 0) {
|
|
817
|
+
// Check for orphan Pulumi stacks (deployment interrupted before config save)
|
|
818
|
+
const orphans = await findOrphanPulumiStacks(projectConfig);
|
|
819
|
+
|
|
820
|
+
if (orphans.length > 0) {
|
|
821
|
+
p.log.warn('No environments found in .vibecarbon.json');
|
|
822
|
+
p.log.info(
|
|
823
|
+
`Found ${orphans.length} orphan Pulumi stack(s) — likely from interrupted deployments:`,
|
|
824
|
+
);
|
|
825
|
+
for (const orphan of orphans) {
|
|
826
|
+
p.log.info(` • ${c.bold(orphan.name)}`);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Auto-destroying orphans is dangerous: when projectConfig.s3Config is
|
|
830
|
+
// null (e.g., a typo in the saved config), listStacks falls back to
|
|
831
|
+
// the local file:// backend at ~/.vibecarbon/pulumi-state/, which is
|
|
832
|
+
// GLOBAL across projects/scenarios. A scenario's --yes destroy then
|
|
833
|
+
// nukes other scenarios' stacks (observed 2026-04-26 batch run #5:
|
|
834
|
+
// compose's final-destroy destroyed compose-ha's e2-primary/e2-standby
|
|
835
|
+
// mid-deploy because compose's empty config let the orphan path fire).
|
|
836
|
+
//
|
|
837
|
+
// The auto-destroy used to honor --yes, but cross-scenario blast is
|
|
838
|
+
// worse than leaving local state around. Require an explicit flag now.
|
|
839
|
+
// For interactive use, prompt as before.
|
|
840
|
+
p.log.info('');
|
|
841
|
+
if (!args.destroyOrphans) {
|
|
842
|
+
p.log.warn('Skipping orphan cleanup. Re-run with -orphans to remove these stacks.');
|
|
843
|
+
p.outro('Done (orphans not destroyed)');
|
|
844
|
+
process.exit(0);
|
|
845
|
+
}
|
|
846
|
+
const shouldDestroy = args.yes
|
|
847
|
+
? true
|
|
848
|
+
: await p.confirm({
|
|
849
|
+
message: `Destroy these ${orphans.length} orphan stack(s)?`,
|
|
850
|
+
initialValue: false, // Default to NO for safety
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
if (p.isCancel(shouldDestroy) || !shouldDestroy) {
|
|
854
|
+
p.cancel('Operation cancelled.');
|
|
855
|
+
process.exit(0);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Load the HCLOUD token + S3 backend config the same way the
|
|
859
|
+
// main destroy path does. Pulumi's hcloud provider needs a valid
|
|
860
|
+
// token to delete resources; an empty token surfaces as
|
|
861
|
+
// "Missing Hetzner Cloud API token" mid-destroy. The s3Config
|
|
862
|
+
// must match the one findOrphanPulumiStacks used to discover
|
|
863
|
+
// these stacks, otherwise resolveBackendUrl picks a different
|
|
864
|
+
// backend and the destroy can't find them.
|
|
865
|
+
const orphanCreds = loadCredentials();
|
|
866
|
+
const orphanApiToken =
|
|
867
|
+
process.env.HETZNER_API_TOKEN ||
|
|
868
|
+
orphanCreds?.hetzner?.apiToken ||
|
|
869
|
+
orphanCreds?.hetznerApiToken ||
|
|
870
|
+
'';
|
|
871
|
+
const orphanS3Config =
|
|
872
|
+
projectConfig?.s3Config && orphanCreds?.s3
|
|
873
|
+
? {
|
|
874
|
+
...projectConfig.s3Config,
|
|
875
|
+
accessKey: orphanCreds.s3.accessKey,
|
|
876
|
+
secretKey: orphanCreds.s3.secretKey,
|
|
877
|
+
}
|
|
878
|
+
: null;
|
|
879
|
+
|
|
880
|
+
if (!orphanApiToken) {
|
|
881
|
+
p.log.error(
|
|
882
|
+
'No Hetzner API token found in HETZNER_API_TOKEN env var or ~/.vibecarbon/credentials.json — cannot destroy orphan stacks.',
|
|
883
|
+
);
|
|
884
|
+
process.exit(1);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
for (const orphan of orphans) {
|
|
888
|
+
try {
|
|
889
|
+
await destroyOrphanPulumiStack(orphan, {
|
|
890
|
+
apiToken: orphanApiToken,
|
|
891
|
+
s3Config: orphanS3Config,
|
|
892
|
+
});
|
|
893
|
+
p.log.success(`Destroyed: ${orphan.name}`);
|
|
894
|
+
} catch (error) {
|
|
895
|
+
p.log.error(`Failed to destroy ${orphan.name}: ${error.message}`);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
p.outro('Orphan cleanup complete');
|
|
900
|
+
process.exit(0);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
p.log.error('No environments found in .vibecarbon.json');
|
|
904
|
+
p.log.info('Nothing to destroy.');
|
|
905
|
+
process.exit(1);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
p.log.info(`Project: ${c.bold(projectConfig.projectName)}`);
|
|
909
|
+
|
|
910
|
+
// Select environment to destroy
|
|
911
|
+
const envNames = Object.keys(projectConfig.environments);
|
|
912
|
+
let envName = args.env;
|
|
913
|
+
|
|
914
|
+
if (!envName) {
|
|
915
|
+
if (envNames.length === 1) {
|
|
916
|
+
envName = envNames[0];
|
|
917
|
+
} else {
|
|
918
|
+
envName = await p.select({
|
|
919
|
+
message: 'Which environment do you want to destroy?',
|
|
920
|
+
options: envNames.map((name) => ({
|
|
921
|
+
value: name,
|
|
922
|
+
label: name,
|
|
923
|
+
hint: projectConfig.environments[name].servers?.map((s) => s.ip).join(', '),
|
|
924
|
+
})),
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (p.isCancel(envName)) {
|
|
930
|
+
p.cancel('Operation cancelled.');
|
|
931
|
+
process.exit(0);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const envConfig = projectConfig.environments[envName];
|
|
935
|
+
if (!envConfig) {
|
|
936
|
+
p.log.error(`Environment '${envName}' not found`);
|
|
937
|
+
p.log.info(`Available environments: ${envNames.join(', ')}`);
|
|
938
|
+
process.exit(1);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Show what will be deleted
|
|
942
|
+
const serverList =
|
|
943
|
+
envConfig.servers?.map((s) => ` ${c.error('•')} ${s.name} (${s.ip})`).join('\n') ||
|
|
944
|
+
' None';
|
|
945
|
+
|
|
946
|
+
p.note(
|
|
947
|
+
`
|
|
948
|
+
${c.danger('This will permanently delete:')}
|
|
949
|
+
|
|
950
|
+
${c.bold('Servers:')}
|
|
951
|
+
${serverList}
|
|
952
|
+
|
|
953
|
+
${c.bold('Firewalls:')}
|
|
954
|
+
${envConfig.servers?.map((s) => ` ${c.error('•')} ${s.name}-fw`).join('\n') || ' None'}
|
|
955
|
+
|
|
956
|
+
${c.bold('SSH Keys:')}
|
|
957
|
+
${c.error('•')} vibecarbon-${projectConfig.projectName}-${envName}
|
|
958
|
+
|
|
959
|
+
${
|
|
960
|
+
envConfig.dns?.provider === 'cloudflare'
|
|
961
|
+
? `${c.bold('Cloudflare Resources:')}
|
|
962
|
+
${c.error('•')} DNS record: ${envConfig.domain}
|
|
963
|
+
${c.error('•')} Health checks
|
|
964
|
+
${envConfig.ha ? `${c.error('•')} Wildcard DNS record` : ''}
|
|
965
|
+
`
|
|
966
|
+
: ''
|
|
967
|
+
}
|
|
968
|
+
${
|
|
969
|
+
envConfig.s3?.bucket
|
|
970
|
+
? `${c.bold('S3 Storage:')}
|
|
971
|
+
${c.error('•')} Bucket: ${envConfig.s3.bucket} (all objects will be deleted)
|
|
972
|
+
`
|
|
973
|
+
: ''
|
|
974
|
+
}${
|
|
975
|
+
envConfig.backupS3?.bucket
|
|
976
|
+
? `${c.bold('S3 Backups:')}
|
|
977
|
+
${args.purgeBackups ? `${c.error('•')} Bucket: ${envConfig.backupS3.bucket} (WILL BE DELETED — --purge-backups)` : `${c.success('•')} Bucket: ${envConfig.backupS3.bucket} (PRESERVED for recovery)`}
|
|
978
|
+
`
|
|
979
|
+
: ''
|
|
980
|
+
}
|
|
981
|
+
${c.bold('GitHub:')}
|
|
982
|
+
${c.error('•')} Environment: ${envName}
|
|
983
|
+
|
|
984
|
+
${c.danger('WARNING: All data on these servers will be permanently lost!')}
|
|
985
|
+
`,
|
|
986
|
+
`Destroying: ${envName}`,
|
|
987
|
+
);
|
|
988
|
+
|
|
989
|
+
const needsProdConfirm = requiresProdTypeToConfirm(envName);
|
|
990
|
+
|
|
991
|
+
// First prompt: only when not --yes.
|
|
992
|
+
if (!args.yes) {
|
|
993
|
+
const confirmed = await p.confirm({
|
|
994
|
+
message: `Are you sure you want to destroy the ${c.bold(envName)} environment?`,
|
|
995
|
+
initialValue: false,
|
|
996
|
+
});
|
|
997
|
+
if (!confirmed || p.isCancel(confirmed)) {
|
|
998
|
+
p.cancel('Operation cancelled.');
|
|
999
|
+
process.exit(0);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// Type-to-confirm: runs whenever NOT --yes, OR the env is prod/production
|
|
1004
|
+
// (even with --yes). Protects against `--env prod --yes` typos in CI/scripts.
|
|
1005
|
+
if (!args.yes || needsProdConfirm) {
|
|
1006
|
+
const confirmSlug = `${projectConfig.projectName}-${envName}`;
|
|
1007
|
+
if (args.yes && needsProdConfirm) {
|
|
1008
|
+
p.log.warn(
|
|
1009
|
+
`Destroying a production environment still requires type-to-confirm, even with --yes.`,
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
const doubleConfirm = await p.text({
|
|
1013
|
+
message: `Type "${confirmSlug}" to confirm:`,
|
|
1014
|
+
validate: (v) => (v !== confirmSlug ? `Please type "${confirmSlug}" to confirm` : undefined),
|
|
1015
|
+
});
|
|
1016
|
+
if (p.isCancel(doubleConfirm)) {
|
|
1017
|
+
p.cancel('Operation cancelled.');
|
|
1018
|
+
process.exit(0);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Offer to create a backup before destroying
|
|
1023
|
+
const serverIp = envConfig.servers?.[0]?.ip;
|
|
1024
|
+
const sshKeyPath = join(cwd, '.vibecarbon', `deploy_key_${envName}`);
|
|
1025
|
+
if (serverIp && existsSync(sshKeyPath) && !args.yes) {
|
|
1026
|
+
const offerBackup = await p.confirm({
|
|
1027
|
+
message: 'Create a backup before destroying? (recommended)',
|
|
1028
|
+
initialValue: true,
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
if (!p.isCancel(offerBackup) && offerBackup) {
|
|
1032
|
+
const backupSpinner = p.spinner();
|
|
1033
|
+
backupSpinner.start('Creating backup...');
|
|
1034
|
+
try {
|
|
1035
|
+
if (envConfig.deployMode === 'compose' || envConfig.deployMode === 'compose-ha') {
|
|
1036
|
+
const { backupCompose, uploadComposeBackupToS3 } = await import(
|
|
1037
|
+
'./lib/deploy/compose/index.js'
|
|
1038
|
+
);
|
|
1039
|
+
const backupFile = backupCompose(serverIp, sshKeyPath, projectConfig.projectName);
|
|
1040
|
+
|
|
1041
|
+
const backupS3 = envConfig.backupS3;
|
|
1042
|
+
if (backupS3) {
|
|
1043
|
+
const { getS3Credentials } = await import('./lib/hetzner-guided-setup.js');
|
|
1044
|
+
const creds = await getS3Credentials(projectConfig.projectName, { save: false });
|
|
1045
|
+
if (creds) {
|
|
1046
|
+
await uploadComposeBackupToS3(
|
|
1047
|
+
serverIp,
|
|
1048
|
+
sshKeyPath,
|
|
1049
|
+
projectConfig.projectName,
|
|
1050
|
+
backupFile,
|
|
1051
|
+
{
|
|
1052
|
+
...backupS3,
|
|
1053
|
+
accessKey: creds.accessKey,
|
|
1054
|
+
secretKey: creds.secretKey,
|
|
1055
|
+
},
|
|
1056
|
+
);
|
|
1057
|
+
backupSpinner.stop(`Backup saved to S3: ${backupS3.bucket}/backups/${backupFile}`);
|
|
1058
|
+
} else {
|
|
1059
|
+
backupSpinner.stop(`Backup created on server: ${backupFile}`);
|
|
1060
|
+
p.log.warn(
|
|
1061
|
+
'Could not upload to S3 (no credentials). Download before destroy completes:',
|
|
1062
|
+
);
|
|
1063
|
+
p.log.info(
|
|
1064
|
+
` scp root@${serverIp}:/opt/${projectConfig.projectName}/backups/${backupFile} .`,
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
} else {
|
|
1068
|
+
// No S3 — download locally
|
|
1069
|
+
const { scpDownload } = await import('./lib/ssh.js');
|
|
1070
|
+
const localPath = join(cwd, backupFile);
|
|
1071
|
+
scpDownload(
|
|
1072
|
+
serverIp,
|
|
1073
|
+
sshKeyPath,
|
|
1074
|
+
`/opt/${projectConfig.projectName}/backups/${backupFile}`,
|
|
1075
|
+
localPath,
|
|
1076
|
+
);
|
|
1077
|
+
backupSpinner.stop(`Backup saved locally: ${backupFile}`);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
} catch (backupError) {
|
|
1081
|
+
backupSpinner.stop(`Backup failed: ${backupError.message}`);
|
|
1082
|
+
p.log.warn('Continuing with destroy...');
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Get API tokens (env var → credentials file → interactive prompt)
|
|
1088
|
+
const hetznerToken = await getApiToken(projectConfig.projectName);
|
|
1089
|
+
if (!hetznerToken) {
|
|
1090
|
+
p.log.error('Hetzner API token is required');
|
|
1091
|
+
process.exit(1);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
let cloudflareToken = null;
|
|
1095
|
+
if (envConfig.dns?.provider === 'cloudflare' && envConfig.dns?.cloudflareZoneId) {
|
|
1096
|
+
// Check env var → credentials file → prompt
|
|
1097
|
+
cloudflareToken = process.env.CLOUDFLARE_API_TOKEN;
|
|
1098
|
+
|
|
1099
|
+
if (!cloudflareToken) {
|
|
1100
|
+
const creds = loadCredentials();
|
|
1101
|
+
if (creds.cloudflare?.apiToken) {
|
|
1102
|
+
cloudflareToken = creds.cloudflare.apiToken;
|
|
1103
|
+
p.log.info('✓ Using Cloudflare API token from ~/.vibecarbon/credentials.json');
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
if (!cloudflareToken) {
|
|
1108
|
+
cloudflareToken = await p.password({
|
|
1109
|
+
message: 'Enter Cloudflare API token (for DNS cleanup)',
|
|
1110
|
+
validate: (v) => (v.length < 10 ? 'API token is required' : undefined),
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
if (p.isCancel(cloudflareToken)) {
|
|
1114
|
+
p.cancel('Operation cancelled.');
|
|
1115
|
+
process.exit(0);
|
|
1116
|
+
}
|
|
1117
|
+
} else if (process.env.CLOUDFLARE_API_TOKEN) {
|
|
1118
|
+
p.log.info('✓ Using Cloudflare API token from CLOUDFLARE_API_TOKEN environment variable');
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// ========== DESTRUCTION ==========
|
|
1123
|
+
|
|
1124
|
+
const tracker = createTracker('destroy', { environment: envName });
|
|
1125
|
+
const s = tracker.spinner();
|
|
1126
|
+
const results = {
|
|
1127
|
+
servers: [],
|
|
1128
|
+
volumes: [],
|
|
1129
|
+
firewalls: [],
|
|
1130
|
+
sshKeys: [],
|
|
1131
|
+
dns: [],
|
|
1132
|
+
healthChecks: [],
|
|
1133
|
+
loadBalancers: [],
|
|
1134
|
+
s3Bucket: null,
|
|
1135
|
+
github: false,
|
|
1136
|
+
// Sub-step failures that didn't abort destroy but left state behind
|
|
1137
|
+
// (e.g., S3 bucket couldn't be emptied). The outro surfaces these so the
|
|
1138
|
+
// operator doesn't miss them, and the process exits non-zero so CI sees it.
|
|
1139
|
+
issues: [],
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
// ========== COMPOSE-HA DESTRUCTION (if deployed via Docker Compose HA) ==========
|
|
1143
|
+
if (envConfig.deployMode === 'compose-ha') {
|
|
1144
|
+
const { destroyComposeHA } = await import('./lib/deploy/compose/ha.js');
|
|
1145
|
+
|
|
1146
|
+
const s2 = tracker.spinner();
|
|
1147
|
+
s2.start('Destroying Compose HA environment...');
|
|
1148
|
+
try {
|
|
1149
|
+
await destroyComposeHA({
|
|
1150
|
+
projectName: projectConfig.projectName,
|
|
1151
|
+
environment: envName,
|
|
1152
|
+
envConfig,
|
|
1153
|
+
hetznerToken,
|
|
1154
|
+
cloudflareToken,
|
|
1155
|
+
onProgress: (msg) => s2.message(msg),
|
|
1156
|
+
});
|
|
1157
|
+
s2.stop('Compose HA environment destroyed');
|
|
1158
|
+
} catch (error) {
|
|
1159
|
+
s2.stop(`Compose HA destruction failed: ${error.message}`);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Delete S3 bucket if configured
|
|
1163
|
+
if (envConfig.s3?.bucket) {
|
|
1164
|
+
const s3BucketName = envConfig.s3.bucket;
|
|
1165
|
+
const s3Region = envConfig.s3?.region || 'fsn1';
|
|
1166
|
+
s2.start(`Deleting S3 bucket: ${s3BucketName}`);
|
|
1167
|
+
try {
|
|
1168
|
+
const s3Creds = await getS3Credentials(projectConfig.projectName, { save: false });
|
|
1169
|
+
if (s3Creds) {
|
|
1170
|
+
const s3Provider = new HetznerS3Provider(s3Creds.accessKey, s3Creds.secretKey, s3Region);
|
|
1171
|
+
try {
|
|
1172
|
+
const result = await s3Provider.emptyAndDeleteBucket(s3BucketName);
|
|
1173
|
+
s2.stop(result.deleted ? 'S3 bucket deleted' : 'S3 bucket not found');
|
|
1174
|
+
} catch (bucketError) {
|
|
1175
|
+
if (
|
|
1176
|
+
bucketError.name === 'NoSuchBucket' ||
|
|
1177
|
+
bucketError.$metadata?.httpStatusCode === 404
|
|
1178
|
+
) {
|
|
1179
|
+
s2.stop('S3 bucket not found (already deleted)');
|
|
1180
|
+
} else {
|
|
1181
|
+
s2.stop(`S3 bucket deletion failed: ${bucketError.message}`);
|
|
1182
|
+
results.issues.push({
|
|
1183
|
+
step: 'S3 bucket',
|
|
1184
|
+
detail: `${s3BucketName} (${s3Region}) not deleted: ${bucketError.message}`,
|
|
1185
|
+
hint: 'Empty manually via the Hetzner Object Storage console, then DeleteBucket. Bucket continues to incur storage charges until deleted.',
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
} else {
|
|
1190
|
+
s2.stop('S3 bucket skipped (no credentials available)');
|
|
1191
|
+
results.issues.push({
|
|
1192
|
+
step: 'S3 bucket',
|
|
1193
|
+
detail: `${s3BucketName} (${s3Region}) not deleted: no S3 credentials available`,
|
|
1194
|
+
hint: 'Run `vibecarbon destroy` again with credentials configured, or delete via the Hetzner console.',
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
} catch (error) {
|
|
1198
|
+
s2.stop(`S3 bucket deletion failed: ${error.message}`);
|
|
1199
|
+
results.issues.push({
|
|
1200
|
+
step: 'S3 bucket',
|
|
1201
|
+
detail: `${s3BucketName} not deleted: ${error.message}`,
|
|
1202
|
+
hint: 'Empty manually via the Hetzner Object Storage console, then DeleteBucket.',
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// Backup bucket: preserved by default, deleted with --purge-backups.
|
|
1208
|
+
await handleBackupBucket(envConfig, projectConfig, args, s2);
|
|
1209
|
+
|
|
1210
|
+
// Update project config to remove environment
|
|
1211
|
+
const updatedConfig = { ...projectConfig };
|
|
1212
|
+
if (updatedConfig.environments) {
|
|
1213
|
+
delete updatedConfig.environments[envName];
|
|
1214
|
+
}
|
|
1215
|
+
saveProjectConfig(updatedConfig);
|
|
1216
|
+
|
|
1217
|
+
cleanupLocalEnvFiles(cwd, envName);
|
|
1218
|
+
|
|
1219
|
+
const { formatted: timeStr } = tracker.finish();
|
|
1220
|
+
destroyOutro(envName, timeStr, results);
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// ========== COMPOSE DESTRUCTION (if deployed via Docker Compose) ==========
|
|
1225
|
+
if (envConfig.deployMode === 'compose') {
|
|
1226
|
+
const serverIp = envConfig.servers?.[0]?.ip;
|
|
1227
|
+
const sshKeyPath = join(cwd, '.vibecarbon', `deploy_key_${envName}`);
|
|
1228
|
+
|
|
1229
|
+
if (serverIp && existsSync(sshKeyPath)) {
|
|
1230
|
+
// Stop and remove Docker Compose services
|
|
1231
|
+
s.start('Stopping Docker Compose services...');
|
|
1232
|
+
try {
|
|
1233
|
+
const { destroyCompose } = await import('./lib/deploy/compose/index.js');
|
|
1234
|
+
destroyCompose(serverIp, sshKeyPath, projectConfig.projectName);
|
|
1235
|
+
s.stop('Docker Compose services removed');
|
|
1236
|
+
results.servers.push(`vps-${serverIp}`);
|
|
1237
|
+
} catch (error) {
|
|
1238
|
+
s.stop(`Failed to stop services: ${error.message}`);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// Delete Hetzner VPS if auto-provisioned (has server ID in config)
|
|
1243
|
+
const serverId = envConfig.servers?.[0]?.id;
|
|
1244
|
+
// The `name` field in envConfig.servers is a role label ("master") set by
|
|
1245
|
+
// the orchestrator, not the Hetzner-assigned hostname. Pulumi's compose
|
|
1246
|
+
// program names the server `${projectName}-${environment}`. The
|
|
1247
|
+
// orchestrator persists that under `hetznerName`; configs older than
|
|
1248
|
+
// that change only have role-style names, so derive the Pulumi name as
|
|
1249
|
+
// a deterministic fallback — looking up "master" in Hetzner never
|
|
1250
|
+
// matches and leaks the VPS.
|
|
1251
|
+
const serverHetznerName =
|
|
1252
|
+
envConfig.servers?.[0]?.hetznerName || `${projectConfig.projectName}-${envName}`;
|
|
1253
|
+
if (serverId && hetznerToken) {
|
|
1254
|
+
s.start(`Deleting VPS (${serverIp})...`);
|
|
1255
|
+
try {
|
|
1256
|
+
const deleted = await hetznerDeleteServer(hetznerToken, serverId);
|
|
1257
|
+
s.stop(deleted ? 'VPS deleted' : 'VPS not found');
|
|
1258
|
+
} catch (error) {
|
|
1259
|
+
s.stop(`Failed to delete VPS: ${error.message}`);
|
|
1260
|
+
}
|
|
1261
|
+
} else if (!serverId && hetznerToken && serverHetznerName) {
|
|
1262
|
+
// Fallback: find server by name if ID wasn't saved (older deploys
|
|
1263
|
+
// or runs where Pulumi didn't propagate serverId).
|
|
1264
|
+
s.start(`Looking up VPS by name: ${serverHetznerName}...`);
|
|
1265
|
+
try {
|
|
1266
|
+
const servers = await hetznerListServers(hetznerToken);
|
|
1267
|
+
const server = servers.find((sv) => sv.name === serverHetznerName);
|
|
1268
|
+
if (server) {
|
|
1269
|
+
const deleted = await hetznerDeleteServer(hetznerToken, server.id);
|
|
1270
|
+
s.stop(deleted ? 'VPS deleted' : 'VPS not found');
|
|
1271
|
+
} else {
|
|
1272
|
+
s.stop('VPS not found');
|
|
1273
|
+
}
|
|
1274
|
+
} catch (error) {
|
|
1275
|
+
s.stop(`Failed to delete VPS: ${error.message}`);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// Delete Hetzner firewall.
|
|
1280
|
+
// Pulumi (src/lib/iac/programs/hetzner-compose.js) names the firewall
|
|
1281
|
+
// `${projectName}-${environment}-firewall`
|
|
1282
|
+
// Earlier this used `vibecarbon-${projectName}-${envName}` which never
|
|
1283
|
+
// matched anything Pulumi created — the firewall always leaked, and
|
|
1284
|
+
// the next compose restore re-deploy hit "name is already used
|
|
1285
|
+
// (uniqueness_error)" on Pulumi up. Match the actual Pulumi naming
|
|
1286
|
+
// convention (the equivalent fix exists in destroyComposeHA).
|
|
1287
|
+
const fwName = `${projectConfig.projectName}-${envName}-firewall`;
|
|
1288
|
+
if (hetznerToken) {
|
|
1289
|
+
s.start(`Deleting firewall: ${fwName}`);
|
|
1290
|
+
try {
|
|
1291
|
+
const deleted = await hetznerDeleteFirewall(hetznerToken, fwName);
|
|
1292
|
+
if (deleted) results.firewalls.push(fwName);
|
|
1293
|
+
s.stop(deleted ? 'Firewall deleted' : 'Firewall not found');
|
|
1294
|
+
} catch (error) {
|
|
1295
|
+
s.stop(`Failed to delete firewall: ${error.message}`);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// Delete Hetzner SSH key.
|
|
1300
|
+
// Pulumi names the key
|
|
1301
|
+
// `${projectName}-${environment}-${location}-key`
|
|
1302
|
+
// — the location suffix is what kept the old `vibecarbon-` prefix from
|
|
1303
|
+
// ever matching. Same root cause as the firewall above.
|
|
1304
|
+
const composeRegion = envConfig.region || envConfig.servers?.[0]?.region;
|
|
1305
|
+
const hetznerSshKeyName = composeRegion
|
|
1306
|
+
? `${projectConfig.projectName}-${envName}-${composeRegion}-key`
|
|
1307
|
+
: null;
|
|
1308
|
+
if (hetznerToken && hetznerSshKeyName) {
|
|
1309
|
+
s.start(`Deleting SSH key: ${hetznerSshKeyName}`);
|
|
1310
|
+
try {
|
|
1311
|
+
const deleted = await hetznerDeleteSSHKey(hetznerToken, hetznerSshKeyName);
|
|
1312
|
+
if (deleted) results.sshKeys.push(hetznerSshKeyName);
|
|
1313
|
+
s.stop(deleted ? 'SSH key deleted' : 'SSH key not found');
|
|
1314
|
+
} catch (error) {
|
|
1315
|
+
s.stop(`Failed to delete SSH key: ${error.message}`);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// DNS cleanup is ownership-filtered: only records pointing at this env's
|
|
1320
|
+
// server IPs are deleted. Shared zones (e.g. parallel e2e scenarios
|
|
1321
|
+
// on the same root domain) used to lose neighbours' records here — see
|
|
1322
|
+
// the deleteDNSRecord doc for context.
|
|
1323
|
+
const ownedIps = (envConfig.servers || []).map((srv) => srv.ip).filter(Boolean);
|
|
1324
|
+
|
|
1325
|
+
// Delete Cloudflare DNS if configured
|
|
1326
|
+
if (cloudflareToken && envConfig.dns?.cloudflareZoneId && envConfig.domain) {
|
|
1327
|
+
const zoneId = envConfig.dns.cloudflareZoneId;
|
|
1328
|
+
|
|
1329
|
+
s.start(`Deleting DNS record: ${envConfig.domain}`);
|
|
1330
|
+
try {
|
|
1331
|
+
const result = await cloudflareDeleteDNSRecord(
|
|
1332
|
+
cloudflareToken,
|
|
1333
|
+
zoneId,
|
|
1334
|
+
envConfig.domain,
|
|
1335
|
+
ownedIps,
|
|
1336
|
+
);
|
|
1337
|
+
if (result.deleted > 0) results.dns.push(envConfig.domain);
|
|
1338
|
+
if (result.skipped > 0) {
|
|
1339
|
+
s.stop(
|
|
1340
|
+
`DNS record preserved (${result.skipped} unowned target(s): ${result.skippedTargets.join(', ')})`,
|
|
1341
|
+
);
|
|
1342
|
+
} else {
|
|
1343
|
+
s.stop(result.deleted > 0 ? 'DNS record deleted' : 'DNS record not found');
|
|
1344
|
+
}
|
|
1345
|
+
} catch (error) {
|
|
1346
|
+
s.stop(`DNS deletion failed: ${error.message}`);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// Delete Hetzner DNS if configured
|
|
1351
|
+
if (envConfig.dns?.provider === 'hetzner' && envConfig.dns?.hetznerZoneId && envConfig.domain) {
|
|
1352
|
+
const zoneId = envConfig.dns.hetznerZoneId;
|
|
1353
|
+
s.start(`Deleting Hetzner DNS record: ${envConfig.domain}`);
|
|
1354
|
+
try {
|
|
1355
|
+
const zone = await hetznerDnsGetZone(hetznerToken, zoneId);
|
|
1356
|
+
const [root, wildcard] = await Promise.all([
|
|
1357
|
+
hetznerDnsDeleteRecord(hetznerToken, zoneId, zone.name, envConfig.domain, ownedIps),
|
|
1358
|
+
hetznerDnsDeleteRecord(
|
|
1359
|
+
hetznerToken,
|
|
1360
|
+
zoneId,
|
|
1361
|
+
zone.name,
|
|
1362
|
+
`*.${envConfig.domain}`,
|
|
1363
|
+
ownedIps,
|
|
1364
|
+
),
|
|
1365
|
+
]);
|
|
1366
|
+
if (root.deleted || wildcard.deleted) results.dns.push(envConfig.domain);
|
|
1367
|
+
const preserved = [root, wildcard].filter((r) => r.skipped);
|
|
1368
|
+
if (preserved.length > 0) {
|
|
1369
|
+
const targets = preserved.flatMap((p) => p.skippedTargets);
|
|
1370
|
+
s.stop(`Hetzner DNS records preserved (unowned targets: ${targets.join(', ')})`);
|
|
1371
|
+
} else {
|
|
1372
|
+
s.stop('Hetzner DNS records deleted');
|
|
1373
|
+
}
|
|
1374
|
+
} catch (error) {
|
|
1375
|
+
s.stop(`Hetzner DNS cleanup failed: ${error.message}`);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// Delete S3 bucket if configured
|
|
1380
|
+
if (envConfig.s3?.bucket) {
|
|
1381
|
+
const s3BucketName = envConfig.s3.bucket;
|
|
1382
|
+
const s3Region = envConfig.s3?.region || 'fsn1';
|
|
1383
|
+
s.start(`Deleting S3 bucket: ${s3BucketName}`);
|
|
1384
|
+
try {
|
|
1385
|
+
const s3Creds = await getS3Credentials(projectConfig.projectName, { save: false });
|
|
1386
|
+
if (s3Creds) {
|
|
1387
|
+
const s3Provider = new HetznerS3Provider(s3Creds.accessKey, s3Creds.secretKey, s3Region);
|
|
1388
|
+
try {
|
|
1389
|
+
const result = await s3Provider.emptyAndDeleteBucket(s3BucketName);
|
|
1390
|
+
if (result.deleted) results.s3Bucket = s3BucketName;
|
|
1391
|
+
s.stop(result.deleted ? 'S3 bucket deleted' : 'S3 bucket not found');
|
|
1392
|
+
} catch (bucketError) {
|
|
1393
|
+
if (
|
|
1394
|
+
bucketError.name === 'NoSuchBucket' ||
|
|
1395
|
+
bucketError.$metadata?.httpStatusCode === 404
|
|
1396
|
+
) {
|
|
1397
|
+
s.stop('S3 bucket not found (already deleted or never created)');
|
|
1398
|
+
} else {
|
|
1399
|
+
throw bucketError;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
} else {
|
|
1403
|
+
s.stop('S3 bucket skipped (no credentials available)');
|
|
1404
|
+
results.issues.push({
|
|
1405
|
+
step: 'S3 bucket',
|
|
1406
|
+
detail: `${s3BucketName} (${s3Region}) not deleted: no S3 credentials available`,
|
|
1407
|
+
hint: 'Run `vibecarbon destroy` again with credentials configured, or delete via the Hetzner console.',
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
} catch (error) {
|
|
1411
|
+
s.stop(`S3 bucket deletion failed: ${error.message}`);
|
|
1412
|
+
results.issues.push({
|
|
1413
|
+
step: 'S3 bucket',
|
|
1414
|
+
detail: `${s3BucketName} (${s3Region}) not deleted: ${error.message}`,
|
|
1415
|
+
hint: 'Empty manually via the Hetzner Object Storage console, then DeleteBucket. Bucket continues to incur storage charges until deleted.',
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// Backup bucket: preserved by default, deleted with --purge-backups.
|
|
1421
|
+
await handleBackupBucket(envConfig, projectConfig, args, s);
|
|
1422
|
+
|
|
1423
|
+
// Clean up local SSH key files
|
|
1424
|
+
const sshKeyFiles = [sshKeyPath, `${sshKeyPath}.pub`];
|
|
1425
|
+
for (const f of sshKeyFiles) {
|
|
1426
|
+
if (existsSync(f)) {
|
|
1427
|
+
try {
|
|
1428
|
+
rmSync(f);
|
|
1429
|
+
} catch {
|
|
1430
|
+
// Non-fatal
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// Update project config to remove environment
|
|
1436
|
+
const updatedConfig = { ...projectConfig };
|
|
1437
|
+
if (updatedConfig.environments) {
|
|
1438
|
+
delete updatedConfig.environments[envName];
|
|
1439
|
+
}
|
|
1440
|
+
saveProjectConfig(updatedConfig);
|
|
1441
|
+
|
|
1442
|
+
cleanupLocalEnvFiles(cwd, envName);
|
|
1443
|
+
|
|
1444
|
+
const { formatted: timeStr } = tracker.finish();
|
|
1445
|
+
destroyOutro(envName, timeStr, results);
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// Identify Pulumi stacks to destroy. HA k8s uses two stacks:
|
|
1450
|
+
// {env}-primary + {env}-standby; single k8s uses just {env}.
|
|
1451
|
+
const isHAK8s = envConfig.ha?.enabled && envConfig.deployMode === 'kubernetes';
|
|
1452
|
+
const pulumiStackEnvs = isHAK8s ? [`${envName}-primary`, `${envName}-standby`] : [envName];
|
|
1453
|
+
const hasPulumiStack = envConfig.deployMode === 'kubernetes';
|
|
1454
|
+
|
|
1455
|
+
// Volume IDs collected from ALL cluster servers (master, supabase, workers).
|
|
1456
|
+
// Hetzner CSI names volumes pvc-<uuid> — not the cluster name — so we must collect IDs
|
|
1457
|
+
// before the servers are deleted rather than trying to match by name/label afterwards.
|
|
1458
|
+
const allClusterVolumeIds = [];
|
|
1459
|
+
// Datacenter location names for all servers in the cluster (e.g. "fsn1", "nbg1").
|
|
1460
|
+
// Used to find pvc-* volumes created by CSI that were detached before destroy runs.
|
|
1461
|
+
const clusterLocations = new Set();
|
|
1462
|
+
|
|
1463
|
+
// Owned IPs gate every DNS delete — only records pointing at this env's
|
|
1464
|
+
// servers are removed. See deleteDNSRecord doc in lib/cloudflare.js.
|
|
1465
|
+
const ownedIps = (envConfig.servers || []).map((srv) => srv.ip).filter(Boolean);
|
|
1466
|
+
|
|
1467
|
+
// 1. Delete Cloudflare resources first (while servers still exist for reference)
|
|
1468
|
+
if (cloudflareToken && envConfig.dns?.cloudflareZoneId) {
|
|
1469
|
+
const zoneId = envConfig.dns.cloudflareZoneId;
|
|
1470
|
+
|
|
1471
|
+
// Delete DNS records (main domain and api subdomain)
|
|
1472
|
+
if (envConfig.domain) {
|
|
1473
|
+
// Main domain
|
|
1474
|
+
s.start(`Deleting DNS record: ${envConfig.domain}`);
|
|
1475
|
+
try {
|
|
1476
|
+
const result = await cloudflareDeleteDNSRecord(
|
|
1477
|
+
cloudflareToken,
|
|
1478
|
+
zoneId,
|
|
1479
|
+
envConfig.domain,
|
|
1480
|
+
ownedIps,
|
|
1481
|
+
);
|
|
1482
|
+
if (result.deleted > 0) results.dns.push(envConfig.domain);
|
|
1483
|
+
if (result.skipped > 0) {
|
|
1484
|
+
s.stop(
|
|
1485
|
+
`DNS record preserved (${result.skipped} unowned target(s): ${result.skippedTargets.join(', ')})`,
|
|
1486
|
+
);
|
|
1487
|
+
} else {
|
|
1488
|
+
s.stop(result.deleted > 0 ? 'DNS record deleted' : 'DNS record not found');
|
|
1489
|
+
}
|
|
1490
|
+
} catch (error) {
|
|
1491
|
+
s.stop(`DNS deletion failed: ${error.message}`);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// API subdomain (for Supabase Kong gateway)
|
|
1495
|
+
const apiDomain = `api.${envConfig.domain}`;
|
|
1496
|
+
s.start(`Deleting DNS record: ${apiDomain}`);
|
|
1497
|
+
try {
|
|
1498
|
+
const result = await cloudflareDeleteDNSRecord(
|
|
1499
|
+
cloudflareToken,
|
|
1500
|
+
zoneId,
|
|
1501
|
+
apiDomain,
|
|
1502
|
+
ownedIps,
|
|
1503
|
+
);
|
|
1504
|
+
if (result.deleted > 0) results.dns.push(apiDomain);
|
|
1505
|
+
if (result.skipped > 0) {
|
|
1506
|
+
s.stop(`API DNS record preserved (unowned: ${result.skippedTargets.join(', ')})`);
|
|
1507
|
+
} else {
|
|
1508
|
+
s.stop(result.deleted > 0 ? 'API DNS record deleted' : 'API DNS record not found');
|
|
1509
|
+
}
|
|
1510
|
+
} catch (error) {
|
|
1511
|
+
s.stop(`API DNS deletion failed: ${error.message}`);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// Delete health checks
|
|
1516
|
+
for (const server of envConfig.servers || []) {
|
|
1517
|
+
const healthCheckName = `${server.name}-health`;
|
|
1518
|
+
s.start(`Deleting health check: ${healthCheckName}`);
|
|
1519
|
+
try {
|
|
1520
|
+
const deleted = await cloudflareDeleteHealthCheck(cloudflareToken, zoneId, healthCheckName);
|
|
1521
|
+
if (deleted) results.healthChecks.push(healthCheckName);
|
|
1522
|
+
s.stop(deleted ? 'Health check deleted' : 'Health check not found');
|
|
1523
|
+
} catch (error) {
|
|
1524
|
+
s.stop(`Health check deletion failed: ${error.message}`);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// Also try domain-health format
|
|
1529
|
+
if (envConfig.domain) {
|
|
1530
|
+
const healthCheckName = `${envConfig.domain}-health`;
|
|
1531
|
+
s.start(`Deleting health check: ${healthCheckName}`);
|
|
1532
|
+
try {
|
|
1533
|
+
const deleted = await cloudflareDeleteHealthCheck(cloudflareToken, zoneId, healthCheckName);
|
|
1534
|
+
if (deleted) results.healthChecks.push(healthCheckName);
|
|
1535
|
+
s.stop(deleted ? 'Health check deleted' : 'Health check not found');
|
|
1536
|
+
} catch (error) {
|
|
1537
|
+
s.stop(`Health check deletion failed: ${error.message}`);
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// 1b. Delete Hetzner DNS resources (while servers still exist for reference)
|
|
1543
|
+
if (envConfig.dns?.provider === 'hetzner' && envConfig.dns?.hetznerZoneId && envConfig.domain) {
|
|
1544
|
+
const zoneId = envConfig.dns.hetznerZoneId;
|
|
1545
|
+
s.start(`Deleting Hetzner DNS records: ${envConfig.domain}`);
|
|
1546
|
+
try {
|
|
1547
|
+
const zone = await hetznerDnsGetZone(hetznerToken, zoneId);
|
|
1548
|
+
const [root, wildcard] = await Promise.all([
|
|
1549
|
+
hetznerDnsDeleteRecord(hetznerToken, zoneId, zone.name, envConfig.domain, ownedIps),
|
|
1550
|
+
hetznerDnsDeleteRecord(hetznerToken, zoneId, zone.name, `*.${envConfig.domain}`, ownedIps),
|
|
1551
|
+
]);
|
|
1552
|
+
if (root.deleted || wildcard.deleted) results.dns.push(envConfig.domain);
|
|
1553
|
+
const preserved = [root, wildcard].filter((r) => r.skipped);
|
|
1554
|
+
if (preserved.length > 0) {
|
|
1555
|
+
const targets = preserved.flatMap((p) => p.skippedTargets);
|
|
1556
|
+
s.stop(`Hetzner DNS records preserved (unowned targets: ${targets.join(', ')})`);
|
|
1557
|
+
} else {
|
|
1558
|
+
s.stop('Hetzner DNS records deleted');
|
|
1559
|
+
}
|
|
1560
|
+
} catch (error) {
|
|
1561
|
+
s.stop(`Hetzner DNS cleanup failed: ${error.message}`);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// 2. Delete Hetzner resources
|
|
1566
|
+
if (hasPulumiStack) {
|
|
1567
|
+
// Pulumi owns the servers, firewalls, SSH keys, and networks — `pulumi
|
|
1568
|
+
// destroy` tears them all down in one pass.
|
|
1569
|
+
|
|
1570
|
+
// For HA, each sub-cluster has its own network (e.g. havibes-prod-primary-network)
|
|
1571
|
+
const clusterNames = isHAK8s
|
|
1572
|
+
? [
|
|
1573
|
+
`${projectConfig.projectName}-${envName}-primary`,
|
|
1574
|
+
`${projectConfig.projectName}-${envName}-standby`,
|
|
1575
|
+
]
|
|
1576
|
+
: [`${projectConfig.projectName}-${envName}`];
|
|
1577
|
+
|
|
1578
|
+
// Pre-scan: collect volume IDs from ALL servers in the cluster network(s) before deletion.
|
|
1579
|
+
// Hetzner CSI driver names volumes with PVC UUIDs (e.g. pvc-xxxxxxxx), not the cluster
|
|
1580
|
+
// name, so name/label matching in hetznerCleanupOrphanedVolumes is unreliable. By
|
|
1581
|
+
// collecting the IDs now we can delete them reliably after Pulumi tears the servers down.
|
|
1582
|
+
s.start('Scanning cluster servers for attached volumes');
|
|
1583
|
+
try {
|
|
1584
|
+
const networks = await hetznerListNetworks(hetznerToken);
|
|
1585
|
+
const clusterNetworks = networks.filter((n) =>
|
|
1586
|
+
clusterNames.some((cn) => n.name === `${cn}-network`),
|
|
1587
|
+
);
|
|
1588
|
+
if (clusterNetworks.length > 0) {
|
|
1589
|
+
const clusterNetworkIds = new Set(clusterNetworks.map((n) => n.id));
|
|
1590
|
+
const allServers = await hetznerListServers(hetznerToken);
|
|
1591
|
+
for (const server of allServers) {
|
|
1592
|
+
const inCluster = server.private_net?.some((n) => clusterNetworkIds.has(n.network_id));
|
|
1593
|
+
if (inCluster) {
|
|
1594
|
+
if (Array.isArray(server.volumes)) {
|
|
1595
|
+
allClusterVolumeIds.push(...server.volumes);
|
|
1596
|
+
}
|
|
1597
|
+
if (server.datacenter?.location?.name) {
|
|
1598
|
+
clusterLocations.add(server.datacenter.location.name);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
s.stop(
|
|
1604
|
+
allClusterVolumeIds.length > 0
|
|
1605
|
+
? `Found ${allClusterVolumeIds.length} volume(s) to clean up`
|
|
1606
|
+
: 'No volumes attached to cluster servers',
|
|
1607
|
+
);
|
|
1608
|
+
} catch (error) {
|
|
1609
|
+
s.stop(`Volume pre-scan failed (will fall back to label matching): ${error.message}`);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// Clean up CCM-created load balancers before Pulumi destroys the network.
|
|
1613
|
+
// The cluster network still exists at this point, so matchesNetwork is reliable.
|
|
1614
|
+
// Once the network is gone, matchesNetwork always returns false.
|
|
1615
|
+
// HA: fan out across primary + standby — clusters are isolated.
|
|
1616
|
+
if (clusterNames.length > 0) {
|
|
1617
|
+
console.log(`Cleaning up Hetzner load balancers across ${clusterNames.length} cluster(s)...`);
|
|
1618
|
+
await Promise.allSettled(
|
|
1619
|
+
clusterNames.map(async (clusterName) => {
|
|
1620
|
+
try {
|
|
1621
|
+
const subEnv = clusterName.replace(`${projectConfig.projectName}-`, '');
|
|
1622
|
+
const deletedLBs = await hetznerCleanupLoadBalancers(
|
|
1623
|
+
hetznerToken,
|
|
1624
|
+
projectConfig.projectName,
|
|
1625
|
+
subEnv,
|
|
1626
|
+
);
|
|
1627
|
+
results.loadBalancers.push(...deletedLBs);
|
|
1628
|
+
console.log(
|
|
1629
|
+
deletedLBs.length > 0
|
|
1630
|
+
? ` [${clusterName}] Deleted ${deletedLBs.length} load balancer(s)`
|
|
1631
|
+
: ` [${clusterName}] No CCM load balancers found`,
|
|
1632
|
+
);
|
|
1633
|
+
} catch (error) {
|
|
1634
|
+
console.log(` [${clusterName}] Load balancer cleanup failed: ${error.message}`);
|
|
1635
|
+
}
|
|
1636
|
+
}),
|
|
1637
|
+
);
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// Pre-Pulumi: release Hetzner CSI volumes via the cluster's own
|
|
1641
|
+
// finalizers while the CSI controller is still up. Pulumi destroy
|
|
1642
|
+
// yanks the cluster before the controller can react, so without
|
|
1643
|
+
// this step the underlying volumes survive each PVC. Iterate over
|
|
1644
|
+
// every cluster (single = 1, HA = primary + standby) using the
|
|
1645
|
+
// matching kubeconfig file. Helper is best-effort; the post-destroy
|
|
1646
|
+
// sweep below catches anything left over.
|
|
1647
|
+
// HA: release CSI volumes on primary + standby in parallel — each
|
|
1648
|
+
// cluster's controller is independent.
|
|
1649
|
+
console.log(
|
|
1650
|
+
`Releasing CSI volumes via cluster finalizers across ${pulumiStackEnvs.length} cluster(s)...`,
|
|
1651
|
+
);
|
|
1652
|
+
await Promise.allSettled(
|
|
1653
|
+
pulumiStackEnvs.map(async (stackEnv) => {
|
|
1654
|
+
const kubeconfigPath = join(cwd, '.vibecarbon', `kubeconfig-${stackEnv}`);
|
|
1655
|
+
const dirLabel = isHAK8s ? ` (${stackEnv})` : '';
|
|
1656
|
+
try {
|
|
1657
|
+
await cleanupClusterPVCs(kubeconfigPath);
|
|
1658
|
+
console.log(` CSI volume release attempted${dirLabel}`);
|
|
1659
|
+
} catch (error) {
|
|
1660
|
+
console.log(` CSI volume release failed${dirLabel}: ${error.message}`);
|
|
1661
|
+
}
|
|
1662
|
+
}),
|
|
1663
|
+
);
|
|
1664
|
+
|
|
1665
|
+
// Destroy via Pulumi. Each stack lives in the backend we configured
|
|
1666
|
+
// (Hetzner S3 by default) — `pulumi destroy` removes Hetzner resources,
|
|
1667
|
+
// then `removeStack` cleans the state record.
|
|
1668
|
+
// Build s3Config for Pulumi backend access. The orchestrator persists
|
|
1669
|
+
// envConfig.s3 = { bucket, region, endpoint } (NOT envConfig.s3Config —
|
|
1670
|
+
// that field never exists), and accessKey/secretKey live in
|
|
1671
|
+
// ~/.vibecarbon/credentials.json. Without merging both sources here,
|
|
1672
|
+
// resolveBackendUrl falls back to the local file:// backend, Pulumi
|
|
1673
|
+
// destroys an empty stack in ~1s, and Pulumi-managed FloatingIps +
|
|
1674
|
+
// Networks silently leak (observed 2026-04-26 matrix runs #4 + #5,
|
|
1675
|
+
// independent of the destroyK8s argument-passing bug that was fixed
|
|
1676
|
+
// around the same time).
|
|
1677
|
+
const credsForDestroy = loadCredentials();
|
|
1678
|
+
const destroyS3Config =
|
|
1679
|
+
envConfig.s3 && credsForDestroy.s3
|
|
1680
|
+
? {
|
|
1681
|
+
bucket: envConfig.s3.bucket,
|
|
1682
|
+
region: envConfig.s3.region,
|
|
1683
|
+
endpoint: envConfig.s3.endpoint,
|
|
1684
|
+
accessKey: credsForDestroy.s3.accessKey,
|
|
1685
|
+
secretKey: credsForDestroy.s3.secretKey,
|
|
1686
|
+
}
|
|
1687
|
+
: null;
|
|
1688
|
+
// Pre-Pulumi sweep: reap cluster-autoscaler-spawned workers BEFORE
|
|
1689
|
+
// Pulumi tries to destroy the cluster network/firewall. CA-spawned
|
|
1690
|
+
// servers exist outside Pulumi state — leaving them attached fails
|
|
1691
|
+
// Pulumi's network destroy with "still in use".
|
|
1692
|
+
// For HA, sweep both primary and standby cluster networks.
|
|
1693
|
+
// HA: sweep CA-spawned workers on primary + standby in parallel — each
|
|
1694
|
+
// cluster's CA tags its workers with a distinct cluster name.
|
|
1695
|
+
console.log(
|
|
1696
|
+
`Sweeping cluster-autoscaler workers across ${pulumiStackEnvs.length} cluster(s)...`,
|
|
1697
|
+
);
|
|
1698
|
+
await Promise.allSettled(
|
|
1699
|
+
pulumiStackEnvs.map(async (stackEnv) => {
|
|
1700
|
+
const dirLabel = isHAK8s ? ` (${stackEnv})` : '';
|
|
1701
|
+
const caClusterName = `${projectConfig.projectName}-${stackEnv}`;
|
|
1702
|
+
try {
|
|
1703
|
+
const caResult = await hetznerCleanupAutoscalerWorkers(hetznerToken, caClusterName);
|
|
1704
|
+
if (caResult.deleted.length > 0) {
|
|
1705
|
+
console.log(
|
|
1706
|
+
` Deleted ${caResult.deleted.length} CA-spawned worker(s)${dirLabel}: ${caResult.deleted.join(', ')}`,
|
|
1707
|
+
);
|
|
1708
|
+
results.servers.push(...caResult.deleted);
|
|
1709
|
+
if (caResult.volumeIds.length > 0) {
|
|
1710
|
+
allClusterVolumeIds.push(...caResult.volumeIds);
|
|
1711
|
+
}
|
|
1712
|
+
} else {
|
|
1713
|
+
console.log(` No CA-spawned workers found${dirLabel}`);
|
|
1714
|
+
}
|
|
1715
|
+
} catch (err) {
|
|
1716
|
+
console.log(` CA worker sweep failed${dirLabel}: ${err.message}`);
|
|
1717
|
+
// Pulumi destroy will fail noisily if any CA-spawned servers remain
|
|
1718
|
+
// attached, surfacing the issue to the operator.
|
|
1719
|
+
}
|
|
1720
|
+
}),
|
|
1721
|
+
);
|
|
1722
|
+
|
|
1723
|
+
const { destroyK8s: destroyK8sStack } = await import('./lib/deploy/k8s/index.js');
|
|
1724
|
+
// HA: tear down primary + standby Pulumi stacks in parallel. Each stack
|
|
1725
|
+
// owns disjoint Hetzner resources (different network, different servers)
|
|
1726
|
+
// so there's no shared state to race on. Saves ~200s on k8s-ha critical
|
|
1727
|
+
// path (Pulumi destroy is ~100s/stack and was strictly sequential).
|
|
1728
|
+
console.log(
|
|
1729
|
+
`Destroying infrastructure via Pulumi across ${pulumiStackEnvs.length} stack(s) — this may take a few minutes...`,
|
|
1730
|
+
);
|
|
1731
|
+
await Promise.allSettled(
|
|
1732
|
+
pulumiStackEnvs.map(async (stackEnv) => {
|
|
1733
|
+
const dirLabel = isHAK8s ? ` (${stackEnv})` : '';
|
|
1734
|
+
try {
|
|
1735
|
+
await perfAsync(`destroy.pulumi.${stackEnv}`, async () =>
|
|
1736
|
+
destroyK8sStack({
|
|
1737
|
+
projectName: projectConfig.projectName,
|
|
1738
|
+
environment: stackEnv,
|
|
1739
|
+
apiToken: hetznerToken,
|
|
1740
|
+
region: envConfig.region || 'nbg1',
|
|
1741
|
+
s3Config: destroyS3Config,
|
|
1742
|
+
}),
|
|
1743
|
+
);
|
|
1744
|
+
results.servers.push('(all via Pulumi)');
|
|
1745
|
+
results.firewalls.push('(all via Pulumi)');
|
|
1746
|
+
results.sshKeys.push('(all via Pulumi)');
|
|
1747
|
+
console.log(` Pulumi infrastructure destroyed${dirLabel}`);
|
|
1748
|
+
} catch (error) {
|
|
1749
|
+
console.log(` Pulumi destroy failed${dirLabel}: ${error.message}`);
|
|
1750
|
+
p.log.warn(
|
|
1751
|
+
`You may need to manually clean up resources${dirLabel} in the Hetzner console.`,
|
|
1752
|
+
);
|
|
1753
|
+
}
|
|
1754
|
+
}),
|
|
1755
|
+
);
|
|
1756
|
+
|
|
1757
|
+
// Sweep for orphaned servers not in the Pulumi stack's resource graph.
|
|
1758
|
+
// This catches servers created by a partially-failed `pulumi up` that
|
|
1759
|
+
// never made it into the persisted state.
|
|
1760
|
+
s.start('Checking for orphaned servers');
|
|
1761
|
+
try {
|
|
1762
|
+
const allServers = await hetznerListServers(hetznerToken);
|
|
1763
|
+
const orphanedServers = allServers.filter((sv) => {
|
|
1764
|
+
const labels = sv.labels || {};
|
|
1765
|
+
return (
|
|
1766
|
+
labels['managed-by'] === 'vibecarbon' &&
|
|
1767
|
+
labels.project === projectConfig.projectName &&
|
|
1768
|
+
clusterNames.some(
|
|
1769
|
+
(cn) => labels.environment === cn.replace(`${projectConfig.projectName}-`, ''),
|
|
1770
|
+
)
|
|
1771
|
+
);
|
|
1772
|
+
});
|
|
1773
|
+
if (orphanedServers.length > 0) {
|
|
1774
|
+
s.stop(`Found ${orphanedServers.length} orphaned server(s)`);
|
|
1775
|
+
for (const sv of orphanedServers) {
|
|
1776
|
+
s.start(`Deleting orphaned server: ${sv.name} (${sv.public_net?.ipv4?.ip || sv.id})`);
|
|
1777
|
+
try {
|
|
1778
|
+
await hetznerDeleteServer(hetznerToken, sv.id);
|
|
1779
|
+
results.servers.push(sv.name);
|
|
1780
|
+
s.stop(`Deleted ${sv.name}`);
|
|
1781
|
+
} catch (error) {
|
|
1782
|
+
s.stop(`Failed to delete ${sv.name}: ${error.message}`);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
} else {
|
|
1786
|
+
s.stop('No orphaned servers found');
|
|
1787
|
+
}
|
|
1788
|
+
} catch (error) {
|
|
1789
|
+
s.stop(`Orphan server check failed: ${error.message}`);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// HA k8s: delete the shared SSH key created OUTSIDE Pulumi by
|
|
1793
|
+
// src/lib/deploy/k8s/ha/index.js (named `${project}-${env}-ha-key`).
|
|
1794
|
+
// Both Pulumi stacks reference it via `existingSshKeyId`, so neither
|
|
1795
|
+
// stack's destroy removes it — without this manual cleanup it leaks
|
|
1796
|
+
// on every HA destroy and the next preflight scan blocks the matrix
|
|
1797
|
+
// (RCA from k8s-ha 2026-04-30: post-destroy sweep flagged the same
|
|
1798
|
+
// key as REGRESSION every run).
|
|
1799
|
+
if (isHAK8s && hetznerToken) {
|
|
1800
|
+
const haSshKeyName = `${projectConfig.projectName}-${envName}-ha-key`;
|
|
1801
|
+
s.start(`Deleting shared HA SSH key: ${haSshKeyName}`);
|
|
1802
|
+
try {
|
|
1803
|
+
const deleted = await hetznerDeleteSSHKey(hetznerToken, haSshKeyName);
|
|
1804
|
+
if (deleted) results.sshKeys.push(haSshKeyName);
|
|
1805
|
+
s.stop(deleted ? 'HA SSH key deleted' : 'HA SSH key not found');
|
|
1806
|
+
} catch (error) {
|
|
1807
|
+
s.stop(`Failed to delete HA SSH key: ${error.message}`);
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
cleanupLocalEnvFiles(cwd, envName);
|
|
1813
|
+
|
|
1814
|
+
if (!hasPulumiStack) {
|
|
1815
|
+
// Manual API cleanup for compose deployments — k8s mode is fully
|
|
1816
|
+
// Pulumi-managed and was already torn down above.
|
|
1817
|
+
for (const server of envConfig.servers || []) {
|
|
1818
|
+
s.start(`Deleting server: ${server.name} (${server.ip})`);
|
|
1819
|
+
try {
|
|
1820
|
+
const deleted = await hetznerDeleteServer(hetznerToken, server.id);
|
|
1821
|
+
if (deleted) results.servers.push(server.name);
|
|
1822
|
+
s.stop(deleted ? `Server ${server.name} deleted` : `Server ${server.name} not found`);
|
|
1823
|
+
} catch (error) {
|
|
1824
|
+
s.stop(`Failed to delete ${server.name}: ${error.message}`);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// 3. Delete Hetzner firewalls
|
|
1829
|
+
for (const server of envConfig.servers || []) {
|
|
1830
|
+
const fwName = `${server.name}-fw`;
|
|
1831
|
+
s.start(`Deleting firewall: ${fwName}`);
|
|
1832
|
+
try {
|
|
1833
|
+
const deleted = await hetznerDeleteFirewall(hetznerToken, fwName);
|
|
1834
|
+
if (deleted) results.firewalls.push(fwName);
|
|
1835
|
+
s.stop(deleted ? `Firewall ${fwName} deleted` : `Firewall ${fwName} not found`);
|
|
1836
|
+
} catch (error) {
|
|
1837
|
+
s.stop(`Failed to delete firewall: ${error.message}`);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// 4. Delete Hetzner SSH key.
|
|
1842
|
+
// Pulumi (src/lib/iac/programs/hetzner-k8s.js) names the SSH key
|
|
1843
|
+
// `${projectName}-${environment}-${location}-key`
|
|
1844
|
+
// (`vibecarbon-` prefix dropped — the project name is already unique).
|
|
1845
|
+
// This branch only runs when Pulumi state is missing, so we have to
|
|
1846
|
+
// know the location to find the key. Fall back to the prefix-only
|
|
1847
|
+
// form for older deploys that may not have a region in envConfig.
|
|
1848
|
+
const k8sRegion = envConfig.region || envConfig.servers?.[0]?.region;
|
|
1849
|
+
const sshKeyName = k8sRegion
|
|
1850
|
+
? `${projectConfig.projectName}-${envName}-${k8sRegion}-key`
|
|
1851
|
+
: null;
|
|
1852
|
+
if (sshKeyName) {
|
|
1853
|
+
s.start(`Deleting SSH key: ${sshKeyName}`);
|
|
1854
|
+
try {
|
|
1855
|
+
const deleted = await hetznerDeleteSSHKey(hetznerToken, sshKeyName);
|
|
1856
|
+
if (deleted) results.sshKeys.push(sshKeyName);
|
|
1857
|
+
s.stop(deleted ? 'SSH key deleted' : 'SSH key not found');
|
|
1858
|
+
} catch (error) {
|
|
1859
|
+
s.stop(`Failed to delete SSH key: ${error.message}`);
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
} // end manual API cleanup
|
|
1863
|
+
|
|
1864
|
+
// Clean up Kubernetes-managed resources (created by CCM/CSI at runtime,
|
|
1865
|
+
// not by the Pulumi program). These must run for ALL deployments since
|
|
1866
|
+
// they aren't in any stack's resource graph.
|
|
1867
|
+
|
|
1868
|
+
s.start('Cleaning up orphaned volumes');
|
|
1869
|
+
try {
|
|
1870
|
+
const serverNames = (envConfig.servers || []).map((sv) => sv.name);
|
|
1871
|
+
const knownVolumeIds = [...new Set(allClusterVolumeIds)];
|
|
1872
|
+
const deletedVolumes = await hetznerCleanupOrphanedVolumes(
|
|
1873
|
+
hetznerToken,
|
|
1874
|
+
projectConfig.projectName,
|
|
1875
|
+
envName,
|
|
1876
|
+
serverNames,
|
|
1877
|
+
knownVolumeIds,
|
|
1878
|
+
[...clusterLocations],
|
|
1879
|
+
);
|
|
1880
|
+
results.volumes = deletedVolumes;
|
|
1881
|
+
s.stop(
|
|
1882
|
+
deletedVolumes.length > 0
|
|
1883
|
+
? `Deleted ${deletedVolumes.length} orphaned volume(s)`
|
|
1884
|
+
: 'No orphaned volumes found',
|
|
1885
|
+
);
|
|
1886
|
+
} catch (error) {
|
|
1887
|
+
s.stop(`Volume cleanup failed: ${error.message}`);
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// 4.5 Delete S3 bucket (the Pulumi backend bucket itself, created during
|
|
1891
|
+
// deploy bootstrap, not part of any stack's resource graph)
|
|
1892
|
+
// Use the saved bucket name from config, or fall back to the predictable deploy-time name.
|
|
1893
|
+
// The deploy script creates buckets as sanitizeBucketName(projectName) → e.g. "myapp-storage".
|
|
1894
|
+
const s3BucketName = envConfig.s3?.bucket || sanitizeBucketName(projectConfig.projectName);
|
|
1895
|
+
const s3Region = envConfig.s3?.region || 'fsn1';
|
|
1896
|
+
s.start(`Deleting S3 bucket: ${s3BucketName}`);
|
|
1897
|
+
try {
|
|
1898
|
+
// S3 credentials from env vars or credentials file (prompts if needed)
|
|
1899
|
+
const s3Creds = await getS3Credentials(projectConfig.projectName, { save: false });
|
|
1900
|
+
if (s3Creds) {
|
|
1901
|
+
const s3Provider = new HetznerS3Provider(s3Creds.accessKey, s3Creds.secretKey, s3Region);
|
|
1902
|
+
try {
|
|
1903
|
+
const result = await s3Provider.emptyAndDeleteBucket(s3BucketName);
|
|
1904
|
+
if (result.deleted) {
|
|
1905
|
+
results.s3Bucket = s3BucketName;
|
|
1906
|
+
s.stop(
|
|
1907
|
+
result.objectsRemoved > 0
|
|
1908
|
+
? `S3 bucket deleted (${result.objectsRemoved} objects removed)`
|
|
1909
|
+
: 'S3 bucket deleted',
|
|
1910
|
+
);
|
|
1911
|
+
}
|
|
1912
|
+
} catch (bucketError) {
|
|
1913
|
+
// NoSuchBucket means it was already deleted or never created — not an error
|
|
1914
|
+
if (bucketError.name === 'NoSuchBucket' || bucketError.$metadata?.httpStatusCode === 404) {
|
|
1915
|
+
s.stop('S3 bucket not found (already deleted or never created)');
|
|
1916
|
+
} else {
|
|
1917
|
+
throw bucketError;
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
} else {
|
|
1921
|
+
s.stop('S3 bucket skipped (no credentials available)');
|
|
1922
|
+
results.issues.push({
|
|
1923
|
+
step: 'S3 bucket',
|
|
1924
|
+
detail: `${s3BucketName} (${s3Region}) not deleted: no S3 credentials available`,
|
|
1925
|
+
hint: 'Run `vibecarbon destroy` again with credentials configured, or delete via the Hetzner console.',
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
} catch (error) {
|
|
1929
|
+
s.stop(`S3 bucket deletion failed: ${error.message}`);
|
|
1930
|
+
results.issues.push({
|
|
1931
|
+
step: 'S3 bucket',
|
|
1932
|
+
detail: `${s3BucketName} (${s3Region}) not deleted: ${error.message}`,
|
|
1933
|
+
hint: 'Empty manually via the Hetzner Object Storage console, then DeleteBucket. Bucket continues to incur storage charges until deleted.',
|
|
1934
|
+
});
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// Backup bucket: preserved by default, deleted with --purge-backups.
|
|
1938
|
+
await handleBackupBucket(envConfig, projectConfig, args, s);
|
|
1939
|
+
|
|
1940
|
+
// 5. Delete GitHub environment
|
|
1941
|
+
s.start(`Deleting GitHub environment: ${envName}`);
|
|
1942
|
+
try {
|
|
1943
|
+
results.github = deleteGitHubEnvironment(envName);
|
|
1944
|
+
s.stop(
|
|
1945
|
+
results.github ? 'GitHub environment deleted' : 'GitHub environment not found (or no access)',
|
|
1946
|
+
);
|
|
1947
|
+
} catch (error) {
|
|
1948
|
+
s.stop(`Failed to delete GitHub environment: ${error.message}`);
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
// 6. Update project config
|
|
1952
|
+
s.start('Updating project configuration');
|
|
1953
|
+
delete projectConfig.environments[envName];
|
|
1954
|
+
saveProjectConfig(projectConfig);
|
|
1955
|
+
s.stop('Configuration updated');
|
|
1956
|
+
|
|
1957
|
+
// Summary
|
|
1958
|
+
const deletedCount =
|
|
1959
|
+
results.servers.length +
|
|
1960
|
+
results.volumes.length +
|
|
1961
|
+
results.firewalls.length +
|
|
1962
|
+
results.sshKeys.length +
|
|
1963
|
+
results.dns.length +
|
|
1964
|
+
results.healthChecks.length +
|
|
1965
|
+
results.loadBalancers.length +
|
|
1966
|
+
(results.s3Bucket ? 1 : 0) +
|
|
1967
|
+
(results.github ? 1 : 0);
|
|
1968
|
+
|
|
1969
|
+
p.note(
|
|
1970
|
+
`
|
|
1971
|
+
${c.success('Deleted resources:')}
|
|
1972
|
+
Servers: ${results.servers.length > 0 ? results.servers.join(', ') : 'None'}
|
|
1973
|
+
Volumes: ${results.volumes.length > 0 ? results.volumes.join(', ') : 'None'}
|
|
1974
|
+
Firewalls: ${results.firewalls.length > 0 ? results.firewalls.join(', ') : 'None'}
|
|
1975
|
+
SSH Keys: ${results.sshKeys.length > 0 ? results.sshKeys.join(', ') : 'None'}
|
|
1976
|
+
DNS Records: ${results.dns.length > 0 ? results.dns.join(', ') : 'None'}
|
|
1977
|
+
Health Checks: ${results.healthChecks.length > 0 ? results.healthChecks.join(', ') : 'None'}
|
|
1978
|
+
Load Balancers: ${results.loadBalancers.length > 0 ? results.loadBalancers.join(', ') : 'None'}
|
|
1979
|
+
S3 Bucket: ${results.s3Bucket || 'None'}
|
|
1980
|
+
GitHub Env: ${results.github ? envName : 'None'}
|
|
1981
|
+
|
|
1982
|
+
${c.dim(`Total: ${deletedCount} resources deleted`)}
|
|
1983
|
+
`,
|
|
1984
|
+
'Destruction Complete',
|
|
1985
|
+
);
|
|
1986
|
+
|
|
1987
|
+
const { formatted: timeStr } = tracker.finish();
|
|
1988
|
+
destroyOutro(envName, timeStr, results);
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// ============================================================================
|
|
1992
|
+
// RUN FUNCTION (called by CLI entry point)
|
|
1993
|
+
// ============================================================================
|
|
1994
|
+
|
|
1995
|
+
export async function run(args) {
|
|
1996
|
+
// Re-inject args into process.argv for the main function
|
|
1997
|
+
const originalArgv = process.argv;
|
|
1998
|
+
process.argv = ['node', 'destroy', ...args];
|
|
1999
|
+
try {
|
|
2000
|
+
await main();
|
|
2001
|
+
} finally {
|
|
2002
|
+
process.argv = originalArgv;
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
// ============================================================================
|
|
2007
|
+
// EXPORTS FOR TESTING
|
|
2008
|
+
// ============================================================================
|
|
2009
|
+
|
|
2010
|
+
export {
|
|
2011
|
+
cloudflareDeleteDNSRecord,
|
|
2012
|
+
cloudflareDeleteHealthCheck,
|
|
2013
|
+
deleteGitHubEnvironment,
|
|
2014
|
+
getApiToken,
|
|
2015
|
+
hetznerCleanupAutoscalerWorkers,
|
|
2016
|
+
hetznerCleanupLoadBalancers,
|
|
2017
|
+
hetznerCleanupOrphanedVolumes,
|
|
2018
|
+
hetznerDeleteFirewall,
|
|
2019
|
+
hetznerDeleteLoadBalancer,
|
|
2020
|
+
hetznerDeleteServer,
|
|
2021
|
+
hetznerDeleteSSHKey,
|
|
2022
|
+
hetznerDeleteVolume,
|
|
2023
|
+
hetznerListLoadBalancers,
|
|
2024
|
+
hetznerListNetworks,
|
|
2025
|
+
hetznerListServers,
|
|
2026
|
+
hetznerListVolumes,
|
|
2027
|
+
loadCredentials,
|
|
2028
|
+
loadProjectConfig,
|
|
2029
|
+
main,
|
|
2030
|
+
SPEC,
|
|
2031
|
+
saveProjectConfig,
|
|
2032
|
+
VERSION,
|
|
2033
|
+
};
|