mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
9d9f13e7b0
The evaluation mode added earlier today was hollow. An unlicensed control plane starts, but release-build client runtimes only trust entitlement leases chained to a Pulse-signed licence, so its client workspaces ran without the capabilities the provider was evaluating. Standing the stack up proved the portal and the isolation boundary and nothing else. setup.sh now requests a capped evaluation licence from the licence server when no licence path is set, sending only the public half of the key it generated locally. It degrades rather than blocks: a missing key, an unreachable server, or a licence-free response leaves the install unlicensed with an explicit warning, an existing licence on disk is reused, and PULSE_PROVIDER_MSP_SKIP_EVAL_LICENSE skips it for air-gapped hosts. Guarded with an if-test rather than a trailing true inside the command substitution, because the derive helper calls die and exit in a subshell is not a status that can be caught, so setup.sh aborted under set -e. Second fix. The lease capability ceiling was selected by licence presence, so an unlicensed provider control plane fell through to the Pulse-hosted branch and minted leases claiming relay, mobile and push, which a provider deployment cannot serve and which previously caused repeating relay registration failures in client runtimes. The ceiling now follows hosting via SetProviderHosted. providerChained keeps its narrower meaning of having a licence available to embed. Third. Corrects a sentence I wrote into cloud-paid.md this afternoon claiming msp_eval carries the same MSP capabilities. A plan version selects the workspace cap; it does not entitle a workspace. The regression test was negative-tested by reverting the ceiling selector and confirming it catches relay. All three setup.sh degradation paths were exercised directly. Licence server side is pulse-pro 7f6a319 and is not live until the next deploy-license-server run.
245 lines
7.3 KiB
Go
245 lines
7.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"
|
|
)
|
|
|
|
// 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,
|
|
}
|
|
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
|
|
}
|