mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Require license-backed provider MSP proof
This commit is contained in:
parent
3630ab1867
commit
c9c415d7a6
9 changed files with 202 additions and 7 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue