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) + } + } +}