mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Provider-hosted MSP client workspaces previously sat at Community tier forever: the runtime refreshed leases against the built-in Pulse Cloud URL (hibernated, 522) instead of the provider control plane, and release-build images verify leases only against the embedded Pulse key, which an operator-generated CP_TRIAL_ACTIVATION_PRIVATE_KEY can never satisfy. - Inject PULSE_PRO_TRIAL_SIGNUP_URL=CP_BASE_URL into client containers so lease refresh targets the provider control plane. - Chain trust through the Pulse-signed provider MSP license: the license binds the provider's lease signing public key (entitlement_signing_public_key claim); the control plane embeds the license in every lease (provider_license claim); release-build runtimes verify embedded Pulse root -> provider license -> lease signature. - Cap chain-verified leases at ProviderChainedLeaseCapabilities: MSP tier plus white_label (branded per-client reports), minus Pulse-service-backed relay/mobile_app/push_notifications, which otherwise loop doomed registrations against Pulse's relay. - Fail fast at control-plane startup when the license does not bind the configured signing key, instead of provisioning silently unlicensed client workspaces. Verified live on a Colima harness: release-tagged tenant image with test embedded root, Traefik TLS, full provider-msp proof, tenant reports valid=true plan_version=msp_growth with white_label and zero relay failures.
247 lines
7.5 KiB
Go
247 lines
7.5 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/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,
|
|
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()
|
|
|
|
// 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)
|
|
if cfg.IsProviderHostedMSP() {
|
|
hostedEntitlements.SetProviderLicense(cfg.ProviderMSPLicenseKey)
|
|
}
|
|
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
|
|
}
|