mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 12:42:49 +00:00
37d1874904
Replace obsolete v6.2.1 guidance using retained signed v6.4.1 delivery and payload evidence. Limit privacy claims to the evaluated licence request, synchronize the shipped guide, and adapt the registered deployment regression and contract without claiming installed onboarding acceptance. Change-source: pulse-maintainer
596 lines
23 KiB
Go
596 lines
23 KiB
Go
package installtests
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
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:",
|
|
"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"`,
|
|
"eval_license_id=",
|
|
)
|
|
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")
|
|
}
|
|
}
|