diff --git a/cmd/pulse-control-plane/main.go b/cmd/pulse-control-plane/main.go index 69445a188..6642edc70 100644 --- a/cmd/pulse-control-plane/main.go +++ b/cmd/pulse-control-plane/main.go @@ -374,6 +374,7 @@ func init() { rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(newCloudCmd()) rootCmd.AddCommand(newMobileProofCmd()) + rootCmd.AddCommand(newProviderMSPCmd()) rootCmd.AddCommand(newTenantRuntimeCmd()) } diff --git a/cmd/pulse-control-plane/provider_msp.go b/cmd/pulse-control-plane/provider_msp.go new file mode 100644 index 000000000..d126c2b42 --- /dev/null +++ b/cmd/pulse-control-plane/provider_msp.go @@ -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) + } +} diff --git a/deploy/provider-msp/.env.example b/deploy/provider-msp/.env.example new file mode 100644 index 000000000..b1a836639 --- /dev/null +++ b/deploy/provider-msp/.env.example @@ -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: +CONTROL_PLANE_IMAGE=ghcr.io/rcourtman/pulse-control-plane@sha256: +CP_PULSE_IMAGE=ghcr.io/rcourtman/pulse@sha256: + +# 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 diff --git a/deploy/provider-msp/docker-compose.yml b/deploy/provider-msp/docker-compose.yml new file mode 100644 index 000000000..8416322ac --- /dev/null +++ b/deploy/provider-msp/docker-compose.yml @@ -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: diff --git a/deploy/provider-msp/traefik-dynamic.yml b/deploy/provider-msp/traefik-dynamic.yml new file mode 100644 index 000000000..b30c36eb6 --- /dev/null +++ b/deploy/provider-msp/traefik-dynamic.yml @@ -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" diff --git a/deploy/provider-msp/traefik.yml b/deploy/provider-msp/traefik.yml new file mode 100644 index 000000000..f43009500 --- /dev/null +++ b/deploy/provider-msp/traefik.yml @@ -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" diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index e635840a8..ba24c429c 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index e636b1062..a3f57b7b7 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index f263e1269..abe83e826 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -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" ] }, { diff --git a/internal/cloudcp/admin/status.go b/internal/cloudcp/admin/status.go index a6920af03..872f77f09 100644 --- a/internal/cloudcp/admin/status.go +++ b/internal/cloudcp/admin/status.go @@ -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") diff --git a/internal/cloudcp/admin/status_test.go b/internal/cloudcp/admin/status_test.go index 3d4a3abcc..dbf034f0e 100644 --- a/internal/cloudcp/admin/status_test.go +++ b/internal/cloudcp/admin/status_test.go @@ -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) diff --git a/internal/cloudcp/config.go b/internal/cloudcp/config.go index e1d5e8b5a..29db4d5ea 100644 --- a/internal/cloudcp/config.go +++ b/internal/cloudcp/config.go @@ -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 { diff --git a/internal/cloudcp/config_test.go b/internal/cloudcp/config_test.go index 11cfe94ca..9475805d2 100644 --- a/internal/cloudcp/config_test.go +++ b/internal/cloudcp/config_test.go @@ -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) diff --git a/internal/cloudcp/provider_msp_bootstrap.go b/internal/cloudcp/provider_msp_bootstrap.go new file mode 100644 index 000000000..28fbd528f --- /dev/null +++ b/internal/cloudcp/provider_msp_bootstrap.go @@ -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 := ®istry.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 = ®istry.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(®istry.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 +} diff --git a/internal/cloudcp/provider_msp_bootstrap_test.go b/internal/cloudcp/provider_msp_bootstrap_test.go new file mode 100644 index 000000000..a475557ed --- /dev/null +++ b/internal/cloudcp/provider_msp_bootstrap_test.go @@ -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, + } +} diff --git a/internal/cloudcp/routes.go b/internal/cloudcp/routes.go index 15ff67dbd..e1090fc20 100644 --- a/internal/cloudcp/routes.go +++ b/internal/cloudcp/routes.go @@ -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 { diff --git a/internal/cloudcp/routes_test.go b/internal/cloudcp/routes_test.go index 067c3bcc3..e286d12ab 100644 --- a/internal/cloudcp/routes_test.go +++ b/internal/cloudcp/routes_test.go @@ -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) diff --git a/scripts/installtests/provider_msp_deploy_test.go b/scripts/installtests/provider_msp_deploy_test.go new file mode 100644 index 000000000..4236f2771 --- /dev/null +++ b/scripts/installtests/provider_msp_deploy_test.go @@ -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) + } + } +} diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index e0b0abd6a..087559e17 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -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: