Files
pulse/internal/cloudcp/server.go
T
rcourtman 0e40ec07cb Make the provider MSP portal honest and self-sufficient without an email provider
Exercised the full provider portal as a pilot MSP would and fixed what
made it feel broken:

- The signed-out portal promised "a sign-in link is on the way" even when
  the control plane has no email provider (the bundle default), and team
  invitations silently sent nothing. The portal bootstrap now carries
  email_sign_in_available and provider_hosted_mode; the sign-in page shows
  the host command that actually prints a link, and the invite panel says
  invitation emails are not sent and how to hand over a link instead.
- New "provider-msp portal-link --email" CLI mints a one-time portal link
  for an account member or pending invitee, so teammates can sign in at
  all on email-less installs (bootstrap only covers the owner).
- Portal sessions were fixed at 12h; CP_SESSION_TTL now configures them
  and provider-hosted MSP mode defaults to 7 days.
- Creating a client past the license cap showed a generic "Failed to
  create workspace." toast: the limit error is now a JSON payload with
  current/limit, the API client no longer drops non-JSON error bodies
  (double body read), and the toast explains the license limit.
- Copy polish: provider-mode sign-in intro (no refunds/privacy register),
  least-privilege default invite role, queue tile label matches "Client
  onboarding", softer Support tab with a docs/MSP.md pointer, setup.sh
  summary now prints the bootstrap next step and day-2 sign-in commands,
  .env.example and docs/MSP.md document portal sign-in and sessions.

Contracts updated (cloud-paid, api-contracts, deployment-installability,
security-privacy) with verification pins in tenant_handlers_test,
config_test, magiclink_test, and provider_msp_deploy_test.

Verified live against a dockerless control plane: portal-link for an
invitee redeems, promotes the invitation, and sets a 7-day session;
the at-cap toast shows the license copy; portal vitest suite and
cloudcp/auth/account/installtests Go suites pass.
2026-07-10 11:22:57 +01:00

249 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()
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 := 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
}