diff --git a/cmd/pulse-control-plane/provider_msp.go b/cmd/pulse-control-plane/provider_msp.go index 2f831d2e1..1d0cd67ac 100644 --- a/cmd/pulse-control-plane/provider_msp.go +++ b/cmd/pulse-control-plane/provider_msp.go @@ -66,6 +66,8 @@ func printProviderMSPBootstrapResult(result *cloudcp.ProviderMSPBootstrapResult) fmt.Printf("owner_email=%s\n", result.OwnerEmail) fmt.Printf("plan_version=%s\n", result.PlanVersion) fmt.Printf("plan_source=%s\n", result.PlanSource) + fmt.Printf("license_id=%s\n", result.LicenseID) + fmt.Printf("license_email=%s\n", result.LicenseEmail) fmt.Printf("workspace_limit=%d\n", result.WorkspaceLimit) if result.MagicLinkURL != "" { fmt.Printf("portal_magic_link=%s\n", result.MagicLinkURL) diff --git a/cmd/pulse-control-plane/provider_msp_proof.go b/cmd/pulse-control-plane/provider_msp_proof.go index 8684602c5..a3748a13e 100644 --- a/cmd/pulse-control-plane/provider_msp_proof.go +++ b/cmd/pulse-control-plane/provider_msp_proof.go @@ -41,6 +41,7 @@ type providerMSPProofOptions struct { InstallType string TargetPath string Cleanup bool + AllowEnvPlan bool AllowNonProofWorkspaceName bool } @@ -78,6 +79,8 @@ type providerMSPProofReport struct { OwnerEmail string PlanVersion string PlanSource string + LicenseID string + LicenseEmail string WorkspaceLimit int WorkspaceCount int Workspaces []providerMSPProofWorkspace @@ -124,6 +127,7 @@ func newProviderMSPProofCmd() *cobra.Command { cmd.Flags().StringVar(&opts.InstallType, "install-type", opts.InstallType, "Hosted tenant install command type: pve or pbs") cmd.Flags().StringVar(&opts.TargetPath, "target-path", opts.TargetPath, "Tenant-local target path to verify during handoff exchange") cmd.Flags().BoolVar(&opts.Cleanup, "cleanup", false, "Delete proof workspaces after the proof completes") + cmd.Flags().BoolVar(&opts.AllowEnvPlan, "allow-env-plan", false, "Allow CP_PROVIDER_MSP_PLAN_VERSION fallback instead of a signed provider MSP license file for local development") cmd.Flags().BoolVar(&opts.AllowNonProofWorkspaceName, "allow-non-proof-workspace-name", false, "Allow proof workspace names without proof/canary/rehearsal markers") _ = cmd.MarkFlagRequired("account-name") _ = cmd.MarkFlagRequired("owner-email") @@ -213,6 +217,9 @@ func (rt *providerMSPProofRuntime) runProviderMSPProof(ctx context.Context, opts if err != nil { return nil, err } + if !opts.AllowEnvPlan && strings.TrimSpace(rt.cfg.ProviderMSPPlanSource) != cloudcp.ProviderMSPPlanSourceLicenseFile { + return nil, fmt.Errorf("provider MSP proof requires %s plan source; rerun with --allow-env-plan only for local development", cloudcp.ProviderMSPPlanSourceLicenseFile) + } bootstrap, err := cloudcp.BootstrapProviderMSP(ctx, rt.cfg, cloudcp.ProviderMSPBootstrapOptions{ AccountName: opts.AccountName, @@ -237,6 +244,8 @@ func (rt *providerMSPProofRuntime) runProviderMSPProof(ctx context.Context, opts OwnerEmail: bootstrap.OwnerEmail, PlanVersion: bootstrap.PlanVersion, PlanSource: bootstrap.PlanSource, + LicenseID: bootstrap.LicenseID, + LicenseEmail: bootstrap.LicenseEmail, WorkspaceLimit: bootstrap.WorkspaceLimit, RuntimeContainerVerified: true, HandoffExchangeVerified: true, @@ -792,6 +801,8 @@ func printProviderMSPProofReport(report *providerMSPProofReport) { fmt.Printf("owner_email=%s\n", report.OwnerEmail) fmt.Printf("plan_version=%s\n", report.PlanVersion) fmt.Printf("plan_source=%s\n", report.PlanSource) + fmt.Printf("license_id=%s\n", report.LicenseID) + fmt.Printf("license_email=%s\n", report.LicenseEmail) fmt.Printf("workspace_limit=%d\n", report.WorkspaceLimit) fmt.Printf("workspace_count=%d\n", report.WorkspaceCount) fmt.Printf("dockerless_provisioning=%t\n", report.DockerlessProvisioning) diff --git a/cmd/pulse-control-plane/provider_msp_proof_test.go b/cmd/pulse-control-plane/provider_msp_proof_test.go index 6de20ba93..eff0f6f7c 100644 --- a/cmd/pulse-control-plane/provider_msp_proof_test.go +++ b/cmd/pulse-control-plane/provider_msp_proof_test.go @@ -5,10 +5,14 @@ import ( "crypto/ed25519" "crypto/rand" "encoding/base64" + "encoding/json" + "os" "strings" "testing" + "time" "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp" + pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" ) func TestProviderMSPCommandExposesProof(t *testing.T) { @@ -35,6 +39,32 @@ func TestNormalizeProviderMSPProofOptionsRequiresIsolationWorkspaceCount(t *test } } +func TestProviderMSPProofRequiresLicenseBackedPlanSourceByDefault(t *testing.T) { + cfg := testProviderMSPProofConfig(t) + cfg.ProviderMSPPlanSource = cloudcp.ProviderMSPPlanSourceEnvFallback + + rt, err := newProviderMSPProofRuntimeFromConfig(cfg) + if err != nil { + t.Fatalf("newProviderMSPProofRuntimeFromConfig: %v", err) + } + defer rt.close() + + _, err = rt.runProviderMSPProof(context.Background(), providerMSPProofOptions{ + AccountName: "Acme Provider", + OwnerEmail: "owner@example.com", + WorkspacePrefix: "Provider MSP Proof", + WorkspaceCount: 2, + InstallType: "pve", + TargetPath: "/settings/infrastructure?add=linux-host", + }) + if err == nil { + t.Fatal("expected provider MSP proof to reject environment-fallback plan source") + } + if !strings.Contains(err.Error(), cloudcp.ProviderMSPPlanSourceLicenseFile) { + t.Fatalf("unexpected error: %v", err) + } +} + func TestProviderMSPProofExercisesWorkspaceInstallHandoffAndIsolation(t *testing.T) { t.Setenv("DOCKER_HOST", "unix:///tmp/pulse-provider-msp-proof-missing-docker.sock") t.Setenv("DOCKER_TLS_VERIFY", "") @@ -68,6 +98,9 @@ func TestProviderMSPProofExercisesWorkspaceInstallHandoffAndIsolation(t *testing if report.PlanVersion != "msp_growth" || report.WorkspaceLimit != 15 { t.Fatalf("plan proof = %q limit=%d, want msp_growth limit 15", report.PlanVersion, report.WorkspaceLimit) } + if report.PlanSource != cloudcp.ProviderMSPPlanSourceLicenseFile { + t.Fatalf("PlanSource = %q, want %q", report.PlanSource, cloudcp.ProviderMSPPlanSourceLicenseFile) + } if report.WorkspaceCount != 2 || len(report.Workspaces) != 2 { t.Fatalf("WorkspaceCount = %d len=%d, want 2", report.WorkspaceCount, len(report.Workspaces)) } @@ -129,6 +162,60 @@ func TestProviderMSPProofExercisesWorkspaceInstallHandoffAndIsolation(t *testing } } +func TestProviderMSPProofLoadsSignedLicenseFilePlan(t *testing.T) { + t.Setenv("DOCKER_HOST", "unix:///tmp/pulse-provider-msp-proof-missing-docker.sock") + t.Setenv("DOCKER_TLS_VERIFY", "") + t.Setenv("DOCKER_CERT_PATH", "") + + dataDir := t.TempDir() + licenseFile := writeProviderMSPProofLicenseForTest(t, "lic_provider_msp_proof", "provider@example.com", "msp_growth") + setProviderMSPProofLoadConfigEnv(t, dataDir, licenseFile) + + cfg, err := cloudcp.LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.ProviderMSPPlanSource != cloudcp.ProviderMSPPlanSourceLicenseFile { + t.Fatalf("ProviderMSPPlanSource = %q, want %q", cfg.ProviderMSPPlanSource, cloudcp.ProviderMSPPlanSourceLicenseFile) + } + if cfg.ProviderMSPPlanVersion != "msp_growth" { + t.Fatalf("ProviderMSPPlanVersion = %q, want msp_growth", cfg.ProviderMSPPlanVersion) + } + + rt, err := newProviderMSPProofRuntimeFromConfig(cfg) + if err != nil { + t.Fatalf("newProviderMSPProofRuntimeFromConfig: %v", err) + } + defer rt.close() + + report, err := rt.runProviderMSPProof(context.Background(), providerMSPProofOptions{ + AccountName: "Acme Provider", + OwnerEmail: "owner@example.com", + WorkspacePrefix: "Provider MSP Proof", + WorkspaceCount: 2, + InstallType: "pve", + TargetPath: "/settings/infrastructure?add=linux-host", + }) + if err != nil { + t.Fatalf("runProviderMSPProof: %v", err) + } + if report.PlanVersion != "msp_growth" || report.WorkspaceLimit != 15 { + t.Fatalf("plan proof = %q limit=%d, want msp_growth limit 15", report.PlanVersion, report.WorkspaceLimit) + } + if report.PlanSource != cloudcp.ProviderMSPPlanSourceLicenseFile { + t.Fatalf("PlanSource = %q, want %q", report.PlanSource, cloudcp.ProviderMSPPlanSourceLicenseFile) + } + if report.LicenseID != "lic_provider_msp_proof" { + t.Fatalf("LicenseID = %q, want lic_provider_msp_proof", report.LicenseID) + } + if report.LicenseEmail != "provider@example.com" { + t.Fatalf("LicenseEmail = %q, want provider@example.com", report.LicenseEmail) + } + if !report.AgentReportIngestVerified || !report.InstallTokenBoundaryOK || !report.HandoffExchangeVerified { + t.Fatalf("provider MSP proof did not complete core runtime checks: %#v", report) + } +} + func testProviderMSPProofConfig(t *testing.T) *cloudcp.CPConfig { t.Helper() publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) @@ -150,8 +237,68 @@ func testProviderMSPProofConfig(t *testing.T) *cloudcp.CPConfig { StorageGuardrailsEnabled: false, ProviderMSPPlanVersion: "msp_growth", ProviderMSPPlanSource: cloudcp.ProviderMSPPlanSourceLicenseFile, + ProviderMSPLicenseID: "lic_provider_msp_test", + ProviderMSPLicenseEmail: "provider@example.com", TrialActivationPrivateKey: base64.StdEncoding.EncodeToString(privateKey), TrialActivationPublicKey: base64.StdEncoding.EncodeToString(publicKey), EmailFrom: "noreply@example.com", } } + +func setProviderMSPProofLoadConfigEnv(t *testing.T, dataDir, licenseFile string) { + t.Helper() + t.Setenv("CP_ADMIN_KEY", "test-key") + t.Setenv("CP_BASE_URL", "https://msp.example.com") + t.Setenv("CP_DATA_DIR", dataDir) + t.Setenv("CP_ENV", "development") + t.Setenv("CP_CONTROL_PLANE_MODE", string(cloudcp.ControlPlaneModeProviderHostedMSP)) + t.Setenv("CP_REQUIRE_EMAIL_PROVIDER", "false") + t.Setenv("STRIPE_WEBHOOK_SECRET", "") + t.Setenv("STRIPE_API_KEY", "") + t.Setenv("CP_PUBLIC_CLOUD_SIGNUP_ENABLED", "false") + t.Setenv("CP_MSP_STARTER_PRICE_ID", "") + t.Setenv("CP_MSP_GROWTH_PRICE_ID", "") + t.Setenv("CP_MSP_SCALE_PRICE_ID", "") + t.Setenv("CP_PROVIDER_MSP_PLAN_VERSION", "msp_starter") + t.Setenv("CP_PROVIDER_MSP_LICENSE_FILE", licenseFile) + t.Setenv("CP_PULSE_IMAGE", "pulse:test") + t.Setenv("CP_DOCKER_NETWORK", "bridge") + t.Setenv("CP_ALLOW_DOCKERLESS_PROVISIONING", "true") + t.Setenv("CP_STORAGE_GUARDRAILS_ENABLED", "false") + t.Setenv("CP_TRIAL_ACTIVATION_PRIVATE_KEY", "A8medgdNdm12GXfTXWo6+TMZ2BeHPCLg2kd0znn6ZUk=") +} + +func writeProviderMSPProofLicenseForTest(t *testing.T, licenseID, email, planVersion string) string { + t.Helper() + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + t.Setenv("PULSE_LICENSE_PUBLIC_KEY", base64.StdEncoding.EncodeToString(publicKey)) + t.Setenv("PULSE_LICENSE_DEV_MODE", "false") + t.Cleanup(func() { pkglicensing.SetPublicKey(nil) }) + + claims := pkglicensing.Claims{ + LicenseID: licenseID, + Email: email, + Tier: pkglicensing.TierMSP, + IssuedAt: time.Now().Add(-time.Minute).Unix(), + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + PlanVersion: planVersion, + } + 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) + signedData := []byte(header + "." + payload) + signature := base64.RawURLEncoding.EncodeToString(ed25519.Sign(privateKey, signedData)) + licenseKey := header + "." + payload + "." + signature + + path := t.TempDir() + "/provider-msp-license.jwt" + if err := os.WriteFile(path, []byte(licenseKey+"\n"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + return path +} diff --git a/deploy/provider-msp/.env.example b/deploy/provider-msp/.env.example index b1a836639..76ea703e0 100644 --- a/deploy/provider-msp/.env.example +++ b/deploy/provider-msp/.env.example @@ -36,3 +36,15 @@ PULSE_EMAIL_REPLY_TO=support@example.com # docker compose run --rm control-plane provider-msp bootstrap \ # --account-name "Example MSP" \ # --owner-email owner@example.com +# +# Install readiness check: +# docker compose run --rm control-plane provider-msp preflight +# +# End-to-end provider proof. This creates two proof client workspaces, generates +# client-bound agent install tokens, verifies tenant-local agent report ingest, +# and exercises the portal handoff path. Keep --cleanup once you have captured +# the output you need. +# docker compose run --rm control-plane provider-msp proof \ +# --account-name "Example MSP" \ +# --owner-email owner@example.com \ +# --cleanup diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index b1940d491..5a11a6e4b 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -20,6 +20,12 @@ agreement, the Pulse Cloud control plane, provider-hosted MSP account bootstrap/licensing, hosted tenant lifecycle, and cloud-specific enforcement rules. +Provider-hosted MSP bootstrap output is part of the cloud-paid operator +contract. It must carry the resolved MSP plan version, plan source, workspace +limit, and validated provider MSP license id/email so installability proof can +distinguish signed license-backed activation from local environment fallback +configuration. + ## Canonical Files 1. `pkg/licensing/features.go` diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 1bcaa5c81..c74c9d588 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -127,7 +127,9 @@ surfaces. onboarding path through workspace creation, client-bound install token generation, tenant-local unified-agent report ingest, handoff exchange, and duplicate-hostname isolation before provider-hosted MSP installability is - treated as proven. + treated as proven. The proof is license-backed by default: `license_file` + must be the resolved provider MSP plan source unless the operator explicitly + opts into the local-development `--allow-env-plan` escape hatch. 5. `internal/cloudcp/tenant_runtime_rollout.go` shared with `cloud-paid`: hosted tenant runtime rollout is both a Pulse Cloud runtime contract boundary and a deployment-installability release-rollout boundary. 6. `scripts/install.ps1` shared with `agent-lifecycle`: the Windows installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary. It must expose a non-mutating preflight for the exact Windows agent diff --git a/internal/cloudcp/provider_msp_bootstrap.go b/internal/cloudcp/provider_msp_bootstrap.go index 28fbd528f..1941fc14a 100644 --- a/internal/cloudcp/provider_msp_bootstrap.go +++ b/internal/cloudcp/provider_msp_bootstrap.go @@ -30,6 +30,8 @@ type ProviderMSPBootstrapResult struct { OwnerEmail string PlanVersion string PlanSource string + LicenseID string + LicenseEmail string WorkspaceLimit int MagicLinkURL string } @@ -107,6 +109,8 @@ func BootstrapProviderMSP(ctx context.Context, cfg *CPConfig, opts ProviderMSPBo OwnerEmail: ownerEmail, PlanVersion: cfg.ProviderMSPPlanVersion, PlanSource: providerMSPPlanSourceOrDefault(cfg.ProviderMSPPlanSource), + LicenseID: strings.TrimSpace(cfg.ProviderMSPLicenseID), + LicenseEmail: strings.ToLower(strings.TrimSpace(cfg.ProviderMSPLicenseEmail)), WorkspaceLimit: workspaceLimit, MagicLinkURL: magicLinkURL, }, nil diff --git a/internal/cloudcp/provider_msp_bootstrap_test.go b/internal/cloudcp/provider_msp_bootstrap_test.go index a475557ed..6d62b8cb2 100644 --- a/internal/cloudcp/provider_msp_bootstrap_test.go +++ b/internal/cloudcp/provider_msp_bootstrap_test.go @@ -34,6 +34,12 @@ func TestBootstrapProviderMSPCreatesOwnerAccountAndMagicLink(t *testing.T) { if result.PlanSource != ProviderMSPPlanSourceLicenseFile { t.Fatalf("PlanSource = %q, want %q", result.PlanSource, ProviderMSPPlanSourceLicenseFile) } + if result.LicenseID != "lic_provider_msp_test" { + t.Fatalf("LicenseID = %q, want lic_provider_msp_test", result.LicenseID) + } + if result.LicenseEmail != "provider@example.com" { + t.Fatalf("LicenseEmail = %q, want provider@example.com", result.LicenseEmail) + } if result.WorkspaceLimit != 15 { t.Fatalf("WorkspaceLimit = %d, want 15", result.WorkspaceLimit) } @@ -114,11 +120,13 @@ func TestBootstrapProviderMSPRejectsPulseHostedMode(t *testing.T) { func testProviderMSPBootstrapConfig(t *testing.T) *CPConfig { t.Helper() return &CPConfig{ - DataDir: t.TempDir(), - Environment: "production", - ControlPlaneMode: ControlPlaneModeProviderHostedMSP, - BaseURL: "https://msp.example.com", - ProviderMSPPlanVersion: "msp_growth", - ProviderMSPPlanSource: ProviderMSPPlanSourceLicenseFile, + DataDir: t.TempDir(), + Environment: "production", + ControlPlaneMode: ControlPlaneModeProviderHostedMSP, + BaseURL: "https://msp.example.com", + ProviderMSPPlanVersion: "msp_growth", + ProviderMSPPlanSource: ProviderMSPPlanSourceLicenseFile, + ProviderMSPLicenseID: "lic_provider_msp_test", + ProviderMSPLicenseEmail: "Provider@Example.com", } } diff --git a/scripts/installtests/provider_msp_deploy_test.go b/scripts/installtests/provider_msp_deploy_test.go index 4236f2771..0294540ca 100644 --- a/scripts/installtests/provider_msp_deploy_test.go +++ b/scripts/installtests/provider_msp_deploy_test.go @@ -47,8 +47,11 @@ func TestProviderMSPDeployEnvExampleMatchesBootstrapPath(t *testing.T) { "CP_PROVIDER_MSP_LICENSE_FILE=./provider-msp-license.jwt", "CP_TRIAL_ACTIVATION_PRIVATE_KEY=", "docker compose run --rm control-plane provider-msp bootstrap", + "docker compose run --rm control-plane provider-msp preflight", + "docker compose run --rm control-plane provider-msp proof", "--account-name", "--owner-email", + "--cleanup", ) assertNotContainsAny(t, text, "STRIPE_",