package cloudcp import ( "context" "fmt" "net/http" "os" "os/signal" "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/entitlements" "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, BaseDomain: baseDomainFromURL(cfg.BaseURL), MemoryLimit: cfg.TenantMemoryLimit, CPUShares: cfg.TenantCPUShares, }) 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() trialSignupStore, err := NewTrialSignupStore(cfg.ControlPlaneDir()) if err != nil { return fmt.Errorf("init trial signup store: %w", err) } defer trialSignupStore.Close() // Initialize email sender var emailSender email.Sender if cfg.ResendAPIKey != "" { emailSender = email.NewResendSender(cfg.ResendAPIKey) 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 := entitlements.NewService(reg, cfg.BaseURL, cfg.TrialActivationPrivateKey) provisioner := cpstripe.NewProvisioner( reg, cfg.TenantsDir(), dockerMgr, magicLinkSvc, cfg.BaseURL, emailSender, cfg.EmailFrom, cfg.AllowDockerlessProvisioning, cpstripe.WithHostedEntitlementService(hostedEntitlements), cpstripe.WithTrialActivationPrivateKey(cfg.TrialActivationPrivateKey), ) deps := &Deps{ Config: cfg, Registry: reg, Docker: dockerMgr, MagicLinks: magicLinkSvc, TrialSignupStore: trialSignupStore, 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 } // 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 }