From e99ca62c81ebc61da6e562d88343ee2defbb9b46 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 2 Jun 2026 13:02:13 +0100 Subject: [PATCH] Add provider MSP preflight readiness checks --- cmd/pulse-control-plane/provider_msp.go | 1 + .../provider_msp_preflight.go | 258 ++++++++++++++++++ .../provider_msp_preflight_test.go | 202 ++++++++++++++ cmd/pulse-control-plane/provider_msp_proof.go | 18 +- .../v6/internal/subsystems/cloud-paid.md | 5 + .../subsystems/deployment-installability.md | 167 ++++++------ .../v6/internal/subsystems/registry.json | 4 + internal/cloudcp/docker/manager.go | 110 ++++++++ internal/cloudcp/docker/manager_test.go | 39 +++ .../release_control/subsystem_lookup_test.py | 2 + 10 files changed, 711 insertions(+), 95 deletions(-) create mode 100644 cmd/pulse-control-plane/provider_msp_preflight.go create mode 100644 cmd/pulse-control-plane/provider_msp_preflight_test.go diff --git a/cmd/pulse-control-plane/provider_msp.go b/cmd/pulse-control-plane/provider_msp.go index 69c306a0b..2f831d2e1 100644 --- a/cmd/pulse-control-plane/provider_msp.go +++ b/cmd/pulse-control-plane/provider_msp.go @@ -13,6 +13,7 @@ func newProviderMSPCmd() *cobra.Command { Short: "Operate a provider-hosted MSP control plane", } cmd.AddCommand(newProviderMSPBootstrapCmd()) + cmd.AddCommand(newProviderMSPPreflightCmd()) cmd.AddCommand(newProviderMSPProofCmd()) return cmd } diff --git a/cmd/pulse-control-plane/provider_msp_preflight.go b/cmd/pulse-control-plane/provider_msp_preflight.go new file mode 100644 index 000000000..1a01ff942 --- /dev/null +++ b/cmd/pulse-control-plane/provider_msp_preflight.go @@ -0,0 +1,258 @@ +package main + +import ( + "context" + "fmt" + "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp" + cpDocker "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/docker" + "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry" + pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" + "github.com/spf13/cobra" +) + +type providerMSPPreflightOptions struct { + AllowEnvPlan bool + SkipImagePull bool +} + +type providerMSPPreflightReport struct { + OK bool + Environment string + ControlMode string + BaseURL string + PlanVersion string + PlanSource string + LicenseID string + LicenseEmail string + WorkspaceLimit int + RegistryReady bool + Docker *cpDocker.RuntimePrerequisiteReport + Storage *cloudcp.StorageGuardrailReport + Failures []string +} + +type providerMSPPreflightRegistry interface { + Ping() error + Close() error +} + +type providerMSPPreflightDocker interface { + CheckRuntimePrerequisites(context.Context, cpDocker.RuntimePrerequisiteOptions) (*cpDocker.RuntimePrerequisiteReport, error) + DiskUsage(context.Context) (*cpDocker.DiskUsageSnapshot, error) + Close() error +} + +type providerMSPPreflightDependencies struct { + OpenRegistry func(*cloudcp.CPConfig) (providerMSPPreflightRegistry, error) + NewDocker func(*cloudcp.CPConfig) (providerMSPPreflightDocker, error) + CheckStorage func(context.Context, *cloudcp.CPConfig, cloudcp.StorageDockerUsageProvider) (*cloudcp.StorageGuardrailReport, error) +} + +func newProviderMSPPreflightCmd() *cobra.Command { + opts := providerMSPPreflightOptions{} + cmd := &cobra.Command{ + Use: "preflight", + Short: "Check provider-hosted MSP install readiness", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := cloudcp.LoadConfig() + if err != nil { + return fmt.Errorf("load control plane config: %w", err) + } + report, err := runProviderMSPPreflight(cmd.Context(), cfg, opts) + printProviderMSPPreflightReport(report) + return err + }, + } + cmd.Flags().BoolVar(&opts.AllowEnvPlan, "allow-env-plan", false, "Allow CP_PROVIDER_MSP_PLAN_VERSION fallback instead of a signed provider MSP license file") + cmd.Flags().BoolVar(&opts.SkipImagePull, "skip-image-pull", false, "Only inspect the tenant runtime image instead of pulling it when missing") + return cmd +} + +func runProviderMSPPreflight(ctx context.Context, cfg *cloudcp.CPConfig, opts providerMSPPreflightOptions) (*providerMSPPreflightReport, error) { + return runProviderMSPPreflightWithDependencies(ctx, cfg, opts, providerMSPPreflightDependencies{}) +} + +func runProviderMSPPreflightWithDependencies(ctx context.Context, cfg *cloudcp.CPConfig, opts providerMSPPreflightOptions, deps providerMSPPreflightDependencies) (*providerMSPPreflightReport, error) { + if cfg == nil { + return nil, fmt.Errorf("control plane config is required") + } + deps = normalizeProviderMSPPreflightDependencies(deps) + report := &providerMSPPreflightReport{ + OK: true, + Environment: strings.TrimSpace(cfg.Environment), + ControlMode: string(cfg.ControlPlaneMode), + BaseURL: strings.TrimSpace(cfg.BaseURL), + PlanVersion: strings.TrimSpace(cfg.ProviderMSPPlanVersion), + PlanSource: strings.TrimSpace(cfg.ProviderMSPPlanSource), + LicenseID: strings.TrimSpace(cfg.ProviderMSPLicenseID), + LicenseEmail: strings.TrimSpace(cfg.ProviderMSPLicenseEmail), + } + addFailure := func(format string, args ...any) { + report.OK = false + report.Failures = append(report.Failures, fmt.Sprintf(format, args...)) + } + + if !cfg.IsProviderHostedMSP() { + addFailure("CP_CONTROL_PLANE_MODE must be %s", cloudcp.ControlPlaneModeProviderHostedMSP) + } + if cfg.UsesStripeBilling() { + addFailure("provider-hosted MSP preflight must be Stripe-free") + } + workspaceLimit, known := pkglicensing.WorkspaceLimitForPlan(cfg.ProviderMSPPlanVersion) + if !known { + addFailure("provider MSP plan %q has no known workspace limit", cfg.ProviderMSPPlanVersion) + } else { + report.WorkspaceLimit = workspaceLimit + } + if !opts.AllowEnvPlan && strings.TrimSpace(cfg.ProviderMSPPlanSource) != cloudcp.ProviderMSPPlanSourceLicenseFile { + addFailure("provider MSP preflight requires %s plan source; rerun with --allow-env-plan only for local development", cloudcp.ProviderMSPPlanSourceLicenseFile) + } + + reg, err := deps.OpenRegistry(cfg) + if err != nil { + addFailure("open tenant registry: %v", err) + } else { + defer reg.Close() + if err := reg.Ping(); err != nil { + addFailure("tenant registry ping: %v", err) + } else { + report.RegistryReady = true + } + } + + dockerMgr, err := deps.NewDocker(cfg) + if err != nil { + addFailure("create Docker manager: %v", err) + } else { + defer dockerMgr.Close() + dockerReport, checkErr := dockerMgr.CheckRuntimePrerequisites(ctx, cpDocker.RuntimePrerequisiteOptions{ + PullImage: !opts.SkipImagePull, + }) + report.Docker = dockerReport + if checkErr != nil { + addFailure("check Docker runtime prerequisites: %v", checkErr) + } + if dockerReport == nil { + addFailure("Docker runtime prerequisite report unavailable") + } else if !dockerReport.OK { + for _, failure := range dockerReport.Failures { + addFailure("Docker prerequisite: %s", failure) + } + } + + storageReport, storageErr := deps.CheckStorage(ctx, cfg, dockerMgr) + report.Storage = storageReport + if storageErr != nil { + addFailure("check storage guardrails: %v", storageErr) + } + if storageReport == nil { + addFailure("storage guardrail report unavailable") + } else if !storageReport.OK { + for _, failure := range storageReport.Failures { + addFailure("storage guardrail: %s", failure) + } + } + } + + if !report.OK { + return report, fmt.Errorf("provider MSP preflight failed: %s", strings.Join(report.Failures, "; ")) + } + return report, nil +} + +func normalizeProviderMSPPreflightDependencies(deps providerMSPPreflightDependencies) providerMSPPreflightDependencies { + if deps.OpenRegistry == nil { + deps.OpenRegistry = func(cfg *cloudcp.CPConfig) (providerMSPPreflightRegistry, error) { + return registry.NewTenantRegistry(cfg.ControlPlaneDir()) + } + } + if deps.NewDocker == nil { + deps.NewDocker = func(cfg *cloudcp.CPConfig) (providerMSPPreflightDocker, error) { + return cpDocker.NewManager(providerMSPDockerManagerConfig(cfg)) + } + } + if deps.CheckStorage == nil { + deps.CheckStorage = cloudcp.CheckStorageGuardrails + } + return deps +} + +func providerMSPDockerManagerConfig(cfg *cloudcp.CPConfig) cpDocker.ManagerConfig { + if cfg == nil { + return cpDocker.ManagerConfig{} + } + return cpDocker.ManagerConfig{ + Image: cfg.PulseImage, + Network: cfg.DockerNetwork, + BaseDomain: providerMSPBaseDomainFromURL(cfg.BaseURL), + TrialActivationPublicKey: cfg.TrialActivationPublicKey, + TrustedProxyCIDRs: cfg.TrustedProxyCIDRs, + MemoryLimit: cfg.TenantMemoryLimit, + CPUShares: cfg.TenantCPUShares, + TenantLogMaxSize: cfg.TenantLogMaxSize, + TenantLogMaxFile: cfg.TenantLogMaxFile, + } +} + +func printProviderMSPPreflightReport(report *providerMSPPreflightReport) { + if report == nil { + fmt.Println("provider_msp_preflight_ok=false") + return + } + fmt.Printf("provider_msp_preflight_ok=%t\n", report.OK) + fmt.Printf("environment=%s\n", report.Environment) + fmt.Printf("control_plane_mode=%s\n", report.ControlMode) + fmt.Printf("base_url=%s\n", report.BaseURL) + fmt.Printf("plan_version=%s\n", report.PlanVersion) + fmt.Printf("plan_source=%s\n", report.PlanSource) + fmt.Printf("license_id=%s\n", report.LicenseID) + fmt.Printf("license_email=%s\n", report.LicenseEmail) + fmt.Printf("workspace_limit=%d\n", report.WorkspaceLimit) + fmt.Printf("registry_ready=%t\n", report.RegistryReady) + if report.Docker != nil { + fmt.Printf("docker_reachable=%t\n", report.Docker.DockerReachable) + fmt.Printf("docker_network=%s\n", report.Docker.NetworkName) + fmt.Printf("docker_network_ok=%t\n", report.Docker.NetworkOK) + fmt.Printf("docker_network_id=%s\n", report.Docker.NetworkID) + fmt.Printf("tenant_runtime_image=%s\n", report.Docker.ImageRef) + fmt.Printf("tenant_runtime_image_available=%t\n", report.Docker.ImageAvailable) + fmt.Printf("tenant_runtime_image_pulled=%t\n", report.Docker.ImagePulled) + fmt.Printf("tenant_runtime_image_pull_required=%t\n", report.Docker.ImagePullRequired) + fmt.Printf("tenant_runtime_image_id=%s\n", report.Docker.ImageID) + } + if report.Storage != nil { + fmt.Printf("storage_guardrails_enabled=%t\n", report.Storage.Enabled) + fmt.Printf("storage_guardrails_ok=%t\n", report.Storage.OK) + for _, fs := range report.Storage.Filesystems { + status := "ok" + if !fs.OK { + status = "fail" + } + fmt.Printf("storage_filesystem=%s path=%s status=%s available_bytes=%d min_available_bytes=%d\n", + fs.Name, + fs.Path, + status, + fs.AvailableBytes, + fs.MinAvailableBytes, + ) + if fs.Error != "" { + fmt.Printf("storage_filesystem_error=%s path=%s error=%s\n", fs.Name, fs.Path, fs.Error) + } + } + buildCacheStatus := "ok" + if !report.Storage.BuildCache.OK { + buildCacheStatus = "fail" + } + fmt.Printf("docker_build_cache_status=%s\n", buildCacheStatus) + fmt.Printf("docker_build_cache_total_bytes=%d\n", report.Storage.BuildCache.TotalBytes) + fmt.Printf("docker_build_cache_max_bytes=%d\n", report.Storage.BuildCache.MaxBytes) + if report.Storage.BuildCache.Error != "" { + fmt.Printf("docker_build_cache_error=%s\n", report.Storage.BuildCache.Error) + } + } + for _, failure := range report.Failures { + fmt.Printf("failure=%s\n", failure) + } +} diff --git a/cmd/pulse-control-plane/provider_msp_preflight_test.go b/cmd/pulse-control-plane/provider_msp_preflight_test.go new file mode 100644 index 000000000..34c93ca12 --- /dev/null +++ b/cmd/pulse-control-plane/provider_msp_preflight_test.go @@ -0,0 +1,202 @@ +package main + +import ( + "context" + "strings" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp" + cpDocker "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/docker" +) + +func TestProviderMSPCommandExposesPreflight(t *testing.T) { + cmd := newProviderMSPCmd() + for _, child := range cmd.Commands() { + if child.Name() == "preflight" { + return + } + } + t.Fatal("provider-msp preflight command is not registered") +} + +func TestProviderMSPPreflightRequiresLicenseSourceByDefault(t *testing.T) { + docker := &fakeProviderMSPPreflightDocker{ + report: &cpDocker.RuntimePrerequisiteReport{ + OK: true, + DockerReachable: true, + NetworkName: "pulse-provider-msp", + NetworkOK: true, + NetworkID: "network-test", + ImageRef: "pulse:test", + ImageID: "sha256:test", + ImageAvailable: true, + }, + } + report, err := runProviderMSPPreflightWithDependencies( + context.Background(), + testProviderMSPPreflightConfig(t, cloudcp.ProviderMSPPlanSourceEnvFallback), + providerMSPPreflightOptions{}, + fakeProviderMSPPreflightDependencies(docker, &cloudcp.StorageGuardrailReport{Enabled: false, OK: true}), + ) + + if err == nil { + t.Fatal("expected preflight to reject environment-fallback MSP plan source") + } + if report == nil || report.OK { + t.Fatalf("report.OK = %v, want false", report != nil && report.OK) + } + if got := strings.Join(report.Failures, "; "); !strings.Contains(got, cloudcp.ProviderMSPPlanSourceLicenseFile) { + t.Fatalf("failures = %q, want license-file source failure", got) + } + if !report.RegistryReady { + t.Fatalf("registry readiness should still be checked: %#v", report) + } + if docker.pullImage != true { + t.Fatalf("PullImage = %t, want true by default", docker.pullImage) + } +} + +func TestProviderMSPPreflightPassesWithLicenseDockerAndStorage(t *testing.T) { + docker := &fakeProviderMSPPreflightDocker{ + report: &cpDocker.RuntimePrerequisiteReport{ + OK: true, + DockerReachable: true, + NetworkName: "pulse-provider-msp", + NetworkOK: true, + NetworkID: "network-test", + ImageRef: "pulse:test", + ImageID: "sha256:test", + ImageAvailable: true, + }, + } + storage := &cloudcp.StorageGuardrailReport{ + Enabled: true, + OK: true, + BuildCache: cloudcp.StorageBuildCacheReport{ + OK: true, + MaxBytes: 1024, + }, + Filesystems: []cloudcp.StorageFilesystemReport{{ + Name: "data", + Path: "/tmp/pulse-data", + AvailableBytes: 2048, + MinAvailableBytes: 1, + OK: true, + }}, + } + + report, err := runProviderMSPPreflightWithDependencies( + context.Background(), + testProviderMSPPreflightConfig(t, cloudcp.ProviderMSPPlanSourceLicenseFile), + providerMSPPreflightOptions{}, + fakeProviderMSPPreflightDependencies(docker, storage), + ) + + if err != nil { + t.Fatalf("runProviderMSPPreflightWithDependencies: %v", err) + } + if !report.OK { + t.Fatalf("report.OK = false, failures = %v", report.Failures) + } + if report.WorkspaceLimit != 15 { + t.Fatalf("WorkspaceLimit = %d, want 15", report.WorkspaceLimit) + } + if report.PlanSource != cloudcp.ProviderMSPPlanSourceLicenseFile { + t.Fatalf("PlanSource = %q, want %q", report.PlanSource, cloudcp.ProviderMSPPlanSourceLicenseFile) + } + if !report.RegistryReady || report.Docker == nil || !report.Docker.ImageAvailable || report.Storage == nil || !report.Storage.OK { + t.Fatalf("readiness report incomplete: %#v", report) + } + if docker.pullImage != true { + t.Fatalf("PullImage = %t, want true so fresh installs prepare the runtime image", docker.pullImage) + } +} + +func TestProviderMSPPreflightSkipImagePullUsesInspectOnly(t *testing.T) { + docker := &fakeProviderMSPPreflightDocker{ + report: &cpDocker.RuntimePrerequisiteReport{ + OK: true, + DockerReachable: true, + NetworkName: "pulse-provider-msp", + NetworkOK: true, + ImageRef: "pulse:test", + ImageAvailable: true, + }, + } + _, err := runProviderMSPPreflightWithDependencies( + context.Background(), + testProviderMSPPreflightConfig(t, cloudcp.ProviderMSPPlanSourceLicenseFile), + providerMSPPreflightOptions{SkipImagePull: true}, + fakeProviderMSPPreflightDependencies(docker, &cloudcp.StorageGuardrailReport{Enabled: false, OK: true}), + ) + if err != nil { + t.Fatalf("runProviderMSPPreflightWithDependencies: %v", err) + } + if docker.pullImage { + t.Fatal("PullImage = true, want false with --skip-image-pull") + } +} + +func testProviderMSPPreflightConfig(t *testing.T, planSource string) *cloudcp.CPConfig { + t.Helper() + dir := t.TempDir() + return &cloudcp.CPConfig{ + DataDir: dir, + Environment: "production", + ControlPlaneMode: cloudcp.ControlPlaneModeProviderHostedMSP, + BaseURL: "https://msp.example.com", + PulseImage: "pulse:test", + DockerNetwork: "pulse-provider-msp", + TenantMemoryLimit: 512 * 1024 * 1024, + TenantCPUShares: 256, + TenantLogMaxSize: "10m", + TenantLogMaxFile: 3, + StorageGuardrailsEnabled: true, + StorageRootPath: dir, + StorageDataPath: dir, + StorageDockerPath: dir, + StorageMinRootAvailableBytes: 1, + StorageMinDataAvailableBytes: 1, + StorageMinDockerAvailableBytes: 1, + StorageMaxDockerBuildCacheBytes: 1024, + ProviderMSPPlanVersion: "msp_growth", + ProviderMSPPlanSource: planSource, + ProviderMSPLicenseID: "lic_provider_msp_test", + ProviderMSPLicenseEmail: "provider@example.com", + } +} + +func fakeProviderMSPPreflightDependencies(docker *fakeProviderMSPPreflightDocker, storage *cloudcp.StorageGuardrailReport) providerMSPPreflightDependencies { + return providerMSPPreflightDependencies{ + OpenRegistry: func(*cloudcp.CPConfig) (providerMSPPreflightRegistry, error) { + return &fakeProviderMSPPreflightRegistry{}, nil + }, + NewDocker: func(*cloudcp.CPConfig) (providerMSPPreflightDocker, error) { + return docker, nil + }, + CheckStorage: func(context.Context, *cloudcp.CPConfig, cloudcp.StorageDockerUsageProvider) (*cloudcp.StorageGuardrailReport, error) { + return storage, nil + }, + } +} + +type fakeProviderMSPPreflightRegistry struct{} + +func (f *fakeProviderMSPPreflightRegistry) Ping() error { return nil } +func (f *fakeProviderMSPPreflightRegistry) Close() error { return nil } + +type fakeProviderMSPPreflightDocker struct { + report *cpDocker.RuntimePrerequisiteReport + pullImage bool +} + +func (f *fakeProviderMSPPreflightDocker) CheckRuntimePrerequisites(_ context.Context, opts cpDocker.RuntimePrerequisiteOptions) (*cpDocker.RuntimePrerequisiteReport, error) { + f.pullImage = opts.PullImage + return f.report, nil +} + +func (f *fakeProviderMSPPreflightDocker) DiskUsage(context.Context) (*cpDocker.DiskUsageSnapshot, error) { + return &cpDocker.DiskUsageSnapshot{}, nil +} + +func (f *fakeProviderMSPPreflightDocker) Close() error { return nil } diff --git a/cmd/pulse-control-plane/provider_msp_proof.go b/cmd/pulse-control-plane/provider_msp_proof.go index 0c7534712..e1daf0159 100644 --- a/cmd/pulse-control-plane/provider_msp_proof.go +++ b/cmd/pulse-control-plane/provider_msp_proof.go @@ -152,17 +152,7 @@ func newProviderMSPProofRuntimeFromConfig(cfg *cloudcp.CPConfig) (*providerMSPPr } var dockerMgr *cpDocker.Manager - dockerMgr, err = cpDocker.NewManager(cpDocker.ManagerConfig{ - Image: cfg.PulseImage, - Network: cfg.DockerNetwork, - BaseDomain: providerMSPProofBaseDomainFromURL(cfg.BaseURL), - TrialActivationPublicKey: cfg.TrialActivationPublicKey, - TrustedProxyCIDRs: cfg.TrustedProxyCIDRs, - MemoryLimit: cfg.TenantMemoryLimit, - CPUShares: cfg.TenantCPUShares, - TenantLogMaxSize: cfg.TenantLogMaxSize, - TenantLogMaxFile: cfg.TenantLogMaxFile, - }) + dockerMgr, err = cpDocker.NewManager(providerMSPDockerManagerConfig(cfg)) if err != nil { if !cfg.AllowDockerlessProvisioning { reg.Close() @@ -461,7 +451,7 @@ func markProviderMSPProofAgentTokenUsed(tenantDataDir, tenantID, rawToken string } func (rt *providerMSPProofRuntime) verifyProviderMSPProofHandoff(ctx context.Context, tenant *registry.Tenant, ownerUserID, targetPath string) (string, error) { - baseDomain := providerMSPProofBaseDomainFromURL(rt.cfg.BaseURL) + baseDomain := providerMSPBaseDomainFromURL(rt.cfg.BaseURL) if baseDomain == "" { return "", fmt.Errorf("could not derive provider base domain from %q", rt.cfg.BaseURL) } @@ -628,7 +618,7 @@ func providerMSPProofPlanVersion(cfg *cloudcp.CPConfig) string { return strings.TrimSpace(cfg.ProviderMSPPlanVersion) } -func providerMSPProofBaseDomainFromURL(baseURL string) string { +func providerMSPBaseDomainFromURL(baseURL string) string { domain := strings.TrimSpace(baseURL) for _, prefix := range []string{"https://", "http://"} { if strings.HasPrefix(domain, prefix) { @@ -643,7 +633,7 @@ func providerMSPProofBaseDomainFromURL(baseURL string) string { } func (rt *providerMSPProofRuntime) providerMSPProofTenantPublicURL(tenantID string) string { - baseDomain := providerMSPProofBaseDomainFromURL(rt.cfg.BaseURL) + baseDomain := providerMSPBaseDomainFromURL(rt.cfg.BaseURL) if baseDomain == "" { return "" } diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 296607a8c..b1940d491 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -149,6 +149,11 @@ rules. Hosted tenant container creation must also bound Docker `json-file` logs through the control-plane Docker manager so tenant runtime logging cannot fill the live Pulse Cloud host independently of tenant data quotas. + Provider-hosted MSP and Pulse-hosted tenant creation must also prepare or + fail closed on the configured tenant runtime image before mutating the + workspace into a state that expects a live runtime container; readiness + checks must surface missing Docker daemon, network, image, and storage + prerequisites as operator-facing failures. Hosted checkout and MSP workspace provisioning must also pass the control-plane storage admission guard before tenant/account mutation: root filesystem, tenant data, Docker runtime store, and Docker build-cache diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 1e3917a8f..cc880046c 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -27,87 +27,88 @@ surfaces. 4. `cmd/pulse-control-plane/main.go` 5. `cmd/pulse-control-plane/mobile_proof_cmd.go` 6. `cmd/pulse-control-plane/provider_msp.go` -7. `cmd/pulse-control-plane/provider_msp_proof.go` -8. `internal/cloudcp/docker/manager.go` -9. `internal/cloudcp/docker/labels.go` -10. `internal/cloudcp/tenant_runtime_rollout.go` -11. `.github/workflows/create-release.yml` -12. `.github/workflows/deploy-demo-server.yml` -13. `.github/workflows/helm-pages.yml` -14. `.github/workflows/promote-floating-tags.yml` -15. `.github/workflows/publish-docker.yml` -16. `.github/workflows/publish-helm-chart.yml` -17. `.github/workflows/release-dry-run.yml` -18. `.github/workflows/update-demo-server.yml` -19. `.github/workflows/validate-release-assets.yml` -20. `.github/workflows/install-sh-smoke.yml` -21. `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml` -22. `docs/RELEASE_NOTES.md` -23. `docs/releases/` -24. `docs/UPGRADE_v6.md` -25. `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md` -26. `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md` -27. `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md` -28. `package.json` -29. `package-lock.json` -30. `frontend-modern/package.json` -31. `frontend-modern/package-lock.json` -32. `frontend-modern/vite.config.ts` -33. `go.mod` -34. `go.sum` -35. `scripts/build-release.sh` -36. `scripts/check-workflow-dispatch-inputs.py` -37. `scripts/clean-mock-alerts.sh` -38. `scripts/com.pulse.hot-dev.plist.template` -39. `scripts/dev-check.sh` -40. `scripts/dev-deploy-agent.sh` -41. `scripts/dev-launchd-setup.sh` -42. `scripts/dev-launchd-wrapper.sh` -43. `scripts/hot-dev-bg.sh` -44. `scripts/hot-dev.sh` -45. `scripts/lib/hot-dev-runtime.sh` -46. `scripts/lib/hot-dev-auth.sh` -47. `scripts/install-container-agent.sh` -48. `install.sh` -49. `scripts/install.ps1` -50. `scripts/install.sh` -51. `scripts/install-mcp.sh` -52. `scripts/install-mcp.ps1` -53. `cmd/pulse-mcp/` -54. `scripts/pulse-auto-update.sh` -55. `scripts/release_control/internal/record_rc_to_ga_rehearsal.py` -56. `scripts/release_control/record_rc_to_ga_rehearsal.py` -57. `scripts/release_control/release_promotion_policy_support.py` -58. `scripts/release_control/resolve_release_promotion.py` -59. `scripts/release_ldflags.sh` -60. `scripts/run_cloud_public_signup_smoke.sh` -61. `scripts/run_demo_public_browser_smoke.sh` -62. `scripts/demo_public_browser_smoke.cjs` -63. `scripts/run_hosted_staging_smoke.sh` -64. `scripts/trigger-release-dry-run.sh` -65. `scripts/trigger-release.sh` -66. `scripts/toggle-mock.sh` -67. `deploy/provider-msp/` -68. `deploy/helm/pulse/` -69. `tests/integration/playwright.config.ts` -70. `tests/integration/QUICK_START.md` -71. `tests/integration/README.md` -72. `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs` -73. `tests/integration/scripts/hosted-mobile-token-runtime.mjs` -74. `tests/integration/scripts/hosted-tenant-approval-store.mjs` -75. `tests/integration/scripts/hosted-tenant-runtime.mjs` -76. `tests/integration/scripts/hosted-tenant-runtime-restart.mjs` -77. `tests/integration/scripts/managed-dev-runtime.mjs` -78. `tests/integration/scripts/relay-mobile-token-helper.go` -79. `tests/integration/tests/helpers.ts` -80. `tests/integration/tests/runtime-defaults.ts` -81. `docker-compose.yml` -82. `scripts/install-docker.sh` -83. `scripts/validate-published-release.sh` -84. `scripts/validate-release.sh` -85. `scripts/release_asset_common.sh` -86. `scripts/backfill-release-assets.sh` -87. `.github/workflows/backfill-release-assets.yml` +7. `cmd/pulse-control-plane/provider_msp_preflight.go` +8. `cmd/pulse-control-plane/provider_msp_proof.go` +9. `internal/cloudcp/docker/manager.go` +10. `internal/cloudcp/docker/labels.go` +11. `internal/cloudcp/tenant_runtime_rollout.go` +12. `.github/workflows/create-release.yml` +13. `.github/workflows/deploy-demo-server.yml` +14. `.github/workflows/helm-pages.yml` +15. `.github/workflows/promote-floating-tags.yml` +16. `.github/workflows/publish-docker.yml` +17. `.github/workflows/publish-helm-chart.yml` +18. `.github/workflows/release-dry-run.yml` +19. `.github/workflows/update-demo-server.yml` +20. `.github/workflows/validate-release-assets.yml` +21. `.github/workflows/install-sh-smoke.yml` +22. `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml` +23. `docs/RELEASE_NOTES.md` +24. `docs/releases/` +25. `docs/UPGRADE_v6.md` +26. `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md` +27. `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md` +28. `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md` +29. `package.json` +30. `package-lock.json` +31. `frontend-modern/package.json` +32. `frontend-modern/package-lock.json` +33. `frontend-modern/vite.config.ts` +34. `go.mod` +35. `go.sum` +36. `scripts/build-release.sh` +37. `scripts/check-workflow-dispatch-inputs.py` +38. `scripts/clean-mock-alerts.sh` +39. `scripts/com.pulse.hot-dev.plist.template` +40. `scripts/dev-check.sh` +41. `scripts/dev-deploy-agent.sh` +42. `scripts/dev-launchd-setup.sh` +43. `scripts/dev-launchd-wrapper.sh` +44. `scripts/hot-dev-bg.sh` +45. `scripts/hot-dev.sh` +46. `scripts/lib/hot-dev-runtime.sh` +47. `scripts/lib/hot-dev-auth.sh` +48. `scripts/install-container-agent.sh` +49. `install.sh` +50. `scripts/install.ps1` +51. `scripts/install.sh` +52. `scripts/install-mcp.sh` +53. `scripts/install-mcp.ps1` +54. `cmd/pulse-mcp/` +55. `scripts/pulse-auto-update.sh` +56. `scripts/release_control/internal/record_rc_to_ga_rehearsal.py` +57. `scripts/release_control/record_rc_to_ga_rehearsal.py` +58. `scripts/release_control/release_promotion_policy_support.py` +59. `scripts/release_control/resolve_release_promotion.py` +60. `scripts/release_ldflags.sh` +61. `scripts/run_cloud_public_signup_smoke.sh` +62. `scripts/run_demo_public_browser_smoke.sh` +63. `scripts/demo_public_browser_smoke.cjs` +64. `scripts/run_hosted_staging_smoke.sh` +65. `scripts/trigger-release-dry-run.sh` +66. `scripts/trigger-release.sh` +67. `scripts/toggle-mock.sh` +68. `deploy/provider-msp/` +69. `deploy/helm/pulse/` +70. `tests/integration/playwright.config.ts` +71. `tests/integration/QUICK_START.md` +72. `tests/integration/README.md` +73. `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs` +74. `tests/integration/scripts/hosted-mobile-token-runtime.mjs` +75. `tests/integration/scripts/hosted-tenant-approval-store.mjs` +76. `tests/integration/scripts/hosted-tenant-runtime.mjs` +77. `tests/integration/scripts/hosted-tenant-runtime-restart.mjs` +78. `tests/integration/scripts/managed-dev-runtime.mjs` +79. `tests/integration/scripts/relay-mobile-token-helper.go` +80. `tests/integration/tests/helpers.ts` +81. `tests/integration/tests/runtime-defaults.ts` +82. `docker-compose.yml` +83. `scripts/install-docker.sh` +84. `scripts/validate-published-release.sh` +85. `scripts/validate-release.sh` +86. `scripts/release_asset_common.sh` +87. `scripts/backfill-release-assets.sh` +88. `.github/workflows/backfill-release-assets.yml` ## Shared Boundaries @@ -118,6 +119,10 @@ surfaces. Tenant runtime containers must be created with bounded Docker `json-file` logging so rollout and canary fleets cannot consume unbounded production host storage while they remain running. + Provider-hosted MSP workspace creation and preflight must prepare or report + the configured tenant runtime image, configured Docker network, Docker + daemon reachability, and storage-admission guardrails before the provider + treats a fresh install as ready for client onboarding. 5. `internal/cloudcp/tenant_runtime_rollout.go` shared with `cloud-paid`: hosted tenant runtime rollout is both a Pulse Cloud runtime contract boundary and a deployment-installability release-rollout boundary. 6. `scripts/install.ps1` shared with `agent-lifecycle`: the Windows installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary. It must expose a non-mutating preflight for the exact Windows agent diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index dacfa2977..9650f61dc 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -2624,6 +2624,7 @@ ".github/workflows/validate-release-assets.yml", "cmd/pulse-control-plane/main.go", "cmd/pulse-control-plane/provider_msp.go", + "cmd/pulse-control-plane/provider_msp_preflight.go", "cmd/pulse-control-plane/provider_msp_proof.go", "docker-compose.yml", "Dockerfile", @@ -2891,6 +2892,7 @@ "match_files": [ "cmd/pulse-control-plane/main.go", "cmd/pulse-control-plane/provider_msp.go", + "cmd/pulse-control-plane/provider_msp_preflight.go", "cmd/pulse-control-plane/provider_msp_proof.go", "internal/cloudcp/docker/labels.go", "internal/cloudcp/docker/manager.go", @@ -2899,7 +2901,9 @@ "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ + "cmd/pulse-control-plane/provider_msp_preflight_test.go", "cmd/pulse-control-plane/provider_msp_proof_test.go", + "internal/cloudcp/docker/manager_test.go", "internal/cloudcp/tenant_runtime_rollout_test.go", "scripts/installtests/provider_msp_deploy_test.go" ] diff --git a/internal/cloudcp/docker/manager.go b/internal/cloudcp/docker/manager.go index 34034dfc3..a31836ac9 100644 --- a/internal/cloudcp/docker/manager.go +++ b/internal/cloudcp/docker/manager.go @@ -77,6 +77,24 @@ type RuntimeContainerSummary struct { Created int64 } +type RuntimePrerequisiteOptions struct { + PullImage bool +} + +type RuntimePrerequisiteReport struct { + OK bool + DockerReachable bool + NetworkName string + NetworkID string + NetworkOK bool + ImageRef string + ImageID string + ImageAvailable bool + ImagePulled bool + ImagePullRequired bool + Failures []string +} + const immutableOwnershipPathsEnv = "PULSE_IMMUTABLE_OWNERSHIP_PATHS" const ( @@ -193,6 +211,58 @@ func (m *Manager) ListManagedRuntimeContainers(ctx context.Context) ([]RuntimeCo return containers, nil } +func (m *Manager) CheckRuntimePrerequisites(ctx context.Context, opts RuntimePrerequisiteOptions) (*RuntimePrerequisiteReport, error) { + if m == nil { + return &RuntimePrerequisiteReport{ + OK: false, + Failures: []string{"docker manager is not initialized"}, + }, nil + } + report := &RuntimePrerequisiteReport{ + OK: true, + NetworkName: strings.TrimSpace(m.cfg.Network), + ImageRef: strings.TrimSpace(m.cfg.Image), + } + addFailure := func(format string, args ...any) { + report.OK = false + report.Failures = append(report.Failures, fmt.Sprintf(format, args...)) + } + + if err := m.ensureDaemonReachable(ctx); err != nil { + addFailure("%v", err) + return report, nil + } + report.DockerReachable = true + + if report.NetworkName == "" { + addFailure("tenant Docker network is required") + } else { + inspect, err := m.cli.NetworkInspect(ctx, report.NetworkName, client.NetworkInspectOptions{}) + if err != nil { + addFailure("inspect tenant Docker network %q: %v", report.NetworkName, err) + } else { + report.NetworkOK = true + report.NetworkID = strings.TrimSpace(inspect.Network.ID) + } + } + + image, pulled, err := m.ensureRuntimeImageAvailable(ctx, opts.PullImage) + report.ImagePulled = pulled + report.ImageID = image + if err != nil { + if !opts.PullImage && errdefs.IsNotFound(err) { + report.ImagePullRequired = true + addFailure("tenant runtime image %q is not present locally; rerun preflight with image pulling enabled or pull it before creating a workspace", report.ImageRef) + } else { + addFailure("%v", err) + } + } else { + report.ImageAvailable = true + } + + return report, nil +} + // IsNotFound reports whether Docker treated an identifier as missing. func IsNotFound(err error) bool { return errdefs.IsNotFound(err) @@ -208,12 +278,52 @@ func (m *Manager) ensureDaemonReachable(ctx context.Context) error { return nil } +func (m *Manager) ensureRuntimeImageAvailable(ctx context.Context, pullIfMissing bool) (imageID string, pulled bool, err error) { + if err := m.ensureDaemonReachable(ctx); err != nil { + return "", false, err + } + imageRef := strings.TrimSpace(m.cfg.Image) + if imageRef == "" { + return "", false, fmt.Errorf("tenant runtime image is required") + } + inspect, err := m.cli.ImageInspect(ctx, imageRef) + if err == nil { + return strings.TrimSpace(inspect.ID), false, nil + } + if !errdefs.IsNotFound(err) { + return "", false, fmt.Errorf("inspect tenant runtime image %q: %w", imageRef, err) + } + if !pullIfMissing { + return "", false, err + } + rc, err := m.cli.ImagePull(ctx, imageRef, client.ImagePullOptions{}) + if err != nil { + return "", false, fmt.Errorf("pull tenant runtime image %q: %w", imageRef, err) + } + defer func() { + if closeErr := rc.Close(); closeErr != nil && err == nil { + err = fmt.Errorf("close tenant runtime image pull stream %q: %w", imageRef, closeErr) + } + }() + if _, err := io.Copy(io.Discard, rc); err != nil { + return "", true, fmt.Errorf("read tenant runtime image pull output %q: %w", imageRef, err) + } + inspect, err = m.cli.ImageInspect(ctx, imageRef) + if err != nil { + return "", true, fmt.Errorf("inspect pulled tenant runtime image %q: %w", imageRef, err) + } + return strings.TrimSpace(inspect.ID), true, nil +} + // CreateAndStart creates and starts a tenant container. // tenantDataDir is the host path that gets bind-mounted to /etc/pulse in the container. func (m *Manager) CreateAndStart(ctx context.Context, tenantID, tenantDataDir string) (containerID string, err error) { if err := m.ensureDaemonReachable(ctx); err != nil { return "", err } + if _, _, err := m.ensureRuntimeImageAvailable(ctx, true); err != nil { + return "", fmt.Errorf("prepare tenant runtime image: %w", err) + } if err := prepareTenantRuntimeMountSources(tenantDataDir, tenantRuntimeUID, tenantRuntimeGID); err != nil { return "", fmt.Errorf("prepare tenant runtime mounts for %s: %w", tenantID, err) } diff --git a/internal/cloudcp/docker/manager_test.go b/internal/cloudcp/docker/manager_test.go index 246c16d26..46bc07b85 100644 --- a/internal/cloudcp/docker/manager_test.go +++ b/internal/cloudcp/docker/manager_test.go @@ -280,3 +280,42 @@ func TestCreateAndStartFailsBeforeImmutablePrepWhenDockerUnavailable(t *testing. t.Fatalf("CreateAndStart error = %v, want daemon failure before mount preparation", err) } } + +func TestCheckRuntimePrerequisitesReportsMissingDockerDaemon(t *testing.T) { + t.Setenv("DOCKER_HOST", "unix:///tmp/pulse-missing-docker-preflight.sock") + t.Setenv("DOCKER_TLS_VERIFY", "") + t.Setenv("DOCKER_CERT_PATH", "") + + mgr, err := NewManager(ManagerConfig{ + Image: "pulse:test", + Network: "pulse-provider-msp", + BaseDomain: "msp.example.com", + }) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + t.Cleanup(func() { _ = mgr.Close() }) + + report, err := mgr.CheckRuntimePrerequisites(context.Background(), RuntimePrerequisiteOptions{PullImage: false}) + if err != nil { + t.Fatalf("CheckRuntimePrerequisites: %v", err) + } + if report == nil { + t.Fatal("report is nil") + } + if report.OK { + t.Fatalf("report.OK = true, want false") + } + if report.DockerReachable { + t.Fatal("DockerReachable = true, want false") + } + if report.NetworkName != "pulse-provider-msp" { + t.Fatalf("NetworkName = %q, want pulse-provider-msp", report.NetworkName) + } + if report.ImageRef != "pulse:test" { + t.Fatalf("ImageRef = %q, want pulse:test", report.ImageRef) + } + if got := strings.Join(report.Failures, "; "); !strings.Contains(got, "ping docker daemon") { + t.Fatalf("failures = %q, want docker daemon ping failure", got) + } +} diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 3fc174058..106f6cd4b 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -3405,7 +3405,9 @@ class SubsystemLookupTest(unittest.TestCase): self.assertEqual( match["verification_requirement"]["exact_files"], [ + "cmd/pulse-control-plane/provider_msp_preflight_test.go", "cmd/pulse-control-plane/provider_msp_proof_test.go", + "internal/cloudcp/docker/manager_test.go", "internal/cloudcp/tenant_runtime_rollout_test.go", "scripts/installtests/provider_msp_deploy_test.go", ],