Add provider-hosted MSP install path

This commit is contained in:
rcourtman 2026-06-02 12:11:35 +01:00
parent d90352ea89
commit 5134e36c28
19 changed files with 1144 additions and 98 deletions

View file

@ -374,6 +374,7 @@ func init() {
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(newCloudCmd())
rootCmd.AddCommand(newMobileProofCmd())
rootCmd.AddCommand(newProviderMSPCmd())
rootCmd.AddCommand(newTenantRuntimeCmd())
}

View file

@ -0,0 +1,71 @@
package main
import (
"fmt"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp"
"github.com/spf13/cobra"
)
func newProviderMSPCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "provider-msp",
Short: "Operate a provider-hosted MSP control plane",
}
cmd.AddCommand(newProviderMSPBootstrapCmd())
return cmd
}
func newProviderMSPBootstrapCmd() *cobra.Command {
var accountID string
var accountName string
var ownerEmail string
var magicLink bool
cmd := &cobra.Command{
Use: "bootstrap",
Short: "Create or update the provider MSP account owner",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := cloudcp.LoadConfig()
if err != nil {
return fmt.Errorf("load control plane config: %w", err)
}
result, err := cloudcp.BootstrapProviderMSP(cmd.Context(), cfg, cloudcp.ProviderMSPBootstrapOptions{
AccountID: accountID,
AccountName: accountName,
OwnerEmail: ownerEmail,
GenerateMagicLink: magicLink,
})
if err != nil {
return err
}
printProviderMSPBootstrapResult(result)
return nil
},
}
cmd.Flags().StringVar(&accountID, "account-id", "", "Existing MSP account ID to bootstrap; omitted on first install")
cmd.Flags().StringVar(&accountName, "account-name", "", "Provider account display name")
cmd.Flags().StringVar(&ownerEmail, "owner-email", "", "Provider owner email address")
cmd.Flags().BoolVar(&magicLink, "magic-link", true, "Generate a one-time Pulse Account portal sign-in link")
_ = cmd.MarkFlagRequired("account-name")
_ = cmd.MarkFlagRequired("owner-email")
return cmd
}
func printProviderMSPBootstrapResult(result *cloudcp.ProviderMSPBootstrapResult) {
if result == nil {
fmt.Println("provider_msp_bootstrap_ok=false")
return
}
fmt.Println("provider_msp_bootstrap_ok=true")
fmt.Printf("account_id=%s\n", result.AccountID)
fmt.Printf("account_name=%s\n", result.AccountName)
fmt.Printf("owner_user_id=%s\n", result.OwnerUserID)
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("workspace_limit=%d\n", result.WorkspaceLimit)
if result.MagicLinkURL != "" {
fmt.Printf("portal_magic_link=%s\n", result.MagicLinkURL)
}
}

View file

@ -0,0 +1,38 @@
# Domain
DOMAIN=msp.example.com
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:<pin>
CONTROL_PLANE_IMAGE=ghcr.io/rcourtman/pulse-control-plane@sha256:<pin>
CP_PULSE_IMAGE=ghcr.io/rcourtman/pulse@sha256:<pin>
# Control plane
CP_ENV=production
CP_ADMIN_KEY=
CP_PROVIDER_MSP_LICENSE_FILE=./provider-msp-license.jwt
CP_TRIAL_ACTIVATION_PRIVATE_KEY=
CP_TENANT_MEMORY_LIMIT=536870912
CP_ALLOW_DOCKERLESS_PROVISIONING=false
CP_STORAGE_GUARDRAILS_ENABLED=true
CP_STORAGE_MIN_ROOT_AVAILABLE=10GiB
CP_STORAGE_MIN_DATA_AVAILABLE=5GiB
CP_STORAGE_MIN_DOCKER_AVAILABLE=10GiB
CP_STORAGE_MAX_DOCKER_BUILD_CACHE=2GiB
CP_PROOF_TENANT_MAX_AGE=24h
CP_PROOF_TENANT_MATCHERS=proof,canary,rehearsal,msp_prod,ownerseed,owner_seed
# Email is optional for bootstrap because the CLI can print the first portal link.
# Set CP_REQUIRE_EMAIL_PROVIDER=true once transactional email is configured.
CP_REQUIRE_EMAIL_PROVIDER=false
RESEND_API_KEY=
PULSE_EMAIL_FROM=noreply@example.com
PULSE_EMAIL_REPLY_TO=support@example.com
# First owner bootstrap:
# docker compose run --rm control-plane provider-msp bootstrap \
# --account-name "Example MSP" \
# --owner-email owner@example.com

View file

@ -0,0 +1,86 @@
services:
traefik:
image: ${TRAEFIK_IMAGE:?TRAEFIK_IMAGE must be set to a digest-pinned image}
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik.yml:/etc/traefik/traefik.yml:ro
- ./traefik-dynamic.yml:/etc/traefik/traefik-dynamic.yml:ro
- acme-data:/etc/traefik/acme
env_file:
- .env
environment:
- CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN}
- TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_EMAIL=${ACME_EMAIL}
- TRAEFIK_CERTIFICATESRESOLVERS_LE_ACME_EMAIL=${ACME_EMAIL}
networks:
- pulse-provider-msp
restart: unless-stopped
control-plane:
image: ${CONTROL_PLANE_IMAGE:?CONTROL_PLANE_IMAGE must be set to a digest-pinned image}
volumes:
- /data:/data
- /:/host-root:ro
- /var/lib/docker:/host-var-lib-docker:ro
- /var/run/docker.sock:/var/run/docker.sock
secrets:
- provider_msp_license
env_file:
- .env
environment:
- CP_DATA_DIR=/data
- CP_ENV=${CP_ENV}
- CP_CONTROL_PLANE_MODE=provider_hosted_msp
- CP_BIND_ADDRESS=0.0.0.0
- CP_PORT=8443
- CP_ADMIN_KEY=${CP_ADMIN_KEY}
- CP_BASE_URL=https://${DOMAIN}
- CP_PULSE_IMAGE=${CP_PULSE_IMAGE}
- CP_DOCKER_NETWORK=pulse-provider-msp
- CP_PROVIDER_MSP_LICENSE_FILE=/run/secrets/provider_msp_license
- CP_TENANT_MEMORY_LIMIT=${CP_TENANT_MEMORY_LIMIT:-536870912}
- CP_ALLOW_DOCKERLESS_PROVISIONING=${CP_ALLOW_DOCKERLESS_PROVISIONING:-false}
- CP_STORAGE_GUARDRAILS_ENABLED=${CP_STORAGE_GUARDRAILS_ENABLED:-true}
- CP_STORAGE_ROOT_PATH=/host-root
- CP_STORAGE_DATA_PATH=/data
- CP_STORAGE_DOCKER_PATH=/host-var-lib-docker
- CP_STORAGE_MIN_ROOT_AVAILABLE=${CP_STORAGE_MIN_ROOT_AVAILABLE:-10GiB}
- CP_STORAGE_MIN_DATA_AVAILABLE=${CP_STORAGE_MIN_DATA_AVAILABLE:-5GiB}
- CP_STORAGE_MIN_DOCKER_AVAILABLE=${CP_STORAGE_MIN_DOCKER_AVAILABLE:-10GiB}
- CP_STORAGE_MAX_DOCKER_BUILD_CACHE=${CP_STORAGE_MAX_DOCKER_BUILD_CACHE:-2GiB}
- CP_PROOF_TENANT_MAX_AGE=${CP_PROOF_TENANT_MAX_AGE:-24h}
- CP_PROOF_TENANT_MATCHERS=${CP_PROOF_TENANT_MATCHERS:-proof,canary,rehearsal,msp_prod,ownerseed,owner_seed}
- CP_REQUIRE_EMAIL_PROVIDER=${CP_REQUIRE_EMAIL_PROVIDER:-false}
- CP_TRIAL_ACTIVATION_PRIVATE_KEY=${CP_TRIAL_ACTIVATION_PRIVATE_KEY}
- RESEND_API_KEY=${RESEND_API_KEY}
- PULSE_EMAIL_FROM=${PULSE_EMAIL_FROM}
- PULSE_EMAIL_REPLY_TO=${PULSE_EMAIL_REPLY_TO}
networks:
- pulse-provider-msp
depends_on:
- traefik
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.docker.network=pulse-provider-msp
- traefik.http.routers.provider-control-plane.rule=Host(`${DOMAIN}`)
- traefik.http.routers.provider-control-plane.entrypoints=websecure
- traefik.http.routers.provider-control-plane.tls=true
- traefik.http.routers.provider-control-plane.tls.certresolver=letsencrypt
- traefik.http.routers.provider-control-plane.tls.domains[0].main=${DOMAIN}
- traefik.http.routers.provider-control-plane.tls.domains[0].sans=*.${DOMAIN}
- traefik.http.services.provider-control-plane.loadbalancer.server.port=8443
secrets:
provider_msp_license:
file: ${CP_PROVIDER_MSP_LICENSE_FILE:?CP_PROVIDER_MSP_LICENSE_FILE must point to the signed provider MSP license file}
networks:
pulse-provider-msp:
name: pulse-provider-msp
volumes:
acme-data:

