Files
pulse/internal/cloudcp/server.go
T
rcourtman 185c6ac80b Let a provider-hosted platform buy and renew its own licence
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.
2026-09-23 23:30:34 +01:00

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
}