From 6be2af1c196a2bbbd998376d19ccb1b983d424ce Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 3 Aug 2026 16:31:24 +0100 Subject: [PATCH] Let providers evaluate MSP without asking permission first Two mandatory round-trips stood between an interested MSP and their first screen, and neither was technical. setup.sh required four image digests shipped as literal placeholders, so the only way to get them was to ask. All four images are publicly readable, so there was never anything to hand out. setup.sh now resolves each blank pin to an immutable digest from its published tag via buildx imagetools and writes it back to .env; hand-set values are left alone. setup.sh then died outright without a licence file, so nobody could start the stack, create a workspace, or see the portal until a human minted a licence for them. The control plane already ran unlicensed via ProviderMSPPlanSourceEnvFallback; only the installer refused. A licence path that is set but missing is still a hard failure, since that is a misconfiguration rather than a choice. Unlicensed now means evaluation rather than the cheapest paid tier. The env fallback defaulted to msp_starter, handing every unlicensed deployment the full 5-client Starter allowance and leaving no boundary between evaluating and buying. Adds msp_eval at 2 workspaces: same capabilities, smaller cap, not purchasable, not on the public ladder. An isolation guarantee is the one claim a provider cannot evaluate from a screenshot, and both MSP leads this year went quiet at exactly this step. Contracts: cloud-paid records the unlicensed plan rule and the strictly-below-paid invariant; deployment-installability records credential-free, correspondence-free installability. Verification: TestMSPEvalCapStaysBelowCheapestPaidTier, TestCanonicalizePlanVersion_MSPEval, TestProviderMSPSetupScriptSupportsUnlicensedEvaluation. The last was negative-tested by reintroducing a placeholder and confirming it fails. ensure_image_pins exercised against the live registries. licensing, cloudcp, control-plane and installtests all green. --- deploy/provider-msp/.env.example | 24 ++++-- deploy/provider-msp/setup.sh | 80 ++++++++++++++++++- docs/MSP.md | 16 ++++ .../v6/internal/subsystems/cloud-paid.md | 17 ++++ .../subsystems/deployment-installability.md | 18 +++++ internal/cloudcp/config.go | 18 +++-- internal/cloudcp/config_test.go | 16 +++- pkg/licensing/features.go | 11 +++ pkg/licensing/features_test.go | 24 ++++++ pkg/licensing/stripe_subscription.go | 2 + pkg/licensing/stripe_subscription_test.go | 15 ++++ .../installtests/provider_msp_deploy_test.go | 52 +++++++++++- 12 files changed, 271 insertions(+), 22 deletions(-) diff --git a/deploy/provider-msp/.env.example b/deploy/provider-msp/.env.example index 96d863052..b5f5a7004 100644 --- a/deploy/provider-msp/.env.example +++ b/deploy/provider-msp/.env.example @@ -8,11 +8,16 @@ ACME_EMAIL=admin@example.com # Cloudflare DNS-01 wildcard TLS CF_DNS_API_TOKEN= -# Image pins; use immutable digest refs in production -TRAEFIK_IMAGE=traefik@sha256: -DOCKER_SOCKET_PROXY_IMAGE=tecnativa/docker-socket-proxy@sha256: -CONTROL_PLANE_IMAGE=ghcr.io/rcourtman/pulse-control-plane@sha256: -CP_PULSE_IMAGE=ghcr.io/rcourtman/pulse@sha256: +# Image pins. Leave blank and setup.sh resolves each one to an immutable +# digest from its published tag, then writes the digest back here. All four +# images are public, so this needs no registry credentials. +# +# Pin them by hand instead if you want to hold a specific build; setup.sh +# leaves any value you set alone. +TRAEFIK_IMAGE= +DOCKER_SOCKET_PROXY_IMAGE= +CONTROL_PLANE_IMAGE= +CP_PULSE_IMAGE= # Control plane CP_ENV=production @@ -25,7 +30,14 @@ PULSE_PROVIDER_MSP_DOCKER_SOCKET=/var/run/docker.sock PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR=/var/lib/pulse-provider-msp/spacecheck/root PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR=/var/lib/docker/.pulse-provider-msp-spacecheck CP_TRUSTED_PROXY_CIDRS=172.30.0.0/24 -CP_PROVIDER_MSP_LICENSE_FILE=./provider-msp-license.jwt + +# Licence file. Leave BLANK to evaluate: the control plane runs on msp_eval, +# which is the full product capped at 2 client workspaces, so you can stand +# this up and onboard two real clients without talking to anyone first. +# +# Set it to your licence path once you buy. Paid client caps come from the +# licence, never from this file. +CP_PROVIDER_MSP_LICENSE_FILE= # Entitlement lease signing key. setup.sh generates this; the private key # never leaves this host. Your provider MSP license must bind the derived # PUBLIC key (entitlement_signing_public_key); print it with diff --git a/deploy/provider-msp/setup.sh b/deploy/provider-msp/setup.sh index 4af39b37e..0c1346220 100755 --- a/deploy/provider-msp/setup.sh +++ b/deploy/provider-msp/setup.sh @@ -281,6 +281,59 @@ set_env_value() { rm -f "${tmp}" } +# default_image_ref maps each image variable to the tag its digest is resolved +# from when the operator has not pinned one by hand. +default_image_ref() { + case "$1" in + TRAEFIK_IMAGE) echo "traefik:v3" ;; + DOCKER_SOCKET_PROXY_IMAGE) echo "tecnativa/docker-socket-proxy:latest" ;; + CONTROL_PLANE_IMAGE) echo "ghcr.io/rcourtman/pulse-control-plane:latest" ;; + CP_PULSE_IMAGE) echo "ghcr.io/rcourtman/pulse:latest" ;; + *) return 1 ;; + esac +} + +# resolve_image_digest turns a tag into an immutable digest ref. Uses buildx +# imagetools, which the Docker install above provides, and which reads the +# registry without pulling the image. +resolve_image_digest() { + local ref="$1" digest + digest="$(docker buildx imagetools inspect "${ref}" --format '{{.Manifest.Digest}}' 2>/dev/null || true)" + [[ "${digest}" == sha256:* ]] || return 1 + printf '%s@%s\n' "${ref%:*}" "${digest}" +} + +# ensure_image_pins fills in any image variable the operator left blank or on +# the shipped placeholder. +# +# The bundle used to ship four unfillable "@sha256:" placeholders that +# setup.sh then refused to run without, so the only way to obtain them was to +# ask us. All four images are publicly readable, so there was never anything +# to hand out; it just meant nobody could start without a conversation first. +# +# Still resolved to an immutable digest, not left on a floating tag, so a +# rebuild of the tag cannot silently change what a provider is running. +ensure_image_pins() { + local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env" + [[ -f "${env_path}" ]] || die "missing ${env_path}" + + local key current ref resolved + for key in TRAEFIK_IMAGE DOCKER_SOCKET_PROXY_IMAGE CONTROL_PLANE_IMAGE CP_PULSE_IMAGE; do + current="$(env_value "${key}" "${env_path}")" + if [[ -n "${current}" && "${current}" != *""* ]]; then + continue + fi + ref="$(default_image_ref "${key}")" || die "no default image ref for ${key}" + log "resolving ${key} digest from ${ref}" + if ! resolved="$(resolve_image_digest "${ref}")"; then + die "could not resolve a digest for ${ref} +Set ${key} in ${env_path} by hand, or check this host can reach the registry." + fi + log " ${resolved}" + set_env_value "${key}" "${resolved}" "${env_path}" + done +} + ensure_generated_secrets() { local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env" [[ -f "${env_path}" ]] || die "missing ${env_path}" @@ -377,7 +430,6 @@ Edit it now and set required values: - PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR - PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR - CP_TRUSTED_PROXY_CIDRS - - CP_PROVIDER_MSP_LICENSE_FILE setup.sh will generate CP_ADMIN_KEY and CP_ENTITLEMENT_SIGNING_PRIVATE_KEY if they are still blank. @@ -404,7 +456,7 @@ validate_env_file() { local missing=() local k v - for k in DOMAIN ACME_EMAIL CF_DNS_API_TOKEN CP_ENV TRAEFIK_IMAGE DOCKER_SOCKET_PROXY_IMAGE CONTROL_PLANE_IMAGE CP_ADMIN_KEY CP_PULSE_IMAGE PULSE_PROVIDER_MSP_DATA_DIR PULSE_PROVIDER_MSP_DOCKER_NETWORK PULSE_PROVIDER_MSP_DOCKER_SUBNET PULSE_PROVIDER_MSP_DOCKER_SOCKET PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR CP_TRUSTED_PROXY_CIDRS CP_PROVIDER_MSP_LICENSE_FILE CP_ENTITLEMENT_SIGNING_PRIVATE_KEY CP_TENANT_MEMORY_LIMIT CP_ALLOW_DOCKERLESS_PROVISIONING CP_STORAGE_GUARDRAILS_ENABLED CP_STORAGE_MIN_ROOT_AVAILABLE CP_STORAGE_MIN_DATA_AVAILABLE CP_STORAGE_MIN_DOCKER_AVAILABLE CP_STORAGE_MAX_DOCKER_BUILD_CACHE CP_PROOF_TENANT_MAX_AGE CP_PROOF_TENANT_MATCHERS CP_REQUIRE_EMAIL_PROVIDER PULSE_EMAIL_FROM PULSE_EMAIL_REPLY_TO; do + for k in DOMAIN ACME_EMAIL CF_DNS_API_TOKEN CP_ENV TRAEFIK_IMAGE DOCKER_SOCKET_PROXY_IMAGE CONTROL_PLANE_IMAGE CP_ADMIN_KEY CP_PULSE_IMAGE PULSE_PROVIDER_MSP_DATA_DIR PULSE_PROVIDER_MSP_DOCKER_NETWORK PULSE_PROVIDER_MSP_DOCKER_SUBNET PULSE_PROVIDER_MSP_DOCKER_SOCKET PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR CP_TRUSTED_PROXY_CIDRS CP_ENTITLEMENT_SIGNING_PRIVATE_KEY CP_TENANT_MEMORY_LIMIT CP_ALLOW_DOCKERLESS_PROVISIONING CP_STORAGE_GUARDRAILS_ENABLED CP_STORAGE_MIN_ROOT_AVAILABLE CP_STORAGE_MIN_DATA_AVAILABLE CP_STORAGE_MIN_DOCKER_AVAILABLE CP_STORAGE_MAX_DOCKER_BUILD_CACHE CP_PROOF_TENANT_MAX_AGE CP_PROOF_TENANT_MATCHERS CP_REQUIRE_EMAIL_PROVIDER PULSE_EMAIL_FROM PULSE_EMAIL_REPLY_TO; do v="$(env_value "${k}" "${env_path}")" if [[ -z "${v}" ]]; then missing+=("${k}") @@ -485,14 +537,31 @@ validate_env_file() { die "RESEND_API_KEY is required when CP_REQUIRE_EMAIL_PROVIDER=true" fi + # An empty CP_PROVIDER_MSP_LICENSE_FILE is evaluation mode, not a mistake. + # + # This used to be mandatory, which meant nobody could start the stack, create + # a client workspace, or see the portal until they had emailed for a licence + # and waited for a human to mint one. That put a round-trip with us in front + # of the first screen, and an isolation guarantee is the one claim a provider + # cannot evaluate from a screenshot. + # + # Unlicensed runs on msp_eval (2 client workspaces). Set the licence file + # when you buy; the paid caps come from the licence, never from here. local license_file license_file="$(env_value CP_PROVIDER_MSP_LICENSE_FILE "${env_path}")" + if [[ -z "${license_file}" ]]; then + log "no CP_PROVIDER_MSP_LICENSE_FILE set: evaluation mode, 2 client workspaces" + log "to buy, request a licence bound to this lease signing public key:" + log " $(derive_lease_signing_public_key)" + return 0 + fi if [[ "${license_file}" != /* ]]; then license_file="${PULSE_PROVIDER_MSP_INSTALL_DIR}/${license_file}" fi if [[ ! -f "${license_file}" ]]; then - die "CP_PROVIDER_MSP_LICENSE_FILE does not exist: ${license_file} -Request your provider MSP license with this lease signing public key + die "CP_PROVIDER_MSP_LICENSE_FILE is set but does not exist: ${license_file} +Leave it blank to run in evaluation mode (2 client workspaces), or request your +provider MSP license with this lease signing public key (./setup.sh --print-lease-signing-public-key): $(derive_lease_signing_public_key) The license must bind this key or the control plane will refuse to start." @@ -598,6 +667,9 @@ main() { install_deploy_bundle ensure_env_file ensure_generated_secrets + # After install_docker_ce, which provides the buildx used to read the + # registry, and before validation, which requires the pins to be set. + ensure_image_pins validate_env_file create_data_dirs ensure_docker_network diff --git a/docs/MSP.md b/docs/MSP.md index 8c901d33e..2038ff410 100644 --- a/docs/MSP.md +++ b/docs/MSP.md @@ -261,6 +261,22 @@ are carried on the licence key. MSP plans are sized by client workspace count when the limit is reached. MSP and Enterprise keys are issued through sales — contact support to get set up or to join the MSP design-partner program. +### Evaluating without a licence + +Leave `CP_PROVIDER_MSP_LICENSE_FILE` blank and the control plane runs on +`msp_eval`: the same product, capped at 2 client workspaces. Nothing to +request and nobody to wait for. Stand the stack up, onboard two real clients, +and confirm the isolation boundary holds on your own infrastructure before +you spend anything. + +`setup.sh` also resolves the four image pins from their published tags when +you leave them blank, writing the resolved digests back into `.env`. The +images are public, so this needs no credentials. + +Set the licence file when you buy; paid client caps come from the licence. + +### Licensing a provider deployment + In the provider-hosted model the licence is a signed file (`CP_PROVIDER_MSP_LICENSE_FILE`) that also binds your control plane's entitlement lease signing key: diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index e754a7a72..ef38f8d6b 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -3251,3 +3251,20 @@ consume that filtered response for the header and document title, while `frontend-modern/src/useAppRuntimeState.ts` keeps the fetch inside the existing authenticated bootstrap and does not add a commercial-posture, checkout, or organization probe. + +An MSP control plane without a licence file runs on the `msp_eval` plan +version, not on the cheapest paid tier. `msp_eval` carries the same MSP +capabilities and a workspace limit of two, and it is neither purchasable nor +part of the published pricing ladder. The environment fallback previously +defaulted to `msp_starter`, which granted the full five-workspace Starter +allowance to any unlicensed deployment and left no commercial boundary between +evaluating and buying. `pkg/licensing.PlanVersionMSPEval` is the only spelling +of this plan; `CanonicalizePlanVersion` must continue to fold `msp-eval` onto +it, because an unrecognized plan version falls through the passthrough default +and then fails closed at `UnknownPlanDefaultWorkspaceLimit`, which surfaces as +a provider preflight failure rather than a smaller cap. + +The eval limit must stay strictly below every paid MSP tier. Paid workspace +caps continue to come only from the signed licence file, never from the +environment fallback, and `ProviderMSPPlanSourceEnvFallback` remains the +recorded plan source whenever no licence is present. diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index ff3f2e26c..67d2e95b9 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -2892,3 +2892,21 @@ OpenShift-native Routes and DeploymentConfigs remain outside this slice. contains a Docker socket mount or fixed `runAsUser`, `runAsGroup`, or `fsGroup` values. `scripts/installtests/build_release_assets_test.go` pins the packaged values, templates, RBAC, render assertions, and operator documentation. + +The provider MSP bundle must be installable without a prior exchange with +Pulse. `deploy/provider-msp/setup.sh` treats a blank +`CP_PROVIDER_MSP_LICENSE_FILE` as evaluation and proceeds, reporting the +two-workspace cap and printing the lease signing public key for a later licence +request. A non-empty licence path that does not resolve to a file remains a +hard failure, because that is a misconfiguration rather than a choice. + +`setup.sh` resolves any blank or placeholder image variable +(`TRAEFIK_IMAGE`, `DOCKER_SOCKET_PROXY_IMAGE`, `CONTROL_PLANE_IMAGE`, +`CP_PULSE_IMAGE`) to an immutable digest from its published tag using +`docker buildx imagetools inspect`, and writes the resolved digest back to +`.env`. Operator-supplied values are left untouched. The shipped +`.env.example` must therefore carry blank image variables rather than +unfillable `@sha256:` placeholders, and a blank +`CP_PROVIDER_MSP_LICENSE_FILE`, so the default install path requires no +credentials and no correspondence. All four images are publicly readable, so +digest resolution must not assume registry authentication. diff --git a/internal/cloudcp/config.go b/internal/cloudcp/config.go index 8de6542d3..b44bfc796 100644 --- a/internal/cloudcp/config.go +++ b/internal/cloudcp/config.go @@ -21,13 +21,17 @@ import ( type ControlPlaneMode string const ( - ControlPlaneModePulseHosted ControlPlaneMode = "pulse_hosted" - ControlPlaneModePulseHostedMSP ControlPlaneMode = "pulse_hosted_msp" - ControlPlaneModeProviderHostedMSP ControlPlaneMode = "provider_hosted_msp" - defaultProviderHostedMSPPlanVersion = "msp_starter" - ProviderMSPPlanSourceLicenseFile = "license_file" - ProviderMSPPlanSourceEnvFallback = "environment_fallback" - maxProviderMSPLicenseFileBytes = 64 * 1024 + ControlPlaneModePulseHosted ControlPlaneMode = "pulse_hosted" + ControlPlaneModePulseHostedMSP ControlPlaneMode = "pulse_hosted_msp" + ControlPlaneModeProviderHostedMSP ControlPlaneMode = "provider_hosted_msp" + // No licence file means evaluation, not the cheapest paid tier. This used + // to default to msp_starter, which meant an unlicensed control plane ran + // on the full 5-client Starter allowance and there was nothing left to + // buy. See pkglicensing.PlanVersionMSPEval. + defaultProviderHostedMSPPlanVersion = pkglicensing.PlanVersionMSPEval + ProviderMSPPlanSourceLicenseFile = "license_file" + ProviderMSPPlanSourceEnvFallback = "environment_fallback" + maxProviderMSPLicenseFileBytes = 64 * 1024 // cpauthDefaultSessionTTL mirrors cpauth.SessionTTL for Pulse-hosted // control planes; providerHostedSessionTTL is the default for diff --git a/internal/cloudcp/config_test.go b/internal/cloudcp/config_test.go index 8f7b503be..ba05053a5 100644 --- a/internal/cloudcp/config_test.go +++ b/internal/cloudcp/config_test.go @@ -540,12 +540,17 @@ func TestLoadConfig_ProviderHostedMSPDoesNotRequireStripe(t *testing.T) { if cfg.UsesStripeBilling() { t.Fatal("UsesStripeBilling = true, want false") } - if cfg.ProviderMSPPlanVersion != "msp_starter" { - t.Fatalf("ProviderMSPPlanVersion = %q, want msp_starter", cfg.ProviderMSPPlanVersion) + // No licence file means evaluation, not the cheapest paid tier. + if cfg.ProviderMSPPlanVersion != pkglicensing.PlanVersionMSPEval { + t.Fatalf("ProviderMSPPlanVersion = %q, want %q", cfg.ProviderMSPPlanVersion, pkglicensing.PlanVersionMSPEval) } if cfg.ProviderMSPPlanSource != ProviderMSPPlanSourceEnvFallback { t.Fatalf("ProviderMSPPlanSource = %q, want %q", cfg.ProviderMSPPlanSource, ProviderMSPPlanSourceEnvFallback) } + limit, known := pkglicensing.WorkspaceLimitForPlan(cfg.ProviderMSPPlanVersion) + if !known || limit != 2 { + t.Fatalf("unlicensed workspace limit = %d (known=%v), want 2", limit, known) + } } func TestLoadConfig_PulseHostedMSPUsesStripeFreeMSPStack(t *testing.T) { @@ -567,8 +572,11 @@ func TestLoadConfig_PulseHostedMSPUsesStripeFreeMSPStack(t *testing.T) { if cfg.UsesStripeBilling() { t.Fatal("UsesStripeBilling = true, want false") } - if cfg.ProviderMSPPlanVersion != "msp_starter" { - t.Fatalf("ProviderMSPPlanVersion = %q, want msp_starter", cfg.ProviderMSPPlanVersion) + // Unlicensed is unlicensed in either hosting mode. A real Pulse-hosted + // customer resolves their plan from the licence file, covered by + // TestLoadConfig_PulseHostedMSPUsesSignedLicenseFilePlan below. + if cfg.ProviderMSPPlanVersion != pkglicensing.PlanVersionMSPEval { + t.Fatalf("ProviderMSPPlanVersion = %q, want %q", cfg.ProviderMSPPlanVersion, pkglicensing.PlanVersionMSPEval) } } diff --git a/pkg/licensing/features.go b/pkg/licensing/features.go index da474f0ed..9c79ded13 100644 --- a/pkg/licensing/features.go +++ b/pkg/licensing/features.go @@ -190,11 +190,22 @@ var CloudPlanWorkspaceLimits = map[string]int{ "cloud_founding": 1, // MSP tiers — client caps from pricing spec + "msp_eval": 2, // MSP evaluation: up to 2 clients, unlicensed, not sold "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 } +// PlanVersionMSPEval is the plan a provider-hosted control plane runs on when +// it has no licence file. It exists so a provider can stand the stack up and +// onboard real clients before talking to anyone, which is the only way to +// evaluate an isolation guarantee honestly. +// +// Deliberately capped BELOW the cheapest paid tier. The capabilities are the +// same, so what is being evaluated is the real product; the client cap is what +// converts. Not a purchasable plan and not part of the public pricing ladder. +const PlanVersionMSPEval = "msp_eval" + // UnknownPlanDefaultWorkspaceLimit is the safe-default workspace limit applied // when a plan version is not recognized. Fail-closed: unknown plans get the // smallest MSP tier limit. diff --git a/pkg/licensing/features_test.go b/pkg/licensing/features_test.go index c6359a47d..1a3da72d3 100644 --- a/pkg/licensing/features_test.go +++ b/pkg/licensing/features_test.go @@ -881,3 +881,27 @@ func TestBusinessTierContractShape(t *testing.T) { t.Fatalf("GetTierDisplayName(TierBusiness) = %q, want Business", got) } } + +// The unlicensed evaluation cap is a commercial boundary, not a default that +// happens to be small. If it ever reaches the cheapest paid tier there is +// nothing left to sell, which is exactly the state this replaced: the +// unlicensed control plane used to run on the full 5-client Starter allowance. +func TestMSPEvalCapStaysBelowCheapestPaidTier(t *testing.T) { + evalLimit, known := WorkspaceLimitForPlan(PlanVersionMSPEval) + if !known { + t.Fatalf("WorkspaceLimitForPlan(%q) unknown; an unrecognized plan fails closed to %d and breaks provider preflight", PlanVersionMSPEval, UnknownPlanDefaultWorkspaceLimit) + } + if evalLimit != 2 { + t.Fatalf("eval workspace limit = %d, want 2", evalLimit) + } + + for _, paid := range []string{"msp_starter", "msp_growth", "msp_scale"} { + paidLimit, paidKnown := WorkspaceLimitForPlan(paid) + if !paidKnown { + t.Fatalf("paid plan %q has no workspace limit", paid) + } + if evalLimit >= paidLimit { + t.Fatalf("eval limit %d is not below paid plan %q limit %d", evalLimit, paid, paidLimit) + } + } +} diff --git a/pkg/licensing/stripe_subscription.go b/pkg/licensing/stripe_subscription.go index d2ac0e473..4b84078f9 100644 --- a/pkg/licensing/stripe_subscription.go +++ b/pkg/licensing/stripe_subscription.go @@ -90,6 +90,8 @@ func CanonicalizePlanVersion(raw string) string { return "cloud_starter" case "msp", "msp-hosted-v1", "msp_hosted_v1", "msp-starter", "msp_starter": return "msp_starter" + case "msp-eval", "msp_eval": + return PlanVersionMSPEval case "power", "cloud-power", "cloud_power": return "cloud_power" case "max", "cloud-max", "cloud_max": diff --git a/pkg/licensing/stripe_subscription_test.go b/pkg/licensing/stripe_subscription_test.go index f135ddcf7..fc29e68eb 100644 --- a/pkg/licensing/stripe_subscription_test.go +++ b/pkg/licensing/stripe_subscription_test.go @@ -203,3 +203,18 @@ func TestCanonicalizePlanVersion(t *testing.T) { }) } } + +// msp_eval is not a Stripe-purchasable plan, but it flows through the same +// canonicalizer as every paid plan version, so an unrecognized spelling would +// fall through to the passthrough default and then fail closed at a +// 1-workspace limit in provider preflight. +func TestCanonicalizePlanVersion_MSPEval(t *testing.T) { + for _, raw := range []string{"msp_eval", "msp-eval", " MSP-Eval "} { + if got := CanonicalizePlanVersion(raw); got != PlanVersionMSPEval { + t.Fatalf("CanonicalizePlanVersion(%q) = %q, want %q", raw, got, PlanVersionMSPEval) + } + } + if got := CanonicalizePlanVersion("msp_starter"); got != "msp_starter" { + t.Fatalf("msp_starter must not be captured by the eval case, got %q", got) + } +} diff --git a/scripts/installtests/provider_msp_deploy_test.go b/scripts/installtests/provider_msp_deploy_test.go index 33ab257c3..c4eb4efba 100644 --- a/scripts/installtests/provider_msp_deploy_test.go +++ b/scripts/installtests/provider_msp_deploy_test.go @@ -74,7 +74,9 @@ func TestProviderMSPDeployEnvExampleMatchesBootstrapPath(t *testing.T) { "PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR=/var/lib/pulse-provider-msp/spacecheck/root", "PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR=/var/lib/docker/.pulse-provider-msp-spacecheck", "CP_TRUSTED_PROXY_CIDRS=172.30.0.0/24", - "CP_PROVIDER_MSP_LICENSE_FILE=./provider-msp-license.jwt", + // Ships blank on purpose: blank is evaluation. The exact blank form is + // asserted in TestProviderMSPSetupScriptSupportsUnlicensedEvaluation. + "CP_PROVIDER_MSP_LICENSE_FILE=", "CP_ENTITLEMENT_SIGNING_PRIVATE_KEY=", "sudo -E ./setup.sh", "docker compose run --rm control-plane provider-msp bootstrap", @@ -328,3 +330,51 @@ func assertNotContainsAny(t *testing.T, text string, forbidden ...string) { } } } + +// The unlicensed path is both a commercial boundary and the whole conversion +// path, so it is pinned here rather than left to whoever next edits setup.sh. +// +// Before this, setup.sh required a licence file and four hand-supplied image +// digests shipped as unfillable "" placeholders. That put two human +// round-trips in front of a provider's first screen, for no technical reason: +// all four images are public, and the control plane already ran unlicensed on +// the environment fallback. +func TestProviderMSPSetupScriptSupportsUnlicensedEvaluation(t *testing.T) { + scriptBytes, err := os.ReadFile(repoFile("deploy", "provider-msp", "setup.sh")) + if err != nil { + t.Fatalf("read provider MSP setup: %v", err) + } + script := string(scriptBytes) + + assertContainsAll(t, script, + "evaluation mode, 2 client workspaces", + "ensure_image_pins", + "default_image_ref", + "resolve_image_digest", + "buildx imagetools inspect", + ) + + // A blank licence path means evaluation. If it returns to the + // must-be-non-empty list, setup.sh refuses to start unlicensed again. + if strings.Contains(script, "CP_TRUSTED_PROXY_CIDRS CP_PROVIDER_MSP_LICENSE_FILE") { + t.Fatal("CP_PROVIDER_MSP_LICENSE_FILE is back in the required non-empty list; blank must mean evaluation") + } + + envBytes, err := os.ReadFile(repoFile("deploy", "provider-msp", ".env.example")) + if err != nil { + t.Fatalf("read provider MSP env example: %v", err) + } + env := string(envBytes) + + if strings.Contains(env, "") { + t.Fatal(".env.example still ships image placeholders; setup.sh resolves blank pins from published tags instead") + } + if !strings.Contains(env, "\nCP_PROVIDER_MSP_LICENSE_FILE=\n") { + t.Fatal(".env.example must ship a blank CP_PROVIDER_MSP_LICENSE_FILE so the shipped default is evaluation") + } + for _, key := range []string{"TRAEFIK_IMAGE", "DOCKER_SOCKET_PROXY_IMAGE", "CONTROL_PLANE_IMAGE", "CP_PULSE_IMAGE"} { + if !strings.Contains(env, "\n"+key+"=\n") { + t.Fatalf(".env.example must ship %s blank so setup.sh resolves its digest", key) + } + } +}