View file

@ -0,0 +1,11 @@
http:
middlewares:
security-headers:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: true
frameDeny: true
contentTypeNosniff: true
browserXssFilter: true
referrerPolicy: "strict-origin-when-cross-origin"

View file

@ -0,0 +1,45 @@
api:
dashboard: false
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
http:
middlewares:
- security-headers@file
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
network: pulse-provider-msp
file:
filename: /etc/traefik/traefik-dynamic.yml
watch: true
certificatesResolvers:
letsencrypt:
acme:
email: "admin@example.com"
storage: /etc/traefik/acme/acme.json
dnsChallenge:
provider: cloudflare
resolvers:
- "1.1.1.1:53"
- "8.8.8.8:53"
le:
acme:
email: "admin@example.com"
storage: /etc/traefik/acme/acme.json
dnsChallenge:
provider: cloudflare
resolvers:
- "1.1.1.1:53"
- "8.8.8.8:53"

View file

@ -16,8 +16,9 @@
## Purpose
Own cloud plan/version semantics, entitlement limits, hosted billing/runtime
agreement, the Pulse Cloud control plane, hosted tenant lifecycle, and
cloud-specific enforcement rules.
agreement, the Pulse Cloud control plane, provider-hosted MSP account
bootstrap/licensing, hosted tenant lifecycle, and cloud-specific enforcement
rules.
## Canonical Files
@ -99,6 +100,7 @@ cloud-specific enforcement rules.
87. `internal/cloudcp/stripe/grace_enforcer.go`, `internal/cloudcp/stripe/helpers.go`, `internal/cloudcp/stripe/reconciler.go`, `internal/cloudcp/stripe/webhook.go`
88. `internal/hosted/hosted_metrics.go`, `internal/hosted/reaper.go`
89. `internal/cloudcp/public_msp_signup_handlers.go`
90. `internal/cloudcp/provider_msp_bootstrap.go`
## Shared Boundaries

View file

