Add provider MSP first-time setup artifact

This commit is contained in:
rcourtman 2026-06-02 15:18:11 +01:00
parent 5505f930c6
commit f68d0ae079
5 changed files with 486 additions and 6 deletions

View file

@ -13,6 +13,8 @@ CP_PULSE_IMAGE=ghcr.io/rcourtman/pulse@sha256:<pin>
# Control plane
CP_ENV=production
CP_ADMIN_KEY=
PULSE_PROVIDER_MSP_DATA_DIR=/data
PULSE_PROVIDER_MSP_DOCKER_NETWORK=pulse-provider-msp
CP_PROVIDER_MSP_LICENSE_FILE=./provider-msp-license.jwt
CP_TRIAL_ACTIVATION_PRIVATE_KEY=
CP_TENANT_MEMORY_LIMIT=536870912
@ -32,6 +34,14 @@ RESEND_API_KEY=
PULSE_EMAIL_FROM=noreply@example.com
PULSE_EMAIL_REPLY_TO=support@example.com
# Fresh host setup:
# sudo -E ./setup.sh
#
# Optional one-command setup proof after .env is filled:
# sudo -E PULSE_PROVIDER_MSP_ACCOUNT_NAME="Example MSP" \
# PULSE_PROVIDER_MSP_OWNER_EMAIL=owner@example.com \
# ./setup.sh
#
# First owner bootstrap:
# docker compose run --rm control-plane provider-msp bootstrap \
# --account-name "Example MSP" \

View file

@ -22,7 +22,7 @@ services:
control-plane:
image: ${CONTROL_PLANE_IMAGE:?CONTROL_PLANE_IMAGE must be set to a digest-pinned image}
volumes:
- /data:/data
- ${PULSE_PROVIDER_MSP_DATA_DIR:-/data}:/data
- /:/host-root:ro
- /var/lib/docker:/host-var-lib-docker:ro
- /var/run/docker.sock:/var/run/docker.sock
@ -39,7 +39,7 @@ services:
- CP_ADMIN_KEY=${CP_ADMIN_KEY}
- CP_BASE_URL=https://${DOMAIN}
- CP_PULSE_IMAGE=${CP_PULSE_IMAGE}
- CP_DOCKER_NETWORK=pulse-provider-msp
- CP_DOCKER_NETWORK=${PULSE_PROVIDER_MSP_DOCKER_NETWORK:-pulse-provider-msp}
- CP_PROVIDER_MSP_LICENSE_FILE=/run/secrets/provider_msp_license
- CP_TENANT_MEMORY_LIMIT=${CP_TENANT_MEMORY_LIMIT:-536870912}
- CP_ALLOW_DOCKERLESS_PROVISIONING=${CP_ALLOW_DOCKERLESS_PROVISIONING:-false}
@ -65,7 +65,7 @@ services:
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.docker.network=pulse-provider-msp
- traefik.docker.network=${PULSE_PROVIDER_MSP_DOCKER_NETWORK:-pulse-provider-msp}
- traefik.http.routers.provider-control-plane.rule=Host(`${DOMAIN}`)
- traefik.http.routers.provider-control-plane.entrypoints=websecure
- traefik.http.routers.provider-control-plane.tls=true
@ -80,7 +80,7 @@ secrets:
networks:
pulse-provider-msp:
name: pulse-provider-msp
name: ${PULSE_PROVIDER_MSP_DOCKER_NETWORK:-pulse-provider-msp}
volumes:
acme-data:

420
deploy/provider-msp/setup.sh Executable file
View file

