package cloudcp import ( "context" "fmt" "net/http" "os" "os/signal" "strings" "syscall" "time" cpauth "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/auth" cpDocker "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/docker" "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/email" "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry" cpstripe "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/stripe" "github.com/rcourtman/pulse-go-rewrite/internal/logging" "github.com/rs/zerolog/log" ) // Run starts the control plane HTTP server with graceful shutdown. func Run(ctx context.Context, version string) error { logging.Init(logging.Config{ Format: "auto", Level: "info", Component: "control-plane", }) defer logging.Shutdown() log.Info().Str("version", version).Msg("Starting Pulse Cloud Control Plane") cfg, err := LoadConfig() if err != nil { return fmt.Errorf("load config: %w", err) } // Ensure data directories exist if err := os.MkdirAll(cfg.TenantsDir(), 0o755); err != nil { return fmt.Errorf("create tenants dir: %w", err) } if err := os.MkdirAll(cfg.ControlPlaneDir(), 0o755); err != nil { return fmt.Errorf("create control-plane dir: %w", err) } // Open tenant registry reg, err := registry.NewTenantRegistry(cfg.ControlPlaneDir()) if err != nil { return fmt.Errorf("open tenant registry: %w", err) } defer reg.Close() // Initialize Docker manager (best-effort — control plane can run without Docker for dev/testing) var dockerMgr *cpDocker.Manager dockerMgr, err = cpDocker.NewManager(cpDocker.ManagerConfig{ Image: cfg.PulseImage, Network: cfg.DockerNetwork, IsolateTenantNetworks: cfg.IsMSPControlPlane(), BaseDomain: baseDomainFromURL(cfg.BaseURL), TrialActivationPublicKey: cfg.TrialActivationPublicKey, ControlPlaneBaseURL: cfg.BaseURL, TrustedProxyCIDRs: cfg.TrustedProxyCIDRs, TenantDisplayName: tenantDisplayNameResolver(reg), TenantReportBrand: cpDocker.TenantReportBrandConfig{ DisplayName: cfg.ReportBrandDisplayName, LogoPath: cfg.ReportBrandLogoPath, LogoBase64: cfg.ReportBrandLogoBase64, LogoFormat: cfg.ReportBrandLogoFormat, }, MemoryLimit: cfg.TenantMemoryLimit, CPUShares: cfg.TenantCPUShares, TenantLogMaxSize: cfg.TenantLogMaxSize, TenantLogMaxFile: cfg.TenantLogMaxFile, }) if err != nil { log.Warn().Err(err).Msg("Docker unavailable — container management disabled") dockerMgr = nil } else { defer dockerMgr.Close() } // Initialize magic link service magicLinkSvc, err := cpauth.NewService(cfg.ControlPlaneDir()) if err != nil { return fmt.Errorf("init magic link service: %w", err) } defer magicLinkSvc.Close() magicLinkSvc.SetSessionTTL(cfg.SessionTTL) // Initialize email sender var emailSender email.Sender if cfg.ResendAPIKey != "" { emailSender = email.NewResendSender(cfg.ResendAPIKey, cfg.EmailReplyTo) log.Info().Msg("Email sender configured (Resend)") } else if cfg.RequireEmailProvider { return fmt.Errorf("email provider required but RESEND_API_KEY is not configured") } else { emailSender = email.NewLogSender(func(to, subject, body string) { const maxBody = 4096 bodyForLog := body if len(bodyForLog) > maxBody { bodyForLog = bodyForLog[:maxBody] + "...(truncated)" } log.Info(). Str("to", to). Str("subject", subject). Str("body", bodyForLog). Msg("Email (log-only, no email provider configured)") }) log.Info().Msg("Email sender: log-only (set RESEND_API_KEY to enable)") } // Build HTTP routes mux := http.NewServeMux() hostedEntitlements := NewHostedEntitlementsService(cfg, reg) provisioner := cpstripe.NewProvisioner( reg, cfg.TenantsDir(), dockerMgr, magicLinkSvc, cfg.BaseURL, emailSender, cfg.EmailFrom, cfg.AllowDockerlessProvisioning, cpstripe.WithHostedEntitlementService(hostedEntitlements), cpstripe.WithTrialActivationPrivateKey(cfg.TrialActivationPrivateKey), cpstripe.WithAdmissionCheck(func(checkCtx context.Context) error { return EnforceStorageAdmission(checkCtx, cfg, dockerMgr) }), ) deps := &Deps{ Config: cfg, Registry: reg, Docker: dockerMgr, MagicLinks: magicLinkSvc, Provisioner: provisioner, HostedEntitlements: hostedEntitlements, Version: version, EmailSender: emailSender, } RegisterRoutes(mux, deps) addr := fmt.Sprintf("%s:%d", cfg.BindAddress, cfg.Port) srv := &http.Server{ Addr: addr, Handler: CPSecurityHeaders(mux), ReadHeaderTimeout: 15 * time.Second, IdleTimeout: 120 * time.Second, } // Create derived context for background goroutines ctx, cancel := context.WithCancel(ctx) defer cancel() // Start health monitor if Docker is available if dockerMgr != nil { monitor := NewMonitor(reg, dockerMgr, MonitorConfig{ Interval: 60 * time.Second, RestartOnFail: true, FailThreshold: 3, }) go monitor.Run(ctx) } // Start grace period enforcer graceEnforcer := cpstripe.NewGraceEnforcer(reg, provisioner) go graceEnforcer.Run(ctx) // Start Stripe billing reconciler (best effort; no-op when STRIPE_API_KEY is unset). reconciler := cpstripe.NewReconciler(reg, provisioner, cfg.StripeAPIKey) go reconciler.Run(ctx) // Start stuck provisioning cleanup stuckCleanup := NewStuckProvisioningCleanup(reg) go stuckCleanup.Run(ctx) // Start metrics updater go runTenantStateMetrics(ctx, reg) // Start server in background go func() { log.Info().Str("addr", addr).Msg("Control plane listening") if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Error().Err(err).Msg("Server failed") } }() // Signal handling sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) defer signal.Stop(sigChan) select { case <-ctx.Done(): log.Info().Msg("Context cancelled, shutting down...") case sig := <-sigChan: log.Info().Str("signal", sig.String()).Msg("Received signal, shutting down...") } shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) defer shutdownCancel() if err := srv.Shutdown(shutdownCtx); err != nil { log.Error().Err(err).Msg("Server shutdown error") } cancel() log.Info().Msg("Control plane stopped") return nil } // tenantDisplayNameResolver resolves the workspace display name stamped into // tenant runtime env (PULSE_TENANT_NAME) from the tenant registry. The lookup // happens at container-create time, so display-name changes after creation // apply on the next runtime rollout, which recreates the container. func tenantDisplayNameResolver(reg *registry.TenantRegistry) func(string) string { return func(tenantID string) string { tenant, err := reg.Get(tenantID) if err != nil || tenant == nil { return "" } return strings.TrimSpace(tenant.DisplayName) } } // baseDomainFromURL extracts a base domain from a URL like "https://cloud.pulserelay.pro". func baseDomainFromURL(baseURL string) string { // Strip scheme domain := baseURL for _, prefix := range []string{"https://", "http://"} { if len(domain) > len(prefix) && domain[:len(prefix)] == prefix { domain = domain[len(prefix):] break } } // Strip port and path for i := 0; i < len(domain); i++ { if domain[i] == ':' || domain[i] == '/' { domain = domain[:i] break } } return domain }