@ -15,8 +15,9 @@
## Purpose
Own server installation, deployment bootstrap behavior, update planning, and
server-side update execution surfaces.
Own server installation, deployment bootstrap behavior, provider-hosted MSP
deployment artifacts, update planning, and server-side update execution
surfaces.
## Canonical Files
@ -25,85 +26,87 @@ server-side update execution surfaces.
3. `frontend-modern/src/api/updates.ts`
4. `cmd/pulse-control-plane/main.go`
5. `cmd/pulse-control-plane/mobile_proof_cmd.go`
6. `internal/cloudcp/docker/manager.go`
7. `internal/cloudcp/docker/labels.go`
8. `internal/cloudcp/tenant_runtime_rollout.go`
9. `.github/workflows/create-release.yml`
10. `.github/workflows/deploy-demo-server.yml`
11. `.github/workflows/helm-pages.yml`
12. `.github/workflows/promote-floating-tags.yml`
13. `.github/workflows/publish-docker.yml`
14. `.github/workflows/publish-helm-chart.yml`
15. `.github/workflows/release-dry-run.yml`
16. `.github/workflows/update-demo-server.yml`
17. `.github/workflows/validate-release-assets.yml`
18. `.github/workflows/install-sh-smoke.yml`
19. `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`
20. `docs/RELEASE_NOTES.md`
21. `docs/releases/`
22. `docs/UPGRADE_v6.md`
23. `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`
24. `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`
25. `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`
26. `package.json`
27. `package-lock.json`
28. `frontend-modern/package.json`
29. `frontend-modern/package-lock.json`
30. `frontend-modern/vite.config.ts`
31. `go.mod`
32. `go.sum`
33. `scripts/build-release.sh`
34. `scripts/check-workflow-dispatch-inputs.py`
35. `scripts/clean-mock-alerts.sh`
36. `scripts/com.pulse.hot-dev.plist.template`
37. `scripts/dev-check.sh`
38. `scripts/dev-deploy-agent.sh`
39. `scripts/dev-launchd-setup.sh`
40. `scripts/dev-launchd-wrapper.sh`
41. `scripts/hot-dev-bg.sh`
42. `scripts/hot-dev.sh`
43. `scripts/lib/hot-dev-runtime.sh`
44. `scripts/lib/hot-dev-auth.sh`
45. `scripts/install-container-agent.sh`
46. `install.sh`
47. `scripts/install.ps1`
48. `scripts/install.sh`
49. `scripts/install-mcp.sh`
50. `scripts/install-mcp.ps1`
51. `cmd/pulse-mcp/`
52. `scripts/pulse-auto-update.sh`
53. `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`
54. `scripts/release_control/record_rc_to_ga_rehearsal.py`
55. `scripts/release_control/release_promotion_policy_support.py`
56. `scripts/release_control/resolve_release_promotion.py`
57. `scripts/release_ldflags.sh`
58. `scripts/run_cloud_public_signup_smoke.sh`
59. `scripts/run_demo_public_browser_smoke.sh`
60. `scripts/demo_public_browser_smoke.cjs`
61. `scripts/run_hosted_staging_smoke.sh`
62. `scripts/trigger-release-dry-run.sh`
63. `scripts/trigger-release.sh`
64. `scripts/toggle-mock.sh`
65. `deploy/helm/pulse/`
66. `tests/integration/playwright.config.ts`
67. `tests/integration/QUICK_START.md`
68. `tests/integration/README.md`
69. `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs`
70. `tests/integration/scripts/hosted-mobile-token-runtime.mjs`
71. `tests/integration/scripts/hosted-tenant-approval-store.mjs`
72. `tests/integration/scripts/hosted-tenant-runtime.mjs`
73. `tests/integration/scripts/hosted-tenant-runtime-restart.mjs`
74. `tests/integration/scripts/managed-dev-runtime.mjs`
75. `tests/integration/scripts/relay-mobile-token-helper.go`
76. `tests/integration/tests/helpers.ts`
77. `tests/integration/tests/runtime-defaults.ts`
78. `docker-compose.yml`
79. `scripts/install-docker.sh`
80. `scripts/validate-published-release.sh`
81. `scripts/validate-release.sh`
82. `scripts/release_asset_common.sh`
83. `scripts/backfill-release-assets.sh`
84. `.github/workflows/backfill-release-assets.yml`
6. `cmd/pulse-control-plane/provider_msp.go`
7. `internal/cloudcp/docker/manager.go`
8. `internal/cloudcp/docker/labels.go`
9. `internal/cloudcp/tenant_runtime_rollout.go`
10. `.github/workflows/create-release.yml`
11. `.github/workflows/deploy-demo-server.yml`
12. `.github/workflows/helm-pages.yml`
13. `.github/workflows/promote-floating-tags.yml`
14. `.github/workflows/publish-docker.yml`
15. `.github/workflows/publish-helm-chart.yml`
16. `.github/workflows/release-dry-run.yml`
17. `.github/workflows/update-demo-server.yml`
18. `.github/workflows/validate-release-assets.yml`
19. `.github/workflows/install-sh-smoke.yml`
20. `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`
21. `docs/RELEASE_NOTES.md`
22. `docs/releases/`
23. `docs/UPGRADE_v6.md`
24. `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`
25. `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`
26. `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`
27. `package.json`
28. `package-lock.json`
29. `frontend-modern/package.json`
30. `frontend-modern/package-lock.json`
31. `frontend-modern/vite.config.ts`
32. `go.mod`
33. `go.sum`
34. `scripts/build-release.sh`
35. `scripts/check-workflow-dispatch-inputs.py`
36. `scripts/clean-mock-alerts.sh`
37. `scripts/com.pulse.hot-dev.plist.template`
38. `scripts/dev-check.sh`
39. `scripts/dev-deploy-agent.sh`
40. `scripts/dev-launchd-setup.sh`
41. `scripts/dev-launchd-wrapper.sh`
42. `scripts/hot-dev-bg.sh`
43. `scripts/hot-dev.sh`
44. `scripts/lib/hot-dev-runtime.sh`
45. `scripts/lib/hot-dev-auth.sh`
46. `scripts/install-container-agent.sh`
47. `install.sh`
48. `scripts/install.ps1`
49. `scripts/install.sh`
50. `scripts/install-mcp.sh`
51. `scripts/install-mcp.ps1`
52. `cmd/pulse-mcp/`
53. `scripts/pulse-auto-update.sh`
54. `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`
55. `scripts/release_control/record_rc_to_ga_rehearsal.py`
56. `scripts/release_control/release_promotion_policy_support.py`
57. `scripts/release_control/resolve_release_promotion.py`
58. `scripts/release_ldflags.sh`
59. `scripts/run_cloud_public_signup_smoke.sh`
60. `scripts/run_demo_public_browser_smoke.sh`
61. `scripts/demo_public_browser_smoke.cjs`
62. `scripts/run_hosted_staging_smoke.sh`
63. `scripts/trigger-release-dry-run.sh`
64. `scripts/trigger-release.sh`
65. `scripts/toggle-mock.sh`
66. `deploy/provider-msp/`
67. `deploy/helm/pulse/`
68. `tests/integration/playwright.config.ts`
69. `tests/integration/QUICK_START.md`
70. `tests/integration/README.md`
71. `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs`
72. `tests/integration/scripts/hosted-mobile-token-runtime.mjs`
73. `tests/integration/scripts/hosted-tenant-approval-store.mjs`
74. `tests/integration/scripts/hosted-tenant-runtime.mjs`
75. `tests/integration/scripts/hosted-tenant-runtime-restart.mjs`
76. `tests/integration/scripts/managed-dev-runtime.mjs`
77. `tests/integration/scripts/relay-mobile-token-helper.go`
78. `tests/integration/tests/helpers.ts`
79. `tests/integration/tests/runtime-defaults.ts`
80. `docker-compose.yml`
81. `scripts/install-docker.sh`
82. `scripts/validate-published-release.sh`
83. `scripts/validate-release.sh`
84. `scripts/release_asset_common.sh`
85. `scripts/backfill-release-assets.sh`
86. `.github/workflows/backfill-release-assets.yml`
## Shared Boundaries

View file

