mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
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.
This commit is contained in:
@@ -106,7 +106,7 @@ func runProviderMSPPreflightWithDependencies(ctx context.Context, cfg *cloudcp.C
|
||||
} else {
|
||||
report.WorkspaceLimit = workspaceLimit
|
||||
}
|
||||
if !opts.AllowEnvPlan && strings.TrimSpace(cfg.ProviderMSPPlanSource) != cloudcp.ProviderMSPPlanSourceLicenseFile {
|
||||
if !opts.AllowEnvPlan && !cloudcp.ProviderMSPPlanSourceIsSignedLicense(cfg.ProviderMSPPlanSource) {
|
||||
addFailure("provider MSP preflight requires %s plan source; rerun with --allow-env-plan only for local development", cloudcp.ProviderMSPPlanSourceLicenseFile)
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,32 @@ func TestProviderMSPPreflightPassesWithLicenseDockerAndStorage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A paying provider runs on the licence its control plane renewed into the
|
||||
// data directory; preflight must treat that signed source like the host
|
||||
// licence file rather than refuse it as an environment fallback.
|
||||
func TestProviderMSPPreflightAcceptsRenewedLicenseSource(t *testing.T) {
|
||||
docker := &fakeProviderMSPPreflightDocker{
|
||||
report: &cpDocker.RuntimePrerequisiteReport{
|
||||
OK: true, DockerReachable: true, NetworkName: "pulse-provider-msp", NetworkOK: true,
|
||||
NetworkID: "network-test", ImageRef: "pulse:test", ImageID: "sha256:test", ImageAvailable: true,
|
||||
},
|
||||
}
|
||||
storage := &cloudcp.StorageGuardrailReport{Enabled: true, OK: true, BuildCache: cloudcp.StorageBuildCacheReport{OK: true, MaxBytes: 1024}}
|
||||
|
||||
report, err := runProviderMSPPreflightWithDependencies(
|
||||
context.Background(),
|
||||
testProviderMSPPreflightConfig(t, cloudcp.ProviderMSPPlanSourceRenewedLicense),
|
||||
providerMSPPreflightOptions{},
|
||||
fakeProviderMSPPreflightDependencies(docker, storage),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("runProviderMSPPreflightWithDependencies: %v", err)
|
||||
}
|
||||
if !report.OK {
|
||||
t.Fatalf("preflight refused a renewed licence source: %v", report.Failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderMSPPreflightAcceptsPulseHostedMSPMode(t *testing.T) {
|
||||
docker := &fakeProviderMSPPreflightDocker{
|
||||
report: &cpDocker.RuntimePrerequisiteReport{
|
||||
|
||||
@@ -247,7 +247,7 @@ func (rt *providerMSPProofRuntime) runProviderMSPProof(ctx context.Context, opts
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !opts.AllowEnvPlan && strings.TrimSpace(rt.cfg.ProviderMSPPlanSource) != cloudcp.ProviderMSPPlanSourceLicenseFile {
|
||||
if !opts.AllowEnvPlan && !cloudcp.ProviderMSPPlanSourceIsSignedLicense(rt.cfg.ProviderMSPPlanSource) {
|
||||
return nil, fmt.Errorf("provider MSP proof requires %s plan source; rerun with --allow-env-plan only for local development", cloudcp.ProviderMSPPlanSourceLicenseFile)
|
||||
}
|
||||
|
||||
|
||||
@@ -3431,6 +3431,36 @@ Pulse-service-backed capabilities. `providerChained` retains its narrower
|
||||
meaning: a Pulse-signed licence is available to embed so release builds can
|
||||
verify the lease.
|
||||
|
||||
### Provider-hosted MSP platforms buy and renew their own licence
|
||||
|
||||
A provider-hosted control plane now buys and renews its licence through the
|
||||
licence server's self-serve provider MSP path (pulse-pro
|
||||
`license-server/provider_msp_purchase.go`), with no key copy and nobody at
|
||||
Pulse in the loop. Portal routes under `/api/accounts/{account_id}/provider-msp/`
|
||||
serve plan state to any member. Checkout, the Stripe billing portal and an
|
||||
immediate licence refresh are restricted to owners and admins. The purchasable
|
||||
plan list comes from the licence server's `GET /v1/provider-msp/plans`, so the
|
||||
portal offers only what the catalogue sells, at Stripe's price, and drops any
|
||||
plan without a known workspace limit. `ProviderMSPLicenseRefresher` signs
|
||||
every request that must come from this platform with its lease signing key,
|
||||
validates the returned licence the way startup does, requires it to bind this
|
||||
platform's key, and installs it at `ProviderMSPRenewedLicensePath`.
|
||||
`LoadConfig` prefers that renewed licence while it validates (plan source
|
||||
`renewed_license`), so a paying provider keeps starting after the self-issued
|
||||
evaluation on the host expires. A changed licence restarts the control plane
|
||||
through the graceful shutdown path, because the plan version is read once at
|
||||
load. `msp_solo` (3 client workspaces) is the first paid step above the
|
||||
2-workspace evaluation. Regression coverage:
|
||||
`TestLoadConfig_ProviderHostedMSPPrefersRenewedLicense` in
|
||||
`internal/cloudcp/config_test.go`,
|
||||
`TestProviderMSPLicenseRefreshInstallsThePaidLicenceAndRestarts` and
|
||||
`TestProviderMSPPortalRoutesRelayToTheLicenceServer` in
|
||||
`internal/cloudcp/provider_msp_license_refresh_test.go`,
|
||||
`TestRegisterRoutes_ProviderMSPPurchaseRoutesRequireOwnerOrAdmin` in
|
||||
`internal/cloudcp/routes_auth_test.go`, and
|
||||
`TestProviderMSPWorkspaceLadderRisesFromTheEvaluation` in
|
||||
`pkg/licensing/features_test.go`.
|
||||
|
||||
### Runtime display load remains outside commercial bootstrap ownership
|
||||
|
||||
`frontend-modern/src/useAppRuntimeState.ts` now loads presentation defaults
|
||||
|
||||
@@ -2416,6 +2416,19 @@ artifact-selection behaviour.
|
||||
|
||||
## Current State
|
||||
|
||||
### Provider MSP operations accept a renewed licence
|
||||
|
||||
`provider-msp preflight`, `proof`, `recover` and `backup` treat a licence
|
||||
the control plane renewed into its data directory (plan source
|
||||
`renewed_license`) the same as the host `CP_PROVIDER_MSP_LICENSE_FILE`,
|
||||
through `ProviderMSPPlanSourceIsSignedLicense`. Only the development
|
||||
environment fallback is still refused. Without this, the first paid renewal
|
||||
would have made every day-2 command refuse to run on the platform it was paid
|
||||
for. Backups keep including the host licence file, and a restored platform
|
||||
fetches its renewed licence again on the next refresh. Regression coverage:
|
||||
`TestProviderMSPPreflightAcceptsRenewedLicenseSource` in
|
||||
`cmd/pulse-control-plane/provider_msp_preflight_test.go`.
|
||||
|
||||
### Reviewed frontend dependency security floors
|
||||
|
||||
The 2026-09-21 npm-minor-patch group raises the reviewed floors of the
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/joho/godotenv"
|
||||
runtimeconfig "github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// ControlPlaneMode selects the business/runtime shape exposed by cloudcp.
|
||||
@@ -31,6 +32,10 @@ const (
|
||||
defaultProviderHostedMSPPlanVersion = pkglicensing.PlanVersionMSPEval
|
||||
ProviderMSPPlanSourceLicenseFile = "license_file"
|
||||
ProviderMSPPlanSourceEnvFallback = "environment_fallback"
|
||||
// ProviderMSPPlanSourceRenewedLicense is a licence the control plane
|
||||
// fetched from the licence server for its paid subscription and kept in
|
||||
// its data directory (ProviderMSPRenewedLicensePath).
|
||||
ProviderMSPPlanSourceRenewedLicense = "renewed_license"
|
||||
maxProviderMSPLicenseFileBytes = 64 * 1024
|
||||
|
||||
// cpauthDefaultSessionTTL mirrors cpauth.SessionTTL for Pulse-hosted
|
||||
@@ -95,6 +100,7 @@ type CPConfig struct {
|
||||
ProviderMSPLicenseEmail string // Validated provider MSP license holder
|
||||
ProviderMSPLicenseKey string // Raw validated provider MSP license (chained into tenant leases)
|
||||
ProviderMSPLeaseSigningPublicKey string // entitlement_signing_public_key claim from the provider MSP license
|
||||
ProviderMSPLicenseExpiresAt time.Time
|
||||
LicenseServerURL string
|
||||
LicenseAdminToken string
|
||||
TrialActivationPrivateKey string
|
||||
@@ -215,13 +221,15 @@ func LoadConfig() (*CPConfig, error) {
|
||||
providerMSPLicenseEmail := ""
|
||||
providerMSPLicenseKey := ""
|
||||
providerMSPLeaseSigningPublicKey := ""
|
||||
providerMSPLicenseExpiresAt := time.Time{}
|
||||
if isMSPControlPlaneMode(controlPlaneMode) && providerMSPLicenseFile != "" {
|
||||
resolved, err := resolveProviderMSPPlanFromLicenseFile(providerMSPLicenseFile)
|
||||
resolved, source, err := resolveProviderMSPLicense(providerMSPLicenseFile, dataDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve provider MSP license: %w", err)
|
||||
}
|
||||
providerMSPPlanVersion = resolved.PlanVersion
|
||||
providerMSPPlanSource = ProviderMSPPlanSourceLicenseFile
|
||||
providerMSPPlanSource = source
|
||||
providerMSPLicenseExpiresAt = resolved.ExpiresAt
|
||||
providerMSPLicenseID = resolved.LicenseID
|
||||
providerMSPLicenseEmail = resolved.LicenseEmail
|
||||
providerMSPLicenseKey = resolved.LicenseKey
|
||||
@@ -280,6 +288,7 @@ func LoadConfig() (*CPConfig, error) {
|
||||
ProviderMSPLicenseFile: providerMSPLicenseFile,
|
||||
ProviderMSPLicenseKey: providerMSPLicenseKey,
|
||||
ProviderMSPLeaseSigningPublicKey: providerMSPLeaseSigningPublicKey,
|
||||
ProviderMSPLicenseExpiresAt: providerMSPLicenseExpiresAt,
|
||||
ProviderMSPLicenseID: providerMSPLicenseID,
|
||||
ProviderMSPLicenseEmail: providerMSPLicenseEmail,
|
||||
LicenseServerURL: envOrDefault("PULSE_LICENSE_SERVER_URL", "https://license.pulserelay.pro"),
|
||||
@@ -614,6 +623,7 @@ type providerMSPLicenseResolution struct {
|
||||
LicenseEmail string
|
||||
LicenseKey string // raw signed license, chained into tenant entitlement leases
|
||||
LeaseSigningPublicKey string // entitlement_signing_public_key claim, empty when the license predates lease chaining
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func resolveProviderMSPPlanFromLicenseFile(path string) (*providerMSPLicenseResolution, error) {
|
||||
@@ -646,13 +656,59 @@ func resolveProviderMSPPlanFromLicenseFile(path string) (*providerMSPLicenseReso
|
||||
if _, known := pkglicensing.WorkspaceLimitForPlan(plan); !known {
|
||||
return nil, fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE plan_version must have a known workspace limit, got %q", plan)
|
||||
}
|
||||
return &providerMSPLicenseResolution{
|
||||
resolution := &providerMSPLicenseResolution{
|
||||
PlanVersion: plan,
|
||||
LicenseID: strings.TrimSpace(license.Claims.LicenseID),
|
||||
LicenseEmail: strings.ToLower(strings.TrimSpace(license.Claims.Email)),
|
||||
LicenseKey: licenseKey,
|
||||
LeaseSigningPublicKey: strings.TrimSpace(license.Claims.EntitlementSigningPublicKey),
|
||||
}, nil
|
||||
}
|
||||
if license.Claims.ExpiresAt > 0 {
|
||||
resolution.ExpiresAt = time.Unix(license.Claims.ExpiresAt, 0).UTC()
|
||||
}
|
||||
return resolution, nil
|
||||
}
|
||||
|
||||
// ProviderMSPPlanSourceIsSignedLicense reports whether the plan came from a
|
||||
// Pulse-signed licence, either the host licence file or a renewed licence,
|
||||
// rather than the development-only environment fallback.
|
||||
func ProviderMSPPlanSourceIsSignedLicense(source string) bool {
|
||||
switch strings.TrimSpace(source) {
|
||||
case ProviderMSPPlanSourceLicenseFile, ProviderMSPPlanSourceRenewedLicense:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ProviderMSPRenewedLicensePath is where a provider-hosted control plane
|
||||
// keeps the licence it fetched for its paid subscription. It lives in the
|
||||
// data directory, which the control plane can write and backups carry,
|
||||
// because CP_PROVIDER_MSP_LICENSE_FILE is a read-only secret mount.
|
||||
func ProviderMSPRenewedLicensePath(dataDir string) string {
|
||||
return filepath.Join(dataDir, "control-plane", "provider-msp-license.jwt")
|
||||
}
|
||||
|
||||
// resolveProviderMSPLicense prefers a renewed licence that still validates
|
||||
// and falls back to CP_PROVIDER_MSP_LICENSE_FILE. The renewed licence is
|
||||
// tried first because the host file is usually the self-issued evaluation,
|
||||
// which expires; a paying provider must keep starting after it does. The
|
||||
// lease signing key check in validate still ties either licence to this
|
||||
// control plane's private key.
|
||||
func resolveProviderMSPLicense(hostFile, dataDir string) (*providerMSPLicenseResolution, string, error) {
|
||||
renewedPath := ProviderMSPRenewedLicensePath(dataDir)
|
||||
if _, err := os.Stat(renewedPath); err == nil {
|
||||
renewed, err := resolveProviderMSPPlanFromLicenseFile(renewedPath)
|
||||
if err == nil {
|
||||
return renewed, ProviderMSPPlanSourceRenewedLicense, nil
|
||||
}
|
||||
log.Warn().Err(err).Str("path", renewedPath).Msg("Renewed provider MSP license is unusable; falling back to CP_PROVIDER_MSP_LICENSE_FILE")
|
||||
}
|
||||
resolved, err := resolveProviderMSPPlanFromLicenseFile(hostFile)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return resolved, ProviderMSPPlanSourceLicenseFile, nil
|
||||
}
|
||||
|
||||
func readProviderMSPLicenseFile(path string) (string, error) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -1011,3 +1012,50 @@ func TestLoadConfig_SessionTTLInvalid(t *testing.T) {
|
||||
t.Fatal("expected error for invalid CP_SESSION_TTL")
|
||||
}
|
||||
}
|
||||
|
||||
// A paying provider must keep starting after the self-issued evaluation on
|
||||
// the host expires: a renewed licence that validates wins, and an unusable
|
||||
// one falls back to the host file.
|
||||
func TestLoadConfig_ProviderHostedMSPPrefersRenewedLicense(t *testing.T) {
|
||||
setProviderHostedMSPEnv(t)
|
||||
t.Setenv("CP_ENV", "production")
|
||||
dataDir := t.TempDir()
|
||||
t.Setenv("CP_DATA_DIR", dataDir)
|
||||
issuer := newProviderMSPTestIssuer(t)
|
||||
key := trialSigningEnvPublicKey(t)
|
||||
|
||||
hostFile := writeProviderMSPTestFile(t, filepath.Join(t.TempDir(), "eval.jwt"),
|
||||
issuer.sign(t, "lic_msp_eval", pkglicensing.PlanVersionMSPEval, time.Now().Add(-60*24*time.Hour), key))
|
||||
t.Setenv("CP_PROVIDER_MSP_LICENSE_FILE", hostFile)
|
||||
renewedExpiry := time.Now().Add(40 * 24 * time.Hour).Truncate(time.Second)
|
||||
writeProviderMSPTestFile(t, ProviderMSPRenewedLicensePath(dataDir),
|
||||
issuer.sign(t, "lic_msp_paid", "msp_solo", renewedExpiry, key))
|
||||
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig with an expired evaluation and a valid renewed licence: %v", err)
|
||||
}
|
||||
if cfg.ProviderMSPPlanVersion != "msp_solo" || cfg.ProviderMSPPlanSource != ProviderMSPPlanSourceRenewedLicense || cfg.ProviderMSPLicenseID != "lic_msp_paid" {
|
||||
t.Fatalf("resolved plan=%q source=%q license=%q", cfg.ProviderMSPPlanVersion, cfg.ProviderMSPPlanSource, cfg.ProviderMSPLicenseID)
|
||||
}
|
||||
if !cfg.ProviderMSPLicenseExpiresAt.Equal(renewedExpiry) {
|
||||
t.Fatalf("ProviderMSPLicenseExpiresAt = %v, want %v", cfg.ProviderMSPLicenseExpiresAt, renewedExpiry)
|
||||
}
|
||||
if limit, _ := pkglicensing.WorkspaceLimitForPlan(cfg.ProviderMSPPlanVersion); limit != 3 {
|
||||
t.Fatalf("msp_solo workspace limit = %d, want 3", limit)
|
||||
}
|
||||
|
||||
// An expired renewed licence falls back to a valid host licence.
|
||||
writeProviderMSPTestFile(t, hostFile, issuer.sign(t, "lic_msp_eval", pkglicensing.PlanVersionMSPEval, time.Now().Add(30*24*time.Hour), key))
|
||||
writeProviderMSPTestFile(t, ProviderMSPRenewedLicensePath(dataDir), issuer.sign(t, "lic_msp_paid", "msp_solo", time.Now().Add(-60*24*time.Hour), key))
|
||||
cfg, err = LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig with an expired renewed licence: %v", err)
|
||||
}
|
||||
if cfg.ProviderMSPPlanSource != ProviderMSPPlanSourceLicenseFile || cfg.ProviderMSPPlanVersion != pkglicensing.PlanVersionMSPEval {
|
||||
t.Fatalf("fallback plan=%q source=%q", cfg.ProviderMSPPlanVersion, cfg.ProviderMSPPlanSource)
|
||||
}
|
||||
if !ProviderMSPPlanSourceIsSignedLicense(ProviderMSPPlanSourceRenewedLicense) || ProviderMSPPlanSourceIsSignedLicense(ProviderMSPPlanSourceEnvFallback) {
|
||||
t.Fatal("ProviderMSPPlanSourceIsSignedLicense must accept both signed sources and nothing else")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -801,7 +801,7 @@ func providerMSPBackupRequiresRuntimeTenantDir(state registry.TenantState) bool
|
||||
}
|
||||
|
||||
func resolveProviderMSPBackupLicensePath(cfg *CPConfig) (string, bool, error) {
|
||||
if providerMSPPlanSourceOrDefault(cfg.ProviderMSPPlanSource) != ProviderMSPPlanSourceLicenseFile {
|
||||
if !ProviderMSPPlanSourceIsSignedLicense(providerMSPPlanSourceOrDefault(cfg.ProviderMSPPlanSource)) {
|
||||
return "", false, nil
|
||||
}
|
||||
licensePath := strings.TrimSpace(cfg.ProviderMSPLicenseFile)
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
package cloudcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/portal"
|
||||
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// A provider-hosted control plane buys and renews its own licence. The
|
||||
// licence server knows it only by the Ed25519 lease signing key setup.sh
|
||||
// generated on the host, so every call that must come from this platform is
|
||||
// signed with that key, and the licence the server returns binds the same
|
||||
// key. See pulse-pro license-server/provider_msp_purchase.go for the server
|
||||
// half.
|
||||
|
||||
const (
|
||||
providerMSPChallengePrefix = "pulse-provider-msp-v1"
|
||||
providerMSPLicenseRefreshInterval = 6 * time.Hour
|
||||
providerMSPLicenseRefreshInitialDelay = 2 * time.Minute
|
||||
providerMSPLicenseRestartDelay = 2 * time.Second
|
||||
providerMSPPlansCacheTTL = 5 * time.Minute
|
||||
providerMSPLicenseHTTPTimeout = 20 * time.Second
|
||||
maxProviderMSPLicenseResponseBytes = 256 * 1024
|
||||
)
|
||||
|
||||
// ErrProviderMSPAlreadySubscribed is returned when checkout is refused because
|
||||
// this platform already pays; the billing portal is the way to change plan.
|
||||
var ErrProviderMSPAlreadySubscribed = errors.New("provider MSP platform already has a paid subscription")
|
||||
|
||||
// ErrProviderMSPNoSubscription is returned when the licence server has no paid
|
||||
// subscription bound to this platform's key.
|
||||
var ErrProviderMSPNoSubscription = errors.New("no paid provider MSP subscription")
|
||||
|
||||
// ProviderMSPPurchasablePlan is one plan the licence server sells now.
|
||||
type ProviderMSPPurchasablePlan struct {
|
||||
PlanVersion string `json:"plan_version"`
|
||||
BillingCycle string `json:"billing_cycle"`
|
||||
UnitAmount int64 `json:"unit_amount"`
|
||||
Currency string `json:"currency"`
|
||||
WorkspaceLimit int `json:"workspace_limit"`
|
||||
}
|
||||
|
||||
// ProviderMSPLicenseRefreshResult reports one refresh.
|
||||
type ProviderMSPLicenseRefreshResult struct {
|
||||
Status string `json:"status"` // "active" or "no_paid_subscription"
|
||||
Changed bool `json:"changed"`
|
||||
RestartScheduled bool `json:"restart_scheduled"`
|
||||
LicenseID string `json:"license_id,omitempty"`
|
||||
PlanVersion string `json:"plan_version,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
PaidThrough string `json:"paid_through,omitempty"`
|
||||
}
|
||||
|
||||
// ProviderMSPLicenseRefresher fetches, validates and installs the licence for
|
||||
// this platform's paid subscription, and relays checkout and billing-portal
|
||||
// requests to the licence server.
|
||||
type ProviderMSPLicenseRefresher struct {
|
||||
cfg *CPConfig
|
||||
client *http.Client
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex // serialises refreshes
|
||||
restart func()
|
||||
|
||||
plansMu sync.Mutex
|
||||
plans []ProviderMSPPurchasablePlan
|
||||
plansCachedAt time.Time
|
||||
}
|
||||
|
||||
// NewProviderMSPLicenseRefresher returns nil unless this control plane is a
|
||||
// provider-hosted MSP platform that can sign requests to a licence server.
|
||||
func NewProviderMSPLicenseRefresher(cfg *CPConfig) *ProviderMSPLicenseRefresher {
|
||||
if cfg == nil || !cfg.IsProviderHostedMSP() {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(cfg.TrialActivationPrivateKey) == "" || strings.TrimSpace(cfg.LicenseServerURL) == "" {
|
||||
return nil
|
||||
}
|
||||
return &ProviderMSPLicenseRefresher{
|
||||
cfg: cfg,
|
||||
client: &http.Client{Timeout: providerMSPLicenseHTTPTimeout},
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// SetRestart installs the hook that restarts the control plane so a changed
|
||||
// licence takes effect. The plan version is read at load by every workspace
|
||||
// limit, backup and status path, so a restart is the one way to apply it
|
||||
// everywhere at once; client workspaces keep running meanwhile.
|
||||
func (r *ProviderMSPLicenseRefresher) SetRestart(restart func()) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.restart = restart
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
type providerMSPSignedRequest struct {
|
||||
EntitlementSigningPublicKey string `json:"entitlement_signing_public_key"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Signature string `json:"signature"`
|
||||
ReturnURL string `json:"return_url,omitempty"`
|
||||
}
|
||||
|
||||
func (r *ProviderMSPLicenseRefresher) signedRequest(action, returnURL string) (providerMSPSignedRequest, error) {
|
||||
privateKey, err := pkglicensing.DecodeEd25519PrivateKey(strings.TrimSpace(r.cfg.TrialActivationPrivateKey))
|
||||
if err != nil {
|
||||
return providerMSPSignedRequest{}, fmt.Errorf("decode lease signing key: %w", err)
|
||||
}
|
||||
publicKey, ok := privateKey.Public().(ed25519.PublicKey)
|
||||
if !ok {
|
||||
return providerMSPSignedRequest{}, fmt.Errorf("derive lease signing public key")
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString(publicKey)
|
||||
timestamp := r.now().Unix()
|
||||
message := providerMSPChallengePrefix + "\n" + action + "\n" + encoded + "\n" + strconv.FormatInt(timestamp, 10)
|
||||
return providerMSPSignedRequest{
|
||||
EntitlementSigningPublicKey: encoded,
|
||||
Timestamp: timestamp,
|
||||
Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, []byte(message))),
|
||||
ReturnURL: returnURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *ProviderMSPLicenseRefresher) call(ctx context.Context, method, path string, body any, out any) (int, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
reader = bytes.NewReader(payload)
|
||||
}
|
||||
endpoint := strings.TrimRight(strings.TrimSpace(r.cfg.LicenseServerURL), "/") + path
|
||||
req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("licence server unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxProviderMSPLicenseResponseBytes))
|
||||
if err != nil {
|
||||
return resp.StatusCode, err
|
||||
}
|
||||
if out != nil && len(raw) > 0 && resp.StatusCode < 500 {
|
||||
if err := json.Unmarshal(raw, out); err != nil && resp.StatusCode == http.StatusOK {
|
||||
return resp.StatusCode, fmt.Errorf("decode licence server response: %w", err)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// Refresh asks the licence server for the licence its paid subscription
|
||||
// entitles, installs it when it differs from the running one, and schedules
|
||||
// a restart so it takes effect. A platform without a paid subscription keeps
|
||||
// the licence it already has.
|
||||
func (r *ProviderMSPLicenseRefresher) Refresh(ctx context.Context) (ProviderMSPLicenseRefreshResult, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
request, err := r.signedRequest("license-current", "")
|
||||
if err != nil {
|
||||
return ProviderMSPLicenseRefreshResult{}, err
|
||||
}
|
||||
var resp struct {
|
||||
Status string `json:"status"`
|
||||
LicenseID string `json:"license_id"`
|
||||
License string `json:"license"`
|
||||
PlanVersion string `json:"plan_version"`
|
||||
PaidThrough string `json:"paid_through"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
status, err := r.call(ctx, http.MethodPost, "/v1/provider-msp/license/current", request, &resp)
|
||||
if err != nil {
|
||||
return ProviderMSPLicenseRefreshResult{}, err
|
||||
}
|
||||
switch status {
|
||||
case http.StatusOK:
|
||||
case http.StatusNotFound:
|
||||
return ProviderMSPLicenseRefreshResult{Status: "no_paid_subscription"}, nil
|
||||
default:
|
||||
return ProviderMSPLicenseRefreshResult{}, fmt.Errorf("licence server refused the refresh (HTTP %d)", status)
|
||||
}
|
||||
|
||||
renewedPath := ProviderMSPRenewedLicensePath(r.cfg.DataDir)
|
||||
if err := os.MkdirAll(filepath.Dir(renewedPath), 0o700); err != nil {
|
||||
return ProviderMSPLicenseRefreshResult{}, err
|
||||
}
|
||||
staged, err := os.CreateTemp(filepath.Dir(renewedPath), ".provider-msp-license-*.jwt")
|
||||
if err != nil {
|
||||
return ProviderMSPLicenseRefreshResult{}, err
|
||||
}
|
||||
stagedPath := staged.Name()
|
||||
defer os.Remove(stagedPath)
|
||||
if _, err := staged.WriteString(strings.TrimSpace(resp.License) + "\n"); err != nil {
|
||||
staged.Close()
|
||||
return ProviderMSPLicenseRefreshResult{}, err
|
||||
}
|
||||
if err := staged.Chmod(0o600); err != nil {
|
||||
staged.Close()
|
||||
return ProviderMSPLicenseRefreshResult{}, err
|
||||
}
|
||||
if err := staged.Close(); err != nil {
|
||||
return ProviderMSPLicenseRefreshResult{}, err
|
||||
}
|
||||
|
||||
// The same validation a startup applies: Pulse signature, MSP tier, a
|
||||
// plan with a known workspace limit. Then the key must be this
|
||||
// platform's, or validate would refuse to start on it.
|
||||
renewed, err := resolveProviderMSPPlanFromLicenseFile(stagedPath)
|
||||
if err != nil {
|
||||
return ProviderMSPLicenseRefreshResult{}, fmt.Errorf("licence server returned an unusable licence: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(r.cfg.TrialActivationPublicKey) == "" || renewed.LeaseSigningPublicKey != r.cfg.TrialActivationPublicKey {
|
||||
return ProviderMSPLicenseRefreshResult{}, fmt.Errorf("licence server returned a licence bound to another platform's key")
|
||||
}
|
||||
|
||||
result := ProviderMSPLicenseRefreshResult{
|
||||
Status: "active",
|
||||
LicenseID: renewed.LicenseID,
|
||||
PlanVersion: renewed.PlanVersion,
|
||||
PaidThrough: resp.PaidThrough,
|
||||
}
|
||||
if !renewed.ExpiresAt.IsZero() {
|
||||
result.ExpiresAt = renewed.ExpiresAt.Format(time.RFC3339)
|
||||
}
|
||||
result.Changed = renewed.LicenseID != r.cfg.ProviderMSPLicenseID ||
|
||||
renewed.PlanVersion != r.cfg.ProviderMSPPlanVersion ||
|
||||
renewed.ExpiresAt.After(r.cfg.ProviderMSPLicenseExpiresAt.Add(time.Hour))
|
||||
if !result.Changed {
|
||||
return result, nil
|
||||
}
|
||||
if err := os.Rename(stagedPath, renewedPath); err != nil {
|
||||
return ProviderMSPLicenseRefreshResult{}, err
|
||||
}
|
||||
log.Info().
|
||||
Str("license_id", renewed.LicenseID).
|
||||
Str("plan_version", renewed.PlanVersion).
|
||||
Str("expires_at", result.ExpiresAt).
|
||||
Msg("Installed renewed provider MSP license")
|
||||
if r.restart != nil {
|
||||
restart := r.restart
|
||||
result.RestartScheduled = true
|
||||
time.AfterFunc(providerMSPLicenseRestartDelay, func() {
|
||||
log.Info().Msg("Restarting control plane to apply the renewed provider MSP license")
|
||||
restart()
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Run refreshes shortly after start and then periodically. Failures are
|
||||
// logged and retried on the next tick; the running licence stays in force.
|
||||
func (r *ProviderMSPLicenseRefresher) Run(ctx context.Context) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
timer := time.NewTimer(providerMSPLicenseRefreshInitialDelay)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
if result, err := r.Refresh(ctx); err != nil {
|
||||
log.Warn().Err(err).Msg("Provider MSP license refresh failed; keeping the current license")
|
||||
} else if result.Changed {
|
||||
log.Info().Str("plan_version", result.PlanVersion).Msg("Provider MSP license refresh installed a new license")
|
||||
}
|
||||
timer.Reset(providerMSPLicenseRefreshInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Plans returns what the licence server sells now, with each plan's client
|
||||
// workspace cap, cached briefly.
|
||||
func (r *ProviderMSPLicenseRefresher) Plans(ctx context.Context) ([]ProviderMSPPurchasablePlan, error) {
|
||||
r.plansMu.Lock()
|
||||
defer r.plansMu.Unlock()
|
||||
if r.plans != nil && r.now().Sub(r.plansCachedAt) < providerMSPPlansCacheTTL {
|
||||
return r.plans, nil
|
||||
}
|
||||
var resp struct {
|
||||
Plans []ProviderMSPPurchasablePlan `json:"plans"`
|
||||
}
|
||||
status, err := r.call(ctx, http.MethodGet, "/v1/provider-msp/plans", nil, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("licence server plans unavailable (HTTP %d)", status)
|
||||
}
|
||||
plans := make([]ProviderMSPPurchasablePlan, 0, len(resp.Plans))
|
||||
for _, plan := range resp.Plans {
|
||||
limit, known := pkglicensing.WorkspaceLimitForPlan(plan.PlanVersion)
|
||||
if !known {
|
||||
// A plan this control plane cannot enforce is not offered.
|
||||
continue
|
||||
}
|
||||
plan.WorkspaceLimit = limit
|
||||
plans = append(plans, plan)
|
||||
}
|
||||
r.plans = plans
|
||||
r.plansCachedAt = r.now()
|
||||
return plans, nil
|
||||
}
|
||||
|
||||
// Checkout starts a Stripe checkout for this platform and returns its URL.
|
||||
func (r *ProviderMSPLicenseRefresher) Checkout(ctx context.Context, planVersion, billingCycle string) (string, error) {
|
||||
var resp struct {
|
||||
URL string `json:"url"`
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
status, err := r.call(ctx, http.MethodPost, "/v1/provider-msp/checkout", map[string]string{
|
||||
"license_id": r.cfg.ProviderMSPLicenseID,
|
||||
"plan_version": planVersion,
|
||||
"billing_cycle": billingCycle,
|
||||
"return_url": buildCPURL(r.cfg.BaseURL, portal.PortalPagePath, nil),
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch {
|
||||
case status == http.StatusOK && strings.HasPrefix(resp.URL, "https://"):
|
||||
return resp.URL, nil
|
||||
case status == http.StatusConflict:
|
||||
return "", ErrProviderMSPAlreadySubscribed
|
||||
default:
|
||||
return "", fmt.Errorf("licence server refused checkout (HTTP %d)", status)
|
||||
}
|
||||
}
|
||||
|
||||
// BillingPortal opens the Stripe billing portal for this platform's
|
||||
// subscription and returns its URL.
|
||||
func (r *ProviderMSPLicenseRefresher) BillingPortal(ctx context.Context) (string, error) {
|
||||
request, err := r.signedRequest("billing-portal", buildCPURL(r.cfg.BaseURL, portal.PortalPagePath, nil))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
status, err := r.call(ctx, http.MethodPost, "/v1/provider-msp/billing-portal", request, &resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch {
|
||||
case status == http.StatusOK && strings.HasPrefix(resp.URL, "https://"):
|
||||
return resp.URL, nil
|
||||
case status == http.StatusNotFound:
|
||||
return "", ErrProviderMSPNoSubscription
|
||||
default:
|
||||
return "", fmt.Errorf("licence server refused the billing portal (HTTP %d)", status)
|
||||
}
|
||||
}
|
||||
|
||||
// ProviderMSPPlanState is what the portal shows about this platform's plan.
|
||||
type ProviderMSPPlanState struct {
|
||||
PlanVersion string `json:"plan_version"`
|
||||
PlanSource string `json:"plan_source"`
|
||||
Evaluation bool `json:"evaluation"`
|
||||
LicenseID string `json:"license_id,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
WorkspaceLimit int `json:"workspace_limit"`
|
||||
PurchaseAvailable bool `json:"purchase_available"`
|
||||
Plans []ProviderMSPPurchasablePlan `json:"plans"`
|
||||
PlansError string `json:"plans_error,omitempty"`
|
||||
}
|
||||
|
||||
func providerMSPPlanState(ctx context.Context, cfg *CPConfig, refresher *ProviderMSPLicenseRefresher) ProviderMSPPlanState {
|
||||
plan := providerMSPPlanVersion(cfg)
|
||||
limit, _ := pkglicensing.WorkspaceLimitForPlan(plan)
|
||||
state := ProviderMSPPlanState{
|
||||
PlanVersion: plan,
|
||||
PlanSource: providerMSPPlanSourceOrDefault(cfg.ProviderMSPPlanSource),
|
||||
Evaluation: plan == pkglicensing.PlanVersionMSPEval,
|
||||
LicenseID: strings.TrimSpace(cfg.ProviderMSPLicenseID),
|
||||
WorkspaceLimit: limit,
|
||||
Plans: []ProviderMSPPurchasablePlan{},
|
||||
}
|
||||
if !cfg.ProviderMSPLicenseExpiresAt.IsZero() {
|
||||
state.ExpiresAt = cfg.ProviderMSPLicenseExpiresAt.Format(time.RFC3339)
|
||||
}
|
||||
if refresher == nil || state.LicenseID == "" {
|
||||
return state
|
||||
}
|
||||
plans, err := refresher.Plans(ctx)
|
||||
if err != nil {
|
||||
state.PlansError = "Plans are unavailable right now. Try again in a few minutes."
|
||||
return state
|
||||
}
|
||||
state.Plans = plans
|
||||
state.PurchaseAvailable = len(plans) > 0
|
||||
return state
|
||||
}
|
||||
|
||||
func writeProviderMSPJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
// HandleProviderMSPPlan serves GET .../provider-msp/plan.
|
||||
func HandleProviderMSPPlan(cfg *CPConfig, refresher *ProviderMSPLicenseRefresher) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
writeProviderMSPJSON(w, http.StatusOK, providerMSPPlanState(r.Context(), cfg, refresher))
|
||||
}
|
||||
}
|
||||
|
||||
// HandleProviderMSPCheckout serves POST .../provider-msp/checkout.
|
||||
func HandleProviderMSPCheckout(refresher *ProviderMSPLicenseRefresher) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if refresher == nil {
|
||||
writeProviderMSPJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "purchase_unavailable"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
PlanVersion string `json:"plan_version"`
|
||||
BillingCycle string `json:"billing_cycle"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
|
||||
writeProviderMSPJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
checkoutURL, err := refresher.Checkout(r.Context(), strings.TrimSpace(req.PlanVersion), strings.TrimSpace(req.BillingCycle))
|
||||
switch {
|
||||
case errors.Is(err, ErrProviderMSPAlreadySubscribed):
|
||||
writeProviderMSPJSON(w, http.StatusConflict, map[string]string{
|
||||
"error": "already_subscribed",
|
||||
"message": "This platform already has a paid plan. Use Manage billing to change it.",
|
||||
})
|
||||
case err != nil:
|
||||
log.Warn().Err(err).Msg("Provider MSP checkout failed")
|
||||
writeProviderMSPJSON(w, http.StatusBadGateway, map[string]string{
|
||||
"error": "checkout_unavailable",
|
||||
"message": "Checkout is unavailable right now. Try again in a few minutes.",
|
||||
})
|
||||
default:
|
||||
writeProviderMSPJSON(w, http.StatusOK, map[string]string{"url": checkoutURL})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleProviderMSPBillingPortal serves POST .../provider-msp/billing-portal.
|
||||
func HandleProviderMSPBillingPortal(refresher *ProviderMSPLicenseRefresher) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if refresher == nil {
|
||||
writeProviderMSPJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "billing_unavailable"})
|
||||
return
|
||||
}
|
||||
portalURL, err := refresher.BillingPortal(r.Context())
|
||||
switch {
|
||||
case errors.Is(err, ErrProviderMSPNoSubscription):
|
||||
writeProviderMSPJSON(w, http.StatusNotFound, map[string]string{
|
||||
"error": "no_paid_subscription",
|
||||
"message": "This platform has no paid plan yet.",
|
||||
})
|
||||
case err != nil:
|
||||
log.Warn().Err(err).Msg("Provider MSP billing portal failed")
|
||||
writeProviderMSPJSON(w, http.StatusBadGateway, map[string]string{
|
||||
"error": "billing_unavailable",
|
||||
"message": "Billing is unavailable right now. Try again in a few minutes.",
|
||||
})
|
||||
default:
|
||||
writeProviderMSPJSON(w, http.StatusOK, map[string]string{"url": portalURL})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleProviderMSPLicenseRefresh serves POST .../provider-msp/license/refresh,
|
||||
// the "I've just paid" path that applies a purchase without waiting for the
|
||||
// periodic refresh.
|
||||
func HandleProviderMSPLicenseRefresh(refresher *ProviderMSPLicenseRefresher) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if refresher == nil {
|
||||
writeProviderMSPJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "refresh_unavailable"})
|
||||
return
|
||||
}
|
||||
result, err := refresher.Refresh(r.Context())
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Provider MSP license refresh failed")
|
||||
writeProviderMSPJSON(w, http.StatusBadGateway, map[string]string{
|
||||
"error": "refresh_unavailable",
|
||||
"message": "The licence could not be refreshed right now. Try again in a few minutes.",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeProviderMSPJSON(w, http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package cloudcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
|
||||
)
|
||||
|
||||
// providerMSPTestIssuer signs provider MSP licences with one licence-server
|
||||
// key, so a test can hold an evaluation and a renewed licence at once.
|
||||
type providerMSPTestIssuer struct {
|
||||
private ed25519.PrivateKey
|
||||
}
|
||||
|
||||
func newProviderMSPTestIssuer(t *testing.T) *providerMSPTestIssuer {
|
||||
t.Helper()
|
||||
public, private, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKey: %v", err)
|
||||
}
|
||||
t.Setenv("PULSE_LICENSE_PUBLIC_KEY", base64.StdEncoding.EncodeToString(public))
|
||||
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")
|
||||
t.Cleanup(func() { pkglicensing.SetPublicKey(nil) })
|
||||
return &providerMSPTestIssuer{private: private}
|
||||
}
|
||||
|
||||
func (i *providerMSPTestIssuer) sign(t *testing.T, licenseID, planVersion string, expiresAt time.Time, binding ed25519.PublicKey) string {
|
||||
t.Helper()
|
||||
claims := pkglicensing.Claims{
|
||||
LicenseID: licenseID,
|
||||
Email: "provider@example.com",
|
||||
Tier: pkglicensing.TierMSP,
|
||||
IssuedAt: time.Now().Add(-time.Minute).Unix(),
|
||||
ExpiresAt: expiresAt.Unix(),
|
||||
PlanVersion: planVersion,
|
||||
EntitlementSigningPublicKey: base64.StdEncoding.EncodeToString(binding),
|
||||
}
|
||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"EdDSA","typ":"JWT"}`))
|
||||
payloadBytes, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal claims: %v", err)
|
||||
}
|
||||
payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
|
||||
signature := base64.RawURLEncoding.EncodeToString(ed25519.Sign(i.private, []byte(header+"."+payload)))
|
||||
return header + "." + payload + "." + signature
|
||||
}
|
||||
|
||||
func writeProviderMSPTestFile(t *testing.T, path, contents string) string {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(contents+"\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
type fakeProviderMSPLicenseServer struct {
|
||||
t *testing.T
|
||||
|
||||
mu sync.Mutex
|
||||
currentStatus int
|
||||
currentLicense string
|
||||
checkoutStatus int
|
||||
portalStatus int
|
||||
plans string
|
||||
lastCheckout map[string]string
|
||||
signedActions []string
|
||||
}
|
||||
|
||||
func (f *fakeProviderMSPLicenseServer) verify(r *http.Request, action string) map[string]any {
|
||||
f.t.Helper()
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
f.t.Fatalf("decode signed body: %v", err)
|
||||
}
|
||||
key, _ := base64.StdEncoding.DecodeString(body["entitlement_signing_public_key"].(string))
|
||||
sig, _ := base64.StdEncoding.DecodeString(body["signature"].(string))
|
||||
ts := int64(body["timestamp"].(float64))
|
||||
message := "pulse-provider-msp-v1\n" + action + "\n" + body["entitlement_signing_public_key"].(string) + "\n" + strconv.FormatInt(ts, 10)
|
||||
if !ed25519.Verify(ed25519.PublicKey(key), []byte(message), sig) {
|
||||
f.t.Fatalf("%s request is not signed by the lease signing key", action)
|
||||
}
|
||||
f.signedActions = append(f.signedActions, action)
|
||||
return body
|
||||
}
|
||||
|
||||
func (f *fakeProviderMSPLicenseServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/v1/provider-msp/license/current":
|
||||
f.verify(r, "license-current")
|
||||
w.WriteHeader(f.currentStatus)
|
||||
if f.currentStatus == http.StatusOK {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "active", "license": f.currentLicense, "paid_through": "2026-10-23T00:00:00Z"})
|
||||
} else {
|
||||
_, _ = w.Write([]byte(`{"status":"no_paid_subscription"}`))
|
||||
}
|
||||
case "/v1/provider-msp/billing-portal":
|
||||
body := f.verify(r, "billing-portal")
|
||||
if body["return_url"] != "https://msp.example.com/portal" {
|
||||
f.t.Fatalf("billing portal return_url = %v", body["return_url"])
|
||||
}
|
||||
w.WriteHeader(f.portalStatus)
|
||||
_, _ = w.Write([]byte(`{"url":"https://billing.stripe.com/p/session/test"}`))
|
||||
case "/v1/provider-msp/checkout":
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &f.lastCheckout)
|
||||
w.WriteHeader(f.checkoutStatus)
|
||||
_, _ = w.Write([]byte(`{"url":"https://checkout.stripe.com/c/pay/cs_test"}`))
|
||||
case "/v1/provider-msp/plans":
|
||||
_, _ = w.Write([]byte(f.plans))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func newProviderMSPRefreshTestConfig(t *testing.T, serverURL string) *CPConfig {
|
||||
t.Helper()
|
||||
setTrialSigningEnv(t)
|
||||
publicKey := base64.StdEncoding.EncodeToString(trialSigningEnvPublicKey(t))
|
||||
return &CPConfig{
|
||||
DataDir: t.TempDir(),
|
||||
ControlPlaneMode: ControlPlaneModeProviderHostedMSP,
|
||||
BaseURL: "https://msp.example.com",
|
||||
LicenseServerURL: serverURL,
|
||||
TrialActivationPrivateKey: "A8medgdNdm12GXfTXWo6+TMZ2BeHPCLg2kd0znn6ZUk=",
|
||||
TrialActivationPublicKey: publicKey,
|
||||
ProviderMSPPlanVersion: pkglicensing.PlanVersionMSPEval,
|
||||
ProviderMSPPlanSource: ProviderMSPPlanSourceLicenseFile,
|
||||
ProviderMSPLicenseID: "lic_msp_eval",
|
||||
ProviderMSPLeaseSigningPublicKey: publicKey,
|
||||
ProviderMSPLicenseExpiresAt: time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderMSPLicenseRefreshInstallsThePaidLicenceAndRestarts(t *testing.T) {
|
||||
issuer := newProviderMSPTestIssuer(t)
|
||||
fake := &fakeProviderMSPLicenseServer{t: t, currentStatus: http.StatusNotFound}
|
||||
server := httptest.NewServer(fake)
|
||||
defer server.Close()
|
||||
cfg := newProviderMSPRefreshTestConfig(t, server.URL)
|
||||
refresher := NewProviderMSPLicenseRefresher(cfg)
|
||||
restarted := make(chan struct{}, 1)
|
||||
refresher.SetRestart(func() { restarted <- struct{}{} })
|
||||
|
||||
// No paid subscription: nothing installed, nothing restarted.
|
||||
result, err := refresher.Refresh(context.Background())
|
||||
if err != nil || result.Status != "no_paid_subscription" || result.Changed {
|
||||
t.Fatalf("unpaid refresh = %+v, %v", result, err)
|
||||
}
|
||||
if _, err := os.Stat(ProviderMSPRenewedLicensePath(cfg.DataDir)); !os.IsNotExist(err) {
|
||||
t.Fatalf("unpaid refresh wrote a licence: %v", err)
|
||||
}
|
||||
|
||||
// A licence bound to another platform's key is refused and not installed.
|
||||
otherKey, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
fake.currentStatus = http.StatusOK
|
||||
fake.currentLicense = issuer.sign(t, "lic_msp_paid", "msp_solo", time.Now().Add(45*24*time.Hour), otherKey)
|
||||
if _, err := refresher.Refresh(context.Background()); err == nil || !strings.Contains(err.Error(), "another platform") {
|
||||
t.Fatalf("foreign-key licence accepted: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(ProviderMSPRenewedLicensePath(cfg.DataDir)); !os.IsNotExist(err) {
|
||||
t.Fatalf("foreign-key licence was installed: %v", err)
|
||||
}
|
||||
|
||||
// The paid licence for this key is installed and applied by a restart.
|
||||
fake.currentLicense = issuer.sign(t, "lic_msp_paid", "msp_solo", time.Now().Add(45*24*time.Hour), trialSigningEnvPublicKey(t))
|
||||
result, err = refresher.Refresh(context.Background())
|
||||
if err != nil || result.Status != "active" || !result.Changed || !result.RestartScheduled || result.PlanVersion != "msp_solo" {
|
||||
t.Fatalf("paid refresh = %+v, %v", result, err)
|
||||
}
|
||||
installed, err := os.ReadFile(ProviderMSPRenewedLicensePath(cfg.DataDir))
|
||||
if err != nil || strings.TrimSpace(string(installed)) != fake.currentLicense {
|
||||
t.Fatalf("installed licence mismatch: %v", err)
|
||||
}
|
||||
if info, _ := os.Stat(ProviderMSPRenewedLicensePath(cfg.DataDir)); info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("installed licence mode = %v", info.Mode().Perm())
|
||||
}
|
||||
select {
|
||||
case <-restarted:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("a changed licence did not restart the control plane")
|
||||
}
|
||||
|
||||
// Running on that licence, the same answer is not a change.
|
||||
cfg.ProviderMSPLicenseID = "lic_msp_paid"
|
||||
cfg.ProviderMSPPlanVersion = "msp_solo"
|
||||
resolved, _ := resolveProviderMSPPlanFromLicenseFile(ProviderMSPRenewedLicensePath(cfg.DataDir))
|
||||
cfg.ProviderMSPLicenseExpiresAt = resolved.ExpiresAt
|
||||
if result, err := refresher.Refresh(context.Background()); err != nil || result.Changed || result.RestartScheduled {
|
||||
t.Fatalf("unchanged refresh = %+v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderMSPPortalRoutesRelayToTheLicenceServer(t *testing.T) {
|
||||
fake := &fakeProviderMSPLicenseServer{
|
||||
t: t,
|
||||
currentStatus: http.StatusNotFound,
|
||||
checkoutStatus: http.StatusOK,
|
||||
portalStatus: http.StatusOK,
|
||||
plans: `{"plans":[{"plan_version":"msp_solo","billing_cycle":"monthly","unit_amount":6900,"currency":"usd"},{"plan_version":"msp_mystery","billing_cycle":"monthly","unit_amount":1,"currency":"usd"},{"plan_version":"msp_scale","billing_cycle":"annual","unit_amount":399000,"currency":"usd"}]}`,
|
||||
}
|
||||
server := httptest.NewServer(fake)
|
||||
defer server.Close()
|
||||
cfg := newProviderMSPRefreshTestConfig(t, server.URL)
|
||||
refresher := NewProviderMSPLicenseRefresher(cfg)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
HandleProviderMSPPlan(cfg, refresher)(rec, httptest.NewRequest(http.MethodGet, "/api/accounts/a_1/provider-msp/plan", nil))
|
||||
var state ProviderMSPPlanState
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &state); err != nil {
|
||||
t.Fatalf("decode plan state: %v", err)
|
||||
}
|
||||
if !state.Evaluation || state.WorkspaceLimit != 2 || !state.PurchaseAvailable || state.LicenseID != "lic_msp_eval" {
|
||||
t.Fatalf("plan state = %+v", state)
|
||||
}
|
||||
if len(state.Plans) != 2 || state.Plans[0].PlanVersion != "msp_solo" || state.Plans[0].WorkspaceLimit != 3 || state.Plans[1].WorkspaceLimit != 40 {
|
||||
t.Fatalf("offered plans = %+v (an unenforceable plan must be dropped)", state.Plans)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
HandleProviderMSPCheckout(refresher)(rec, httptest.NewRequest(http.MethodPost, "/api/accounts/a_1/provider-msp/checkout", strings.NewReader(`{"plan_version":"msp_solo","billing_cycle":"monthly"}`)))
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "checkout.stripe.com") {
|
||||
t.Fatalf("checkout status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if fake.lastCheckout["license_id"] != "lic_msp_eval" || fake.lastCheckout["return_url"] != "https://msp.example.com/portal" {
|
||||
t.Fatalf("checkout request = %v", fake.lastCheckout)
|
||||
}
|
||||
|
||||
fake.checkoutStatus = http.StatusConflict
|
||||
rec = httptest.NewRecorder()
|
||||
HandleProviderMSPCheckout(refresher)(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"plan_version":"msp_solo","billing_cycle":"monthly"}`)))
|
||||
if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "already_subscribed") {
|
||||
t.Fatalf("second checkout status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
HandleProviderMSPBillingPortal(refresher)(rec, httptest.NewRequest(http.MethodPost, "/", nil))
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "billing.stripe.com") {
|
||||
t.Fatalf("billing portal status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
fake.portalStatus = http.StatusNotFound
|
||||
rec = httptest.NewRecorder()
|
||||
HandleProviderMSPBillingPortal(refresher)(rec, httptest.NewRequest(http.MethodPost, "/", nil))
|
||||
if rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), "no_paid_subscription") {
|
||||
t.Fatalf("billing portal without a subscription status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
HandleProviderMSPLicenseRefresh(refresher)(rec, httptest.NewRequest(http.MethodPost, "/", nil))
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "no_paid_subscription") {
|
||||
t.Fatalf("refresh status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.Join(fake.signedActions, ",") != "billing-portal,billing-portal,license-current" {
|
||||
t.Fatalf("signed actions = %v", fake.signedActions)
|
||||
}
|
||||
|
||||
// Without a licence server the portal still reports the plan but offers
|
||||
// no purchase, and the actions answer unavailable instead of failing open.
|
||||
rec = httptest.NewRecorder()
|
||||
HandleProviderMSPPlan(cfg, nil)(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &state)
|
||||
if state.PurchaseAvailable || len(state.Plans) != 0 {
|
||||
t.Fatalf("plan state without refresher = %+v", state)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
HandleProviderMSPCheckout(nil)(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`)))
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("checkout without refresher status=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -213,7 +213,7 @@ func validateProviderMSPRecoveryConfig(cfg *CPConfig, opts ProviderMSPRecoveryOp
|
||||
if _, known := pkglicensing.WorkspaceLimitForPlan(cfg.ProviderMSPPlanVersion); !known {
|
||||
return fmt.Errorf("provider MSP plan %q has no known workspace limit", cfg.ProviderMSPPlanVersion)
|
||||
}
|
||||
if !opts.AllowEnvPlan && strings.TrimSpace(cfg.ProviderMSPPlanSource) != ProviderMSPPlanSourceLicenseFile {
|
||||
if !opts.AllowEnvPlan && !ProviderMSPPlanSourceIsSignedLicense(cfg.ProviderMSPPlanSource) {
|
||||
return fmt.Errorf("provider MSP recovery requires %s plan source; rerun with --allow-env-plan only for local development", ProviderMSPPlanSourceLicenseFile)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -30,6 +30,7 @@ type Deps struct {
|
||||
HostedEntitlements *entitlements.Service
|
||||
Version string
|
||||
EmailSender email.Sender
|
||||
ProviderMSPLicense *ProviderMSPLicenseRefresher // nil unless provider-hosted MSP can reach a licence server
|
||||
}
|
||||
|
||||
func publicCloudSignupPath(cfg *CPConfig) string {
|
||||
@@ -282,6 +283,16 @@ func RegisterRoutes(mux *http.ServeMux, deps *Deps) {
|
||||
handoffHandler := handoff.HandleHandoff(deps.Registry, deps.Config.TenantsDir())
|
||||
mux.Handle("/api/accounts/{account_id}/tenants/{tenant_id}/handoff", accountAPILimiter.Middleware(accountSessionAuth(accountIDFromPath, handoffHandler)))
|
||||
|
||||
if deps.Config.IsProviderHostedMSP() {
|
||||
// Plan, purchase and billing for a provider-hosted platform. Any
|
||||
// member can see the plan; buying, billing and licence refresh are
|
||||
// owner or admin actions.
|
||||
mux.Handle("/api/accounts/{account_id}/provider-msp/plan", accountAPILimiter.Middleware(accountSessionAuth(accountIDFromPath, HandleProviderMSPPlan(deps.Config, deps.ProviderMSPLicense))))
|
||||
mux.Handle("/api/accounts/{account_id}/provider-msp/checkout", accountAPILimiter.Middleware(accountSessionAuth(accountIDFromPath, accountMutationAuth(HandleProviderMSPCheckout(deps.ProviderMSPLicense)))))
|
||||
mux.Handle("/api/accounts/{account_id}/provider-msp/billing-portal", accountAPILimiter.Middleware(accountSessionAuth(accountIDFromPath, accountMutationAuth(HandleProviderMSPBillingPortal(deps.ProviderMSPLicense)))))
|
||||
mux.Handle("/api/accounts/{account_id}/provider-msp/license/refresh", accountAPILimiter.Middleware(accountSessionAuth(accountIDFromPath, accountMutationAuth(HandleProviderMSPLicenseRefresh(deps.ProviderMSPLicense)))))
|
||||
}
|
||||
|
||||
// MSP portal API (session + account-membership authenticated)
|
||||
portalEnv := portal.PortalEnvironment{
|
||||
SignupPath: publicCloudSignupPath,
|
||||
|
||||
@@ -439,3 +439,85 @@ func TestRegisterRoutes_PortalPageSessionModes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// On a provider-hosted platform any member may see the plan, but buying,
|
||||
// opening billing and refreshing the licence are owner or admin actions.
|
||||
func TestRegisterRoutes_ProviderMSPPurchaseRoutesRequireOwnerOrAdmin(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg, err := registry.NewTenantRegistry(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewTenantRegistry: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = reg.Close() })
|
||||
accountID, err := registry.GenerateAccountID()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccountID: %v", err)
|
||||
}
|
||||
if err := reg.CreateAccount(®istry.Account{ID: accountID, Kind: registry.AccountKindMSP, DisplayName: "Acme MSP"}); err != nil {
|
||||
t.Fatalf("CreateAccount: %v", err)
|
||||
}
|
||||
magicSvc, err := cpauth.NewService(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewService: %v", err)
|
||||
}
|
||||
t.Cleanup(magicSvc.Close)
|
||||
sessionFor := func(email string, role registry.MemberRole) string {
|
||||
userID, err := registry.GenerateUserID()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateUserID: %v", err)
|
||||
}
|
||||
if err := reg.CreateUser(®istry.User{ID: userID, Email: email}); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
if err := reg.CreateMembership(®istry.AccountMembership{AccountID: accountID, UserID: userID, Role: role}); err != nil {
|
||||
t.Fatalf("CreateMembership: %v", err)
|
||||
}
|
||||
token, err := magicSvc.GenerateSessionToken(userID, email, cpauth.SessionTTL)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSessionToken: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
owner := sessionFor("owner@example.com", registry.MemberRoleOwner)
|
||||
readOnly := sessionFor("viewer@example.com", registry.MemberRoleReadOnly)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
RegisterRoutes(mux, &Deps{
|
||||
Config: &CPConfig{
|
||||
DataDir: dir,
|
||||
AdminKey: "test-admin-key",
|
||||
BaseURL: "https://msp.example.com",
|
||||
ControlPlaneMode: ControlPlaneModeProviderHostedMSP,
|
||||
ProviderMSPPlanVersion: "msp_eval",
|
||||
},
|
||||
Registry: reg,
|
||||
MagicLinks: magicSvc,
|
||||
Version: "test",
|
||||
})
|
||||
call := func(method, path, token string) int {
|
||||
req := httptest.NewRequest(method, "/api/accounts/"+accountID+"/provider-msp/"+path, strings.NewReader(`{"plan_version":"msp_solo","billing_cycle":"monthly"}`))
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
if got := call(http.MethodGet, "plan", ""); got != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated plan status = %d", got)
|
||||
}
|
||||
if got := call(http.MethodGet, "plan", readOnly); got != http.StatusOK {
|
||||
t.Fatalf("read-only member plan status = %d", got)
|
||||
}
|
||||
for _, path := range []string{"checkout", "billing-portal", "license/refresh"} {
|
||||
if got := call(http.MethodPost, path, readOnly); got != http.StatusForbidden {
|
||||
t.Errorf("read-only member %s status = %d, want 403", path, got)
|
||||
}
|
||||
// The owner reaches the handler, which answers unavailable because
|
||||
// this platform has no licence server to relay to.
|
||||
if got := call(http.MethodPost, path, owner); got != http.StatusServiceUnavailable {
|
||||
t.Errorf("owner %s status = %d, want 503 from the handler", path, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,7 @@ func Run(ctx context.Context, version string) error {
|
||||
HostedEntitlements: hostedEntitlements,
|
||||
Version: version,
|
||||
EmailSender: emailSender,
|
||||
ProviderMSPLicense: NewProviderMSPLicenseRefresher(cfg),
|
||||
}
|
||||
RegisterRoutes(mux, deps)
|
||||
|
||||
@@ -188,6 +189,15 @@ func Run(ctx context.Context, version string) error {
|
||||
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)
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ var CloudPlanWorkspaceLimits = map[string]int{
|
||||
|
||||
// MSP tiers — client caps from pricing spec
|
||||
"msp_eval": 2, // MSP evaluation: up to 2 clients, unlicensed, not sold
|
||||
"msp_solo": 3, // MSP Solo: up to 3 clients, the first paid step above the evaluation
|
||||
"msp_starter": 5, // MSP Starter: up to 5 clients
|
||||
"msp_growth": 15, // MSP Growth: up to 15 clients
|
||||
"msp_scale": 40, // MSP Scale: up to 40 clients
|
||||
|
||||
@@ -623,6 +623,7 @@ func TestWorkspaceLimitForPlan_KnownPlans(t *testing.T) {
|
||||
{"cloud_power", 1},
|
||||
{"cloud_max", 1},
|
||||
{"cloud_founding", 1},
|
||||
{"msp_solo", 3},
|
||||
{"msp_starter", 5},
|
||||
{"msp_hosted_v1", 5},
|
||||
{"msp_growth", 15},
|
||||
@@ -642,6 +643,24 @@ func TestWorkspaceLimitForPlan_KnownPlans(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The provider MSP ladder must rise from the evaluation through every paid
|
||||
// tier: the evaluation stays below the cheapest paid plan so needing one more
|
||||
// client is what converts, and Solo is that first paid step.
|
||||
func TestProviderMSPWorkspaceLadderRisesFromTheEvaluation(t *testing.T) {
|
||||
ladder := []string{PlanVersionMSPEval, "msp_solo", "msp_starter", "msp_growth", "msp_scale"}
|
||||
previous := 0
|
||||
for _, plan := range ladder {
|
||||
limit, known := WorkspaceLimitForPlan(plan)
|
||||
if !known {
|
||||
t.Fatalf("%s has no workspace limit", plan)
|
||||
}
|
||||
if limit <= previous {
|
||||
t.Fatalf("%s allows %d workspaces, not more than the step below it (%d)", plan, limit, previous)
|
||||
}
|
||||
previous = limit
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceLimitForPlan_UnknownPlanFailsClosed(t *testing.T) {
|
||||
unknownPlans := []string{"stripe", "", "unknown_plan", "cloud_unknown"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user