mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Add provider-hosted MSP install path
This commit is contained in:
parent
d90352ea89
commit
5134e36c28
19 changed files with 1144 additions and 98 deletions
|
|
@ -374,6 +374,7 @@ func init() {
|
|||
rootCmd.AddCommand(versionCmd)
|
||||
rootCmd.AddCommand(newCloudCmd())
|
||||
rootCmd.AddCommand(newMobileProofCmd())
|
||||
rootCmd.AddCommand(newProviderMSPCmd())
|
||||
rootCmd.AddCommand(newTenantRuntimeCmd())
|
||||
}
|
||||
|
||||
|
|
|
|||
71
cmd/pulse-control-plane/provider_msp.go
Normal file
71
cmd/pulse-control-plane/provider_msp.go
Normal 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)
|
||||
}
|
||||
}
|
||||
38
deploy/provider-msp/.env.example
Normal file
38
deploy/provider-msp/.env.example
Normal 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
|
||||
86
deploy/provider-msp/docker-compose.yml
Normal file
86
deploy/provider-msp/docker-compose.yml
Normal 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:
|
||||
11
deploy/provider-msp/traefik-dynamic.yml
Normal file
11
deploy/provider-msp/traefik-dynamic.yml
Normal 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"
|
||||
45
deploy/provider-msp/traefik.yml
Normal file
45
deploy/provider-msp/traefik.yml
Normal 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"
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
255
internal/cloudcp/provider_msp_bootstrap.go
Normal file
255
internal/cloudcp/provider_msp_bootstrap.go
Normal 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 := ®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
|
||||
}
|
||||
124
internal/cloudcp/provider_msp_bootstrap_test.go
Normal file
124
internal/cloudcp/provider_msp_bootstrap_test.go
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
96
scripts/installtests/provider_msp_deploy_test.go
Normal file
96
scripts/installtests/provider_msp_deploy_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue