mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
Advance the provider control-plane source builder and integration mock builder on Pulse PR #2275's reviewed 1.26.8 Alpine digest. Keep their exact compiler image locked by an installability proof and record what this does not qualify. The published prebuilt runtime path is unchanged. Change-source: pulse-maintainer
831 lines
34 KiB
Go
831 lines
34 KiB
Go
package installtests
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
func TestProviderMSPGoBuilderMatchesIntegrationMock(t *testing.T) {
|
|
// These builders use the same Go patch level and immutable image. The
|
|
// mock's build must not exercise a different compiler from the hosted
|
|
// control plane merely because Dependabot advanced one Dockerfile.
|
|
const expected = "golang:1.26.8-alpine@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c"
|
|
pattern := regexp.MustCompile(`(?m)^FROM (?:--platform=\$BUILDPLATFORM )?(golang:[^[:space:]]+) AS builder$`)
|
|
for _, path := range [][]string{
|
|
{"deploy", "provider-msp", "Dockerfile.control-plane"},
|
|
{"tests", "integration", "mock-github-server", "Dockerfile"},
|
|
} {
|
|
content, err := os.ReadFile(repoFile(path...))
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", filepath.Join(path...), err)
|
|
}
|
|
match := pattern.FindStringSubmatch(string(content))
|
|
if len(match) != 2 || match[1] != expected {
|
|
t.Errorf("%s Go builder = %q, want %q", filepath.Join(path...), match, expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProviderMSPControlPlaneImageConsumesExactCandidate(t *testing.T) {
|
|
dockerfileBytes, err := os.ReadFile(repoFile("deploy", "provider-msp", "Dockerfile.control-plane"))
|
|
if err != nil {
|
|
t.Fatalf("read provider MSP control-plane Dockerfile: %v", err)
|
|
}
|
|
publishBytes, err := os.ReadFile(repoFile(".github", "workflows", "publish-docker.yml"))
|
|
if err != nil {
|
|
t.Fatalf("read Docker publisher: %v", err)
|
|
}
|
|
dockerfile := string(dockerfileBytes)
|
|
publish := string(publishBytes)
|
|
assertContainsAll(t, dockerfile,
|
|
"FROM control-plane-runtime-foundation AS control_plane_prebuilt",
|
|
"COPY --from=compiled_payload /binaries/pulse-control-plane-linux-${TARGETARCH:-amd64}",
|
|
"FROM control-plane-runtime-foundation AS runtime",
|
|
)
|
|
assertContainsAll(t, publish,
|
|
"Verify exact-candidate container payload",
|
|
"target: control_plane_prebuilt",
|
|
"compiled_payload=${{ runner.temp }}/release-container-payload/payload/compiled",
|
|
)
|
|
assertNotContainsAny(t, publish,
|
|
"PULSE_LICENSE_PUBLIC_KEY",
|
|
"PULSE_UPDATE_SIGNING_KEY",
|
|
"pulse_license_public_key",
|
|
"pulse_update_signing_key",
|
|
)
|
|
}
|
|
|
|
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)
|
|
assertContainsAny(t, text,
|
|
"CP_CONTROL_PLANE_MODE=provider_hosted_msp",
|
|
"CP_CONTROL_PLANE_MODE=${CP_CONTROL_PLANE_MODE:-provider_hosted_msp}",
|
|
)
|
|
assertContainsAll(t, text,
|
|
"CP_DATA_DIR=${PULSE_PROVIDER_MSP_DATA_DIR:-/data}",
|
|
"CP_PROVIDER_MSP_LICENSE_FILE=/run/secrets/provider_msp_license",
|
|
"CP_DOCKER_NETWORK=${PULSE_PROVIDER_MSP_DOCKER_NETWORK:-pulse-provider-msp}",
|
|
"CP_TRUSTED_PROXY_CIDRS=${CP_TRUSTED_PROXY_CIDRS}",
|
|
"DOCKER_HOST=tcp://docker-socket-proxy:2375",
|
|
"CP_STORAGE_DATA_PATH=${PULSE_PROVIDER_MSP_DATA_DIR:-/data}",
|
|
"CP_STORAGE_ROOT_PATH=/storage-root-spacecheck",
|
|
"CP_STORAGE_DOCKER_PATH=/storage-docker-spacecheck",
|
|
"${PULSE_PROVIDER_MSP_DATA_DIR:-/data}:${PULSE_PROVIDER_MSP_DATA_DIR:-/data}",
|
|
"DOCKER_SOCKET_PROXY_IMAGE",
|
|
"PING=1",
|
|
"VERSION=1",
|
|
"INFO=1",
|
|
"${PULSE_PROVIDER_MSP_DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:ro",
|
|
"${PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR:-/var/lib/pulse-provider-msp/spacecheck/root}:/storage-root-spacecheck:ro",
|
|
"${PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR:-/var/lib/docker/.pulse-provider-msp-spacecheck}:/storage-docker-spacecheck:ro",
|
|
"pulse.provider-msp.role=traefik",
|
|
"pulse.provider-msp.role=control-plane",
|
|
"provider_msp_license:",
|
|
"name: ${PULSE_PROVIDER_MSP_DOCKER_NETWORK:-pulse-provider-msp}",
|
|
"subnet: ${PULSE_PROVIDER_MSP_DOCKER_SUBNET:-172.30.0.0/24}",
|
|
)
|
|
assertNotContainsAny(t, text,
|
|
":/host-root",
|
|
":/host-var-lib-docker",
|
|
"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",
|
|
"PULSE_PROVIDER_MSP_DATA_DIR=/data",
|
|
"PULSE_PROVIDER_MSP_DOCKER_NETWORK=pulse-provider-msp",
|
|
"PULSE_PROVIDER_MSP_DOCKER_SUBNET=172.30.0.0/24",
|
|
"PULSE_PROVIDER_MSP_DOCKER_SOCKET=/var/run/docker.sock",
|
|
"PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR=/var/lib/pulse-provider-msp/spacecheck/root",
|
|
"PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR=/var/lib/docker/.pulse-provider-msp-spacecheck",
|
|
"CP_TRUSTED_PROXY_CIDRS=172.30.0.0/24",
|
|
// Ships blank on purpose: blank is evaluation. The exact blank form is
|
|
// asserted in TestProviderMSPSetupScriptSupportsUnlicensedEvaluation.
|
|
"CP_PROVIDER_MSP_LICENSE_FILE=",
|
|
"CP_ENTITLEMENT_SIGNING_PRIVATE_KEY=",
|
|
"ACME_DNS_PROVIDER=cloudflare",
|
|
"sudo -E ./setup.sh",
|
|
"docker compose run --rm control-plane provider-msp bootstrap",
|
|
"docker compose run --rm control-plane provider-msp portal-link",
|
|
"CP_SESSION_TTL",
|
|
"docker compose run --rm control-plane provider-msp preflight",
|
|
"docker compose run --rm control-plane provider-msp status",
|
|
"docker compose run --rm control-plane provider-msp status --require-backup",
|
|
"./upgrade.sh --dry-run",
|
|
"./upgrade.sh",
|
|
"./upgrade.sh --rollout-tenants",
|
|
"./run-install-proof.sh",
|
|
"docker compose run --rm control-plane provider-msp install-proof",
|
|
"docker compose run --rm control-plane provider-msp recover --all-degraded --dry-run",
|
|
"docker compose run --rm control-plane provider-msp recover --all-degraded",
|
|
"docker compose run --rm control-plane provider-msp backup create",
|
|
"docker compose run --rm control-plane provider-msp backup verify",
|
|
"docker compose run --rm control-plane provider-msp backup restore",
|
|
"--target-data-dir",
|
|
"--dry-run",
|
|
"docker compose run --rm control-plane provider-msp proof",
|
|
"--account-name",
|
|
"--owner-email",
|
|
"--cleanup",
|
|
)
|
|
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 TestProviderMSPSetupScriptMatchesProviderContract(t *testing.T) {
|
|
scriptPath := repoFile("deploy", "provider-msp", "setup.sh")
|
|
result := exec.Command("bash", "-n", scriptPath)
|
|
if output, err := result.CombinedOutput(); err != nil {
|
|
t.Fatalf("provider MSP setup shell syntax failed: %v\n%s", err, output)
|
|
}
|
|
scriptBytes, err := os.ReadFile(scriptPath)
|
|
if err != nil {
|
|
t.Fatalf("read provider MSP setup: %v", err)
|
|
}
|
|
text := string(scriptBytes)
|
|
assertContainsAll(t, text,
|
|
"PULSE_PROVIDER_MSP_INSTALL_DIR",
|
|
"PULSE_PROVIDER_MSP_DATA_DIR",
|
|
"PULSE_PROVIDER_MSP_DOCKER_NETWORK",
|
|
"PULSE_PROVIDER_MSP_BUNDLE_URL",
|
|
"docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin",
|
|
"docker-compose.yml",
|
|
"traefik.yml",
|
|
"traefik-dynamic.yml",
|
|
".env.example",
|
|
"run-install-proof.sh",
|
|
"upgrade.sh",
|
|
"CP_PROVIDER_MSP_LICENSE_FILE",
|
|
"CP_ENTITLEMENT_SIGNING_PRIVATE_KEY",
|
|
"PULSE_PROVIDER_MSP_DATA_DIR",
|
|
"PULSE_PROVIDER_MSP_DOCKER_NETWORK",
|
|
"PULSE_PROVIDER_MSP_DOCKER_SUBNET",
|
|
"PULSE_PROVIDER_MSP_DOCKER_SOCKET",
|
|
"PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR",
|
|
"PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR",
|
|
"CP_TRUSTED_PROXY_CIDRS",
|
|
"DOCKER_SOCKET_PROXY_IMAGE",
|
|
"must be an absolute path",
|
|
"must point to a reachable Docker socket",
|
|
"CP_ADMIN_KEY must be at least 32 characters",
|
|
"CP_TRUSTED_PROXY_CIDRS must include PULSE_PROVIDER_MSP_DOCKER_SUBNET",
|
|
"169.254.169.254/32",
|
|
"will be created by compose with subnet",
|
|
"must not be configured in provider-hosted MSP mode",
|
|
"CP_ALLOW_DOCKERLESS_PROVISIONING must be false",
|
|
"CP_STORAGE_GUARDRAILS_ENABLED must be true",
|
|
"docker compose config --quiet",
|
|
`docker pull "${image_ref}"`,
|
|
"PULSE_PROVIDER_MSP_ACCOUNT_NAME",
|
|
"PULSE_PROVIDER_MSP_OWNER_EMAIL",
|
|
"./run-install-proof.sh",
|
|
"Next step: create your operator account",
|
|
"provider-msp portal-link --email",
|
|
)
|
|
assertNotContainsAny(t, text, "docker network create --subnet")
|
|
}
|
|
|
|
func TestProviderMSPUpgradeRunnerMatchesComposeContract(t *testing.T) {
|
|
scriptPath := repoFile("deploy", "provider-msp", "upgrade.sh")
|
|
result := exec.Command("bash", "-n", scriptPath)
|
|
if output, err := result.CombinedOutput(); err != nil {
|
|
t.Fatalf("provider MSP upgrade runner shell syntax failed: %v\n%s", err, output)
|
|
}
|
|
scriptBytes, err := os.ReadFile(scriptPath)
|
|
if err != nil {
|
|
t.Fatalf("read provider MSP upgrade runner: %v", err)
|
|
}
|
|
text := string(scriptBytes)
|
|
assertContainsAll(t, text,
|
|
"PROVIDER_MSP_UPGRADE_DRY_RUN",
|
|
"PROVIDER_MSP_UPGRADE_ROLLOUT_TENANTS",
|
|
"PROVIDER_MSP_UPGRADE_PRUNE_PREVIOUS",
|
|
"PROVIDER_MSP_UPGRADE_BACKUP_OUTPUT",
|
|
"PROVIDER_MSP_UPGRADE_RESTORE_TARGET",
|
|
"PROVIDER_MSP_UPGRADE_RUN_ID",
|
|
"PROVIDER_MSP_UPGRADE_HEALTH_TIMEOUT",
|
|
"docker compose config --quiet",
|
|
"docker version >/dev/null",
|
|
"provider-msp preflight",
|
|
"provider-msp status",
|
|
"provider-msp status --require-backup",
|
|
"provider-msp backup create",
|
|
"provider-msp backup verify",
|
|
"provider-msp backup restore",
|
|
"--target-data-dir",
|
|
"tenant-runtime rollout --all --image",
|
|
"tenant-runtime rollout",
|
|
"--all",
|
|
"--image",
|
|
"--run-id",
|
|
"--health-timeout",
|
|
"--prune-previous",
|
|
"docker compose pull traefik docker-socket-proxy control-plane",
|
|
"docker compose up -d traefik docker-socket-proxy control-plane",
|
|
"docker compose run --rm --no-deps control-plane",
|
|
"provider_msp_upgrade_ok=true",
|
|
"tenant_runtime_rollout_applied=true",
|
|
"tenant_runtime_rollout_applied=false",
|
|
)
|
|
assertNotContainsAny(t, text,
|
|
"STRIPE_",
|
|
"CP_PUBLIC_CLOUD_SIGNUP_ENABLED",
|
|
)
|
|
}
|
|
|
|
func TestProviderMSPInstallProofRunnerMatchesComposeContract(t *testing.T) {
|
|
scriptPath := repoFile("deploy", "provider-msp", "run-install-proof.sh")
|
|
result := exec.Command("bash", "-n", scriptPath)
|
|
if output, err := result.CombinedOutput(); err != nil {
|
|
t.Fatalf("provider MSP install-proof runner shell syntax failed: %v\n%s", err, output)
|
|
}
|
|
scriptBytes, err := os.ReadFile(scriptPath)
|
|
if err != nil {
|
|
t.Fatalf("read provider MSP install-proof runner: %v", err)
|
|
}
|
|
text := string(scriptBytes)
|
|
assertContainsAll(t, text,
|
|
"docker compose config --quiet",
|
|
"docker version >/dev/null",
|
|
"docker compose pull traefik docker-socket-proxy control-plane",
|
|
"docker compose up -d traefik docker-socket-proxy",
|
|
"provider-msp install-proof",
|
|
"--account-name",
|
|
"--owner-email",
|
|
"--workspace-count",
|
|
"--install-type",
|
|
"--target-path",
|
|
"--skip-image-pull",
|
|
"${#extra_install_args[@]}",
|
|
"docker compose run --rm --no-deps control-plane",
|
|
"docker compose up -d traefik docker-socket-proxy control-plane",
|
|
"provider-msp status",
|
|
)
|
|
assertNotContainsAny(t, text,
|
|
"STRIPE_",
|
|
"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 TestProviderMSPControlPlaneDockerfileBuildsReleaseLicenseBinary(t *testing.T) {
|
|
dockerfileBytes, err := os.ReadFile(repoFile("deploy", "provider-msp", "Dockerfile.control-plane"))
|
|
if err != nil {
|
|
t.Fatalf("read control-plane Dockerfile: %v", err)
|
|
}
|
|
text := string(dockerfileBytes)
|
|
assertContainsAll(t, text,
|
|
"# syntax=docker/dockerfile:1.7",
|
|
"FROM --platform=linux/amd64 node:24-alpine@sha256:"+node24Amd64FrontendDigest+" AS frontend-builder",
|
|
"npm ci",
|
|
"npm run build",
|
|
"FROM --platform=$BUILDPLATFORM golang:1.26.8-alpine@sha256:",
|
|
"FROM alpine:3.24@sha256:",
|
|
"ARG PULSE_LICENSE_PUBLIC_KEY_SHA256",
|
|
"ARG TARGETOS",
|
|
"ARG TARGETARCH",
|
|
"--mount=type=secret,id=pulse_license_public_key,required=false",
|
|
"PULSE_LICENSE_PUBLIC_KEY_SHA256 is required for control-plane release image builds.",
|
|
"PULSE_LICENSE_PUBLIC_KEY_SHA256 was provided but no license public key was mounted.",
|
|
`LICENSE_PUBLIC_KEY="$(tr -d '\r\n' < /run/secrets/pulse_license_public_key)"`,
|
|
"mounted license public key must decode to 32 bytes.",
|
|
"mounted license public key does not match PULSE_LICENSE_PUBLIC_KEY_SHA256.",
|
|
"COPY --from=frontend-builder /app/internal/api/frontend-modern/dist ./internal/api/frontend-modern/dist",
|
|
`TARGET_GOOS="${TARGETOS:-linux}"`,
|
|
`TARGET_GOARCH="${TARGETARCH:-$(go env GOARCH)}"`,
|
|
`./scripts/release_ldflags.sh server --version "${VERSION}" --build-time "${BUILD_TIME}" --git-commit "${GIT_COMMIT}" --license-public-key "${LICENSE_PUBLIC_KEY}"`,
|
|
`CGO_ENABLED=0 GOOS="${TARGET_GOOS}" GOARCH="${TARGET_GOARCH}" go build \`,
|
|
"-tags release",
|
|
"-buildvcs=false",
|
|
"-trimpath",
|
|
"-o /pulse-control-plane ./cmd/pulse-control-plane",
|
|
)
|
|
assertNotContainsAny(t, text,
|
|
"golang:1.25.7-alpine AS builder",
|
|
"FROM alpine:3.21",
|
|
"CGO_ENABLED=0 go build -o /pulse-control-plane ./cmd/pulse-control-plane",
|
|
)
|
|
assertDigestPinnedDockerStage(t, text, `FROM --platform=linux/amd64 node:24-alpine@sha256:`, ` AS frontend-builder`)
|
|
assertDigestPinnedDockerStage(t, text, `FROM --platform=$BUILDPLATFORM golang:1.26.8-alpine@sha256:`, ` AS builder`)
|
|
assertDigestPinnedDockerStage(t, text, `FROM alpine:3.24@sha256:`, ` AS control-plane-runtime-foundation`)
|
|
}
|
|
|
|
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 assertContainsAny(t *testing.T, text string, allowed ...string) {
|
|
t.Helper()
|
|
for _, needle := range allowed {
|
|
if strings.Contains(text, needle) {
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("missing any of %q in:\n%s", allowed, 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The unlicensed path is both a commercial boundary and the whole conversion
|
|
// path, so it is pinned here rather than left to whoever next edits setup.sh.
|
|
//
|
|
// Before this, setup.sh required a licence file and four hand-supplied image
|
|
// digests shipped as unfillable "<pin>" placeholders. That put two human
|
|
// round-trips in front of a provider's first screen, for no technical reason:
|
|
// all four images are public, and the control plane already ran unlicensed on
|
|
// the environment fallback.
|
|
func TestProviderMSPSetupScriptSupportsUnlicensedEvaluation(t *testing.T) {
|
|
scriptBytes, err := os.ReadFile(repoFile("deploy", "provider-msp", "setup.sh"))
|
|
if err != nil {
|
|
t.Fatalf("read provider MSP setup: %v", err)
|
|
}
|
|
script := string(scriptBytes)
|
|
|
|
assertContainsAll(t, script,
|
|
"evaluation mode, 2 client workspaces",
|
|
"ensure_image_pins",
|
|
"default_image_ref",
|
|
"resolve_image_digest",
|
|
"buildx imagetools inspect",
|
|
`--format '{{json .Manifest}}'`,
|
|
`jq -r 'if type == "object" then .digest // empty else empty end'`,
|
|
`awk '$1 == "Digest:" {print $2; exit}'`,
|
|
"validate_compose_config --allow-missing-evaluation-license",
|
|
`CP_PROVIDER_MSP_LICENSE_FILE=/dev/null docker compose config --quiet`,
|
|
`docker pull "${image_ref}"`,
|
|
`if [[ "${current}" == *@sha256:*`,
|
|
`ref="${current}"`,
|
|
// Self-issue, and the three ways it must degrade instead of blocking.
|
|
"ensure_eval_license",
|
|
"/v1/provider-msp/eval-license",
|
|
"PULSE_PROVIDER_MSP_SKIP_EVAL_LICENSE",
|
|
"reusing existing evaluation license",
|
|
"could not reach the license server",
|
|
"PULSE_PROVIDER_MSP_EVAL_EMAIL",
|
|
"PULSE_PROVIDER_MSP_SIGNUP_SOURCE",
|
|
`setup_stage: "images_ready"`,
|
|
"Plan in your provider portal shows whether a paid upgrade is available",
|
|
"keep within two clients until Plan confirms a higher active limit",
|
|
)
|
|
// A purchase route is shown only when available; setup must not claim that
|
|
// checkout is live or that the paid limit is already active.
|
|
assertNotContainsAny(t, script, "request an upgrade", "eval_license_id=", "to buy, request a licence bound", "checkout is by Stripe and the new limit applies within")
|
|
if strings.LastIndex(script, "pull_provider_images\n") > strings.LastIndex(script, "ensure_eval_license\n") {
|
|
t.Fatal("evaluation must be issued only after pinned provider images are reachable")
|
|
}
|
|
setupSequence := "validate_compose_config --allow-missing-evaluation-license\n pull_provider_images\n # Issue the evaluation only after the host is configured and the immutable\n # images are reachable. This makes an issued evaluation a useful activation\n # signal rather than a record created before setup can succeed.\n ensure_eval_license\n validate_compose_config"
|
|
if !strings.Contains(script, setupSequence) {
|
|
t.Fatal("setup must validate compose, pull pinned images, issue the evaluation, then validate compose with the installed licence")
|
|
}
|
|
|
|
// The install must never abort because an evaluation licence could not be
|
|
// obtained. `|| true` inside the substitution does not achieve that: the
|
|
// derive helper calls die, and exit in a subshell is not a catchable
|
|
// status, so setup.sh would abort under set -e.
|
|
if strings.Contains(script, "$(derive_lease_signing_public_key 2>/dev/null || true)") {
|
|
t.Fatal("eval license key derivation uses `|| true` inside a command substitution, which cannot catch die/exit under set -e")
|
|
}
|
|
if !strings.Contains(script, "if ! public_key=\"$(derive_lease_signing_public_key 2>/dev/null)\"") {
|
|
t.Fatal("eval license key derivation must be guarded so a failure degrades to unlicensed rather than aborting setup")
|
|
}
|
|
|
|
// A blank licence path means evaluation. If it returns to the
|
|
// must-be-non-empty list, setup.sh refuses to start unlicensed again.
|
|
if strings.Contains(script, "CP_TRUSTED_PROXY_CIDRS CP_PROVIDER_MSP_LICENSE_FILE") {
|
|
t.Fatal("CP_PROVIDER_MSP_LICENSE_FILE is back in the required non-empty list; blank must mean evaluation")
|
|
}
|
|
|
|
envBytes, err := os.ReadFile(repoFile("deploy", "provider-msp", ".env.example"))
|
|
if err != nil {
|
|
t.Fatalf("read provider MSP env example: %v", err)
|
|
}
|
|
env := string(envBytes)
|
|
|
|
if strings.Contains(env, "<pin>") {
|
|
t.Fatal(".env.example still ships <pin> image placeholders; setup.sh resolves blank pins from published tags instead")
|
|
}
|
|
if !strings.Contains(env, "\nCP_PROVIDER_MSP_LICENSE_FILE=\n") {
|
|
t.Fatal(".env.example must ship a blank CP_PROVIDER_MSP_LICENSE_FILE so the shipped default is evaluation")
|
|
}
|
|
for _, key := range []string{"TRAEFIK_IMAGE", "DOCKER_SOCKET_PROXY_IMAGE", "CONTROL_PLANE_IMAGE", "CP_PULSE_IMAGE"} {
|
|
if !strings.Contains(env, "\n"+key+"=\n") {
|
|
t.Fatalf(".env.example must ship %s blank so setup.sh resolves its digest", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProviderMSPResolveImageDigestAcceptsManifestJSON(t *testing.T) {
|
|
scriptBytes, err := os.ReadFile(repoFile("deploy", "provider-msp", "setup.sh"))
|
|
if err != nil {
|
|
t.Fatalf("read provider MSP setup: %v", err)
|
|
}
|
|
script := strings.Replace(string(scriptBytes), `main "$@"`, "", 1)
|
|
if script == string(scriptBytes) {
|
|
t.Fatal("provider MSP setup main invocation not found")
|
|
}
|
|
|
|
tempDir := t.TempDir()
|
|
digest := "sha256:" + strings.Repeat("a", 64)
|
|
fakeDocker := filepath.Join(tempDir, "docker")
|
|
if err := os.WriteFile(fakeDocker, []byte("#!/bin/sh\nprintf '%s\\n' '{\"digest\":\""+digest+"\"}'\n"), 0o755); err != nil {
|
|
t.Fatalf("write fake docker: %v", err)
|
|
}
|
|
runner := filepath.Join(tempDir, "resolve-image-digest.sh")
|
|
if err := os.WriteFile(runner, []byte(script+"\nresolve_image_digest example.invalid/provider:v1\n"), 0o755); err != nil {
|
|
t.Fatalf("write setup runner: %v", err)
|
|
}
|
|
|
|
cmd := exec.Command("bash", runner)
|
|
cmd.Env = append(os.Environ(), "PATH="+tempDir+":"+os.Getenv("PATH"))
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("resolve image digest: %v\n%s", err, output)
|
|
}
|
|
want := "example.invalid/provider@" + digest
|
|
if got := strings.TrimSpace(string(output)); got != want {
|
|
t.Fatalf("resolved image = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestProviderMSPEvaluationDocsUsePublishedSignedBundle(t *testing.T) {
|
|
repoDocBytes, err := os.ReadFile(repoFile("docs", "MSP.md"))
|
|
if err != nil {
|
|
t.Fatalf("read repo MSP guide: %v", err)
|
|
}
|
|
shippedDocBytes, err := os.ReadFile(repoFile("frontend-modern", "public", "docs", "MSP.md"))
|
|
if err != nil {
|
|
t.Fatalf("read shipped MSP guide: %v", err)
|
|
}
|
|
if string(repoDocBytes) != string(shippedDocBytes) {
|
|
t.Fatal("repo and shipped MSP guides must remain byte-synchronized")
|
|
}
|
|
|
|
doc := string(repoDocBytes)
|
|
assertContainsAll(t, doc,
|
|
"signed provider bundle published",
|
|
"with Pulse v6.4.1",
|
|
"**not** download the moving `main` branch archive",
|
|
`export PULSE_VERSION=v6.4.1`,
|
|
`PULSE_MSP_BUNDLE="pulse-provider-msp-${PULSE_VERSION}.tar.gz"`,
|
|
`releases/download/${PULSE_VERSION}`,
|
|
"ssh-keygen -Y verify",
|
|
`-s "${PULSE_MSP_BUNDLE}.sshsig" < "${PULSE_MSP_BUNDLE}"`,
|
|
`sha256sum -c "${PULSE_MSP_BUNDLE}.sha256"`,
|
|
"The licence request omits an email address unless you export",
|
|
"`PULSE_PROVIDER_MSP_EVAL_EMAIL` before running `setup.sh`",
|
|
"address is included in the request",
|
|
"a setup-stage marker, and a signup-source",
|
|
"this is not a claim",
|
|
"that setup makes no other network requests",
|
|
"contact request remains separate from the licence activation",
|
|
`sudo -E bash ./setup.sh`,
|
|
)
|
|
assertNotContainsAny(t, doc,
|
|
"Pulse/archive/refs/heads/main.tar.gz",
|
|
"evaluation request is anonymous",
|
|
"credentials never leave the",
|
|
"cd Pulse-main/deploy/provider-msp",
|
|
"sudo -E ./setup.sh",
|
|
)
|
|
}
|
|
|
|
// Traefik terminates TLS at the internet edge. Handing it the whole operator
|
|
// .env put CP_ADMIN_KEY and the entitlement signing private key one edge CVE
|
|
// away from disclosure, so the compose contract pins the minimal wiring: only
|
|
// ACME/DNS material reaches the traefik container, and the DNS-01 provider is
|
|
// overridable for operators whose DNS is not on Cloudflare.
|
|
func TestProviderMSPTraefikEnvIsMinimalAndDNSProviderOverridable(t *testing.T) {
|
|
composeBytes, err := os.ReadFile(repoFile("deploy", "provider-msp", "docker-compose.yml"))
|
|
if err != nil {
|
|
t.Fatalf("read provider MSP compose: %v", err)
|
|
}
|
|
var compose struct {
|
|
Services map[string]struct {
|
|
EnvFile any `yaml:"env_file"`
|
|
} `yaml:"services"`
|
|
}
|
|
if err := yaml.Unmarshal(composeBytes, &compose); err != nil {
|
|
t.Fatalf("provider MSP compose must be valid YAML: %v", err)
|
|
}
|
|
traefik, ok := compose.Services["traefik"]
|
|
if !ok {
|
|
t.Fatal("compose must define a traefik service")
|
|
}
|
|
var envFiles []string
|
|
switch v := traefik.EnvFile.(type) {
|
|
case nil:
|
|
case string:
|
|
envFiles = append(envFiles, v)
|
|
case []any:
|
|
for _, entry := range v {
|
|
switch e := entry.(type) {
|
|
case string:
|
|
envFiles = append(envFiles, e)
|
|
case map[string]any:
|
|
if p, _ := e["path"].(string); p != "" {
|
|
envFiles = append(envFiles, p)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, f := range envFiles {
|
|
if strings.HasSuffix(f, ".env") && !strings.HasSuffix(f, "dns-credentials.env") {
|
|
t.Fatalf("traefik env_file %q would leak the operator .env (CP_ADMIN_KEY, entitlement signing key) into the edge container", f)
|
|
}
|
|
}
|
|
|
|
text := string(composeBytes)
|
|
assertContainsAll(t, text,
|
|
"TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_DNSCHALLENGE_PROVIDER=${ACME_DNS_PROVIDER:-cloudflare}",
|
|
"TRAEFIK_CERTIFICATESRESOLVERS_LE_ACME_DNSCHALLENGE_PROVIDER=${ACME_DNS_PROVIDER:-cloudflare}",
|
|
"CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN:-}",
|
|
"path: ./dns-credentials.env",
|
|
"required: false",
|
|
)
|
|
|
|
scriptBytes, err := os.ReadFile(repoFile("deploy", "provider-msp", "setup.sh"))
|
|
if err != nil {
|
|
t.Fatalf("read provider MSP setup: %v", err)
|
|
}
|
|
assertContainsAll(t, string(scriptBytes),
|
|
"ensure_dns_credentials_file",
|
|
"CF_DNS_API_TOKEN is required with the default ACME_DNS_PROVIDER=cloudflare",
|
|
"put that provider's credential variables in",
|
|
)
|
|
// CF_DNS_API_TOKEN must not return to the unconditional required list; it
|
|
// is only required when ACME_DNS_PROVIDER resolves to cloudflare.
|
|
if strings.Contains(string(scriptBytes), "ACME_EMAIL CF_DNS_API_TOKEN CP_ENV") {
|
|
t.Fatal("CF_DNS_API_TOKEN is back in the unconditional required-env list; it must be required only when ACME_DNS_PROVIDER is cloudflare")
|
|
}
|
|
}
|
|
|
|
// setup.sh used to finish at "setup prepared" without starting the control
|
|
// plane, so the bootstrap sign-in link it printed as the next step answered
|
|
// 404 until the install proof or a manual compose up happened to start it.
|
|
// Setup must bring the provider services up after the final compose
|
|
// validation, wait for the control plane, and only then run the optional
|
|
// install proof and print the summary.
|
|
func TestProviderMSPSetupLeavesPlatformRunning(t *testing.T) {
|
|
scriptBytes, err := os.ReadFile(repoFile("deploy", "provider-msp", "setup.sh"))
|
|
if err != nil {
|
|
t.Fatalf("read provider MSP setup: %v", err)
|
|
}
|
|
script := string(scriptBytes)
|
|
|
|
assertContainsAll(t, script,
|
|
"start_provider_services() {",
|
|
"docker compose up -d traefik docker-socket-proxy control-plane",
|
|
"docker compose ps --services --status running",
|
|
"grep -qx control-plane",
|
|
"control plane did not reach running state",
|
|
"Pulse Provider MSP is running.",
|
|
)
|
|
assertNotContainsAny(t, script, "Pulse Provider MSP setup prepared.")
|
|
|
|
// The closing summary points at Plan without claiming checkout is live,
|
|
// and no longer asks the provider to carry a lease signing key to anyone.
|
|
summary := script[strings.Index(script, "Pulse Provider MSP is running."):]
|
|
summary = summary[:strings.Index(summary, "EOF")]
|
|
assertContainsAll(t, summary, "Open Plan in the portal to see whether", "Keep within two clients until Plan confirms a", "Manage billing")
|
|
assertNotContainsAny(t, summary, "must bind this key", "derive_lease_signing_public_key", "checkout is by Stripe", "applies within\nseconds")
|
|
|
|
sequence := " ensure_eval_license\n validate_compose_config\n start_provider_services\n run_install_proof_if_requested\n print_summary\n"
|
|
if !strings.Contains(script, sequence) {
|
|
t.Fatal("setup must start provider services after the licensed compose validation and before the install proof and summary")
|
|
}
|
|
|
|
// The first-run prompt names only what a provider must supply.
|
|
first := script[strings.Index(script, "Edit it now and set"):]
|
|
first = first[:strings.Index(first, "EOF")]
|
|
for _, generatedOrDefaulted := range []string{"TRAEFIK_IMAGE", "CP_PULSE_IMAGE", "PULSE_PROVIDER_MSP_DOCKER_SUBNET", "CP_TRUSTED_PROXY_CIDRS"} {
|
|
if strings.Contains(first, " - "+generatedOrDefaulted) {
|
|
t.Fatalf("first-run prompt still lists %s as a value the provider must set", generatedOrDefaulted)
|
|
}
|
|
}
|
|
assertContainsAll(t, first, "DOMAIN", "ACME_EMAIL", "CF_DNS_API_TOKEN")
|
|
}
|
|
|
|
// Setup pins the images in .env to digests once, so an upgrade used to re-pull
|
|
// only the release a provider first installed. Run from a newly extracted
|
|
// bundle, upgrade.sh must install that bundle into the existing install,
|
|
// re-pin the two Pulse images to the bundle's release, keep a copy of the old
|
|
// .env, and only then hand over to the backup-gated flow.
|
|
func TestProviderMSPUpgradeFromBundleRepinsToTheBundleRelease(t *testing.T) {
|
|
newDigest := "sha256:" + strings.Repeat("b", 64)
|
|
oldControlPlane := "ghcr.io/rcourtman/pulse-control-plane@sha256:" + strings.Repeat("a", 64)
|
|
oldRuntime := "ghcr.io/rcourtman/pulse@sha256:" + strings.Repeat("a", 64)
|
|
|
|
setup := func(t *testing.T) (bundle, install, dockerLog string) {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
bundle = filepath.Join(root, "pulse-provider-msp-v6.6.0")
|
|
install = filepath.Join(root, "install")
|
|
bin := filepath.Join(root, "bin")
|
|
for _, dir := range []string{bundle, install, bin} {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, name := range []string{"docker-compose.yml", "traefik.yml", "traefik-dynamic.yml", ".env.example", "run-install-proof.sh", "upgrade.sh", "setup.sh"} {
|
|
content, err := os.ReadFile(repoFile("deploy", "provider-msp", name))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if name == ".env.example" {
|
|
// What scripts/build-release.sh stamps into a release bundle.
|
|
text := strings.Replace(string(content), "\nCONTROL_PLANE_IMAGE=\n", "\nCONTROL_PLANE_IMAGE=ghcr.io/rcourtman/pulse-control-plane:v6.6.0\n", 1)
|
|
text = strings.Replace(text, "\nCP_PULSE_IMAGE=\n", "\nCP_PULSE_IMAGE=ghcr.io/rcourtman/pulse:v6.6.0\n", 1)
|
|
content = []byte(text)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(bundle, name), content, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := os.WriteFile(filepath.Join(bundle, "VERSION"), []byte("6.6.0\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
env := "DOMAIN=msp.example.com\nCONTROL_PLANE_IMAGE=" + oldControlPlane + "\nCP_PULSE_IMAGE=" + oldRuntime + "\nPULSE_PROVIDER_MSP_DATA_DIR=/data\n"
|
|
if err := os.WriteFile(filepath.Join(install, ".env"), []byte(env), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(install, "upgrade.sh"), []byte("#!/bin/sh\necho old-upgrade-script\n"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dockerLog = filepath.Join(root, "docker.log")
|
|
// Log each call with the control-plane pin in .env and the image
|
|
// compose would use (a shell value wins over .env) at that moment.
|
|
fake := "#!/bin/sh\nfile=$(grep '^CONTROL_PLANE_IMAGE=' .env 2>/dev/null | cut -d= -f2)\n" +
|
|
"echo \"$* [env=${file}] [uses=${CONTROL_PLANE_IMAGE:-$file}]\" >> " + dockerLog + "\n" +
|
|
"case \"$*\" in\n" +
|
|
" 'buildx imagetools inspect '*' --format '*) echo '{\"digest\":\"" + newDigest + "\"}' ;;\n" +
|
|
" 'buildx imagetools inspect '*) echo 'Digest: " + newDigest + "' ;;\n" +
|
|
" *'backup create'*) echo 'archive_path=/data/backups/pre-upgrade.tar.gz' ;;\n" +
|
|
"esac\nexit 0\n"
|
|
if err := os.WriteFile(filepath.Join(bin, "docker"), []byte(fake), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return bundle, install, dockerLog
|
|
}
|
|
run := func(t *testing.T, bundle, install string, args ...string) string {
|
|
t.Helper()
|
|
cmd := exec.Command("bash", append([]string{filepath.Join(bundle, "upgrade.sh")}, args...)...)
|
|
cmd.Env = append(os.Environ(), "PATH="+filepath.Join(filepath.Dir(bundle), "bin")+":"+os.Getenv("PATH"), "PULSE_PROVIDER_MSP_INSTALL_DIR="+install)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("upgrade.sh %v: %v\n%s", args, err, output)
|
|
}
|
|
return string(output)
|
|
}
|
|
envOf := func(t *testing.T, install string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(filepath.Join(install, ".env"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
t.Run("upgrade", func(t *testing.T) {
|
|
bundle, install, dockerLog := setup(t)
|
|
output := run(t, bundle, install)
|
|
env := envOf(t, install)
|
|
for _, want := range []string{
|
|
"CONTROL_PLANE_IMAGE=ghcr.io/rcourtman/pulse-control-plane@" + newDigest,
|
|
"CP_PULSE_IMAGE=ghcr.io/rcourtman/pulse@" + newDigest,
|
|
"DOMAIN=msp.example.com",
|
|
} {
|
|
if !strings.Contains(env, want) {
|
|
t.Fatalf("upgraded .env lacks %q:\n%s", want, env)
|
|
}
|
|
}
|
|
backups, _ := filepath.Glob(filepath.Join(install, ".env.pre-upgrade-*"))
|
|
if len(backups) != 1 {
|
|
t.Fatalf("pre-upgrade .env copies = %v, want one", backups)
|
|
}
|
|
if old, _ := os.ReadFile(backups[0]); !strings.Contains(string(old), oldControlPlane) {
|
|
t.Fatal("pre-upgrade copy does not hold the previous pins")
|
|
}
|
|
installed, _ := os.ReadFile(filepath.Join(install, "upgrade.sh"))
|
|
shipped, _ := os.ReadFile(filepath.Join(bundle, "upgrade.sh"))
|
|
if string(installed) != string(shipped) {
|
|
t.Fatal("bundle upgrade.sh was not installed")
|
|
}
|
|
logBytes, _ := os.ReadFile(dockerLog)
|
|
lines := strings.Split(string(logBytes), "\n")
|
|
lineWith := func(needle string) string {
|
|
for _, line := range lines {
|
|
if strings.Contains(line, needle) {
|
|
return line
|
|
}
|
|
}
|
|
t.Fatalf("docker was never called with %q:\n%s", needle, logBytes)
|
|
return ""
|
|
}
|
|
// The check and the backup run the new release's control plane as a
|
|
// one-off while .env still holds the old pins; .env changes only
|
|
// before the new images start.
|
|
newControlPlane := "ghcr.io/rcourtman/pulse-control-plane@" + newDigest
|
|
for _, step := range []string{"control-plane provider-msp status", "provider-msp backup create"} {
|
|
line := lineWith(step)
|
|
if !strings.Contains(line, "[env="+oldControlPlane+"]") || !strings.Contains(line, "[uses="+newControlPlane+"]") {
|
|
t.Fatalf("%s should run the new control plane with .env untouched: %s", step, line)
|
|
}
|
|
}
|
|
// The new runtime image is pulled before status checks it is present.
|
|
pull := strings.Index(string(logBytes), "pull ghcr.io/rcourtman/pulse@"+newDigest)
|
|
status := strings.Index(string(logBytes), "control-plane provider-msp status")
|
|
if pull < 0 || status < 0 || pull > status {
|
|
t.Fatalf("the new tenant runtime image must be pulled before the status gate:\n%s", logBytes)
|
|
}
|
|
if line := lineWith("compose up -d traefik docker-socket-proxy control-plane"); !strings.Contains(line, "[env="+newControlPlane+"]") {
|
|
t.Fatalf("new images did not start on the new pins: %s", line)
|
|
}
|
|
if !strings.Contains(output, "provider_msp_upgrade_ok=true") || !strings.Contains(output, "provider_msp_upgrade_bundle_version=6.6.0") {
|
|
t.Fatalf("upgrade output:\n%s", output)
|
|
}
|
|
})
|
|
|
|
t.Run("keep image pins", func(t *testing.T) {
|
|
bundle, install, _ := setup(t)
|
|
run(t, bundle, install, "--keep-image-pins")
|
|
env := envOf(t, install)
|
|
if !strings.Contains(env, "CONTROL_PLANE_IMAGE="+oldControlPlane) || !strings.Contains(env, "CP_PULSE_IMAGE="+oldRuntime) {
|
|
t.Fatalf("--keep-image-pins changed the pins:\n%s", env)
|
|
}
|
|
})
|
|
|
|
t.Run("dry run changes nothing", func(t *testing.T) {
|
|
bundle, install, _ := setup(t)
|
|
output := run(t, bundle, install, "--dry-run")
|
|
if env := envOf(t, install); !strings.Contains(env, "CONTROL_PLANE_IMAGE="+oldControlPlane) {
|
|
t.Fatalf("dry run changed .env:\n%s", env)
|
|
}
|
|
if installed, _ := os.ReadFile(filepath.Join(install, "upgrade.sh")); string(installed) != "#!/bin/sh\necho old-upgrade-script\n" {
|
|
t.Fatal("dry run installed bundle files")
|
|
}
|
|
if !strings.Contains(output, "current="+oldControlPlane) || !strings.Contains(output, "target=ghcr.io/rcourtman/pulse-control-plane@"+newDigest) {
|
|
t.Fatalf("dry run did not print the pin plan:\n%s", output)
|
|
}
|
|
})
|
|
}
|