vibecarbon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +663 -0
- package/README.md +188 -0
- package/carbon/.claude/hooks/task-completed-gate.sh +19 -0
- package/carbon/.claude/hooks/teammate-idle-gate.sh +29 -0
- package/carbon/.claude/settings.json +29 -0
- package/carbon/.cursor/rules/vibecarbon.mdc +6 -0
- package/carbon/.dockerignore +57 -0
- package/carbon/.env.example +219 -0
- package/carbon/.github/copilot-instructions.md +3 -0
- package/carbon/.github/workflows/deploy.yml +346 -0
- package/carbon/.github/workflows/vibecarbon-build.yml +81 -0
- package/carbon/.windsurfrules +74 -0
- package/carbon/AGENTS.md +422 -0
- package/carbon/CLAUDE.md +3 -0
- package/carbon/DEVELOPMENT.md +187 -0
- package/carbon/Dockerfile +98 -0
- package/carbon/LICENSE +21 -0
- package/carbon/PRODUCTION.md +364 -0
- package/carbon/README.md +193 -0
- package/carbon/TESTING.md +138 -0
- package/carbon/backup/Dockerfile +75 -0
- package/carbon/backup/backup.sh +95 -0
- package/carbon/backup/compose-backup.sh +140 -0
- package/carbon/biome.json +83 -0
- package/carbon/cloud-init/docker-ce-setup.yaml +55 -0
- package/carbon/cloud-init/k3s/master-init.sh +215 -0
- package/carbon/cloud-init/k3s/supabase-init.sh +167 -0
- package/carbon/cloud-init/k3s/worker-init.sh +147 -0
- package/carbon/components.json +24 -0
- package/carbon/content/blog/authentication-guide.mdx +34 -0
- package/carbon/content/blog/getting-started.mdx +41 -0
- package/carbon/content/changelog/v0-1-0.mdx +40 -0
- package/carbon/content/docs/analytics.mdx +90 -0
- package/carbon/content/docs/authentication.mdx +231 -0
- package/carbon/content/docs/background-jobs.mdx +116 -0
- package/carbon/content/docs/cli.mdx +630 -0
- package/carbon/content/docs/database.mdx +236 -0
- package/carbon/content/docs/deployment.mdx +227 -0
- package/carbon/content/docs/development.mdx +238 -0
- package/carbon/content/docs/environments.mdx +84 -0
- package/carbon/content/docs/getting-started.mdx +112 -0
- package/carbon/content/docs/legal/privacy-policy.mdx +89 -0
- package/carbon/content/docs/legal/terms-of-service.mdx +99 -0
- package/carbon/content/docs/optional-services.mdx +160 -0
- package/carbon/db/Dockerfile +23 -0
- package/carbon/docker-compose.dev-init.yml +5 -0
- package/carbon/docker-compose.metabase.override.yml +14 -0
- package/carbon/docker-compose.metabase.prod.yml +28 -0
- package/carbon/docker-compose.metabase.yml +84 -0
- package/carbon/docker-compose.n8n.override.yml +14 -0
- package/carbon/docker-compose.n8n.prod.yml +31 -0
- package/carbon/docker-compose.n8n.yml +97 -0
- package/carbon/docker-compose.observability.override.yml +18 -0
- package/carbon/docker-compose.observability.prod.yml +28 -0
- package/carbon/docker-compose.observability.yml +125 -0
- package/carbon/docker-compose.override.yml +28 -0
- package/carbon/docker-compose.prod.yml +294 -0
- package/carbon/docker-compose.yml +508 -0
- package/carbon/docker-entrypoint.sh +12 -0
- package/carbon/ha/activate-standby.sh +52 -0
- package/carbon/ha/primary-init.sql +36 -0
- package/carbon/ha/standby-init.sh +74 -0
- package/carbon/k8s/LICENSE +99 -0
- package/carbon/k8s/README.md +272 -0
- package/carbon/k8s/base/app/deployment.yaml +130 -0
- package/carbon/k8s/base/app/hpa.yaml +44 -0
- package/carbon/k8s/base/app/kustomization.yaml +10 -0
- package/carbon/k8s/base/app/network-policy.yaml +124 -0
- package/carbon/k8s/base/app/pdb.yaml +14 -0
- package/carbon/k8s/base/app/rbac.yaml +36 -0
- package/carbon/k8s/base/app/service.yaml +16 -0
- package/carbon/k8s/base/backup/cronjob.yaml +120 -0
- package/carbon/k8s/base/backup/kustomization.yaml +7 -0
- package/carbon/k8s/base/backup/network-policy.yaml +31 -0
- package/carbon/k8s/base/backup/rbac.yaml +7 -0
- package/carbon/k8s/base/cluster-autoscaler/deployment.yaml +103 -0
- package/carbon/k8s/base/cluster-autoscaler/kustomization.yaml +17 -0
- package/carbon/k8s/base/cluster-autoscaler/rbac.yaml +109 -0
- package/carbon/k8s/base/config/configmap.yaml +48 -0
- package/carbon/k8s/base/config/kustomization.yaml +8 -0
- package/carbon/k8s/base/hetzner-ccm/kustomization.yaml +9 -0
- package/carbon/k8s/base/hetzner-csi/kustomization.yaml +9 -0
- package/carbon/k8s/base/kustomization.yaml +43 -0
- package/carbon/k8s/base/namespace.yaml +7 -0
- package/carbon/k8s/base/network-policies.yaml +95 -0
- package/carbon/k8s/base/registry/kustomization.yaml +5 -0
- package/carbon/k8s/base/registry/local-registry.yaml +193 -0
- package/carbon/k8s/base/traefik/certificate.yaml +21 -0
- package/carbon/k8s/base/traefik/configmap.yaml +13 -0
- package/carbon/k8s/base/traefik/deployment.yaml +165 -0
- package/carbon/k8s/base/traefik/ingressroute.yaml +141 -0
- package/carbon/k8s/base/traefik/kustomization.yaml +11 -0
- package/carbon/k8s/base/traefik/middleware.yaml +109 -0
- package/carbon/k8s/base/traefik/network-policy.yaml +92 -0
- package/carbon/k8s/base/traefik/service.yaml +38 -0
- package/carbon/k8s/flux/README.md +49 -0
- package/carbon/k8s/flux/clusters/primary/vibecarbon.yaml +128 -0
- package/carbon/k8s/flux/clusters/standby/vibecarbon.yaml +83 -0
- package/carbon/k8s/gitops/cert-manager-webhook-hetzner/helm-release.yaml +38 -0
- package/carbon/k8s/gitops/cert-manager-webhook-hetzner/helm-repository.yaml +16 -0
- package/carbon/k8s/gitops/cert-manager-webhook-hetzner/kustomization.yaml +10 -0
- package/carbon/k8s/gitops/supabase/helm-release.yaml +66 -0
- package/carbon/k8s/gitops/supabase/helm-repository.yaml +18 -0
- package/carbon/k8s/gitops/supabase/kustomization.yaml +33 -0
- package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-cloudflare.yaml +51 -0
- package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-hetzner.yaml +59 -0
- package/carbon/k8s/infra/cert-manager-resources/cluster-issuers-manual.yaml +43 -0
- package/carbon/k8s/infra/cert-manager-resources/kustomization.yaml +15 -0
- package/carbon/k8s/infra/kustomization.yaml +21 -0
- package/carbon/k8s/infra/traefik-crds/kustomization.yaml +10 -0
- package/carbon/k8s/overlays/local/configmap.yaml +23 -0
- package/carbon/k8s/overlays/local/ingressroute-studio.yaml +104 -0
- package/carbon/k8s/overlays/local/kustomization.yaml +215 -0
- package/carbon/k8s/overlays/local/namespace.yaml +8 -0
- package/carbon/k8s/overlays/local/secrets.yaml +32 -0
- package/carbon/k8s/overlays/production/kustomization.yaml +34 -0
- package/carbon/k8s/test-local.sh +318 -0
- package/carbon/k8s/values/supabase.values.yaml +133 -0
- package/carbon/package.json +154 -0
- package/carbon/runtime.Dockerfile +17 -0
- package/carbon/scripts/_dev-jwt.mjs +45 -0
- package/carbon/scripts/check-rls.ts +53 -0
- package/carbon/scripts/dev-init.js +191 -0
- package/carbon/scripts/dev.js +191 -0
- package/carbon/scripts/docker-down.js +63 -0
- package/carbon/scripts/docker-logs.js +59 -0
- package/carbon/scripts/docker-up.js +222 -0
- package/carbon/scripts/generate-dev-configs.sh +131 -0
- package/carbon/scripts/generate-rss.ts +116 -0
- package/carbon/scripts/generate-sitemap.ts +102 -0
- package/carbon/scripts/k8s-apply.js +71 -0
- package/carbon/scripts/k8s-delete.js +75 -0
- package/carbon/scripts/lib/manifest.js +176 -0
- package/carbon/scripts/secret-scan.mjs +278 -0
- package/carbon/scripts/validate-dev-configs.sh +101 -0
- package/carbon/src/client/App.tsx +202 -0
- package/carbon/src/client/assets/hyperformant-dark.svg +29 -0
- package/carbon/src/client/assets/hyperformant-light.svg +29 -0
- package/carbon/src/client/assets/logos/aider.svg +1 -0
- package/carbon/src/client/assets/logos/antigravity.svg +1 -0
- package/carbon/src/client/assets/logos/bolt.svg +1 -0
- package/carbon/src/client/assets/logos/claude-code.svg +1 -0
- package/carbon/src/client/assets/logos/copilot.svg +1 -0
- package/carbon/src/client/assets/logos/cursor.svg +1 -0
- package/carbon/src/client/assets/logos/gemini-cli.svg +1 -0
- package/carbon/src/client/assets/logos/lovable.svg +1 -0
- package/carbon/src/client/assets/logos/openai-codex.svg +1 -0
- package/carbon/src/client/assets/logos/v0.svg +1 -0
- package/carbon/src/client/assets/logos/windsurf.svg +1 -0
- package/carbon/src/client/assets/vibecarbon-icon.svg +19 -0
- package/carbon/src/client/assets/vibecarbon-logo-dark.svg +29 -0
- package/carbon/src/client/assets/vibecarbon-logo-light.svg +29 -0
- package/carbon/src/client/components/AIIntegrationSection.tsx +120 -0
- package/carbon/src/client/components/AppSidebar.tsx +760 -0
- package/carbon/src/client/components/ArchitectureSection.tsx +46 -0
- package/carbon/src/client/components/CTAFooter.tsx +59 -0
- package/carbon/src/client/components/ComparisonSection.tsx +132 -0
- package/carbon/src/client/components/ContentPanel.tsx +66 -0
- package/carbon/src/client/components/ContentSkeleton.tsx +21 -0
- package/carbon/src/client/components/ErrorBoundary.tsx +46 -0
- package/carbon/src/client/components/FAQSection.tsx +76 -0
- package/carbon/src/client/components/FileUpload.tsx +210 -0
- package/carbon/src/client/components/HeaderActions.tsx +17 -0
- package/carbon/src/client/components/Hero.tsx +608 -0
- package/carbon/src/client/components/ImpersonationBanner.tsx +31 -0
- package/carbon/src/client/components/LanguageSwitcher.tsx +67 -0
- package/carbon/src/client/components/Logo.tsx +87 -0
- package/carbon/src/client/components/LogoStrip.tsx +76 -0
- package/carbon/src/client/components/MetricsStrip.tsx +48 -0
- package/carbon/src/client/components/Nav.tsx +195 -0
- package/carbon/src/client/components/NewsletterSignup.tsx +147 -0
- package/carbon/src/client/components/NotificationDrawer.tsx +171 -0
- package/carbon/src/client/components/PageHeader.tsx +24 -0
- package/carbon/src/client/components/PlanGate.tsx +36 -0
- package/carbon/src/client/components/PricingSection.tsx +371 -0
- package/carbon/src/client/components/SEO.tsx +34 -0
- package/carbon/src/client/components/SmoothScroll.tsx +45 -0
- package/carbon/src/client/components/TechStackSection.tsx +165 -0
- package/carbon/src/client/components/WorkflowSection.tsx +604 -0
- package/carbon/src/client/components/admin/DockerLogs.tsx +276 -0
- package/carbon/src/client/components/admin/PerformanceMetrics.tsx +230 -0
- package/carbon/src/client/components/admin/ServiceCard.tsx +45 -0
- package/carbon/src/client/components/admin/ServicesStatus.tsx +296 -0
- package/carbon/src/client/components/architecture/ArchitectureDiagram.tsx +413 -0
- package/carbon/src/client/components/auth/AuthProvider.tsx +466 -0
- package/carbon/src/client/components/effects/FilmGrainOverlay.tsx +35 -0
- package/carbon/src/client/components/effects/FresnelEdge.tsx +49 -0
- package/carbon/src/client/components/effects/GlowTracker.tsx +46 -0
- package/carbon/src/client/components/effects/SparkBurst.tsx +145 -0
- package/carbon/src/client/components/effects/VGlowEffect.tsx +83 -0
- package/carbon/src/client/components/effects/index.ts +2 -0
- package/carbon/src/client/components/layouts/SidebarLayout.tsx +20 -0
- package/carbon/src/client/components/scroll/ScrollSection.tsx +76 -0
- package/carbon/src/client/components/scroll/ScrollytellingProvider.tsx +81 -0
- package/carbon/src/client/components/scroll/index.ts +2 -0
- package/carbon/src/client/components/ui/accordion.tsx +71 -0
- package/carbon/src/client/components/ui/alert-dialog.tsx +162 -0
- package/carbon/src/client/components/ui/alert.tsx +73 -0
- package/carbon/src/client/components/ui/aspect-ratio.tsx +22 -0
- package/carbon/src/client/components/ui/avatar.tsx +91 -0
- package/carbon/src/client/components/ui/badge.tsx +50 -0
- package/carbon/src/client/components/ui/breadcrumb.tsx +100 -0
- package/carbon/src/client/components/ui/button-group.tsx +78 -0
- package/carbon/src/client/components/ui/button.tsx +120 -0
- package/carbon/src/client/components/ui/calendar.tsx +182 -0
- package/carbon/src/client/components/ui/card.tsx +85 -0
- package/carbon/src/client/components/ui/carousel.tsx +227 -0
- package/carbon/src/client/components/ui/chart.tsx +357 -0
- package/carbon/src/client/components/ui/checkbox.tsx +27 -0
- package/carbon/src/client/components/ui/collapsible.tsx +15 -0
- package/carbon/src/client/components/ui/command.tsx +178 -0
- package/carbon/src/client/components/ui/context-menu.tsx +233 -0
- package/carbon/src/client/components/ui/dialog.tsx +132 -0
- package/carbon/src/client/components/ui/drawer.tsx +118 -0
- package/carbon/src/client/components/ui/dropdown-menu.tsx +242 -0
- package/carbon/src/client/components/ui/empty.tsx +94 -0
- package/carbon/src/client/components/ui/field.tsx +226 -0
- package/carbon/src/client/components/ui/hover-card.tsx +44 -0
- package/carbon/src/client/components/ui/input-group.tsx +146 -0
- package/carbon/src/client/components/ui/input-otp.tsx +83 -0
- package/carbon/src/client/components/ui/input.tsx +20 -0
- package/carbon/src/client/components/ui/item.tsx +188 -0
- package/carbon/src/client/components/ui/kbd.tsx +26 -0
- package/carbon/src/client/components/ui/label.tsx +21 -0
- package/carbon/src/client/components/ui/menubar.tsx +250 -0
- package/carbon/src/client/components/ui/navigation-menu.tsx +155 -0
- package/carbon/src/client/components/ui/pagination.tsx +102 -0
- package/carbon/src/client/components/ui/popover.tsx +75 -0
- package/carbon/src/client/components/ui/progress.tsx +66 -0
- package/carbon/src/client/components/ui/radio-group.tsx +36 -0
- package/carbon/src/client/components/ui/resizable.tsx +46 -0
- package/carbon/src/client/components/ui/scroll-area.tsx +48 -0
- package/carbon/src/client/components/ui/select.tsx +186 -0
- package/carbon/src/client/components/ui/separator.tsx +21 -0
- package/carbon/src/client/components/ui/sheet.tsx +124 -0
- package/carbon/src/client/components/ui/sidebar.tsx +702 -0
- package/carbon/src/client/components/ui/skeleton.tsx +13 -0
- package/carbon/src/client/components/ui/slider.tsx +57 -0
- package/carbon/src/client/components/ui/sonner.tsx +45 -0
- package/carbon/src/client/components/ui/spinner.tsx +15 -0
- package/carbon/src/client/components/ui/switch.tsx +30 -0
- package/carbon/src/client/components/ui/table.tsx +89 -0
- package/carbon/src/client/components/ui/tabs.tsx +73 -0
- package/carbon/src/client/components/ui/textarea.tsx +18 -0
- package/carbon/src/client/components/ui/toggle-group.tsx +87 -0
- package/carbon/src/client/components/ui/toggle.tsx +44 -0
- package/carbon/src/client/components/ui/tooltip.tsx +56 -0
- package/carbon/src/client/hooks/api/index.ts +14 -0
- package/carbon/src/client/hooks/api/useAuthSettings.ts +80 -0
- package/carbon/src/client/hooks/api/useSubscription.ts +87 -0
- package/carbon/src/client/hooks/use-mobile.ts +21 -0
- package/carbon/src/client/hooks/useMousePosition.ts +91 -0
- package/carbon/src/client/hooks/useNotifications.tsx +124 -0
- package/carbon/src/client/hooks/useOrganizationMembers.ts +127 -0
- package/carbon/src/client/hooks/useOrganizations.tsx +230 -0
- package/carbon/src/client/hooks/usePackageManager.ts +69 -0
- package/carbon/src/client/hooks/useReducedMotion.ts +20 -0
- package/carbon/src/client/hooks/useRunningServices.ts +114 -0
- package/carbon/src/client/hooks/useScrollProgress.ts +56 -0
- package/carbon/src/client/index.css +467 -0
- package/carbon/src/client/index.html +56 -0
- package/carbon/src/client/lib/admin-services.ts +151 -0
- package/carbon/src/client/lib/api.ts +32 -0
- package/carbon/src/client/lib/blog.ts +35 -0
- package/carbon/src/client/lib/changelog.ts +33 -0
- package/carbon/src/client/lib/docs-search.ts +101 -0
- package/carbon/src/client/lib/docs.ts +37 -0
- package/carbon/src/client/lib/i18n.ts +32 -0
- package/carbon/src/client/lib/supabase.ts +72 -0
- package/carbon/src/client/lib/tailwind-colors.ts +357 -0
- package/carbon/src/client/lib/theme.ts +117 -0
- package/carbon/src/client/lib/utils.ts +22 -0
- package/carbon/src/client/locales/de.json +529 -0
- package/carbon/src/client/locales/en.json +461 -0
- package/carbon/src/client/locales/es.json +529 -0
- package/carbon/src/client/locales/fr.json +529 -0
- package/carbon/src/client/locales/pt.json +529 -0
- package/carbon/src/client/main.tsx +56 -0
- package/carbon/src/client/mdx.d.ts +13 -0
- package/carbon/src/client/pages/ApiDocs.tsx +76 -0
- package/carbon/src/client/pages/AuthCallback.tsx +34 -0
- package/carbon/src/client/pages/Blog.tsx +167 -0
- package/carbon/src/client/pages/Changelog.tsx +171 -0
- package/carbon/src/client/pages/Charts.tsx +388 -0
- package/carbon/src/client/pages/Checkout.tsx +227 -0
- package/carbon/src/client/pages/Contact.tsx +174 -0
- package/carbon/src/client/pages/Dashboard.tsx +368 -0
- package/carbon/src/client/pages/Docs.tsx +372 -0
- package/carbon/src/client/pages/ForgotPassword.tsx +111 -0
- package/carbon/src/client/pages/Home.tsx +187 -0
- package/carbon/src/client/pages/Legal.tsx +100 -0
- package/carbon/src/client/pages/Login.tsx +408 -0
- package/carbon/src/client/pages/MFAVerify.tsx +156 -0
- package/carbon/src/client/pages/NotFound.tsx +21 -0
- package/carbon/src/client/pages/Onboarding.tsx +246 -0
- package/carbon/src/client/pages/ResetPassword.tsx +200 -0
- package/carbon/src/client/pages/UIComponents.tsx +390 -0
- package/carbon/src/client/pages/admin/ContactSubmissions.tsx +220 -0
- package/carbon/src/client/pages/admin/Dashboard.tsx +24 -0
- package/carbon/src/client/pages/admin/Infrastructure.tsx +257 -0
- package/carbon/src/client/pages/admin/Jobs.tsx +225 -0
- package/carbon/src/client/pages/admin/Logs.tsx +18 -0
- package/carbon/src/client/pages/admin/Newsletter.tsx +300 -0
- package/carbon/src/client/pages/admin/Notifications.tsx +603 -0
- package/carbon/src/client/pages/admin/Organizations.tsx +306 -0
- package/carbon/src/client/pages/admin/Settings.tsx +314 -0
- package/carbon/src/client/pages/admin/Theme.tsx +465 -0
- package/carbon/src/client/pages/admin/Users.tsx +365 -0
- package/carbon/src/client/pages/api-docs.css +156 -0
- package/carbon/src/client/pages/organizations/Details.tsx +200 -0
- package/carbon/src/client/pages/organizations/Members.tsx +402 -0
- package/carbon/src/client/pages/settings/Billing.tsx +473 -0
- package/carbon/src/client/pages/settings/Profile.tsx +160 -0
- package/carbon/src/client/pages/settings/Security.tsx +341 -0
- package/carbon/src/client/public/favicon.svg +19 -0
- package/carbon/src/client/public/robots.txt +8 -0
- package/carbon/src/server/billing/index.ts +104 -0
- package/carbon/src/server/billing/provider.ts +81 -0
- package/carbon/src/server/billing/providers/paddle.ts +314 -0
- package/carbon/src/server/billing/providers/polar.ts +325 -0
- package/carbon/src/server/billing/providers/stripe.ts +233 -0
- package/carbon/src/server/emails/templates.ts +116 -0
- package/carbon/src/server/index.ts +554 -0
- package/carbon/src/server/lib/auth.ts +6 -0
- package/carbon/src/server/lib/email.ts +64 -0
- package/carbon/src/server/lib/env.ts +112 -0
- package/carbon/src/server/lib/errors.ts +21 -0
- package/carbon/src/server/lib/logger.ts +17 -0
- package/carbon/src/server/lib/rate-limiter.ts +288 -0
- package/carbon/src/server/lib/request.ts +34 -0
- package/carbon/src/server/lib/stripe.ts +42 -0
- package/carbon/src/server/lib/supabase.ts +65 -0
- package/carbon/src/server/middleware/requirePlan.ts +80 -0
- package/carbon/src/server/routes/_internal/services-status.ts +958 -0
- package/carbon/src/server/routes/_internal/verify-role.ts +185 -0
- package/carbon/src/server/routes/health.ts +48 -0
- package/carbon/src/server/routes/v1/admin/contact.ts +128 -0
- package/carbon/src/server/routes/v1/admin/jobs.ts +171 -0
- package/carbon/src/server/routes/v1/admin/newsletter.ts +237 -0
- package/carbon/src/server/routes/v1/auth.ts +390 -0
- package/carbon/src/server/routes/v1/billing.ts +718 -0
- package/carbon/src/server/routes/v1/contact.ts +93 -0
- package/carbon/src/server/routes/v1/index.ts +1333 -0
- package/carbon/src/server/routes/v1/newsletter.ts +181 -0
- package/carbon/src/server/routes/v1/performance.ts +157 -0
- package/carbon/src/server/routes/v1/stats.ts +170 -0
- package/carbon/src/server/routes/v1/theme.ts +106 -0
- package/carbon/src/server/routes/webhooks/billing.ts +376 -0
- package/carbon/src/server/routes/webhooks/stripe.ts +276 -0
- package/carbon/src/server/types.ts +11 -0
- package/carbon/src/shared/pricing.ts +155 -0
- package/carbon/src/shared/types.ts +338 -0
- package/carbon/supabase/migrations/00001_init.sql +717 -0
- package/carbon/supabase/migrations/00002_theme_settings.sql +13 -0
- package/carbon/supabase/migrations/00003_pg_cron.sql +121 -0
- package/carbon/supabase/migrations/00004_contact_newsletter.sql +81 -0
- package/carbon/supabase/migrations/00005_localization_languages.sql +22 -0
- package/carbon/supabase/seed.sql +16 -0
- package/carbon/tests/_helpers/app.ts +45 -0
- package/carbon/tests/_helpers/env.ts +37 -0
- package/carbon/tests/_helpers/factories.ts +69 -0
- package/carbon/tests/_helpers/jwt.ts +23 -0
- package/carbon/tests/_helpers/setup-integration.ts +20 -0
- package/carbon/tests/_helpers/setup-rtl.ts +12 -0
- package/carbon/tests/component/ErrorBoundary.test.tsx +53 -0
- package/carbon/tests/component/use-auth-settings.test.tsx +119 -0
- package/carbon/tests/integration/server/routes/contact.test.ts +162 -0
- package/carbon/tests/integration/server/routes/health.test.ts +61 -0
- package/carbon/tests/structural/i18n-parity.test.ts +42 -0
- package/carbon/tests/unit/client/utils.test.ts +49 -0
- package/carbon/tests/unit/shared/pricing.test.ts +93 -0
- package/carbon/tsconfig.json +27 -0
- package/carbon/tsconfig.server.json +27 -0
- package/carbon/tsconfig.test.json +9 -0
- package/carbon/vite.config.ts +110 -0
- package/carbon/vitest.config.ts +74 -0
- package/carbon/volumes/db/jwt.sql +57 -0
- package/carbon/volumes/db/metabase-init.sh +29 -0
- package/carbon/volumes/db/n8n-init.sh +25 -0
- package/carbon/volumes/db/realtime.sql +33 -0
- package/carbon/volumes/db/roles.sql +93 -0
- package/carbon/volumes/db/set-passwords.sh +12 -0
- package/carbon/volumes/db/super-admin.dev.sql +113 -0
- package/carbon/volumes/db/super-admin.generated.sql +113 -0
- package/carbon/volumes/db/super-admin.sql +114 -0
- package/carbon/volumes/grafana/dashboards/logs.json +179 -0
- package/carbon/volumes/grafana/dashboards/overview.json +523 -0
- package/carbon/volumes/grafana/dashboards/postgresql.json +337 -0
- package/carbon/volumes/grafana/dashboards.dev/logs.json +179 -0
- package/carbon/volumes/grafana/dashboards.dev/overview.json +156 -0
- package/carbon/volumes/grafana/dashboards.dev/postgresql.json +337 -0
- package/carbon/volumes/grafana/provisioning/dashboards/dashboards.dev.yml +16 -0
- package/carbon/volumes/grafana/provisioning/dashboards/dashboards.yml +16 -0
- package/carbon/volumes/grafana/provisioning/datasources/datasources.yml +43 -0
- package/carbon/volumes/kong/docker-entrypoint.sh +9 -0
- package/carbon/volumes/kong/kong.yml +208 -0
- package/carbon/volumes/loki/loki-config.yml +58 -0
- package/carbon/volumes/n8n/hooks.js +131 -0
- package/carbon/volumes/n8n/scripts/setup.sh +66 -0
- package/carbon/volumes/prometheus/prometheus.yml +43 -0
- package/carbon/volumes/promtail/promtail-config.yml +64 -0
- package/carbon/volumes/traefik/middlewares.dev.yml +83 -0
- package/carbon/volumes/traefik/middlewares.yml +95 -0
- package/carbon/volumes/traefik/vite-dev.yml +20 -0
- package/package.json +95 -0
- package/src/access.js +354 -0
- package/src/activate.js +187 -0
- package/src/add.js +718 -0
- package/src/backup.js +786 -0
- package/src/cli.js +350 -0
- package/src/configure.js +967 -0
- package/src/console.js +155 -0
- package/src/create.js +1499 -0
- package/src/deploy.js +311 -0
- package/src/destroy.js +2033 -0
- package/src/diagnose.js +735 -0
- package/src/down.js +80 -0
- package/src/failover.js +1032 -0
- package/src/lib/backup-s3.js +179 -0
- package/src/lib/build.js +33 -0
- package/src/lib/checksum.js +28 -0
- package/src/lib/ci-setup.js +666 -0
- package/src/lib/cli/help.js +129 -0
- package/src/lib/cli/parse-flags.js +160 -0
- package/src/lib/cli/select-action.js +65 -0
- package/src/lib/cli/select-environment.js +108 -0
- package/src/lib/cli/tty-guard.js +75 -0
- package/src/lib/cloudflare.js +447 -0
- package/src/lib/colors.js +52 -0
- package/src/lib/command.js +361 -0
- package/src/lib/config.js +359 -0
- package/src/lib/cost.js +103 -0
- package/src/lib/deploy/bundle.js +231 -0
- package/src/lib/deploy/compose/acme-verify.js +121 -0
- package/src/lib/deploy/compose/build-args.js +78 -0
- package/src/lib/deploy/compose/ha.js +1874 -0
- package/src/lib/deploy/compose/index.js +1294 -0
- package/src/lib/deploy/compose/reconcile.js +74 -0
- package/src/lib/deploy/github.js +382 -0
- package/src/lib/deploy/image.js +191 -0
- package/src/lib/deploy/index.js +119 -0
- package/src/lib/deploy/k8s/LICENSE +99 -0
- package/src/lib/deploy/k8s/gitops-deploy.js +298 -0
- package/src/lib/deploy/k8s/ha/index.js +1000 -0
- package/src/lib/deploy/k8s/index.js +62 -0
- package/src/lib/deploy/k8s/k3s.js +2515 -0
- package/src/lib/deploy/orchestrator.js +1401 -0
- package/src/lib/deploy/prompts.js +545 -0
- package/src/lib/deploy/remote-build.js +130 -0
- package/src/lib/deploy/state.js +120 -0
- package/src/lib/deploy/utils.js +328 -0
- package/src/lib/deploy-logger.js +93 -0
- package/src/lib/dns-propagation.js +48 -0
- package/src/lib/environment.js +58 -0
- package/src/lib/fetch-retry.js +103 -0
- package/src/lib/github-environments.js +335 -0
- package/src/lib/hetzner-dns.js +377 -0
- package/src/lib/hetzner-guided-setup.js +275 -0
- package/src/lib/host-keys.js +37 -0
- package/src/lib/iac/cloud-init.js +52 -0
- package/src/lib/iac/index.js +325 -0
- package/src/lib/iac/programs/hetzner-compose.js +130 -0
- package/src/lib/iac/programs/hetzner-k8s.js +320 -0
- package/src/lib/kubectl.js +81 -0
- package/src/lib/licensing/index.js +259 -0
- package/src/lib/licensing/tiers.js +181 -0
- package/src/lib/licensing/validator.js +171 -0
- package/src/lib/merge-package-json.js +90 -0
- package/src/lib/operator-ip.js +381 -0
- package/src/lib/package-manager.js +111 -0
- package/src/lib/perf.js +71 -0
- package/src/lib/project-guard.js +39 -0
- package/src/lib/project.js +334 -0
- package/src/lib/providers/base.js +276 -0
- package/src/lib/providers/hetzner-s3.js +656 -0
- package/src/lib/providers/hetzner.js +755 -0
- package/src/lib/providers/index.js +164 -0
- package/src/lib/s3.js +510 -0
- package/src/lib/secret-scan.js +583 -0
- package/src/lib/secrets.js +63 -0
- package/src/lib/server-types.js +195 -0
- package/src/lib/shell.js +91 -0
- package/src/lib/ssh.js +241 -0
- package/src/lib/tracker.js +242 -0
- package/src/lib/upgrade-policy.js +170 -0
- package/src/lib/validators.js +105 -0
- package/src/lib/version.js +5 -0
- package/src/remove.js +292 -0
- package/src/reset.js +97 -0
- package/src/restore.js +871 -0
- package/src/scale.js +1734 -0
- package/src/shell.js +222 -0
- package/src/status.js +981 -0
- package/src/up.js +264 -0
- package/src/upgrade.js +721 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hetzner Object Storage (S3-compatible) Provider
|
|
3
|
+
*
|
|
4
|
+
* Manages S3 bucket operations using AWS SDK v3.
|
|
5
|
+
* Note: S3 credentials must be created manually in Hetzner Console.
|
|
6
|
+
*
|
|
7
|
+
* API Documentation: https://docs.hetzner.com/storage/object-storage/
|
|
8
|
+
*
|
|
9
|
+
* Retry posture: every SDK call routes through `_send()`, which retries
|
|
10
|
+
* transient errors (TimeoutError, ECONNRESET, EAI_AGAIN, 5xx, AWS SDK
|
|
11
|
+
* "UnknownError" wrappers) with capped backoff. Operation-specific
|
|
12
|
+
* conditions (NotFound, BucketAlreadyExists, BucketNotEmpty) are handled
|
|
13
|
+
* by the caller via a `terminal` classifier so they don't waste retries.
|
|
14
|
+
* Hetzner Ceph has a higher transient-error floor than AWS S3, so this
|
|
15
|
+
* uniform posture is what keeps e2e off the whack-a-mole treadmill.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
AbortMultipartUploadCommand,
|
|
20
|
+
CreateBucketCommand,
|
|
21
|
+
DeleteBucketCommand,
|
|
22
|
+
DeleteObjectsCommand,
|
|
23
|
+
HeadBucketCommand,
|
|
24
|
+
ListBucketsCommand,
|
|
25
|
+
ListMultipartUploadsCommand,
|
|
26
|
+
ListObjectsV2Command,
|
|
27
|
+
ListObjectVersionsCommand,
|
|
28
|
+
PutBucketCorsCommand,
|
|
29
|
+
S3Client,
|
|
30
|
+
} from '@aws-sdk/client-s3';
|
|
31
|
+
|
|
32
|
+
// Transient S3 error classifier. Matches AWS SDK error names ("TimeoutError",
|
|
33
|
+
// "UnknownError"), socket-level Node errors ("ECONNRESET", "EAI_AGAIN"), and
|
|
34
|
+
// 5xx HTTP statuses from $metadata.httpStatusCode. The regex covers cases
|
|
35
|
+
// where the status code is embedded in the error name/message (some SDK
|
|
36
|
+
// versions do this); the explicit $metadata check covers the common case
|
|
37
|
+
// where Hetzner returns a clean 502/503/504 with a typed name like
|
|
38
|
+
// "ServiceUnavailable" that wouldn't match the regex.
|
|
39
|
+
const TRANSIENT_S3_ERROR_RE =
|
|
40
|
+
/TimeoutError|ETIMEDOUT|ECONNRESET|EAI_AGAIN|NetworkingError|Network failure|UnknownError|503|504|502/i;
|
|
41
|
+
const TRANSIENT_HTTP_STATUSES = new Set([502, 503, 504]);
|
|
42
|
+
|
|
43
|
+
function isTransientS3Error(error) {
|
|
44
|
+
if (!error) return false;
|
|
45
|
+
if (TRANSIENT_S3_ERROR_RE.test(error.name ?? '')) return true;
|
|
46
|
+
if (TRANSIENT_S3_ERROR_RE.test(error.message ?? '')) return true;
|
|
47
|
+
const status = error.$metadata?.httpStatusCode;
|
|
48
|
+
if (status && TRANSIENT_HTTP_STATUSES.has(status)) return true;
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class HetznerS3Provider {
|
|
53
|
+
static ENDPOINTS = {
|
|
54
|
+
fsn1: 'https://fsn1.your-objectstorage.com',
|
|
55
|
+
nbg1: 'https://nbg1.your-objectstorage.com',
|
|
56
|
+
hel1: 'https://hel1.your-objectstorage.com',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
static REGIONS = {
|
|
60
|
+
fsn1: 'Falkenstein, Germany',
|
|
61
|
+
nbg1: 'Nuremberg, Germany',
|
|
62
|
+
hel1: 'Helsinki, Finland',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create a new Hetzner S3 provider instance
|
|
67
|
+
* @param {string} accessKeyId - S3 access key
|
|
68
|
+
* @param {string} secretAccessKey - S3 secret key
|
|
69
|
+
* @param {string} region - Region (fsn1, nbg1, hel1)
|
|
70
|
+
*/
|
|
71
|
+
constructor(accessKeyId, secretAccessKey, region) {
|
|
72
|
+
if (!accessKeyId || !secretAccessKey) {
|
|
73
|
+
throw new Error('S3 credentials are required');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!HetznerS3Provider.ENDPOINTS[region]) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Invalid region: ${region}. Valid regions: ${Object.keys(HetznerS3Provider.ENDPOINTS).join(', ')}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this.accessKeyId = accessKeyId;
|
|
83
|
+
this.secretAccessKey = secretAccessKey;
|
|
84
|
+
this.region = region;
|
|
85
|
+
this.endpoint = HetznerS3Provider.ENDPOINTS[region];
|
|
86
|
+
this._client = null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get or create the S3 client (lazy initialization)
|
|
91
|
+
* @returns {S3Client}
|
|
92
|
+
*/
|
|
93
|
+
getClient() {
|
|
94
|
+
if (!this._client) {
|
|
95
|
+
// Explicit timeouts on the underlying http handler. The AWS SDK's
|
|
96
|
+
// NodeHttpHandler defaults to no socket timeout, so a Hetzner Ceph
|
|
97
|
+
// endpoint that accepts the TCP connection but stops responding mid-
|
|
98
|
+
// request will hang the call indefinitely. Observed in iter-initbackend
|
|
99
|
+
// 2026-05-01: compose destroy hung for 600s (test runner SIGKILL) on
|
|
100
|
+
// emptyAndDeleteBucket → ListObjectsV2; the per-iteration `client.send`
|
|
101
|
+
// never returned. With requestTimeout set, a stuck call surfaces as a
|
|
102
|
+
// TimeoutError after 60s and `_send` retries. maxAttempts here is the
|
|
103
|
+
// SDK's own internal retry; `_send` adds an outer layer with backoff
|
|
104
|
+
// tuned for Hetzner Ceph's recovery cadence.
|
|
105
|
+
this._client = new S3Client({
|
|
106
|
+
region: this.region,
|
|
107
|
+
endpoint: this.endpoint,
|
|
108
|
+
forcePathStyle: true, // Hetzner requires path-style URLs
|
|
109
|
+
credentials: {
|
|
110
|
+
accessKeyId: this.accessKeyId,
|
|
111
|
+
secretAccessKey: this.secretAccessKey,
|
|
112
|
+
},
|
|
113
|
+
maxAttempts: 3,
|
|
114
|
+
requestHandler: {
|
|
115
|
+
connectionTimeout: 10_000,
|
|
116
|
+
requestTimeout: 60_000,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return this._client;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Send a command through the S3 client with uniform transient-error retry.
|
|
125
|
+
*
|
|
126
|
+
* All SDK calls in this module route through here. The default 3-attempt
|
|
127
|
+
* loop with [5s, 10s] backoff absorbs Hetzner Ceph's typical blip
|
|
128
|
+
* envelope (one or two failed dispatches followed by recovery). Callers
|
|
129
|
+
* that need different retry economics — credential-validation prompts
|
|
130
|
+
* want fast failure, createBucket wants 5×exponential to ride out the
|
|
131
|
+
* post-delete bucket-name reservation window — pass `maxAttempts` and
|
|
132
|
+
* `backoffSeconds` explicitly.
|
|
133
|
+
*
|
|
134
|
+
* `terminal(error) → boolean` lets the caller short-circuit retry for
|
|
135
|
+
* conditions that are deterministic outcomes rather than transient
|
|
136
|
+
* faults (e.g., HeadBucket NotFound is "the bucket doesn't exist", not
|
|
137
|
+
* "Hetzner blipped"). Without this, NotFound would cost 15s+ of retry
|
|
138
|
+
* before bucketExists could return false.
|
|
139
|
+
*
|
|
140
|
+
* @param {object} command - AWS SDK command instance
|
|
141
|
+
* @param {object} [opts]
|
|
142
|
+
* @param {number} [opts.maxAttempts=3]
|
|
143
|
+
* @param {number[]} [opts.backoffSeconds=[5,10]] - delays BETWEEN attempts; length should be maxAttempts-1 (last entry repeats if shorter)
|
|
144
|
+
* @param {(error: Error) => boolean} [opts.terminal] - if returns true, throw immediately without retry
|
|
145
|
+
* @returns {Promise<*>} SDK response
|
|
146
|
+
*/
|
|
147
|
+
async _send(command, { maxAttempts = 3, backoffSeconds = [5, 10], terminal = null } = {}) {
|
|
148
|
+
const client = this.getClient();
|
|
149
|
+
let lastError;
|
|
150
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
151
|
+
try {
|
|
152
|
+
return await client.send(command);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
lastError = error;
|
|
155
|
+
if (terminal?.(error)) throw error;
|
|
156
|
+
if (isTransientS3Error(error) && attempt < maxAttempts - 1) {
|
|
157
|
+
const delay = backoffSeconds[Math.min(attempt, backoffSeconds.length - 1)];
|
|
158
|
+
await new Promise((r) => setTimeout(r, delay * 1000));
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Loop above always returns or throws on the final attempt.
|
|
165
|
+
throw lastError;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Validate credentials by listing buckets
|
|
170
|
+
* @returns {Promise<{valid: boolean, error?: string}>}
|
|
171
|
+
*/
|
|
172
|
+
async validateCredentials() {
|
|
173
|
+
// Tight retry budget tuned for the credential-prompt UX: terminal auth
|
|
174
|
+
// errors fail fast; transient errors get up to 3 quick re-tries before
|
|
175
|
+
// we surface "credentials invalid" to the user. We bypass _send's own
|
|
176
|
+
// retry (`maxAttempts: 1`) so the outer loop here is the single source
|
|
177
|
+
// of retry, keeping the worst-case wait under ~5s.
|
|
178
|
+
const terminalNames = new Set(['InvalidAccessKeyId', 'SignatureDoesNotMatch', 'AccessDenied']);
|
|
179
|
+
const maxAttempts = 3;
|
|
180
|
+
let lastError;
|
|
181
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
182
|
+
try {
|
|
183
|
+
await this._send(new ListBucketsCommand({}), { maxAttempts: 1 });
|
|
184
|
+
return { valid: true };
|
|
185
|
+
} catch (error) {
|
|
186
|
+
lastError = error;
|
|
187
|
+
if (terminalNames.has(error.name)) break;
|
|
188
|
+
if (attempt < maxAttempts) {
|
|
189
|
+
await new Promise((r) => setTimeout(r, 1500 * attempt));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (lastError.name === 'InvalidAccessKeyId') {
|
|
194
|
+
return { valid: false, error: 'Invalid Access Key ID' };
|
|
195
|
+
}
|
|
196
|
+
if (lastError.name === 'SignatureDoesNotMatch') {
|
|
197
|
+
return { valid: false, error: 'Invalid Secret Key' };
|
|
198
|
+
}
|
|
199
|
+
if (lastError.name === 'AccessDenied') {
|
|
200
|
+
return { valid: false, error: 'Access denied. Check credentials permissions.' };
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
valid: false,
|
|
204
|
+
error:
|
|
205
|
+
lastError.message ||
|
|
206
|
+
lastError.Code ||
|
|
207
|
+
lastError.name ||
|
|
208
|
+
`S3 error (HTTP ${lastError.$metadata?.httpStatusCode || 'unknown'})`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* List all buckets
|
|
214
|
+
* @returns {Promise<Array<{name: string, creationDate: Date}>>}
|
|
215
|
+
*/
|
|
216
|
+
async listBuckets() {
|
|
217
|
+
const response = await this._send(new ListBucketsCommand({}));
|
|
218
|
+
return (response.Buckets || []).map((bucket) => ({
|
|
219
|
+
name: bucket.Name,
|
|
220
|
+
creationDate: bucket.CreationDate,
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Check if a bucket exists. NotFound and 403 short-circuit `_send`'s
|
|
226
|
+
* retry via the terminal classifier — they're deterministic outcomes
|
|
227
|
+
* (bucket missing / cross-account collision), not Hetzner blips. Only
|
|
228
|
+
* actual transient errors (TimeoutError, 5xx) get retried by `_send`.
|
|
229
|
+
* @param {string} bucketName - Bucket name
|
|
230
|
+
* @returns {Promise<boolean>}
|
|
231
|
+
*/
|
|
232
|
+
async bucketExists(bucketName) {
|
|
233
|
+
const command = new HeadBucketCommand({ Bucket: bucketName });
|
|
234
|
+
try {
|
|
235
|
+
await this._send(command, {
|
|
236
|
+
terminal: (err) => {
|
|
237
|
+
const status = err.$metadata?.httpStatusCode;
|
|
238
|
+
return err.name === 'NotFound' || status === 404 || status === 403;
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
return true;
|
|
242
|
+
} catch (error) {
|
|
243
|
+
const status = error.$metadata?.httpStatusCode;
|
|
244
|
+
if (error.name === 'NotFound' || status === 404) return false;
|
|
245
|
+
if (status === 403) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`Bucket "${bucketName}" exists but is owned by another account. Try a different project name.`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Search all S3 regions for a bucket we own.
|
|
256
|
+
* Each region's HeadBucket failure is intentionally swallowed — finding
|
|
257
|
+
* the bucket means we got a success on at least one region; finding it
|
|
258
|
+
* nowhere means it doesn't exist anywhere we own. We deliberately don't
|
|
259
|
+
* retry transients here because findBucketRegion is itself a fallback
|
|
260
|
+
* called from createBucket's BucketAlreadyExists branch — a per-region
|
|
261
|
+
* blip just means "try the next region", not "give up".
|
|
262
|
+
* @param {string} bucketName
|
|
263
|
+
* @returns {Promise<string|null>} The region where the bucket was found, or null
|
|
264
|
+
*/
|
|
265
|
+
async findBucketRegion(bucketName) {
|
|
266
|
+
for (const [region, endpoint] of Object.entries(HetznerS3Provider.ENDPOINTS)) {
|
|
267
|
+
try {
|
|
268
|
+
const client = new S3Client({
|
|
269
|
+
region,
|
|
270
|
+
endpoint,
|
|
271
|
+
forcePathStyle: true,
|
|
272
|
+
credentials: {
|
|
273
|
+
accessKeyId: this.accessKeyId,
|
|
274
|
+
secretAccessKey: this.secretAccessKey,
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
await client.send(new HeadBucketCommand({ Bucket: bucketName }));
|
|
278
|
+
return region;
|
|
279
|
+
} catch {
|
|
280
|
+
// Not in this region or not accessible — try next
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Create a new bucket.
|
|
288
|
+
*
|
|
289
|
+
* Outer loop handles two operation-specific conditions that aren't
|
|
290
|
+
* just "Hetzner blipped":
|
|
291
|
+
* - BucketAlreadyExists: Hetzner reserves bucket names for ~30-90s
|
|
292
|
+
* after deletion; on a destroy→restore cycle we ride that window
|
|
293
|
+
* out with exponential backoff before declaring cross-account
|
|
294
|
+
* collision.
|
|
295
|
+
* - BucketAlreadyOwnedByYou: success-equivalent — return early.
|
|
296
|
+
* Transient errors fall through to the same loop's backoff, courtesy
|
|
297
|
+
* of `_send` (with maxAttempts=1 inside) so we don't double-retry.
|
|
298
|
+
* @param {string} bucketName - Bucket name (must be globally unique)
|
|
299
|
+
* @returns {Promise<{name: string, created: boolean}>}
|
|
300
|
+
*/
|
|
301
|
+
async createBucket(bucketName) {
|
|
302
|
+
if (await this.bucketExists(bucketName)) {
|
|
303
|
+
return { name: bucketName, created: false, message: 'Bucket already exists' };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const command = new CreateBucketCommand({ Bucket: bucketName });
|
|
307
|
+
const maxAttempts = 5;
|
|
308
|
+
const backoffSeconds = [10, 20, 30, 40, 50];
|
|
309
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
310
|
+
try {
|
|
311
|
+
await this._send(command, { maxAttempts: 1 });
|
|
312
|
+
return { name: bucketName, created: true };
|
|
313
|
+
} catch (error) {
|
|
314
|
+
if (error.name === 'BucketAlreadyOwnedByYou') {
|
|
315
|
+
return { name: bucketName, created: false, message: 'Bucket already exists' };
|
|
316
|
+
}
|
|
317
|
+
if (isTransientS3Error(error) && attempt < maxAttempts - 1) {
|
|
318
|
+
await new Promise((r) => setTimeout(r, backoffSeconds[attempt] * 1000));
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (error.name === 'BucketAlreadyExists') {
|
|
322
|
+
// Hetzner S3 bucket names are globally unique but region-scoped for access.
|
|
323
|
+
// HeadBucket returns 404 when checking a bucket that lives in a different region.
|
|
324
|
+
// Try all regions to find the bucket before assuming another account owns it.
|
|
325
|
+
const foundRegion = await this.findBucketRegion(bucketName);
|
|
326
|
+
if (foundRegion) {
|
|
327
|
+
this.region = foundRegion;
|
|
328
|
+
this.endpoint = HetznerS3Provider.ENDPOINTS[foundRegion];
|
|
329
|
+
this._client = null;
|
|
330
|
+
return { name: bucketName, created: false, message: 'Bucket already exists' };
|
|
331
|
+
}
|
|
332
|
+
if (attempt < maxAttempts - 1) {
|
|
333
|
+
// Eventual consistency window — probably our own prior delete
|
|
334
|
+
// hasn't fully propagated. Wait and retry.
|
|
335
|
+
await new Promise((r) => setTimeout(r, backoffSeconds[attempt] * 1000));
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
throw new Error(
|
|
339
|
+
`Bucket name "${bucketName}" is already taken by another account. Try a different name.`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// Unreachable; loop above either returns or throws.
|
|
346
|
+
throw new Error(`createBucket: unexpected exit after ${maxAttempts} attempts`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Delete a bucket (must be empty).
|
|
351
|
+
* Outer loop handles BucketNotEmpty (eventual-consistency: object
|
|
352
|
+
* deletes from emptyAndDeleteBucket may not have fully propagated yet).
|
|
353
|
+
* Transient errors get retried by `_send` itself; we only wrap
|
|
354
|
+
* BucketNotEmpty in the operation-specific outer loop here.
|
|
355
|
+
* @param {string} bucketName - Bucket name
|
|
356
|
+
*/
|
|
357
|
+
async deleteBucket(bucketName) {
|
|
358
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
359
|
+
try {
|
|
360
|
+
await this._send(new DeleteBucketCommand({ Bucket: bucketName }));
|
|
361
|
+
return;
|
|
362
|
+
} catch (err) {
|
|
363
|
+
const isNotEmpty = err.Code === 'BucketNotEmpty' || err.message?.includes('not empty');
|
|
364
|
+
if (!isNotEmpty || attempt === 4) throw err;
|
|
365
|
+
await new Promise((r) => setTimeout(r, 3000 * (attempt + 1)));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Empty all objects from a bucket and then delete it.
|
|
372
|
+
* Handles current objects, versioned objects/delete markers, and incomplete
|
|
373
|
+
* multipart uploads.
|
|
374
|
+
*
|
|
375
|
+
* Step errors that look like "API not supported by provider" (NotImplemented,
|
|
376
|
+
* MethodNotAllowed, 405/501) are tolerated and recorded as warnings — not all
|
|
377
|
+
* S3-compatible providers expose ListObjectVersions / multipart listing.
|
|
378
|
+
* Real errors (timeouts, 5xx, auth) propagate so callers can surface them
|
|
379
|
+
* instead of getting an opaque BucketNotEmpty downstream.
|
|
380
|
+
*
|
|
381
|
+
* If `deleteBucket` finally fails with BucketNotEmpty, the thrown error is
|
|
382
|
+
* enriched with a fresh listing summary so the operator can see exactly
|
|
383
|
+
* what's left blocking deletion.
|
|
384
|
+
*
|
|
385
|
+
* @param {string} bucketName
|
|
386
|
+
* @returns {Promise<{deleted: boolean, objectsRemoved: number, warnings: string[]}>}
|
|
387
|
+
*/
|
|
388
|
+
async emptyAndDeleteBucket(bucketName) {
|
|
389
|
+
let objectsRemoved = 0;
|
|
390
|
+
const warnings = [];
|
|
391
|
+
|
|
392
|
+
// 1. Delete current objects via ListObjectsV2.
|
|
393
|
+
let continuationToken;
|
|
394
|
+
do {
|
|
395
|
+
const listResult = await this._send(
|
|
396
|
+
new ListObjectsV2Command({ Bucket: bucketName, ContinuationToken: continuationToken }),
|
|
397
|
+
);
|
|
398
|
+
if (listResult.Contents?.length > 0) {
|
|
399
|
+
await this._send(
|
|
400
|
+
new DeleteObjectsCommand({
|
|
401
|
+
Bucket: bucketName,
|
|
402
|
+
Delete: { Objects: listResult.Contents.map((obj) => ({ Key: obj.Key })) },
|
|
403
|
+
}),
|
|
404
|
+
);
|
|
405
|
+
objectsRemoved += listResult.Contents.length;
|
|
406
|
+
}
|
|
407
|
+
continuationToken = listResult.IsTruncated ? listResult.NextContinuationToken : undefined;
|
|
408
|
+
} while (continuationToken);
|
|
409
|
+
|
|
410
|
+
// 2. Delete all object versions and delete markers (required for versioned
|
|
411
|
+
// buckets — without this, DeleteBucket fails with BucketNotEmpty even after
|
|
412
|
+
// removing current objects).
|
|
413
|
+
try {
|
|
414
|
+
let keyMarker;
|
|
415
|
+
let versionIdMarker;
|
|
416
|
+
do {
|
|
417
|
+
const versionsResult = await this._send(
|
|
418
|
+
new ListObjectVersionsCommand({
|
|
419
|
+
Bucket: bucketName,
|
|
420
|
+
KeyMarker: keyMarker,
|
|
421
|
+
VersionIdMarker: versionIdMarker,
|
|
422
|
+
}),
|
|
423
|
+
);
|
|
424
|
+
const toDelete = [
|
|
425
|
+
...(versionsResult.Versions || []).map((v) => ({ Key: v.Key, VersionId: v.VersionId })),
|
|
426
|
+
...(versionsResult.DeleteMarkers || []).map((d) => ({
|
|
427
|
+
Key: d.Key,
|
|
428
|
+
VersionId: d.VersionId,
|
|
429
|
+
})),
|
|
430
|
+
];
|
|
431
|
+
if (toDelete.length > 0) {
|
|
432
|
+
await this._send(
|
|
433
|
+
new DeleteObjectsCommand({ Bucket: bucketName, Delete: { Objects: toDelete } }),
|
|
434
|
+
);
|
|
435
|
+
objectsRemoved += toDelete.length;
|
|
436
|
+
}
|
|
437
|
+
keyMarker = versionsResult.IsTruncated ? versionsResult.NextKeyMarker : undefined;
|
|
438
|
+
versionIdMarker = versionsResult.IsTruncated
|
|
439
|
+
? versionsResult.NextVersionIdMarker
|
|
440
|
+
: undefined;
|
|
441
|
+
} while (keyMarker);
|
|
442
|
+
} catch (err) {
|
|
443
|
+
if (HetznerS3Provider._isUnsupportedApiError(err)) {
|
|
444
|
+
warnings.push(`ListObjectVersions not supported by provider: ${err.name || err.message}`);
|
|
445
|
+
} else {
|
|
446
|
+
throw new Error(`Failed to list/delete object versions in ${bucketName}: ${err.message}`, {
|
|
447
|
+
cause: err,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// 3. Abort incomplete multipart uploads (can also prevent bucket deletion).
|
|
453
|
+
try {
|
|
454
|
+
const uploads = await this._send(new ListMultipartUploadsCommand({ Bucket: bucketName }));
|
|
455
|
+
for (const upload of uploads.Uploads || []) {
|
|
456
|
+
await this._send(
|
|
457
|
+
new AbortMultipartUploadCommand({
|
|
458
|
+
Bucket: bucketName,
|
|
459
|
+
Key: upload.Key,
|
|
460
|
+
UploadId: upload.UploadId,
|
|
461
|
+
}),
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
} catch (err) {
|
|
465
|
+
if (HetznerS3Provider._isUnsupportedApiError(err)) {
|
|
466
|
+
warnings.push(`ListMultipartUploads not supported by provider: ${err.name || err.message}`);
|
|
467
|
+
} else {
|
|
468
|
+
throw new Error(`Failed to abort multipart uploads in ${bucketName}: ${err.message}`, {
|
|
469
|
+
cause: err,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Now delete the empty bucket. On BucketNotEmpty (eventual consistency
|
|
475
|
+
// OR a step above silently undercounted), enrich the error with a fresh
|
|
476
|
+
// listing summary so operators don't have to open the Hetzner console
|
|
477
|
+
// to figure out what's left.
|
|
478
|
+
try {
|
|
479
|
+
await this.deleteBucket(bucketName);
|
|
480
|
+
} catch (err) {
|
|
481
|
+
const isNotEmpty = err.Code === 'BucketNotEmpty' || /not empty/i.test(err.message ?? '');
|
|
482
|
+
if (!isNotEmpty) throw err;
|
|
483
|
+
const summary = await this._summarizeBucketContents(bucketName);
|
|
484
|
+
const enriched = new Error(`${bucketName} is not empty after cleanup: ${summary}`);
|
|
485
|
+
enriched.cause = err;
|
|
486
|
+
enriched.bucketSummary = summary;
|
|
487
|
+
throw enriched;
|
|
488
|
+
}
|
|
489
|
+
return { deleted: true, objectsRemoved, warnings };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Best-effort listing of remaining bucket contents for diagnostics. Used
|
|
494
|
+
* when DeleteBucket fails with BucketNotEmpty — returns a one-line summary
|
|
495
|
+
* like `47 objects (e.g. .placeholder, public/avatar.png), 3 versions + 1
|
|
496
|
+
* delete marker, 2 incomplete multipart uploads`. Each section is
|
|
497
|
+
* independently try/caught so partial visibility beats none.
|
|
498
|
+
* @param {string} bucketName
|
|
499
|
+
* @returns {Promise<string>}
|
|
500
|
+
*/
|
|
501
|
+
async _summarizeBucketContents(bucketName) {
|
|
502
|
+
const parts = [];
|
|
503
|
+
|
|
504
|
+
try {
|
|
505
|
+
const r = await this._send(new ListObjectsV2Command({ Bucket: bucketName, MaxKeys: 5 }), {
|
|
506
|
+
maxAttempts: 1,
|
|
507
|
+
});
|
|
508
|
+
const count = r.KeyCount ?? r.Contents?.length ?? 0;
|
|
509
|
+
if (count > 0) {
|
|
510
|
+
const examples = (r.Contents ?? [])
|
|
511
|
+
.slice(0, 3)
|
|
512
|
+
.map((o) => o.Key)
|
|
513
|
+
.join(', ');
|
|
514
|
+
const more = r.IsTruncated ? '+' : '';
|
|
515
|
+
parts.push(`${count}${more} objects (e.g. ${examples})`);
|
|
516
|
+
}
|
|
517
|
+
} catch (err) {
|
|
518
|
+
parts.push(`object listing failed: ${err.name || err.message}`);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
try {
|
|
522
|
+
const r = await this._send(
|
|
523
|
+
new ListObjectVersionsCommand({ Bucket: bucketName, MaxKeys: 5 }),
|
|
524
|
+
{ maxAttempts: 1 },
|
|
525
|
+
);
|
|
526
|
+
const versionCount = (r.Versions ?? []).length;
|
|
527
|
+
const markerCount = (r.DeleteMarkers ?? []).length;
|
|
528
|
+
if (versionCount + markerCount > 0) {
|
|
529
|
+
parts.push(
|
|
530
|
+
`${versionCount} version${versionCount === 1 ? '' : 's'} + ${markerCount} delete marker${markerCount === 1 ? '' : 's'}`,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
} catch {
|
|
534
|
+
// versioning API absent — covered by primary listing
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
try {
|
|
538
|
+
const r = await this._send(new ListMultipartUploadsCommand({ Bucket: bucketName }), {
|
|
539
|
+
maxAttempts: 1,
|
|
540
|
+
});
|
|
541
|
+
const uploadCount = (r.Uploads ?? []).length;
|
|
542
|
+
if (uploadCount > 0) {
|
|
543
|
+
parts.push(`${uploadCount} incomplete multipart upload${uploadCount === 1 ? '' : 's'}`);
|
|
544
|
+
}
|
|
545
|
+
} catch {
|
|
546
|
+
// multipart API absent — covered above
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return parts.length > 0 ? parts.join(', ') : 'unable to list remaining contents';
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Heuristic: does this S3 error mean "the provider doesn't expose this API"
|
|
554
|
+
* vs. a transient or auth failure that we should surface? AWS standard
|
|
555
|
+
* error codes plus a few HTTP status fallbacks for non-AWS providers.
|
|
556
|
+
*/
|
|
557
|
+
static _isUnsupportedApiError(err) {
|
|
558
|
+
const name = err?.name ?? '';
|
|
559
|
+
const code = err?.Code ?? '';
|
|
560
|
+
const status = err?.$metadata?.httpStatusCode;
|
|
561
|
+
if (name === 'NotImplemented' || code === 'NotImplemented') return true;
|
|
562
|
+
if (name === 'MethodNotAllowed' || code === 'MethodNotAllowed') return true;
|
|
563
|
+
if (status === 405 || status === 501) return true;
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Configure CORS policy for browser uploads
|
|
569
|
+
* @param {string} bucketName - Bucket name
|
|
570
|
+
* @param {string[]} [allowedOrigins=['*']] - Allowed origins
|
|
571
|
+
* @returns {Promise<void>}
|
|
572
|
+
*/
|
|
573
|
+
async configureCORS(bucketName, allowedOrigins = ['*']) {
|
|
574
|
+
const command = new PutBucketCorsCommand({
|
|
575
|
+
Bucket: bucketName,
|
|
576
|
+
CORSConfiguration: {
|
|
577
|
+
CORSRules: [
|
|
578
|
+
{
|
|
579
|
+
AllowedHeaders: ['*'],
|
|
580
|
+
AllowedMethods: ['GET', 'PUT', 'POST', 'DELETE', 'HEAD'],
|
|
581
|
+
AllowedOrigins: allowedOrigins,
|
|
582
|
+
ExposeHeaders: ['ETag', 'Content-Length', 'Content-Type'],
|
|
583
|
+
MaxAgeSeconds: 3600,
|
|
584
|
+
},
|
|
585
|
+
],
|
|
586
|
+
},
|
|
587
|
+
});
|
|
588
|
+
await this._send(command);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Get the S3 endpoint URL for this region
|
|
593
|
+
* @returns {string}
|
|
594
|
+
*/
|
|
595
|
+
getEndpoint() {
|
|
596
|
+
return this.endpoint;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Get all available regions
|
|
601
|
+
* @returns {Object<string, string>}
|
|
602
|
+
*/
|
|
603
|
+
static getRegions() {
|
|
604
|
+
return HetznerS3Provider.REGIONS;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Get endpoint for a specific region
|
|
609
|
+
* @param {string} region - Region ID
|
|
610
|
+
* @returns {string|null}
|
|
611
|
+
*/
|
|
612
|
+
static getEndpointForRegion(region) {
|
|
613
|
+
return HetznerS3Provider.ENDPOINTS[region] || null;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Resolve the nearest S3 region for a given deployment region.
|
|
618
|
+
* Returns the region itself if S3 is available there, otherwise maps to the closest one.
|
|
619
|
+
*/
|
|
620
|
+
static resolveS3Region(deployRegion) {
|
|
621
|
+
if (HetznerS3Provider.ENDPOINTS[deployRegion]) return deployRegion;
|
|
622
|
+
|
|
623
|
+
// Map non-S3 regions to nearest S3 region
|
|
624
|
+
// hel1 is the only non-German EU S3 region, so EU regions map to fsn1 (central Germany)
|
|
625
|
+
// US regions also map to nearest EU S3 (no US S3 available)
|
|
626
|
+
const regionMap = {
|
|
627
|
+
ash: 'fsn1', // Ashburn, VA → Falkenstein (lowest latency EU)
|
|
628
|
+
hil: 'fsn1', // Hillsboro, OR → Falkenstein
|
|
629
|
+
sin: 'hel1', // Singapore → Helsinki (if ever added)
|
|
630
|
+
};
|
|
631
|
+
return regionMap[deployRegion] || 'fsn1';
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Sanitize a project name into a valid S3 bucket name
|
|
637
|
+
* S3 bucket naming rules:
|
|
638
|
+
* - 3-63 characters
|
|
639
|
+
* - Lowercase letters, numbers, and hyphens only
|
|
640
|
+
* - Must start and end with letter or number
|
|
641
|
+
* - No consecutive periods or adjacent period/hyphen
|
|
642
|
+
*
|
|
643
|
+
* @param {string} projectName - Project name to sanitize
|
|
644
|
+
* @param {string} [suffix='storage'] - Suffix to append
|
|
645
|
+
* @returns {string} - Valid bucket name
|
|
646
|
+
*/
|
|
647
|
+
export function sanitizeBucketName(projectName, suffix = 'storage') {
|
|
648
|
+
const baseName = suffix ? `${projectName}-${suffix}` : projectName;
|
|
649
|
+
|
|
650
|
+
return baseName
|
|
651
|
+
.toLowerCase()
|
|
652
|
+
.replace(/[^a-z0-9-]/g, '-') // Replace invalid chars with hyphens
|
|
653
|
+
.replace(/-+/g, '-') // Collapse multiple hyphens
|
|
654
|
+
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
|
|
655
|
+
.slice(0, 63); // Truncate to max length
|
|
656
|
+
}
|