mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 20:52:51 +00:00
185c6ac80b
A provider could stand the MSP bundle up and evaluate it without asking anyone, but paying still meant emailing its public key to Pulse and copying a licence file onto the host by hand. With the licence server's self-serve purchase path (pulse-pro license-server/provider_msp_purchase), the control plane can do the whole thing itself. The control plane now signs a challenge with its lease signing key to fetch the licence its subscription entitles, validates it exactly as startup does, requires it to bind this platform's key, and keeps it in the data directory, because the host licence is a read-only secret mount. Startup prefers that renewed licence while it validates, so a paying provider keeps starting after the self-issued evaluation on the host expires. A changed licence restarts the control plane through the existing graceful shutdown, since the plan version is read at load by every workspace-limit, backup and status path; client workspaces keep running. Preflight, proof, recovery and backup accept either signed source. The portal gains owner and admin routes for plan state, checkout, the Stripe billing portal and an immediate licence refresh, and the plan list comes from the licence server so it shows only what can be bought, at Stripe's price. Adds the msp_solo plan: 3 client workspaces, the first paid step above the 2-workspace evaluation.
269 lines
8.3 KiB
Go
269 lines
8.3 KiB
Go
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"
|
|
)
|
|
|
|
const (
|
|
controlPlaneReadHeaderTimeout = 15 * time.Second
|
|
controlPlaneReadTimeout = 30 * time.Second
|
|
controlPlaneIdleTimeout = 120 * time.Second
|
|
)
|
|
|
|
func newControlPlaneHTTPServer(addr string, handler http.Handler) *http.Server {
|
|
return &http.Server{
|
|
Addr: addr,
|
|
Handler: handler,
|
|
ReadHeaderTimeout: controlPlaneReadHeaderTimeout,
|
|
// The control plane has no streaming request bodies. Bound the complete
|
|
// request read so a client cannot retain a handler indefinitely after
|
|
// sending valid headers and then stalling a small JSON body.
|
|
ReadTimeout: controlPlaneReadTimeout,
|
|
IdleTimeout: controlPlaneIdleTimeout,
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
ProviderMSPLicense: NewProviderMSPLicenseRefresher(cfg),
|
|
}
|
|
RegisterRoutes(mux, deps)
|
|
|
|
addr := fmt.Sprintf("%s:%d", cfg.BindAddress, cfg.Port)
|
|
srv := newControlPlaneHTTPServer(addr, CPSecurityHeaders(mux))
|
|
|
|
// 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)
|
|
|
|
// Keep a provider-hosted platform's licence in step with its paid
|
|
// subscription. A changed licence cancels ctx, which shuts the server
|
|
// down cleanly; the container restart policy brings it back on the new
|
|
// licence.
|
|
if deps.ProviderMSPLicense != nil {
|
|
deps.ProviderMSPLicense.SetRestart(cancel)
|
|
go deps.ProviderMSPLicense.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
|
|
}
|