@ -1887,6 +1887,7 @@
"internal/cloudcp/auth/session.go",
"internal/cloudcp/config.go",
"internal/cloudcp/entitlements/service.go",
"internal/cloudcp/provider_msp_bootstrap.go",
"internal/cloudcp/public_cloud_signup_handlers.go",
"internal/cloudcp/registry/models.go",
"internal/cloudcp/registry/registry.go",
@ -2602,6 +2603,7 @@
"contract": "docs/release-control/v6/internal/subsystems/deployment-installability.md",
"owned_prefixes": [
"deploy/helm/pulse/",
"deploy/provider-msp/",
"docs/releases/",
"internal/updates/"
],
@ -2619,6 +2621,7 @@
".github/workflows/update-demo-server.yml",
".github/workflows/validate-release-assets.yml",
"cmd/pulse-control-plane/main.go",
"cmd/pulse-control-plane/provider_msp.go",
"docker-compose.yml",
"Dockerfile",
"docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md",
@ -2879,9 +2882,12 @@
{
"id": "hosted-runtime-rollout-control",
"label": "hosted runtime rollout control proof",
"match_prefixes": [],
"match_prefixes": [
"deploy/provider-msp/"
],
"match_files": [
"cmd/pulse-control-plane/main.go",
"cmd/pulse-control-plane/provider_msp.go",
"internal/cloudcp/docker/labels.go",
"internal/cloudcp/docker/manager.go",
"internal/cloudcp/tenant_runtime_rollout.go"
@ -2889,7 +2895,8 @@
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"internal/cloudcp/tenant_runtime_rollout_test.go"
"internal/cloudcp/tenant_runtime_rollout_test.go",
"scripts/installtests/provider_msp_deploy_test.go"
]
},
{

View file

@ -9,11 +9,24 @@ import (
)
type statusResponse struct {
Version string `json:"version"`
TotalTenants int `json:"total_tenants"`
Healthy int `json:"healthy"`
Unhealthy int `json:"unhealthy"`
ByState map[registry.TenantState]int `json:"by_state"`
Version string `json:"version"`
ControlPlaneMode string `json:"control_plane_mode,omitempty"`
ProviderMSPPlanVersion string `json:"provider_msp_plan_version,omitempty"`
ProviderMSPPlanSource string `json:"provider_msp_plan_source,omitempty"`
ProviderMSPWorkspaceLimit int `json:"provider_msp_workspace_limit,omitempty"`
TotalTenants int `json:"total_tenants"`
Healthy int `json:"healthy"`
Unhealthy int `json:"unhealthy"`
ByState map[registry.TenantState]int `json:"by_state"`
}
// RuntimeStatus describes control-plane mode and plan state that belongs in
// the private operator status response.
type RuntimeStatus struct {
ControlPlaneMode string
ProviderMSPPlanVersion string
ProviderMSPPlanSource string
ProviderMSPWorkspaceLimit int
}
// HandleHealthz returns 200 "ok" unconditionally (liveness probe).
@ -55,6 +68,12 @@ func HandleReadyz(reg *registry.TenantRegistry) http.HandlerFunc {
// HandleStatus returns a handler that reports aggregate tenant status.
func HandleStatus(reg *registry.TenantRegistry, version string) http.HandlerFunc {
return HandleStatusWithRuntime(reg, version, RuntimeStatus{})
}
// HandleStatusWithRuntime returns a handler that reports aggregate tenant and
// runtime status.
func HandleStatusWithRuntime(reg *registry.TenantRegistry, version string, runtime RuntimeStatus) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if reg == nil {
log.Error().Msg("Control plane status check failed: registry dependency unavailable")
@ -87,11 +106,15 @@ func HandleStatus(reg *registry.TenantRegistry, version string) http.HandlerFunc
}
resp := statusResponse{
Version: version,
TotalTenants: total,
Healthy: healthy,
Unhealthy: unhealthy,
ByState: counts,
Version: version,
ControlPlaneMode: runtime.ControlPlaneMode,
ProviderMSPPlanVersion: runtime.ProviderMSPPlanVersion,
ProviderMSPPlanSource: runtime.ProviderMSPPlanSource,
ProviderMSPWorkspaceLimit: runtime.ProviderMSPWorkspaceLimit,
TotalTenants: total,
Healthy: healthy,
Unhealthy: unhealthy,
ByState: counts,
}
w.Header().Set("Content-Type", "application/json")

View file

@ -97,6 +97,40 @@ func TestHandleStatus(t *testing.T) {
}
}
func TestHandleStatusWithRuntimeIncludesProviderMSPMode(t *testing.T) {
reg := newTestRegistry(t)
handler := HandleStatusWithRuntime(reg, "test-version", RuntimeStatus{
ControlPlaneMode: "provider_hosted_msp",
ProviderMSPPlanVersion: "msp_growth",
ProviderMSPPlanSource: "license_file",
ProviderMSPWorkspaceLimit: 15,
})
req := httptest.NewRequest(http.MethodGet, "/status", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var resp statusResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.ControlPlaneMode != "provider_hosted_msp" {
t.Fatalf("ControlPlaneMode = %q, want provider_hosted_msp", resp.ControlPlaneMode)
}
if resp.ProviderMSPPlanVersion != "msp_growth" {
t.Fatalf("ProviderMSPPlanVersion = %q, want msp_growth", resp.ProviderMSPPlanVersion)
}
if resp.ProviderMSPPlanSource != "license_file" {
t.Fatalf("ProviderMSPPlanSource = %q, want license_file", resp.ProviderMSPPlanSource)
}
if resp.ProviderMSPWorkspaceLimit != 15 {
t.Fatalf("ProviderMSPWorkspaceLimit = %d, want 15", resp.ProviderMSPWorkspaceLimit)
}
}
func TestHandleStatusNilRegistry(t *testing.T) {
handler := HandleStatus(nil, "test-version")
req := httptest.NewRequest(http.MethodGet, "/status", nil)

View file

@ -4,6 +4,7 @@ import (
"crypto/ed25519"
"encoding/base64"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
@ -22,6 +23,9 @@ const (
ControlPlaneModePulseHosted ControlPlaneMode = "pulse_hosted"
ControlPlaneModeProviderHostedMSP ControlPlaneMode = "provider_hosted_msp"
defaultProviderHostedMSPPlanVersion = "msp_starter"
ProviderMSPPlanSourceLicenseFile = "license_file"
ProviderMSPPlanSourceEnvFallback = "environment_fallback"
maxProviderMSPLicenseFileBytes = 64 * 1024
)
// CPConfig holds all configuration for the control plane.
@ -69,6 +73,10 @@ type CPConfig struct {
CloudMSPGrowthPriceID string // MSP Growth tier price ID (optional)
CloudMSPScalePriceID string // MSP Scale tier price ID (optional)
ProviderMSPPlanVersion string // Local provider-hosted MSP workspace limit plan
ProviderMSPPlanSource string // How ProviderMSPPlanVersion was resolved
ProviderMSPLicenseFile string // Signed provider MSP license source
ProviderMSPLicenseID string // Validated provider MSP license ID
ProviderMSPLicenseEmail string // Validated provider MSP license holder
LicenseServerURL string
LicenseAdminToken string
TrialActivationPrivateKey string
@ -166,6 +174,21 @@ func LoadConfig() (*CPConfig, error) {
if err != nil {
return nil, err
}
providerMSPLicenseFile := strings.TrimSpace(os.Getenv("CP_PROVIDER_MSP_LICENSE_FILE"))
providerMSPPlanVersion := pkglicensing.CanonicalizePlanVersion(envOrDefault("CP_PROVIDER_MSP_PLAN_VERSION", defaultProviderHostedMSPPlanVersion))
providerMSPPlanSource := ProviderMSPPlanSourceEnvFallback
providerMSPLicenseID := ""
providerMSPLicenseEmail := ""
if controlPlaneMode == ControlPlaneModeProviderHostedMSP && providerMSPLicenseFile != "" {
resolvedPlan, licenseID, licenseEmail, err := resolveProviderMSPPlanFromLicenseFile(providerMSPLicenseFile)
if err != nil {
return nil, fmt.Errorf("resolve provider MSP license: %w", err)
}
providerMSPPlanVersion = resolvedPlan
providerMSPPlanSource = ProviderMSPPlanSourceLicenseFile
providerMSPLicenseID = licenseID
providerMSPLicenseEmail = licenseEmail
}
cfg := &CPConfig{
DataDir: dataDir,
@ -210,7 +233,11 @@ func LoadConfig() (*CPConfig, error) {
CloudMSPStarterPriceID: strings.TrimSpace(os.Getenv("CP_MSP_STARTER_PRICE_ID")),
CloudMSPGrowthPriceID: strings.TrimSpace(os.Getenv("CP_MSP_GROWTH_PRICE_ID")),
CloudMSPScalePriceID: strings.TrimSpace(os.Getenv("CP_MSP_SCALE_PRICE_ID")),
ProviderMSPPlanVersion: pkglicensing.CanonicalizePlanVersion(envOrDefault("CP_PROVIDER_MSP_PLAN_VERSION", defaultProviderHostedMSPPlanVersion)),
ProviderMSPPlanVersion: providerMSPPlanVersion,
ProviderMSPPlanSource: providerMSPPlanSource,
ProviderMSPLicenseFile: providerMSPLicenseFile,
ProviderMSPLicenseID: providerMSPLicenseID,
ProviderMSPLicenseEmail: providerMSPLicenseEmail,
LicenseServerURL: envOrDefault("PULSE_LICENSE_SERVER_URL", "https://license.pulserelay.pro"),
LicenseAdminToken: strings.TrimSpace(os.Getenv("PULSE_LICENSE_ADMIN_TOKEN")),
TrialActivationPrivateKey: strings.TrimSpace(os.Getenv("CP_TRIAL_ACTIVATION_PRIVATE_KEY")),
@ -318,6 +345,9 @@ func (c *CPConfig) validate() error {
if strings.TrimSpace(c.TrialActivationPrivateKey) == "" {
return fmt.Errorf("CP_TRIAL_ACTIVATION_PRIVATE_KEY is required when CP_CONTROL_PLANE_MODE=%s", ControlPlaneModeProviderHostedMSP)
}
if c.Environment == "production" && strings.TrimSpace(c.ProviderMSPLicenseFile) == "" {
return fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE is required in production when CP_CONTROL_PLANE_MODE=%s", ControlPlaneModeProviderHostedMSP)
}
if !strings.HasPrefix(strings.ToLower(c.ProviderMSPPlanVersion), "msp_") {
return fmt.Errorf("CP_PROVIDER_MSP_PLAN_VERSION must be a canonical MSP plan version, got %q", c.ProviderMSPPlanVersion)
}
@ -461,6 +491,60 @@ func normalizeControlPlaneMode(raw string) ControlPlaneMode {
}
}
func resolveProviderMSPPlanFromLicenseFile(path string) (planVersion, licenseID, licenseEmail string, err error) {
path = strings.TrimSpace(path)
if path == "" {
return "", "", "", fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE is required")
}
licenseKey, err := readProviderMSPLicenseFile(path)
if err != nil {
return "", "", "", err
}
pkglicensing.InitEmbeddedPublicKey()
license, err := pkglicensing.ValidateLicense(licenseKey)
if err != nil {
return "", "", "", fmt.Errorf("validate CP_PROVIDER_MSP_LICENSE_FILE: %w", err)
}
if license == nil {
return "", "", "", fmt.Errorf("validate CP_PROVIDER_MSP_LICENSE_FILE: no license returned")
}
if license.Claims.Tier != pkglicensing.TierMSP {
return "", "", "", fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE must contain an MSP license tier, got %q", license.Claims.Tier)
}
plan := license.Claims.EntitlementPlanVersion()
if strings.TrimSpace(plan) == "" {
return "", "", "", fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE must contain plan_version")
}
if !strings.HasPrefix(strings.ToLower(plan), "msp_") {
return "", "", "", fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE plan_version must be a canonical MSP plan version, got %q", plan)
}
if _, known := pkglicensing.WorkspaceLimitForPlan(plan); !known {
return "", "", "", fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE plan_version must have a known workspace limit, got %q", plan)
}
return plan, strings.TrimSpace(license.Claims.LicenseID), strings.ToLower(strings.TrimSpace(license.Claims.Email)), nil
}
func readProviderMSPLicenseFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", fmt.Errorf("read CP_PROVIDER_MSP_LICENSE_FILE: %w", err)
}
defer f.Close()
buf, err := io.ReadAll(io.LimitReader(f, maxProviderMSPLicenseFileBytes+1))
if err != nil {
return "", fmt.Errorf("read CP_PROVIDER_MSP_LICENSE_FILE: %w", err)
}
if len(buf) > maxProviderMSPLicenseFileBytes {
return "", fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE exceeds %d bytes", maxProviderMSPLicenseFileBytes)
}
licenseKey := strings.TrimSpace(string(buf))
if licenseKey == "" {
return "", fmt.Errorf("CP_PROVIDER_MSP_LICENSE_FILE is empty")
}
return licenseKey, nil
}
func stripeSecretKeyMode(raw string) string {
key := strings.TrimSpace(raw)
switch {

View file

@ -1,8 +1,16 @@
package cloudcp
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"os"
"strings"
"testing"
"time"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
)
func setRequiredCPEnv(t *testing.T) {
@ -459,6 +467,41 @@ func setProviderHostedMSPEnv(t *testing.T) {
setTrialSigningEnv(t)
}
func writeProviderMSPLicenseForTest(t *testing.T, tier pkglicensing.Tier, 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: "lic_provider_msp_test",
Email: "provider@example.com",
Tier: tier,
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
}
func TestLoadConfig_ProviderHostedMSPDoesNotRequireStripe(t *testing.T) {
setProviderHostedMSPEnv(t)
@ -475,6 +518,9 @@ func TestLoadConfig_ProviderHostedMSPDoesNotRequireStripe(t *testing.T) {
if cfg.ProviderMSPPlanVersion != "msp_starter" {
t.Fatalf("ProviderMSPPlanVersion = %q, want msp_starter", cfg.ProviderMSPPlanVersion)
}
if cfg.ProviderMSPPlanSource != ProviderMSPPlanSourceEnvFallback {
t.Fatalf("ProviderMSPPlanSource = %q, want %q", cfg.ProviderMSPPlanSource, ProviderMSPPlanSourceEnvFallback)
}
}
func TestLoadConfig_ProviderHostedMSPRejectsStripeConfig(t *testing.T) {
@ -539,6 +585,56 @@ func TestLoadConfig_ProviderHostedMSPRejectsInvalidPlan(t *testing.T) {
}
}
func TestLoadConfig_ProviderHostedMSPUsesSignedLicenseFilePlan(t *testing.T) {
setProviderHostedMSPEnv(t)
t.Setenv("CP_ENV", "production")
t.Setenv("CP_PROVIDER_MSP_PLAN_VERSION", "msp_starter")
t.Setenv("CP_PROVIDER_MSP_LICENSE_FILE", writeProviderMSPLicenseForTest(t, pkglicensing.TierMSP, "msp_growth"))
cfg, err := LoadConfig()
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.ProviderMSPPlanVersion != "msp_growth" {
t.Fatalf("ProviderMSPPlanVersion = %q, want msp_growth", cfg.ProviderMSPPlanVersion)
}
if cfg.ProviderMSPPlanSource != ProviderMSPPlanSourceLicenseFile {
t.Fatalf("ProviderMSPPlanSource = %q, want %q", cfg.ProviderMSPPlanSource, ProviderMSPPlanSourceLicenseFile)
}
if cfg.ProviderMSPLicenseID != "lic_provider_msp_test" {
t.Fatalf("ProviderMSPLicenseID = %q", cfg.ProviderMSPLicenseID)
}
if cfg.ProviderMSPLicenseEmail != "provider@example.com" {
t.Fatalf("ProviderMSPLicenseEmail = %q", cfg.ProviderMSPLicenseEmail)
}
}
func TestLoadConfig_ProviderHostedMSPRequiresLicenseFileInProduction(t *testing.T) {
setProviderHostedMSPEnv(t)
t.Setenv("CP_ENV", "production")
_, err := LoadConfig()
if err == nil {
t.Fatal("expected error without CP_PROVIDER_MSP_LICENSE_FILE in production")
}
if !strings.Contains(err.Error(), "CP_PROVIDER_MSP_LICENSE_FILE is required in production") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestLoadConfig_ProviderHostedMSPRejectsNonMSPLicenseFile(t *testing.T) {
setProviderHostedMSPEnv(t)
t.Setenv("CP_PROVIDER_MSP_LICENSE_FILE", writeProviderMSPLicenseForTest(t, pkglicensing.TierCloud, "cloud_starter"))
_, err := LoadConfig()
if err == nil {
t.Fatal("expected error for non-MSP provider license")
}
if !strings.Contains(err.Error(), "must contain an MSP license tier") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestLoadConfig_RequiresTrialSignupPriceWhenPublicCloudSignupEnabled(t *testing.T) {
setRequiredCPEnv(t)
setTrialSigningEnv(t)

View file

@ -0,0 +1,255 @@
package cloudcp
import (
"context"
"fmt"
"net/mail"
"os"
"strings"
cpauth "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/auth"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
)
// ProviderMSPBootstrapOptions describes the first-run owner/account bootstrap
// for provider-hosted MSP control planes.
type ProviderMSPBootstrapOptions struct {
AccountID string
AccountName string
OwnerEmail string
GenerateMagicLink bool
}
// ProviderMSPBootstrapResult is the operator-facing result of a provider MSP
// bootstrap run.
type ProviderMSPBootstrapResult struct {
AccountID string
AccountName string
OwnerUserID string
OwnerEmail string
PlanVersion string
PlanSource string
WorkspaceLimit int
MagicLinkURL string
}
// BootstrapProviderMSP creates or reuses the MSP account and owner identity for
// a provider-hosted MSP control plane. It is deliberately unavailable in normal
// Pulse-hosted mode so the MSP bootstrap path cannot leak into ordinary hosting.
func BootstrapProviderMSP(ctx context.Context, cfg *CPConfig, opts ProviderMSPBootstrapOptions) (*ProviderMSPBootstrapResult, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if cfg == nil {
return nil, fmt.Errorf("control plane config is required")
}
if !cfg.IsProviderHostedMSP() {
return nil, fmt.Errorf("provider MSP bootstrap requires CP_CONTROL_PLANE_MODE=%s", ControlPlaneModeProviderHostedMSP)
}
accountName := strings.TrimSpace(opts.AccountName)
if accountName == "" {
return nil, fmt.Errorf("account name is required")
}
ownerEmail, err := normalizeProviderMSPOwnerEmail(opts.OwnerEmail)
if err != nil {
return nil, err
}
workspaceLimit, known := pkglicensing.WorkspaceLimitForPlan(cfg.ProviderMSPPlanVersion)
if !known {
return nil, fmt.Errorf("provider MSP plan %q has no known workspace limit", cfg.ProviderMSPPlanVersion)
}
if err := os.MkdirAll(cfg.ControlPlaneDir(), 0o755); err != nil {
return nil, fmt.Errorf("create control-plane dir: %w", err)
}
reg, err := registry.NewTenantRegistry(cfg.ControlPlaneDir())
if err != nil {
return nil, fmt.Errorf("open tenant registry: %w", err)
}
defer reg.Close()
account, err := ensureProviderMSPAccount(reg, strings.TrimSpace(opts.AccountID), accountName)
if err != nil {
return nil, err
}
user, err := ensureProviderMSPOwnerUser(reg, ownerEmail)
if err != nil {
return nil, err
}
if err := ensureProviderMSPOwnerMembership(reg, account.ID, user.ID); err != nil {
return nil, err
}
magicLinkURL := ""
if opts.GenerateMagicLink {
magicLinks, err := cpauth.NewService(cfg.ControlPlaneDir())
if err != nil {
return nil, fmt.Errorf("init magic link service: %w", err)
}
defer magicLinks.Close()
token, err := magicLinks.GeneratePortalToken(ownerEmail, "")
if err != nil {
return nil, fmt.Errorf("generate portal magic link: %w", err)
}
magicLinkURL = cpauth.BuildVerifyURL(cfg.BaseURL, token)
if magicLinkURL == "" {
return nil, fmt.Errorf("build portal magic link URL")
}
}
return &ProviderMSPBootstrapResult{
AccountID: account.ID,
AccountName: account.DisplayName,
OwnerUserID: user.ID,
OwnerEmail: ownerEmail,
PlanVersion: cfg.ProviderMSPPlanVersion,
PlanSource: providerMSPPlanSourceOrDefault(cfg.ProviderMSPPlanSource),
WorkspaceLimit: workspaceLimit,
MagicLinkURL: magicLinkURL,
}, nil
}
func normalizeProviderMSPOwnerEmail(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", fmt.Errorf("owner email is required")
}
parsed, err := mail.ParseAddress(raw)
if err != nil {
return "", fmt.Errorf("owner email is invalid: %w", err)
}
email := strings.ToLower(strings.TrimSpace(parsed.Address))
if email == "" || !strings.Contains(email, "@") {
return "", fmt.Errorf("owner email is invalid")
}
return email, nil
}
func providerMSPPlanSourceOrDefault(source string) string {
source = strings.TrimSpace(source)
if source == "" {
return ProviderMSPPlanSourceEnvFallback
}
return source
}
func ensureProviderMSPAccount(reg *registry.TenantRegistry, requestedID, accountName string) (*registry.Account, error) {
if reg == nil {
return nil, fmt.Errorf("registry unavailable")
}
if requestedID != "" {
account, err := reg.GetAccount(requestedID)
if err != nil {
return nil, fmt.Errorf("lookup account: %w", err)
}
if account != nil {
return updateProviderMSPAccountName(reg, account, accountName)
}
return createProviderMSPAccount(reg, requestedID, accountName)
}
accounts, err := reg.ListAccounts()
if err != nil {
return nil, fmt.Errorf("list accounts: %w", err)
}
var mspAccounts []*registry.Account
for _, account := range accounts {
if account != nil && account.Kind == registry.AccountKindMSP {
mspAccounts = append(mspAccounts, account)
}
}
switch len(mspAccounts) {
case 0:
accountID, err := registry.GenerateAccountID()
if err != nil {
return nil, fmt.Errorf("generate account id: %w", err)
}
return createProviderMSPAccount(reg, accountID, accountName)
case 1:
return updateProviderMSPAccountName(reg, mspAccounts[0], accountName)
default:
return nil, fmt.Errorf("multiple MSP accounts exist; rerun with --account-id")
}
}
func createProviderMSPAccount(reg *registry.TenantRegistry, accountID, accountName string) (*registry.Account, error) {
account := &registry.Account{
ID: strings.TrimSpace(accountID),
Kind: registry.AccountKindMSP,
DisplayName: accountName,
}
if account.ID == "" {
return nil, fmt.Errorf("account id is required")
}
if err := reg.CreateAccount(account); err != nil {
return nil, fmt.Errorf("create MSP account: %w", err)
}
return account, nil
}
func updateProviderMSPAccountName(reg *registry.TenantRegistry, account *registry.Account, accountName string) (*registry.Account, error) {
if account.Kind != registry.AccountKindMSP {
return nil, fmt.Errorf("account %q is %q, want %q", account.ID, account.Kind, registry.AccountKindMSP)
}
if account.DisplayName == accountName {
return account, nil
}
account.DisplayName = accountName
if err := reg.UpdateAccount(account); err != nil {
return nil, fmt.Errorf("update MSP account: %w", err)
}
return account, nil
}
func ensureProviderMSPOwnerUser(reg *registry.TenantRegistry, ownerEmail string) (*registry.User, error) {
user, err := reg.GetUserByEmail(ownerEmail)
if err != nil {
return nil, fmt.Errorf("lookup owner user: %w", err)
}
if user != nil {
return user, nil
}
userID, err := registry.GenerateUserID()
if err != nil {
return nil, fmt.Errorf("generate owner user id: %w", err)
}
user = &registry.User{
ID: userID,
Email: ownerEmail,
}
if err := reg.CreateUser(user); err != nil {
reloaded, reloadErr := reg.GetUserByEmail(ownerEmail)
if reloadErr != nil || reloaded == nil {
return nil, fmt.Errorf("create owner user: %w", err)
}
user = reloaded
}
return user, nil
}
func ensureProviderMSPOwnerMembership(reg *registry.TenantRegistry, accountID, userID string) error {
membership, err := reg.GetMembership(accountID, userID)
if err != nil {
return fmt.Errorf("lookup owner membership: %w", err)
}
if membership == nil {
if err := reg.CreateMembership(&registry.AccountMembership{
AccountID: accountID,
UserID: userID,
Role: registry.MemberRoleOwner,
}); err != nil {
return fmt.Errorf("create owner membership: %w", err)
}
return nil
}
if membership.Role == registry.MemberRoleOwner {
return nil
}
if err := reg.UpdateMembershipRole(accountID, userID, registry.MemberRoleOwner); err != nil {
return fmt.Errorf("promote owner membership: %w", err)
}
return nil
}

View file

@ -0,0 +1,124 @@
package cloudcp
import (
"context"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
)
func TestBootstrapProviderMSPCreatesOwnerAccountAndMagicLink(t *testing.T) {
cfg := testProviderMSPBootstrapConfig(t)
result, err := BootstrapProviderMSP(context.Background(), cfg, ProviderMSPBootstrapOptions{
AccountName: "Acme MSP",
OwnerEmail: "OWNER@Example.COM",
GenerateMagicLink: true,
})
if err != nil {
t.Fatalf("BootstrapProviderMSP: %v", err)
}
if result.AccountID == "" {
t.Fatal("AccountID = empty")
}
if result.AccountName != "Acme MSP" {
t.Fatalf("AccountName = %q, want Acme MSP", result.AccountName)
}
if result.OwnerEmail != "owner@example.com" {
t.Fatalf("OwnerEmail = %q, want owner@example.com", result.OwnerEmail)
}
if result.PlanVersion != "msp_growth" {
t.Fatalf("PlanVersion = %q, want msp_growth", result.PlanVersion)
}
if result.PlanSource != ProviderMSPPlanSourceLicenseFile {
t.Fatalf("PlanSource = %q, want %q", result.PlanSource, ProviderMSPPlanSourceLicenseFile)
}
if result.WorkspaceLimit != 15 {
t.Fatalf("WorkspaceLimit = %d, want 15", result.WorkspaceLimit)
}
if !strings.HasPrefix(result.MagicLinkURL, "https://msp.example.com/auth/magic-link/verify?token=ml1_") {
t.Fatalf("MagicLinkURL = %q", result.MagicLinkURL)
}
reg, err := registry.NewTenantRegistry(cfg.ControlPlaneDir())
if err != nil {
t.Fatalf("NewTenantRegistry: %v", err)
}
defer reg.Close()
account, err := reg.GetAccount(result.AccountID)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if account == nil || account.Kind != registry.AccountKindMSP {
t.Fatalf("account = %#v, want MSP account", account)
}
user, err := reg.GetUserByEmail("owner@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
if user == nil || user.ID != result.OwnerUserID {
t.Fatalf("user = %#v, result owner user id = %q", user, result.OwnerUserID)
}
membership, err := reg.GetMembership(result.AccountID, result.OwnerUserID)
if err != nil {
t.Fatalf("GetMembership: %v", err)
}
if membership == nil || membership.Role != registry.MemberRoleOwner {
t.Fatalf("membership = %#v, want owner", membership)
}
}
func TestBootstrapProviderMSPIsIdempotentForExistingMSPAccount(t *testing.T) {
cfg := testProviderMSPBootstrapConfig(t)
first, err := BootstrapProviderMSP(context.Background(), cfg, ProviderMSPBootstrapOptions{
AccountName: "Acme MSP",
OwnerEmail: "owner@example.com",
})
if err != nil {
t.Fatalf("first BootstrapProviderMSP: %v", err)
}
second, err := BootstrapProviderMSP(context.Background(), cfg, ProviderMSPBootstrapOptions{
AccountName: "Acme MSP",
OwnerEmail: "owner@example.com",
})
if err != nil {
t.Fatalf("second BootstrapProviderMSP: %v", err)
}
if second.AccountID != first.AccountID {
t.Fatalf("AccountID changed from %q to %q", first.AccountID, second.AccountID)
}
if second.OwnerUserID != first.OwnerUserID {
t.Fatalf("OwnerUserID changed from %q to %q", first.OwnerUserID, second.OwnerUserID)
}
}
func TestBootstrapProviderMSPRejectsPulseHostedMode(t *testing.T) {
cfg := testProviderMSPBootstrapConfig(t)
cfg.ControlPlaneMode = ControlPlaneModePulseHosted
_, err := BootstrapProviderMSP(context.Background(), cfg, ProviderMSPBootstrapOptions{
AccountName: "Acme MSP",
OwnerEmail: "owner@example.com",
})
if err == nil {
t.Fatal("expected provider mode error")
}
if !strings.Contains(err.Error(), "provider MSP bootstrap requires") {
t.Fatalf("unexpected error: %v", err)
}
}
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,
}
}

View file

@ -17,6 +17,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/portal"
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
cpstripe "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/stripe"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
)
// Deps holds shared dependencies injected into HTTP handlers.
@ -48,6 +49,23 @@ func providerMSPPlanVersion(cfg *CPConfig) string {
return cfg.ProviderMSPPlanVersion
}
func runtimeStatus(cfg *CPConfig) admin.RuntimeStatus {
if cfg == nil {
return admin.RuntimeStatus{}
}
status := admin.RuntimeStatus{
ControlPlaneMode: string(cfg.ControlPlaneMode),
}
if cfg.IsProviderHostedMSP() {
status.ProviderMSPPlanVersion = providerMSPPlanVersion(cfg)
status.ProviderMSPPlanSource = cfg.ProviderMSPPlanSource
if limit, known := pkglicensing.WorkspaceLimitForPlan(status.ProviderMSPPlanVersion); known {
status.ProviderMSPWorkspaceLimit = limit
}
}
return status
}
// RegisterRoutes wires all HTTP handlers onto the given ServeMux.
func RegisterRoutes(mux *http.ServeMux, deps *Deps) {
webhookLimiter := NewCPRateLimiter(deps.Config.WebhookRateLimitPerMinute, time.Minute)
@ -102,7 +120,7 @@ func RegisterRoutes(mux *http.ServeMux, deps *Deps) {
mux.HandleFunc("/favicon.ico", handleControlPlaneFaviconICO)
// Status and metrics are private by default.
statusHandler := http.HandlerFunc(admin.HandleStatus(deps.Registry, deps.Version))
statusHandler := http.HandlerFunc(admin.HandleStatusWithRuntime(deps.Registry, deps.Version, runtimeStatus(deps.Config)))
if deps.Config.PublicStatus {
mux.Handle("/status", statusHandler)
} else {

View file

@ -1,6 +1,7 @@
package cloudcp
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
@ -9,6 +10,54 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
)
func TestRegisterRoutes_StatusIncludesProviderMSPRuntime(t *testing.T) {
dir := t.TempDir()
reg, err := registry.NewTenantRegistry(dir)
if err != nil {
t.Fatalf("NewTenantRegistry: %v", err)
}
t.Cleanup(func() { _ = reg.Close() })
mux := http.NewServeMux()
RegisterRoutes(mux, &Deps{
Config: &CPConfig{
DataDir: dir,
AdminKey: "test-admin-key",
BaseURL: "https://msp.example.com",
ControlPlaneMode: ControlPlaneModeProviderHostedMSP,
ProviderMSPPlanVersion: "msp_growth",
ProviderMSPPlanSource: ProviderMSPPlanSourceLicenseFile,
},
Registry: reg,
Version: "test",
})
req := httptest.NewRequest(http.MethodGet, "/status", nil)
req.Header.Set("X-Admin-Key", "test-admin-key")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /status status=%d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode /status payload: %v", err)
}
if payload["control_plane_mode"] != string(ControlPlaneModeProviderHostedMSP) {
t.Fatalf("control_plane_mode = %#v", payload["control_plane_mode"])
}
if payload["provider_msp_plan_version"] != "msp_growth" {
t.Fatalf("provider_msp_plan_version = %#v", payload["provider_msp_plan_version"])
}
if payload["provider_msp_plan_source"] != ProviderMSPPlanSourceLicenseFile {
t.Fatalf("provider_msp_plan_source = %#v", payload["provider_msp_plan_source"])
}
if payload["provider_msp_workspace_limit"] != float64(15) {
t.Fatalf("provider_msp_workspace_limit = %#v", payload["provider_msp_workspace_limit"])
}
}
func TestRegisterRoutes_AccountAndTenantMethodDispatch(t *testing.T) {
dir := t.TempDir()
reg, err := registry.NewTenantRegistry(dir)

View file

@ -0,0 +1,96 @@
package installtests
import (
"os"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func TestProviderMSPDeployComposeIsProviderModeAndStripeFree(t *testing.T) {
composePath := repoFile("deploy", "provider-msp", "docker-compose.yml")
composeBytes, err := os.ReadFile(composePath)
if err != nil {
t.Fatalf("read provider MSP compose: %v", err)
}
var compose map[string]any
if err := yaml.Unmarshal(composeBytes, &compose); err != nil {
t.Fatalf("provider MSP compose must be valid YAML: %v", err)
}
text := string(composeBytes)
assertContainsAll(t, text,
"CP_CONTROL_PLANE_MODE=provider_hosted_msp",
"CP_PROVIDER_MSP_LICENSE_FILE=/run/secrets/provider_msp_license",
"CP_DOCKER_NETWORK=pulse-provider-msp",
"provider_msp_license:",
"pulse-provider-msp:",
)
assertNotContainsAny(t, text,
"STRIPE_",
"CP_TRIAL_SIGNUP_PRICE_ID",
"CP_MSP_STARTER_PRICE_ID",
"CP_MSP_GROWTH_PRICE_ID",
"CP_MSP_SCALE_PRICE_ID",
"CP_PUBLIC_CLOUD_SIGNUP_ENABLED",
)
}
func TestProviderMSPDeployEnvExampleMatchesBootstrapPath(t *testing.T) {
envBytes, err := os.ReadFile(repoFile("deploy", "provider-msp", ".env.example"))
if err != nil {
t.Fatalf("read provider MSP env example: %v", err)
}
text := string(envBytes)
assertContainsAll(t, text,
"CP_ENV=production",
"CP_PROVIDER_MSP_LICENSE_FILE=./provider-msp-license.jwt",
"CP_TRIAL_ACTIVATION_PRIVATE_KEY=",
"docker compose run --rm control-plane provider-msp bootstrap",
"--account-name",
"--owner-email",
)
assertNotContainsAny(t, text,
"STRIPE_",
"CP_TRIAL_SIGNUP_PRICE_ID",
"CP_MSP_STARTER_PRICE_ID",
"CP_MSP_GROWTH_PRICE_ID",
"CP_MSP_SCALE_PRICE_ID",
"CP_PUBLIC_CLOUD_SIGNUP_ENABLED",
)
}
func TestProviderMSPTraefikUsesProviderNetwork(t *testing.T) {
traefikBytes, err := os.ReadFile(repoFile("deploy", "provider-msp", "traefik.yml"))
if err != nil {
t.Fatalf("read provider MSP Traefik config: %v", err)
}
var cfg map[string]any
if err := yaml.Unmarshal(traefikBytes, &cfg); err != nil {
t.Fatalf("provider MSP Traefik config must be valid YAML: %v", err)
}
assertContainsAll(t, string(traefikBytes),
"network: pulse-provider-msp",
"certificatesResolvers:",
"letsencrypt:",
"le:",
)
}
func assertContainsAll(t *testing.T, text string, required ...string) {
t.Helper()
for _, needle := range required {
if !strings.Contains(text, needle) {
t.Fatalf("missing %q in:\n%s", needle, text)
}
}
}
func assertNotContainsAny(t *testing.T, text string, forbidden ...string) {
t.Helper()
for _, needle := range forbidden {
if strings.Contains(text, needle) {
t.Fatalf("forbidden %q found in:\n%s", needle, text)
}
}
}

View file

@ -3404,7 +3404,10 @@ class SubsystemLookupTest(unittest.TestCase):
)
self.assertEqual(
match["verification_requirement"]["exact_files"],
["internal/cloudcp/tenant_runtime_rollout_test.go"],
[
"internal/cloudcp/tenant_runtime_rollout_test.go",
"scripts/installtests/provider_msp_deploy_test.go",
],
)
def test_lookup_paths_assigns_release_notes_index_to_deployment_installability(self) -> None: