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,755 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hetzner Cloud Provider
|
|
3
|
+
*
|
|
4
|
+
* Implementation of the BaseProvider interface for Hetzner Cloud.
|
|
5
|
+
* Provides server, SSH key, and firewall management through the Hetzner API.
|
|
6
|
+
*
|
|
7
|
+
* API Documentation: https://docs.hetzner.cloud/
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { fetchWithRetry } from '../fetch-retry.js';
|
|
11
|
+
import { BaseProvider } from './base.js';
|
|
12
|
+
|
|
13
|
+
export class HetznerProvider extends BaseProvider {
|
|
14
|
+
static NAME = 'Hetzner Cloud';
|
|
15
|
+
static API_BASE = 'https://api.hetzner.cloud/v1';
|
|
16
|
+
|
|
17
|
+
static REGIONS = {
|
|
18
|
+
fsn1: 'Falkenstein, Germany',
|
|
19
|
+
nbg1: 'Nuremberg, Germany',
|
|
20
|
+
hel1: 'Helsinki, Finland',
|
|
21
|
+
ash: 'Ashburn, Virginia, USA',
|
|
22
|
+
hil: 'Hillsboro, Oregon, USA',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Offline fallback catalog — used when the API is unreachable or no token is available.
|
|
26
|
+
// Prices and availability are approximate; live data from fetchServerTypes() takes precedence.
|
|
27
|
+
static FALLBACK_SERVER_TYPES = {
|
|
28
|
+
cx23: { vcpu: 2, ram: 4, disk: 40, price: '€4.19/mo' },
|
|
29
|
+
cx33: { vcpu: 4, ram: 8, disk: 80, price: '€7.09/mo' },
|
|
30
|
+
cx43: { vcpu: 8, ram: 16, disk: 160, price: '€13.49/mo' },
|
|
31
|
+
cx53: { vcpu: 16, ram: 32, disk: 320, price: '€27.49/mo' },
|
|
32
|
+
cx22: { vcpu: 2, ram: 4, disk: 40, price: '€3.99/mo' },
|
|
33
|
+
cx32: { vcpu: 4, ram: 8, disk: 80, price: '€6.49/mo' },
|
|
34
|
+
cx42: { vcpu: 8, ram: 16, disk: 160, price: '€11.99/mo' },
|
|
35
|
+
cx52: { vcpu: 16, ram: 32, disk: 320, price: '€22.49/mo' },
|
|
36
|
+
cpx11: { vcpu: 2, ram: 2, disk: 40, price: '€7.99/mo' },
|
|
37
|
+
cpx21: { vcpu: 3, ram: 4, disk: 80, price: '€13.99/mo' },
|
|
38
|
+
cpx22: { vcpu: 3, ram: 4, disk: 80, price: '€13.99/mo' },
|
|
39
|
+
cpx31: { vcpu: 4, ram: 8, disk: 160, price: '€25.49/mo' },
|
|
40
|
+
cpx41: { vcpu: 8, ram: 16, disk: 240, price: '€36.49/mo' },
|
|
41
|
+
cpx51: { vcpu: 16, ram: 32, disk: 360, price: '€50.49/mo' },
|
|
42
|
+
cax11: { vcpu: 2, ram: 4, disk: 40, price: '€4.49/mo' },
|
|
43
|
+
cax21: { vcpu: 4, ram: 8, disk: 80, price: '€7.99/mo' },
|
|
44
|
+
cax31: { vcpu: 8, ram: 16, disk: 160, price: '€15.99/mo' },
|
|
45
|
+
cax41: { vcpu: 16, ram: 32, disk: 320, price: '€31.49/mo' },
|
|
46
|
+
ccx13: { vcpu: 2, ram: 8, disk: 80, price: '€15.99/mo' },
|
|
47
|
+
ccx23: { vcpu: 4, ram: 16, disk: 160, price: '€31.49/mo' },
|
|
48
|
+
ccx33: { vcpu: 8, ram: 32, disk: 240, price: '€62.49/mo' },
|
|
49
|
+
ccx43: { vcpu: 16, ram: 64, disk: 360, price: '€124.99/mo' },
|
|
50
|
+
ccx53: { vcpu: 32, ram: 128, disk: 600, price: '€249.99/mo' },
|
|
51
|
+
ccx63: { vcpu: 48, ram: 192, disk: 960, price: '€374.49/mo' },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Live catalog populated by fetchServerTypes(). Falls back to FALLBACK_SERVER_TYPES.
|
|
55
|
+
static SERVER_TYPES = { ...HetznerProvider.FALLBACK_SERVER_TYPES };
|
|
56
|
+
|
|
57
|
+
// Per-location availability populated by fetchServerTypes().
|
|
58
|
+
// Maps location name → Set of available server type names.
|
|
59
|
+
static _locationTypes = null;
|
|
60
|
+
|
|
61
|
+
static DEFAULT_TYPE = 'cpx11';
|
|
62
|
+
static HA_REGIONS = ['fsn1', 'hel1'];
|
|
63
|
+
|
|
64
|
+
// Infrastructure costs (EUR/mo, as of Apr 2026)
|
|
65
|
+
static INFRA_COSTS = {
|
|
66
|
+
floatingIp: 3.57,
|
|
67
|
+
volumePerGb: 0.052, // Hetzner CSI volumes
|
|
68
|
+
s3MinBucket: 4.49, // 250 GB Object Storage minimum
|
|
69
|
+
firewall: 0, // Free
|
|
70
|
+
network: 0, // Free (first 1 TB)
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Known PVC sizes (Gi) per service
|
|
74
|
+
static VOLUME_SIZES = {
|
|
75
|
+
base: 30, // postgres 20Gi + storage 10Gi
|
|
76
|
+
observability: 25, // prometheus 10Gi + loki 10Gi + grafana 5Gi
|
|
77
|
+
n8n: 5,
|
|
78
|
+
metabase: 1,
|
|
79
|
+
redis: 1,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
static EU_REGIONS = ['fsn1', 'nbg1', 'hel1'];
|
|
83
|
+
static US_REGIONS = ['ash', 'hil'];
|
|
84
|
+
|
|
85
|
+
// Prefix-based fallback for region filtering when live data is unavailable.
|
|
86
|
+
static REGION_TYPE_PREFIXES = {
|
|
87
|
+
us: ['cpx', 'ccx'],
|
|
88
|
+
eu: ['cx', 'cpx', 'cax', 'ccx'],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Fetch server types and per-location pricing from the Hetzner API.
|
|
93
|
+
* Populates SERVER_TYPES with live data and caches per-location availability.
|
|
94
|
+
* Safe to call multiple times — returns cached data after the first successful fetch.
|
|
95
|
+
* @param {string} apiToken - Hetzner API token
|
|
96
|
+
* @returns {Promise<boolean>} true if live data was loaded, false on failure (fallback used)
|
|
97
|
+
*/
|
|
98
|
+
static async fetchServerTypes(apiToken) {
|
|
99
|
+
if (HetznerProvider._locationTypes) return true; // already fetched
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const response = await fetchWithRetry(
|
|
103
|
+
`${HetznerProvider.API_BASE}/server_types?per_page=50`,
|
|
104
|
+
{
|
|
105
|
+
headers: { Authorization: `Bearer ${apiToken}` },
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
if (!response.ok) return false;
|
|
109
|
+
|
|
110
|
+
const data = await response.json();
|
|
111
|
+
const types = {};
|
|
112
|
+
const locationTypes = {};
|
|
113
|
+
|
|
114
|
+
for (const t of data.server_types) {
|
|
115
|
+
if (t.deprecation) continue;
|
|
116
|
+
|
|
117
|
+
// Build per-location availability from the `locations` field (actual availability),
|
|
118
|
+
// not `prices` (which includes regions where the type is priced but not placeable).
|
|
119
|
+
for (const loc of t.locations || []) {
|
|
120
|
+
const locName = loc.name || loc;
|
|
121
|
+
if (loc.deprecation) continue;
|
|
122
|
+
if (!locationTypes[locName]) locationTypes[locName] = new Set();
|
|
123
|
+
locationTypes[locName].add(t.name);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Build per-location price map
|
|
127
|
+
const locationPrices = {};
|
|
128
|
+
for (const p of t.prices || []) {
|
|
129
|
+
const loc = p.location;
|
|
130
|
+
if (loc) {
|
|
131
|
+
locationPrices[loc] = `€${parseFloat(p.price_monthly.gross).toFixed(2)}/mo`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Use first available location price as the default display price
|
|
136
|
+
const firstPrice = t.prices[0];
|
|
137
|
+
const defaultMonthly = firstPrice
|
|
138
|
+
? `€${parseFloat(firstPrice.price_monthly.gross).toFixed(2)}/mo`
|
|
139
|
+
: '';
|
|
140
|
+
|
|
141
|
+
types[t.name] = {
|
|
142
|
+
vcpu: t.cores,
|
|
143
|
+
ram: t.memory,
|
|
144
|
+
disk: t.disk,
|
|
145
|
+
price: defaultMonthly,
|
|
146
|
+
locationPrices,
|
|
147
|
+
cpuType: t.cpu_type,
|
|
148
|
+
architecture: t.architecture,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
HetznerProvider.SERVER_TYPES = types;
|
|
153
|
+
HetznerProvider._locationTypes = locationTypes;
|
|
154
|
+
return true;
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Returns region-appropriate default server types.
|
|
162
|
+
*
|
|
163
|
+
* Hetzner's SKU availability is split by geography:
|
|
164
|
+
* - EU (fsn1/nbg1/hel1): `cx*` (Intel shared) + `cax*` (ARM) + `ccx*` (dedicated)
|
|
165
|
+
* - US (ash/hil): `cpx*` (AMD shared) + `cax*` (ARM)
|
|
166
|
+
*
|
|
167
|
+
* We prefer x86 shared-vCPU for cluster nodes (ARM mixes poorly with
|
|
168
|
+
* amd64-only Helm charts like Supabase). Prefer tiers in this order for
|
|
169
|
+
* each role, falling back to the first available:
|
|
170
|
+
* - master/worker: 2 vCPU, 2–4 GB RAM
|
|
171
|
+
* - supabase: 2–4 vCPU, 4+ GB RAM
|
|
172
|
+
*/
|
|
173
|
+
static getRegionDefaults(region) {
|
|
174
|
+
const available = HetznerProvider._locationTypes?.[region];
|
|
175
|
+
|
|
176
|
+
// Preference lists — order matters. First available in the region wins.
|
|
177
|
+
const smallShared = ['cpx11', 'cx23', 'cx22']; // ~2 vCPU, 2–4 GB
|
|
178
|
+
const mediumShared = ['cx23', 'cpx21', 'cx33']; // 2–4 vCPU, 4–8 GB
|
|
179
|
+
|
|
180
|
+
const pickAvailable = (prefs, fallback) => {
|
|
181
|
+
if (available) {
|
|
182
|
+
for (const t of prefs) if (available.has(t)) return t;
|
|
183
|
+
}
|
|
184
|
+
return fallback;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
if (available) {
|
|
188
|
+
return {
|
|
189
|
+
masterType: pickAvailable(smallShared, 'cpx11'),
|
|
190
|
+
supabaseType: pickAvailable(mediumShared, 'cpx21'),
|
|
191
|
+
workerType: pickAvailable(smallShared, 'cpx11'),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Offline fallback — assume US (cpx available) since EU cx SKUs rotate
|
|
196
|
+
// faster (cx11→cx21→cx22→cx23 in five years) and are more likely to be
|
|
197
|
+
// stale in a hard-coded fallback.
|
|
198
|
+
return { masterType: 'cpx11', supabaseType: 'cpx21', workerType: 'cpx11' };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Returns the equivalent server type available in a target region.
|
|
203
|
+
* Maps across architectures (e.g. cax11 → cpx11 when ARM unavailable).
|
|
204
|
+
* Returns the original type if it's available, or the best match otherwise.
|
|
205
|
+
*/
|
|
206
|
+
static resolveServerTypeForRegion(serverType, targetRegion) {
|
|
207
|
+
const available = HetznerProvider._locationTypes?.[targetRegion];
|
|
208
|
+
|
|
209
|
+
// If live data available, check directly
|
|
210
|
+
if (available) {
|
|
211
|
+
if (available.has(serverType)) return serverType;
|
|
212
|
+
|
|
213
|
+
// Map cax→cpx (ARM→shared x86) or cpx→cax (x86→ARM) by numeric suffix
|
|
214
|
+
const suffix = serverType.replace(/^[a-z]+/, '');
|
|
215
|
+
if (serverType.startsWith('cax')) {
|
|
216
|
+
const alt = `cpx${suffix}`;
|
|
217
|
+
if (available.has(alt)) return alt;
|
|
218
|
+
} else if (serverType.startsWith('cpx')) {
|
|
219
|
+
const alt = `cax${suffix}`;
|
|
220
|
+
if (available.has(alt)) return alt;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Fall back to region defaults at same tier
|
|
224
|
+
const defaults = HetznerProvider.getRegionDefaults(targetRegion);
|
|
225
|
+
if (suffix.startsWith('1')) return defaults.masterType;
|
|
226
|
+
if (suffix.startsWith('2')) return defaults.supabaseType;
|
|
227
|
+
return defaults.workerType;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Offline fallback: EU regions have ARM, US regions don't.
|
|
231
|
+
// Only map to types that exist in the fallback catalog — blindly swapping
|
|
232
|
+
// the prefix can produce invalid types (e.g. cpx22 → cax22 which doesn't exist).
|
|
233
|
+
if (HetznerProvider.US_REGIONS.includes(targetRegion) && serverType.startsWith('cax')) {
|
|
234
|
+
const alt = `cpx${serverType.replace(/^cax/, '')}`;
|
|
235
|
+
if (HetznerProvider.FALLBACK_SERVER_TYPES[alt]) return alt;
|
|
236
|
+
}
|
|
237
|
+
if (HetznerProvider.EU_REGIONS.includes(targetRegion) && serverType.startsWith('cpx')) {
|
|
238
|
+
const alt = `cax${serverType.replace(/^cpx/, '')}`;
|
|
239
|
+
if (HetznerProvider.FALLBACK_SERVER_TYPES[alt]) return alt;
|
|
240
|
+
}
|
|
241
|
+
return serverType;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Returns server types available in the given region.
|
|
246
|
+
* Uses live per-location data when available, otherwise falls back to prefix filtering.
|
|
247
|
+
*/
|
|
248
|
+
static getServerTypesForRegion(region) {
|
|
249
|
+
const available = HetznerProvider._locationTypes?.[region];
|
|
250
|
+
|
|
251
|
+
if (available) {
|
|
252
|
+
return [...available]
|
|
253
|
+
.filter((name) => name in HetznerProvider.SERVER_TYPES)
|
|
254
|
+
.map((name) => {
|
|
255
|
+
const spec = HetznerProvider.SERVER_TYPES[name];
|
|
256
|
+
const price = spec.locationPrices?.[region] || spec.price;
|
|
257
|
+
return { name, ...spec, price };
|
|
258
|
+
})
|
|
259
|
+
.sort((a, b) => a.vcpu - b.vcpu || a.ram - b.ram);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Offline fallback — filter by prefix
|
|
263
|
+
const prefixes = HetznerProvider.US_REGIONS.includes(region)
|
|
264
|
+
? HetznerProvider.REGION_TYPE_PREFIXES.us
|
|
265
|
+
: HetznerProvider.REGION_TYPE_PREFIXES.eu;
|
|
266
|
+
return Object.entries(HetznerProvider.SERVER_TYPES)
|
|
267
|
+
.filter(([name]) => prefixes.some((prefix) => name.startsWith(prefix)))
|
|
268
|
+
.map(([name, info]) => ({ name, ...info }));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Returns the monthly price for a server type in a specific location.
|
|
273
|
+
* Only available after fetchServerTypes() — returns null otherwise.
|
|
274
|
+
*/
|
|
275
|
+
static getPrice(serverType, location) {
|
|
276
|
+
const spec = HetznerProvider.SERVER_TYPES[serverType];
|
|
277
|
+
if (!spec) return null;
|
|
278
|
+
return (location && spec.locationPrices?.[location]) || spec.price || null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Create a new Hetzner server
|
|
283
|
+
* @param {object} config - Server configuration
|
|
284
|
+
* @returns {Promise<{id: number, server: object}>}
|
|
285
|
+
*/
|
|
286
|
+
async createServer(config) {
|
|
287
|
+
const {
|
|
288
|
+
name,
|
|
289
|
+
serverType,
|
|
290
|
+
region,
|
|
291
|
+
sshKeyId,
|
|
292
|
+
sshKeys,
|
|
293
|
+
environment,
|
|
294
|
+
image,
|
|
295
|
+
firewalls,
|
|
296
|
+
labels,
|
|
297
|
+
userData,
|
|
298
|
+
} = config;
|
|
299
|
+
|
|
300
|
+
const body = {
|
|
301
|
+
name,
|
|
302
|
+
server_type: serverType,
|
|
303
|
+
location: region,
|
|
304
|
+
image: image || 'docker-ce',
|
|
305
|
+
ssh_keys: sshKeys || (sshKeyId ? [sshKeyId] : []),
|
|
306
|
+
public_net: {
|
|
307
|
+
enable_ipv4: true,
|
|
308
|
+
enable_ipv6: true,
|
|
309
|
+
},
|
|
310
|
+
labels: labels || {
|
|
311
|
+
'managed-by': 'vibecarbon',
|
|
312
|
+
environment: environment || 'default',
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
if (firewalls?.length) {
|
|
316
|
+
body.firewalls = firewalls.map((id) => ({ firewall: id }));
|
|
317
|
+
}
|
|
318
|
+
// user_data runs at boot time via cloud-init. Hetzner accepts either a
|
|
319
|
+
// raw shell script or a #cloud-config YAML document up to 32KB. We pass
|
|
320
|
+
// whatever string the caller supplies; compose deploy uses a cloud-config
|
|
321
|
+
// YAML that front-loads ufw + unattended-upgrades installs so setupServer
|
|
322
|
+
// can shrink to a marker-file probe.
|
|
323
|
+
if (userData) {
|
|
324
|
+
body.user_data = userData;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Retry on transient resource_limit_exceeded — observed when several
|
|
328
|
+
// scenarios concurrently provision in a quota-tight project (e.g.
|
|
329
|
+
// matrix-mode e2e + W1.5a parallel compose-ha scale doubled
|
|
330
|
+
// the in-flight Primary IP allocations and tripped Hetzner's project
|
|
331
|
+
// quota at iter-reliab 2026-05-01T14:13Z). Old servers being torn
|
|
332
|
+
// down free their Primary IPs within 30-90s, so backing off and
|
|
333
|
+
// retrying tends to succeed without operator intervention. If the
|
|
334
|
+
// quota is genuinely too low for the workload, we still surface the
|
|
335
|
+
// error after exhausting attempts.
|
|
336
|
+
const RESOURCE_LIMIT_DELAYS_MS = [15_000, 45_000, 90_000];
|
|
337
|
+
let response;
|
|
338
|
+
let attemptError = null;
|
|
339
|
+
for (let attempt = 0; attempt <= RESOURCE_LIMIT_DELAYS_MS.length; attempt++) {
|
|
340
|
+
response = await this.apiRequest('/servers', {
|
|
341
|
+
method: 'POST',
|
|
342
|
+
body: JSON.stringify(body),
|
|
343
|
+
});
|
|
344
|
+
if (response.ok) break;
|
|
345
|
+
// Body is read at most once per attempt; reuse `attemptError` rather
|
|
346
|
+
// than `response.clone()` so the WHATWG fetch shim and the test mock
|
|
347
|
+
// (which doesn't implement clone()) both work.
|
|
348
|
+
attemptError = await response.json();
|
|
349
|
+
const peekCode = attemptError.error?.code || '';
|
|
350
|
+
const peekMessage = attemptError.error?.message || '';
|
|
351
|
+
const isResourceLimit =
|
|
352
|
+
peekCode === 'resource_limit_exceeded' || /resource_limit_exceeded/i.test(peekMessage);
|
|
353
|
+
if (!isResourceLimit || attempt === RESOURCE_LIMIT_DELAYS_MS.length) {
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
// Log to stderr so operator (or post-run analysis) can see we're
|
|
357
|
+
// queueing rather than dying on first quota bump.
|
|
358
|
+
console.warn(
|
|
359
|
+
`[hetzner] createServer(${name}): quota busy (${peekMessage}); ` +
|
|
360
|
+
`retrying in ${RESOURCE_LIMIT_DELAYS_MS[attempt] / 1000}s ` +
|
|
361
|
+
`(attempt ${attempt + 1}/${RESOURCE_LIMIT_DELAYS_MS.length + 1})`,
|
|
362
|
+
);
|
|
363
|
+
await new Promise((r) => setTimeout(r, RESOURCE_LIMIT_DELAYS_MS[attempt]));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!response.ok) {
|
|
367
|
+
const error = attemptError ?? { error: { message: 'Unknown error', code: 'unknown' } };
|
|
368
|
+
const message = error.error?.message || 'Unknown error';
|
|
369
|
+
const code = error.error?.code || 'unknown';
|
|
370
|
+
|
|
371
|
+
let hint = '';
|
|
372
|
+
if (code === 'invalid_input' && message.includes('unsupported location')) {
|
|
373
|
+
hint = `\nHint: Server type "${serverType}" is not available in region "${region}". Try a different server type or region.`;
|
|
374
|
+
} else if (code === 'uniqueness_error') {
|
|
375
|
+
// Recover: look up the existing server by name and return it
|
|
376
|
+
const listResponse = await this.apiRequest(`/servers?name=${encodeURIComponent(name)}`);
|
|
377
|
+
if (listResponse.ok) {
|
|
378
|
+
const listData = await listResponse.json();
|
|
379
|
+
const existing = listData.servers?.find((s) => s.name === name);
|
|
380
|
+
if (existing) {
|
|
381
|
+
return { id: existing.id, server: existing, reused: true };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
hint = `\nHint: A server with this name already exists. Use --destroy first or choose a different environment name.`;
|
|
385
|
+
} else if (code === 'unauthorized') {
|
|
386
|
+
hint = '\nHint: Check that your API token is valid and has Read & Write permissions.';
|
|
387
|
+
} else if (code === 'resource_limit_exceeded') {
|
|
388
|
+
hint =
|
|
389
|
+
'\nHint: Project quota exhausted even after retries. Either wait for in-flight tear-downs to free resources, or raise the project quota in the Hetzner Console (Resources → Limits).';
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
throw new Error(`Hetzner API error: ${message} (${code})${hint}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const data = await response.json();
|
|
396
|
+
return { id: data.server.id, server: data.server };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Delete a server
|
|
401
|
+
* @param {number} serverId - Server ID
|
|
402
|
+
*/
|
|
403
|
+
async deleteServer(serverId) {
|
|
404
|
+
const response = await this.apiRequest(`/servers/${serverId}`, {
|
|
405
|
+
method: 'DELETE',
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
if (!response.ok && response.status !== 404) {
|
|
409
|
+
const error = await response.json();
|
|
410
|
+
throw new Error(`Failed to delete server: ${error.error?.message || 'Unknown error'}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Get server details
|
|
416
|
+
* @param {number} serverId - Server ID
|
|
417
|
+
* @returns {Promise<object>}
|
|
418
|
+
*/
|
|
419
|
+
async getServer(serverId) {
|
|
420
|
+
const response = await this.apiRequest(`/servers/${serverId}`);
|
|
421
|
+
|
|
422
|
+
if (!response.ok) {
|
|
423
|
+
const error = await response.json();
|
|
424
|
+
throw new Error(`Failed to get server: ${error.error?.message || 'Unknown error'}`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const data = await response.json();
|
|
428
|
+
return data.server;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Wait for a server to become ready
|
|
433
|
+
* @param {number} serverId - Server ID
|
|
434
|
+
* @param {number} [timeout=300000] - Timeout in ms (default 5 minutes)
|
|
435
|
+
* @returns {Promise<object>}
|
|
436
|
+
*/
|
|
437
|
+
async waitForServer(serverId, timeout = 300000) {
|
|
438
|
+
const startTime = Date.now();
|
|
439
|
+
const pollInterval = 5000;
|
|
440
|
+
|
|
441
|
+
while (Date.now() - startTime < timeout) {
|
|
442
|
+
try {
|
|
443
|
+
const response = await this.apiRequest(`/servers/${serverId}`);
|
|
444
|
+
const { server } = await response.json();
|
|
445
|
+
|
|
446
|
+
if (server.status === 'running') {
|
|
447
|
+
return server;
|
|
448
|
+
}
|
|
449
|
+
} catch (error) {
|
|
450
|
+
// Network errors - continue retrying
|
|
451
|
+
if (Date.now() - startTime >= timeout - pollInterval) {
|
|
452
|
+
throw new Error(`Failed to check server status: ${error.message}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
await new Promise((r) => setTimeout(r, pollInterval));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
throw new Error('Server creation timed out');
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Create or update an SSH key
|
|
464
|
+
* @param {string} name - SSH key name
|
|
465
|
+
* @param {string} publicKey - Public key content
|
|
466
|
+
* @returns {Promise<number>} SSH key ID
|
|
467
|
+
*/
|
|
468
|
+
async createSSHKey(name, publicKey) {
|
|
469
|
+
// Hetzner paginates /ssh_keys at 25 entries/page by default. Long-lived
|
|
470
|
+
// Hetzner projects accumulate dozens of keys; with the default pagination
|
|
471
|
+
// the dedup scan below would silently miss keys on pages 2+, and we'd hit
|
|
472
|
+
// the very 409/"not unique" error the dedup is meant to prevent. Ask for
|
|
473
|
+
// the max allowed page size (50) and still walk pagination defensively.
|
|
474
|
+
const ssh_keys = [];
|
|
475
|
+
let page = 1;
|
|
476
|
+
for (let guard = 0; guard < 20; guard++) {
|
|
477
|
+
const resp = await this.apiRequest(`/ssh_keys?per_page=50&page=${page}`);
|
|
478
|
+
const body = await resp.json();
|
|
479
|
+
if (Array.isArray(body.ssh_keys)) ssh_keys.push(...body.ssh_keys);
|
|
480
|
+
const next = body.meta?.pagination?.next_page;
|
|
481
|
+
if (!next) break;
|
|
482
|
+
page = next;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const newKeyPart = publicKey.split(' ').slice(0, 2).join(' ');
|
|
486
|
+
|
|
487
|
+
// 1. If a key with the same name already exists, prefer it (by-name lookup).
|
|
488
|
+
const existingByName = ssh_keys.find((k) => k.name === name);
|
|
489
|
+
if (existingByName) {
|
|
490
|
+
const existingKeyPart = existingByName.public_key.split(' ').slice(0, 2).join(' ');
|
|
491
|
+
if (existingKeyPart === newKeyPart) {
|
|
492
|
+
return existingByName.id;
|
|
493
|
+
}
|
|
494
|
+
// Same name but different key — delete so we can recreate.
|
|
495
|
+
await this.apiRequest(`/ssh_keys/${existingByName.id}`, { method: 'DELETE' });
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// 2. Hetzner also rejects `POST /ssh_keys` with 409/"not unique" if the
|
|
499
|
+
// public-key bytes are already registered under a DIFFERENT name (common
|
|
500
|
+
// in e2e test reruns that share a single dev key pair). Scan the
|
|
501
|
+
// list a second time by key content and reuse if found.
|
|
502
|
+
const existingByKey = ssh_keys.find((k) => {
|
|
503
|
+
const kPart = k.public_key.split(' ').slice(0, 2).join(' ');
|
|
504
|
+
return kPart === newKeyPart;
|
|
505
|
+
});
|
|
506
|
+
if (existingByKey) {
|
|
507
|
+
return existingByKey.id;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// 3. Truly new — create it. Always tag with `managed-by=vibecarbon` so
|
|
511
|
+
// post-run sweeps that filter by label_selector reap it on cleanup.
|
|
512
|
+
// Without the label, leaked keys only show up in the name-prefix
|
|
513
|
+
// preflight scan and silently block the next e2e run.
|
|
514
|
+
const createResponse = await this.apiRequest('/ssh_keys', {
|
|
515
|
+
method: 'POST',
|
|
516
|
+
body: JSON.stringify({
|
|
517
|
+
name,
|
|
518
|
+
public_key: publicKey,
|
|
519
|
+
labels: { 'managed-by': 'vibecarbon' },
|
|
520
|
+
}),
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
if (!createResponse.ok) {
|
|
524
|
+
const error = await createResponse.json();
|
|
525
|
+
throw new Error(`Failed to create SSH key: ${error.error?.message || 'Unknown error'}`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const { ssh_key } = await createResponse.json();
|
|
529
|
+
return ssh_key.id;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Delete an SSH key
|
|
534
|
+
* @param {number} keyId - SSH key ID
|
|
535
|
+
*/
|
|
536
|
+
async deleteSSHKey(keyId) {
|
|
537
|
+
const response = await this.apiRequest(`/ssh_keys/${keyId}`, {
|
|
538
|
+
method: 'DELETE',
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
if (!response.ok && response.status !== 404) {
|
|
542
|
+
const error = await response.json();
|
|
543
|
+
throw new Error(`Failed to delete SSH key: ${error.error?.message || 'Unknown error'}`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Create a firewall and attach to server
|
|
549
|
+
* @param {string} name - Firewall name
|
|
550
|
+
* @param {number} serverId - Server ID
|
|
551
|
+
* @param {number[]} [ports=[22, 80, 443, 3000]] - Ports to open
|
|
552
|
+
* @returns {Promise<object>}
|
|
553
|
+
*/
|
|
554
|
+
async createFirewall(name, serverId, ports = [22, 80, 443, 3000]) {
|
|
555
|
+
// Reuse existing firewall with the same name
|
|
556
|
+
const allFirewalls = await this.listFirewalls();
|
|
557
|
+
const existing = allFirewalls.find((fw) => fw.name === name);
|
|
558
|
+
if (existing) {
|
|
559
|
+
return { firewall: existing };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const rules = ports.map((port) => ({
|
|
563
|
+
direction: 'in',
|
|
564
|
+
protocol: 'tcp',
|
|
565
|
+
port: String(port),
|
|
566
|
+
source_ips: ['0.0.0.0/0', '::/0'],
|
|
567
|
+
}));
|
|
568
|
+
|
|
569
|
+
const body = { name, rules };
|
|
570
|
+
if (serverId) {
|
|
571
|
+
body.apply_to = [{ type: 'server', server: { id: serverId } }];
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const response = await this.apiRequest('/firewalls', {
|
|
575
|
+
method: 'POST',
|
|
576
|
+
body: JSON.stringify(body),
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
if (!response.ok) {
|
|
580
|
+
const error = await response.json();
|
|
581
|
+
throw new Error(`Failed to create firewall: ${error.error?.message || 'Unknown error'}`);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return response.json();
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Delete a firewall
|
|
589
|
+
* @param {number} firewallId - Firewall ID
|
|
590
|
+
*/
|
|
591
|
+
async deleteFirewall(firewallId) {
|
|
592
|
+
const response = await this.apiRequest(`/firewalls/${firewallId}`, {
|
|
593
|
+
method: 'DELETE',
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
if (!response.ok && response.status !== 404) {
|
|
597
|
+
const error = await response.json();
|
|
598
|
+
throw new Error(`Failed to delete firewall: ${error.error?.message || 'Unknown error'}`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* List servers with optional label filter
|
|
604
|
+
* @param {object} labels - Labels to filter by
|
|
605
|
+
* @returns {Promise<object[]>}
|
|
606
|
+
*/
|
|
607
|
+
async listServers(labels = {}) {
|
|
608
|
+
let url = '/servers';
|
|
609
|
+
|
|
610
|
+
// Build label selector query
|
|
611
|
+
if (Object.keys(labels).length > 0) {
|
|
612
|
+
const selector = Object.entries(labels)
|
|
613
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
614
|
+
.join(',');
|
|
615
|
+
url += `?label_selector=${encodeURIComponent(selector)}`;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const response = await this.apiRequest(url);
|
|
619
|
+
const data = await response.json();
|
|
620
|
+
return data.servers || [];
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* List firewalls with optional label filter
|
|
625
|
+
* @param {object} labels - Labels to filter by
|
|
626
|
+
* @returns {Promise<object[]>}
|
|
627
|
+
*/
|
|
628
|
+
async listFirewalls(labels = {}) {
|
|
629
|
+
let url = '/firewalls';
|
|
630
|
+
|
|
631
|
+
if (Object.keys(labels).length > 0) {
|
|
632
|
+
const selector = Object.entries(labels)
|
|
633
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
634
|
+
.join(',');
|
|
635
|
+
url += `?label_selector=${encodeURIComponent(selector)}`;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const response = await this.apiRequest(url);
|
|
639
|
+
const data = await response.json();
|
|
640
|
+
return data.firewalls || [];
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* List all SSH keys
|
|
645
|
+
* @returns {Promise<object[]>}
|
|
646
|
+
*/
|
|
647
|
+
async listSSHKeys() {
|
|
648
|
+
const response = await this.apiRequest('/ssh_keys');
|
|
649
|
+
const data = await response.json();
|
|
650
|
+
return data.ssh_keys || [];
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Power off a server
|
|
655
|
+
* @param {number} serverId - Server ID
|
|
656
|
+
* @returns {Promise<object>} Action object
|
|
657
|
+
*/
|
|
658
|
+
async poweroffServer(serverId) {
|
|
659
|
+
const response = await this.apiRequest(`/servers/${serverId}/actions/poweroff`, {
|
|
660
|
+
method: 'POST',
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
if (!response.ok) {
|
|
664
|
+
const error = await response.json();
|
|
665
|
+
throw new Error(`Failed to power off server: ${error.error?.message || 'Unknown error'}`);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const data = await response.json();
|
|
669
|
+
return data.action;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Power on a server
|
|
674
|
+
* @param {number} serverId - Server ID
|
|
675
|
+
* @returns {Promise<object>} Action object
|
|
676
|
+
*/
|
|
677
|
+
async poweronServer(serverId) {
|
|
678
|
+
const response = await this.apiRequest(`/servers/${serverId}/actions/poweron`, {
|
|
679
|
+
method: 'POST',
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
if (!response.ok) {
|
|
683
|
+
const error = await response.json();
|
|
684
|
+
throw new Error(`Failed to power on server: ${error.error?.message || 'Unknown error'}`);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const data = await response.json();
|
|
688
|
+
return data.action;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Change the type of a server (server must be powered off)
|
|
693
|
+
* @param {number} serverId - Server ID
|
|
694
|
+
* @param {string} serverType - New server type name (e.g. 'cpx31')
|
|
695
|
+
* @param {boolean} [upgradeDisk=true] - Whether to upgrade disk size (irreversible)
|
|
696
|
+
* @returns {Promise<object>} Action object
|
|
697
|
+
*/
|
|
698
|
+
async changeServerType(serverId, serverType, upgradeDisk = true) {
|
|
699
|
+
const response = await this.apiRequest(`/servers/${serverId}/actions/change_type`, {
|
|
700
|
+
method: 'POST',
|
|
701
|
+
body: JSON.stringify({ server_type: serverType, upgrade_disk: upgradeDisk }),
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
if (!response.ok) {
|
|
705
|
+
const error = await response.json();
|
|
706
|
+
throw new Error(`Failed to change server type: ${error.error?.message || 'Unknown error'}`);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const data = await response.json();
|
|
710
|
+
return data.action;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Wait for an action to complete
|
|
715
|
+
* @param {number} actionId - Action ID
|
|
716
|
+
* @param {number} [timeout=300000] - Timeout in ms (default 5 minutes)
|
|
717
|
+
* @returns {Promise<object>} Completed action object
|
|
718
|
+
*/
|
|
719
|
+
async waitForAction(actionId, timeout = 300000) {
|
|
720
|
+
const startTime = Date.now();
|
|
721
|
+
const pollInterval = 5000;
|
|
722
|
+
|
|
723
|
+
while (Date.now() - startTime < timeout) {
|
|
724
|
+
const response = await this.apiRequest(`/actions/${actionId}`);
|
|
725
|
+
const { action } = await response.json();
|
|
726
|
+
|
|
727
|
+
if (action.status === 'success') return action;
|
|
728
|
+
if (action.status === 'error') {
|
|
729
|
+
throw new Error(`Action failed: ${action.error?.message || 'Unknown error'}`);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
await new Promise((r) => setTimeout(r, pollInterval));
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
throw new Error('Action timed out');
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Get public IPv4 address of a server
|
|
740
|
+
* @param {object} server - Server object
|
|
741
|
+
* @returns {string|null}
|
|
742
|
+
*/
|
|
743
|
+
static getPublicIP(server) {
|
|
744
|
+
return server?.public_net?.ipv4?.ip || null;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Get public IPv6 address of a server
|
|
749
|
+
* @param {object} server - Server object
|
|
750
|
+
* @returns {string|null}
|
|
751
|
+
*/
|
|
752
|
+
static getPublicIPv6(server) {
|
|
753
|
+
return server?.public_net?.ipv6?.ip || null;
|
|
754
|
+
}
|
|
755
|
+
}
|