@ -0,0 +1,420 @@
#!/usr/bin/env bash
# Pulse Provider MSP - first-time host setup
# Run on a fresh Ubuntu 24.04 host as root.
set -euo pipefail
IFS=$'\n\t'
PULSE_PROVIDER_MSP_INSTALL_DIR="${PULSE_PROVIDER_MSP_INSTALL_DIR:-/opt/pulse-provider-msp}"
PULSE_PROVIDER_MSP_DATA_DIR="${PULSE_PROVIDER_MSP_DATA_DIR:-/data}"
PULSE_PROVIDER_MSP_DOCKER_NETWORK="${PULSE_PROVIDER_MSP_DOCKER_NETWORK:-pulse-provider-msp}"
PULSE_PROVIDER_MSP_BUNDLE_URL="${PULSE_PROVIDER_MSP_BUNDLE_URL:-}"
PULSE_PROVIDER_MSP_EXPECT_ENV="${PULSE_PROVIDER_MSP_EXPECT_ENV:-production}"
PULSE_PROVIDER_MSP_SKIP_PULL="${PULSE_PROVIDER_MSP_SKIP_PULL:-0}"
PULSE_PROVIDER_MSP_RUN_INSTALL_PROOF="${PULSE_PROVIDER_MSP_RUN_INSTALL_PROOF:-auto}"
PULSE_PROVIDER_MSP_ACCOUNT_NAME="${PULSE_PROVIDER_MSP_ACCOUNT_NAME:-}"
PULSE_PROVIDER_MSP_OWNER_EMAIL="${PULSE_PROVIDER_MSP_OWNER_EMAIL:-}"
log() {
echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"
}
die() {
echo "error: $*" >&2
exit 1
}
have() { command -v "$1" >/dev/null 2>&1; }
need_root() {
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
die "run as root (or: sudo -E bash setup.sh)"
fi
}
apt_install() {
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
apt-get install -y --no-install-recommends "$@"
}
install_docker_ce() {
if have docker && docker --version >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
log "docker and compose already installed"
return 0
fi
log "installing Docker CE and compose plugin"
apt_install ca-certificates curl gnupg lsb-release
install -m 0755 -d /etc/apt/keyrings
if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
fi
local arch codename
arch="$(dpkg --print-architecture)"
codename="$(. /etc/os-release && echo "${VERSION_CODENAME}")"
cat >/etc/apt/sources.list.d/docker.list <<EOF
deb [arch=${arch} signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${codename} stable
EOF
apt-get update -y
apt-get install -y --no-install-recommends \
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
}
install_ops_tools() {
log "installing ops tools"
apt_install jq rsync sqlite3 rclone s3cmd
}
create_data_dirs() {
local data_dir
data_dir="$(provider_data_dir)"
log "creating provider MSP data directories under ${data_dir}"
install -d -m 0700 "${data_dir}"
install -d -m 0700 "${data_dir}/tenants"
install -d -m 0700 "${data_dir}/control-plane"
install -d -m 0700 "${data_dir}/backups"
install -d -m 0700 "${data_dir}/backups/provider-msp"
}
ensure_docker_network() {
local network
network="$(provider_docker_network)"
log "ensuring Docker network ${network} exists"
if ! docker network inspect "${network}" >/dev/null 2>&1; then
docker network create "${network}" >/dev/null
fi
}
script_dir_best_effort() {
if [[ -n "${BASH_SOURCE[0]:-}" && -e "${BASH_SOURCE[0]}" ]]; then
(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
return 0
fi
return 1
}
install_deploy_bundle() {
log "installing provider MSP deploy bundle to ${PULSE_PROVIDER_MSP_INSTALL_DIR}"
install -d -m 0755 "${PULSE_PROVIDER_MSP_INSTALL_DIR}"
local src_dir=""
if src_dir="$(script_dir_best_effort)"; then
:
else
src_dir=""
fi
local required=(
"docker-compose.yml"
"traefik.yml"
"traefik-dynamic.yml"
".env.example"
"run-install-proof.sh"
)
if [[ -n "${src_dir}" ]]; then
local f
for f in "${required[@]}"; do
[[ -f "${src_dir}/${f}" ]] || src_dir=""
done
fi
if [[ -z "${src_dir}" && -n "${PULSE_PROVIDER_MSP_BUNDLE_URL}" ]]; then
log "deploy bundle not found locally; downloading PULSE_PROVIDER_MSP_BUNDLE_URL"
local tmp
tmp="$(mktemp -d)"
# shellcheck disable=SC2064
trap "rm -rf \"${tmp}\"" EXIT
curl -fsSL "${PULSE_PROVIDER_MSP_BUNDLE_URL}" -o "${tmp}/bundle.tgz"
tar -xzf "${tmp}/bundle.tgz" -C "${tmp}"
local cand ok f
while IFS= read -r cand; do
[[ -n "${cand}" ]] || continue
local d
d="$(dirname "${cand}")"
ok="1"
for f in "${required[@]}"; do
[[ -f "${d}/${f}" ]] || ok="0"
done
if [[ "${ok}" == "1" ]]; then
src_dir="${d}"
break
fi
done < <(find "${tmp}" -type f -name docker-compose.yml -print 2>/dev/null || true)
fi
if [[ -z "${src_dir}" ]]; then
cat >&2 <<'EOF'
error: missing deploy bundle files next to setup.sh.
This script needs these files present on disk:
- docker-compose.yml
- traefik.yml
- traefik-dynamic.yml
- .env.example
- run-install-proof.sh
Run it from deploy/provider-msp/, or set PULSE_PROVIDER_MSP_BUNDLE_URL to a
tar.gz containing those files.
EOF
exit 1
fi
install -m 0644 "${src_dir}/docker-compose.yml" "${PULSE_PROVIDER_MSP_INSTALL_DIR}/docker-compose.yml"
install -m 0644 "${src_dir}/traefik.yml" "${PULSE_PROVIDER_MSP_INSTALL_DIR}/traefik.yml"
install -m 0644 "${src_dir}/traefik-dynamic.yml" "${PULSE_PROVIDER_MSP_INSTALL_DIR}/traefik-dynamic.yml"
install -m 0644 "${src_dir}/.env.example" "${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env.example"
install -m 0755 "${src_dir}/run-install-proof.sh" "${PULSE_PROVIDER_MSP_INSTALL_DIR}/run-install-proof.sh"
}
env_value() {
local key="$1"
local env_path="${2:-${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env}"
local value
value="$(grep -E "^${key}=" "${env_path}" | tail -n 1 | cut -d= -f2- || true)"
value="${value%\"}"; value="${value#\"}"
value="${value%\'}"; value="${value#\'}"
echo "${value}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'
}
provider_data_dir() {
local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env"
local configured=""
if [[ -f "${env_path}" ]]; then
configured="$(env_value PULSE_PROVIDER_MSP_DATA_DIR "${env_path}")"
fi
echo "${configured:-${PULSE_PROVIDER_MSP_DATA_DIR}}"
}
provider_docker_network() {
local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env"
local configured=""
if [[ -f "${env_path}" ]]; then
configured="$(env_value PULSE_PROVIDER_MSP_DOCKER_NETWORK "${env_path}")"
fi
echo "${configured:-${PULSE_PROVIDER_MSP_DOCKER_NETWORK}}"
}
truthy() {
case "$(echo "$1" | tr '[:upper:]' '[:lower:]')" in
true|1|yes|on) return 0 ;;
*) return 1 ;;
esac
}
falsy() {
case "$(echo "$1" | tr '[:upper:]' '[:lower:]')" in
false|0|no|off) return 0 ;;
*) return 1 ;;
esac
}
ensure_env_file() {
local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env"
if [[ -f "${env_path}" ]]; then
chmod 0600 "${env_path}" || true
return 0
fi
log "no ${env_path}; creating from .env.example"
cp -n "${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env.example" "${env_path}"
chmod 0600 "${env_path}"
cat <<EOF
Created ${env_path} from .env.example.
Edit it now and set required values:
- DOMAIN
- ACME_EMAIL
- CF_DNS_API_TOKEN
- TRAEFIK_IMAGE (digest pinned)
- CONTROL_PLANE_IMAGE (digest pinned)
- CP_PULSE_IMAGE (digest pinned)
- CP_ADMIN_KEY
- PULSE_PROVIDER_MSP_DATA_DIR
- PULSE_PROVIDER_MSP_DOCKER_NETWORK
- CP_PROVIDER_MSP_LICENSE_FILE
- CP_TRIAL_ACTIVATION_PRIVATE_KEY
EOF
if [[ -t 0 ]]; then
read -r -p "Press Enter to continue after editing ${env_path}..." _
else
die "non-interactive run: edit ${env_path} then re-run setup.sh"
fi
}
validate_env_file() {
local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env"
[[ -f "${env_path}" ]] || die "missing ${env_path}"
local expected_env cp_env
expected_env="$(echo "${PULSE_PROVIDER_MSP_EXPECT_ENV}" | tr '[:upper:]' '[:lower:]' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
case "${expected_env}" in
production|staging) ;;
*) die "PULSE_PROVIDER_MSP_EXPECT_ENV must be production or staging (got '${PULSE_PROVIDER_MSP_EXPECT_ENV}')" ;;
esac
local missing=()
local k v
for k in DOMAIN ACME_EMAIL CF_DNS_API_TOKEN CP_ENV TRAEFIK_IMAGE CONTROL_PLANE_IMAGE CP_ADMIN_KEY CP_PULSE_IMAGE PULSE_PROVIDER_MSP_DATA_DIR PULSE_PROVIDER_MSP_DOCKER_NETWORK CP_PROVIDER_MSP_LICENSE_FILE CP_TRIAL_ACTIVATION_PRIVATE_KEY CP_TENANT_MEMORY_LIMIT CP_ALLOW_DOCKERLESS_PROVISIONING CP_STORAGE_GUARDRAILS_ENABLED CP_STORAGE_MIN_ROOT_AVAILABLE CP_STORAGE_MIN_DATA_AVAILABLE CP_STORAGE_MIN_DOCKER_AVAILABLE CP_STORAGE_MAX_DOCKER_BUILD_CACHE CP_PROOF_TENANT_MAX_AGE CP_PROOF_TENANT_MATCHERS CP_REQUIRE_EMAIL_PROVIDER PULSE_EMAIL_FROM PULSE_EMAIL_REPLY_TO; do
v="$(env_value "${k}" "${env_path}")"
if [[ -z "${v}" ]]; then
missing+=("${k}")
fi
done
if [[ "${#missing[@]}" -ne 0 ]]; then
die "missing required values in ${env_path}: ${missing[*]}"
fi
cp_env="$(env_value CP_ENV "${env_path}" | tr '[:upper:]' '[:lower:]')"
if [[ "${cp_env}" != "${expected_env}" ]]; then
die "CP_ENV must be '${expected_env}' for this setup run (got '${cp_env}')"
fi
local image_ref
for k in TRAEFIK_IMAGE CONTROL_PLANE_IMAGE CP_PULSE_IMAGE; do
image_ref="$(env_value "${k}" "${env_path}")"
if [[ "${image_ref}" != *@sha256:* || "${image_ref}" == *"<pin>"* ]]; then
die "${k} must be an immutable digest ref (expected ...@sha256:...)"
fi
done
local forbidden forbidden_value
for forbidden in STRIPE_API_KEY STRIPE_WEBHOOK_SECRET CP_TRIAL_SIGNUP_PRICE_ID CP_PUBLIC_CLOUD_SIGNUP_ENABLED CP_MSP_STARTER_PRICE_ID CP_MSP_GROWTH_PRICE_ID CP_MSP_SCALE_PRICE_ID; do
forbidden_value="$(env_value "${forbidden}" "${env_path}")"
if [[ -n "${forbidden_value}" ]]; then
die "${forbidden} must not be configured in provider-hosted MSP mode"
fi
done
if ! falsy "$(env_value CP_ALLOW_DOCKERLESS_PROVISIONING "${env_path}")"; then
die "CP_ALLOW_DOCKERLESS_PROVISIONING must be false for provider-hosted MSP deploys"
fi
if ! truthy "$(env_value CP_STORAGE_GUARDRAILS_ENABLED "${env_path}")"; then
die "CP_STORAGE_GUARDRAILS_ENABLED must be true for provider-hosted MSP deploys"
fi
local proof_matchers required_matcher
proof_matchers="$(env_value CP_PROOF_TENANT_MATCHERS "${env_path}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
for required_matcher in proof canary rehearsal msp_prod ownerseed owner_seed; do
if [[ ",${proof_matchers}," != *",${required_matcher},"* ]]; then
die "CP_PROOF_TENANT_MATCHERS must include '${required_matcher}'"
fi
done
local require_email
require_email="$(env_value CP_REQUIRE_EMAIL_PROVIDER "${env_path}")"
if ! truthy "${require_email}" && ! falsy "${require_email}"; then
die "CP_REQUIRE_EMAIL_PROVIDER must be an explicit boolean value"
fi
if truthy "${require_email}" && [[ -z "$(env_value RESEND_API_KEY "${env_path}")" ]]; then
die "RESEND_API_KEY is required when CP_REQUIRE_EMAIL_PROVIDER=true"
fi
local license_file
license_file="$(env_value CP_PROVIDER_MSP_LICENSE_FILE "${env_path}")"
if [[ "${license_file}" != /* ]]; then
license_file="${PULSE_PROVIDER_MSP_INSTALL_DIR}/${license_file}"
fi
[[ -f "${license_file}" ]] || die "CP_PROVIDER_MSP_LICENSE_FILE does not exist: ${license_file}"
}
validate_compose_config() {
log "validating compose config"
(cd "${PULSE_PROVIDER_MSP_INSTALL_DIR}" && docker compose config --quiet)
}
pull_provider_images() {
if truthy "${PULSE_PROVIDER_MSP_SKIP_PULL}"; then
log "skipping image pull because PULSE_PROVIDER_MSP_SKIP_PULL=${PULSE_PROVIDER_MSP_SKIP_PULL}"
return 0
fi
log "pulling provider MSP images"
(cd "${PULSE_PROVIDER_MSP_INSTALL_DIR}" && docker compose pull traefik control-plane)
}
run_install_proof_if_requested() {
local mode
mode="$(echo "${PULSE_PROVIDER_MSP_RUN_INSTALL_PROOF}" | tr '[:upper:]' '[:lower:]')"
case "${mode}" in
auto|true|1|yes|on|false|0|no|off) ;;
*) die "PULSE_PROVIDER_MSP_RUN_INSTALL_PROOF must be auto, true, or false" ;;
esac
if falsy "${mode}"; then
return 0
fi
if [[ -z "${PULSE_PROVIDER_MSP_ACCOUNT_NAME}" || -z "${PULSE_PROVIDER_MSP_OWNER_EMAIL}" ]]; then
if truthy "${mode}"; then
die "PULSE_PROVIDER_MSP_ACCOUNT_NAME and PULSE_PROVIDER_MSP_OWNER_EMAIL are required when PULSE_PROVIDER_MSP_RUN_INSTALL_PROOF=true"
fi
return 0
fi
log "running provider MSP install proof"
(
cd "${PULSE_PROVIDER_MSP_INSTALL_DIR}"
PROVIDER_MSP_ACCOUNT_NAME="${PULSE_PROVIDER_MSP_ACCOUNT_NAME}" \
PROVIDER_MSP_OWNER_EMAIL="${PULSE_PROVIDER_MSP_OWNER_EMAIL}" \
./run-install-proof.sh
)
}
print_summary() {
local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env"
local domain data_dir network
domain="$(env_value DOMAIN "${env_path}")"
data_dir="$(provider_data_dir)"
network="$(provider_docker_network)"
cat <<EOF
Pulse Provider MSP setup prepared.
Paths:
- Deploy dir: ${PULSE_PROVIDER_MSP_INSTALL_DIR}
- Data dir: ${data_dir}
- Network: ${network}
Proof:
cd ${PULSE_PROVIDER_MSP_INSTALL_DIR}
./run-install-proof.sh --account-name "Example MSP" --owner-email owner@example.com
Portal:
https://${domain}/
EOF
}
main() {
need_root
log "starting provider MSP first-time setup"
apt_install apt-transport-https
install_docker_ce
install_ops_tools
install_deploy_bundle
ensure_env_file
validate_env_file
create_data_dirs
ensure_docker_network
validate_compose_config
pull_provider_images
run_install_proof_if_requested
print_summary
}
main "$@"

View file

@ -150,6 +150,15 @@ surfaces.
provider images, run the one-off `provider-msp install-proof` command through
the packaged control-plane service, start the long-running provider stack,
and finish with `provider-msp status`.
`deploy/provider-msp/setup.sh` is the first-time provider host setup
artifact. It must install the Docker/compose host prerequisites, create the
provider data, backup, and Docker-network layout, copy the provider MSP
deploy bundle into a stable operator directory, create a private `.env` from
the provider template when needed, fail closed on placeholder image refs,
missing signed MSP license files, Dockerless production provisioning, disabled
storage guardrails, or Stripe/cloud-signup variables, validate compose, and
optionally hand off to `run-install-proof.sh` when the provider account name
and owner email are supplied.
`pulse-control-plane provider-msp status` is the non-mutating operational
companion to that proof: it must report registry readiness, tenant
state/health counts, stuck provisioning workspaces, Docker runtime

View file

@ -23,9 +23,10 @@ func TestProviderMSPDeployComposeIsProviderModeAndStripeFree(t *testing.T) {
assertContainsAll(t, text,
"CP_CONTROL_PLANE_MODE=provider_hosted_msp",
"CP_PROVIDER_MSP_LICENSE_FILE=/run/secrets/provider_msp_license",
"CP_DOCKER_NETWORK=pulse-provider-msp",
"CP_DOCKER_NETWORK=${PULSE_PROVIDER_MSP_DOCKER_NETWORK:-pulse-provider-msp}",
"${PULSE_PROVIDER_MSP_DATA_DIR:-/data}:/data",
"provider_msp_license:",
"pulse-provider-msp:",
"name: ${PULSE_PROVIDER_MSP_DOCKER_NETWORK:-pulse-provider-msp}",
)
assertNotContainsAny(t, text,
"STRIPE_",
@ -45,8 +46,11 @@ func TestProviderMSPDeployEnvExampleMatchesBootstrapPath(t *testing.T) {
text := string(envBytes)
assertContainsAll(t, text,
"CP_ENV=production",
"PULSE_PROVIDER_MSP_DATA_DIR=/data",
"PULSE_PROVIDER_MSP_DOCKER_NETWORK=pulse-provider-msp",
"CP_PROVIDER_MSP_LICENSE_FILE=./provider-msp-license.jwt",
"CP_TRIAL_ACTIVATION_PRIVATE_KEY=",
"sudo -E ./setup.sh",
"docker compose run --rm control-plane provider-msp bootstrap",
"docker compose run --rm control-plane provider-msp preflight",
"docker compose run --rm control-plane provider-msp status",
@ -74,6 +78,43 @@ func TestProviderMSPDeployEnvExampleMatchesBootstrapPath(t *testing.T) {
)
}
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",
"CP_PROVIDER_MSP_LICENSE_FILE",
"CP_TRIAL_ACTIVATION_PRIVATE_KEY",
"PULSE_PROVIDER_MSP_DATA_DIR",
"PULSE_PROVIDER_MSP_DOCKER_NETWORK",
"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 compose pull traefik control-plane",
"PULSE_PROVIDER_MSP_ACCOUNT_NAME",
"PULSE_PROVIDER_MSP_OWNER_EMAIL",
"./run-install-proof.sh",
)
}
func TestProviderMSPInstallProofRunnerMatchesComposeContract(t *testing.T) {
scriptPath := repoFile("deploy", "provider-msp", "run-install-proof.sh")
result := exec.Command("bash", "-n", scriptPath)