mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 02:33:32 +00:00
bd6f77e093
Tighten v5-to-v6 upgrade safety, release installability, provider MSP mode handling, AI cost accounting, metrics flushing, and frontend guardrails for the v6.0.0 GA candidate.
228 lines
6.7 KiB
Go
228 lines
6.7 KiB
Go
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,
|
|
IsolateTenantNetworks: cfg.IsMSPControlPlane(),
|
|
BaseDomain: baseDomainFromURL(cfg.BaseURL),
|
|
TrialActivationPublicKey: cfg.TrialActivationPublicKey,
|
|
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
|
|
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()
|
|
|
|
// 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 := 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),
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|