Pulse/deploy/provider-msp/setup.sh
pulse-triage[bot] f3518d9b9b fix(provider-msp): qualify installer purchase guidance
Keep first-run evaluation and summary guidance conditional on actual Plan availability and confirmed active limits. Remove the ordinary manual-key purchase prompt while retaining the separate custom-license binding path, and update the installability contract and focused assertions in the same commit.

Change-source: pulse-maintainer
2026-09-24 04:52:30 +01:00

858 lines
34 KiB
Bash
Executable file

#!/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_DOCKER_SUBNET="${PULSE_PROVIDER_MSP_DOCKER_SUBNET:-172.30.0.0/24}"
PULSE_PROVIDER_MSP_DOCKER_SOCKET="${PULSE_PROVIDER_MSP_DOCKER_SOCKET:-/var/run/docker.sock}"
PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR="${PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR:-/var/lib/pulse-provider-msp/spacecheck/root}"
PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR="${PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR:-/var/lib/docker/.pulse-provider-msp-spacecheck}"
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:-}"
PULSE_PROVIDER_MSP_LICENSE_URL="${PULSE_PROVIDER_MSP_LICENSE_URL:-https://license.pulserelay.pro}"
PULSE_PROVIDER_MSP_SKIP_EVAL_LICENSE="${PULSE_PROVIDER_MSP_SKIP_EVAL_LICENSE:-0}"
PULSE_PROVIDER_MSP_EVAL_EMAIL="${PULSE_PROVIDER_MSP_EVAL_EMAIL:-}"
PULSE_PROVIDER_MSP_SIGNUP_SOURCE="${PULSE_PROVIDER_MSP_SIGNUP_SOURCE:-provider_msp_setup}"
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 openssl 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"
local root_spacecheck docker_spacecheck
root_spacecheck="$(provider_root_spacecheck_dir)"
docker_spacecheck="$(provider_docker_spacecheck_dir)"
log "creating storage space-check marker directories"
install -d -m 0700 "${root_spacecheck}"
install -d -m 0700 "${docker_spacecheck}"
}
ensure_docker_network() {
local network subnet existing_subnets
network="$(provider_docker_network)"
subnet="$(provider_docker_subnet)"
log "checking Docker network ${network}"
if ! docker network inspect "${network}" >/dev/null 2>&1; then
log "Docker network ${network} will be created by compose with subnet ${subnet}"
return 0
fi
existing_subnets="$(docker network inspect -f '{{range .IPAM.Config}}{{println .Subnet}}{{end}}' "${network}" 2>/dev/null | tr '\n' ',' | sed 's/,$//' || true)"
if [[ ",${existing_subnets}," != *",${subnet},"* ]]; then
die "Docker network ${network} exists with subnet(s) ${existing_subnets:-<none>}; expected ${subnet} so CP_TRUSTED_PROXY_CIDRS can trust Traefik without trusting every peer"
fi
}
block_container_metadata_service() {
if ! have iptables; then
log "iptables not found; skipping container metadata-service block"
return 0
fi
log "ensuring containers cannot reach cloud metadata service"
iptables -N DOCKER-USER 2>/dev/null || true
if ! iptables -C DOCKER-USER -d 169.254.169.254/32 -j REJECT >/dev/null 2>&1; then
iptables -I DOCKER-USER -d 169.254.169.254/32 -j REJECT
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"
"upgrade.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
- upgrade.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"
install -m 0755 "${src_dir}/upgrade.sh" "${PULSE_PROVIDER_MSP_INSTALL_DIR}/upgrade.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}}"
}
provider_docker_subnet() {
local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env"
local configured=""
if [[ -f "${env_path}" ]]; then
configured="$(env_value PULSE_PROVIDER_MSP_DOCKER_SUBNET "${env_path}")"
fi
echo "${configured:-${PULSE_PROVIDER_MSP_DOCKER_SUBNET}}"
}
provider_root_spacecheck_dir() {
local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env"
local configured=""
if [[ -f "${env_path}" ]]; then
configured="$(env_value PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR "${env_path}")"
fi
echo "${configured:-${PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR}}"
}
provider_docker_spacecheck_dir() {
local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env"
local configured=""
if [[ -f "${env_path}" ]]; then
configured="$(env_value PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR "${env_path}")"
fi
echo "${configured:-${PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR}}"
}
set_env_value() {
local key="$1"
local value="$2"
local env_path="${3:-${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env}"
local tmp
tmp="$(mktemp)"
if grep -q -E "^${key}=" "${env_path}"; then
awk -v key="${key}" -v value="${value}" 'BEGIN{done=0} $0 ~ "^" key "=" && done==0 { print key "=" value; done=1; next } { print }' "${env_path}" >"${tmp}"
else
cat "${env_path}" >"${tmp}"
printf '%s=%s\n' "${key}" "${value}" >>"${tmp}"
fi
cat "${tmp}" >"${env_path}"
rm -f "${tmp}"
}
# default_image_ref maps each image variable to the tag its digest is resolved
# from when the operator has not pinned one by hand.
default_image_ref() {
case "$1" in
TRAEFIK_IMAGE) echo "traefik:v3" ;;
DOCKER_SOCKET_PROXY_IMAGE) echo "tecnativa/docker-socket-proxy:latest" ;;
CONTROL_PLANE_IMAGE) echo "ghcr.io/rcourtman/pulse-control-plane:latest" ;;
CP_PULSE_IMAGE) echo "ghcr.io/rcourtman/pulse:latest" ;;
*) return 1 ;;
esac
}
# resolve_image_digest turns a tag into an immutable digest ref. Uses buildx
# imagetools, which the Docker install above provides, and which reads the
# registry without pulling the image.
resolve_image_digest() {
local ref="$1" manifest_json digest
manifest_json="$(docker buildx imagetools inspect "${ref}" --format '{{json .Manifest}}' 2>/dev/null || true)"
digest="$(printf '%s' "${manifest_json}" | jq -r 'if type == "object" then .digest // empty else empty end' 2>/dev/null || true)"
if [[ "${digest}" != sha256:* ]]; then
# Ubuntu's packaged Buildx and Docker's plugin have differed in which
# fields their Go template exposes. Keep a human-output fallback so an
# already-working Docker install is not rejected only because its Buildx
# formatter is older or distro-patched.
digest="$(docker buildx imagetools inspect "${ref}" 2>/dev/null | awk '$1 == "Digest:" {print $2; exit}' || true)"
fi
[[ "${digest}" == sha256:* ]] || return 1
printf '%s@%s\n' "${ref%:*}" "${digest}"
}
# ensure_image_pins resolves every tag-based image reference to an immutable
# digest. Operators may provide an exact release tag or a digest directly;
# blank and legacy <pin> values use the bundle defaults.
#
# The bundle used to ship four unfillable "@sha256:<pin>" placeholders that
# setup.sh then refused to run without, so the only way to obtain them was to
# ask us. All four images are publicly readable, so there was never anything
# to hand out; it just meant nobody could start without a conversation first.
#
# Still resolved to an immutable digest, not left on a tag, so a later tag
# mutation cannot silently change what a provider is running.
ensure_image_pins() {
local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env"
[[ -f "${env_path}" ]] || die "missing ${env_path}"
local key current ref resolved
for key in TRAEFIK_IMAGE DOCKER_SOCKET_PROXY_IMAGE CONTROL_PLANE_IMAGE CP_PULSE_IMAGE; do
current="$(env_value "${key}" "${env_path}")"
if [[ "${current}" == *@sha256:* && "${current}" != *"<pin>"* ]]; then
continue
fi
if [[ -n "${current}" && "${current}" != *"<pin>"* ]]; then
ref="${current}"
else
ref="$(default_image_ref "${key}")" || die "no default image ref for ${key}"
fi
log "resolving ${key} digest from ${ref}"
if ! resolved="$(resolve_image_digest "${ref}")"; then
die "could not resolve a digest for ${ref}
Set ${key} in ${env_path} by hand, or check this host can reach the registry."
fi
log " ${resolved}"
set_env_value "${key}" "${resolved}" "${env_path}"
done
}
# ensure_eval_license self-issues a capped evaluation license when the operator
# has not supplied one.
#
# An unlicensed control plane starts, but release-build client runtimes only
# trust entitlement leases chained to a Pulse-signed license, so its client
# workspaces would run without the capabilities being evaluated. Requesting one
# by email put a human round-trip in front of the first screen; this asks the
# license server directly with the public half of the key generated above.
#
# The private key never leaves this host. Failure is a warning, not a stop: the
# portal, provisioning and client isolation all work regardless, and an
# air-gapped operator can skip it outright.
ensure_eval_license() {
local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env"
[[ -f "${env_path}" ]] || die "missing ${env_path}"
if [[ -n "$(env_value CP_PROVIDER_MSP_LICENSE_FILE "${env_path}")" ]]; then
return 0
fi
local eval_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/provider-msp-eval-license.jwt"
if [[ -s "${eval_path}" ]]; then
log "reusing existing evaluation license ${eval_path}"
set_env_value CP_PROVIDER_MSP_LICENSE_FILE "./provider-msp-eval-license.jwt" "${env_path}"
return 0
fi
if truthy "${PULSE_PROVIDER_MSP_SKIP_EVAL_LICENSE}"; then
log "PULSE_PROVIDER_MSP_SKIP_EVAL_LICENSE set: staying unlicensed"
log " the portal and client isolation work, but client workspaces will not"
log " carry MSP capabilities until a license is installed"
return 0
fi
have curl || die "curl is required to request an evaluation license"
# Tolerate failure rather than abort: ensure_generated_secrets has already
# created the key, so this only trips on something unexpected, and an
# evaluation licence is never worth failing an otherwise good install.
#
# Tested with `if !` rather than `|| true` inside the substitution: the
# derive helper calls die, and `exit` in a subshell terminates it outright
# instead of yielding a status `||` could catch, so setup.sh would abort.
local public_key=""
if ! public_key="$(derive_lease_signing_public_key 2>/dev/null)"; then
public_key=""
fi
if [[ -z "${public_key}" ]]; then
log "warning: could not derive the lease signing public key; staying unlicensed"
return 0
fi
log "requesting a 2-client evaluation license from ${PULSE_PROVIDER_MSP_LICENSE_URL}"
local body response token
body="$(jq -cn \
--arg public_key "${public_key}" \
--arg email "${PULSE_PROVIDER_MSP_EVAL_EMAIL}" \
--arg signup_source "${PULSE_PROVIDER_MSP_SIGNUP_SOURCE}" \
'{entitlement_signing_public_key: $public_key, setup_stage: "images_ready"}
+ (if $email == "" then {} else {email: $email} end)
+ (if $signup_source == "" then {} else {signup_source: $signup_source} end)')"
response="$(curl -fsS --max-time 20 \
-H 'Content-Type: application/json' \
-d "${body}" \
"${PULSE_PROVIDER_MSP_LICENSE_URL%/}/v1/provider-msp/eval-license" 2>/dev/null || true)"
if [[ -z "${response}" ]]; then
log "warning: could not reach the license server"
log " continuing unlicensed. The portal and client isolation work, but client"
log " workspaces will not carry MSP capabilities. Re-run setup.sh to retry,"
log " or set CP_PROVIDER_MSP_LICENSE_FILE if you already hold a license."
return 0
fi
token="$(printf '%s' "${response}" | jq -r '.license // empty' 2>/dev/null || true)"
if [[ -z "${token}" ]]; then
log "warning: license server response contained no license; continuing unlicensed"
return 0
fi
printf '%s' "${token}" >"${eval_path}"
chmod 0600 "${eval_path}"
set_env_value CP_PROVIDER_MSP_LICENSE_FILE "./provider-msp-eval-license.jwt" "${env_path}"
local expires
expires="$(printf '%s' "${response}" | jq -r '.expires_at // empty' 2>/dev/null || true)"
log "evaluation license installed: 2 client workspaces${expires:+, expires ${expires}}"
log " Plan in your provider portal shows whether a paid upgrade is available"
log " keep within two clients until Plan confirms a higher active limit"
}
ensure_generated_secrets() {
local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env"
[[ -f "${env_path}" ]] || die "missing ${env_path}"
have openssl || die "openssl is required to generate provider MSP secrets"
if [[ -z "$(env_value CP_ADMIN_KEY "${env_path}")" ]]; then
log "generating CP_ADMIN_KEY"
set_env_value CP_ADMIN_KEY "$(openssl rand -hex 32)" "${env_path}"
fi
if [[ -z "$(env_value CP_ENTITLEMENT_SIGNING_PRIVATE_KEY "${env_path}")" ]]; then
log "generating CP_ENTITLEMENT_SIGNING_PRIVATE_KEY"
set_env_value CP_ENTITLEMENT_SIGNING_PRIVATE_KEY "$(openssl rand -base64 32 | tr -d '\n')" "${env_path}"
fi
chmod 0600 "${env_path}"
}
# derive_lease_signing_public_key prints the base64 Ed25519 public key for
# CP_ENTITLEMENT_SIGNING_PRIVATE_KEY. The provider MSP license must bind this
# exact key (entitlement_signing_public_key) or the control plane will refuse
# to start; include it when requesting your license. The private key never
# leaves this host.
derive_lease_signing_public_key() {
local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env"
[[ -f "${env_path}" ]] || die "missing ${env_path}"
have openssl || die "openssl is required to derive the lease signing public key"
local key_b64 key_len tmp_der
key_b64="$(env_value CP_ENTITLEMENT_SIGNING_PRIVATE_KEY "${env_path}")"
[[ -n "${key_b64}" ]] || die "CP_ENTITLEMENT_SIGNING_PRIVATE_KEY is not set; run setup.sh first"
key_len="$(printf '%s' "${key_b64}" | base64 -d 2>/dev/null | wc -c | tr -d ' ')"
case "${key_len}" in
64)
# 64-byte Ed25519 private key: the public key is the trailing 32 bytes.
printf '%s' "${key_b64}" | base64 -d | tail -c 32 | base64 | tr -d '\n'
;;
32)
# 32-byte seed: wrap in a PKCS#8 DER envelope and let openssl derive
# the public key (raw key = trailing 32 bytes of the SPKI DER).
tmp_der="$(mktemp)"
{
printf '\x30\x2e\x02\x01\x00\x30\x05\x06\x03\x2b\x65\x70\x04\x22\x04\x20'
printf '%s' "${key_b64}" | base64 -d
} >"${tmp_der}"
openssl pkey -inform DER -in "${tmp_der}" -pubout -outform DER 2>/dev/null | tail -c 32 | base64 | tr -d '\n'
rm -f "${tmp_der}"
;;
*)
die "CP_ENTITLEMENT_SIGNING_PRIVATE_KEY must decode to a 32-byte seed or 64-byte Ed25519 key (got ${key_len} bytes)"
;;
esac
}
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 the three values only you can supply:
- DOMAIN (client workspaces are served at https://<client-id>.DOMAIN)
- ACME_EMAIL
- CF_DNS_API_TOKEN (with the default ACME_DNS_PROVIDER=cloudflare; for any
other Traefik dnsChallenge provider, set ACME_DNS_PROVIDER and put that
provider's credential variables in dns-credentials.env)
Everything else has a working default. setup.sh resolves the image pins to
digests and generates CP_ADMIN_KEY and CP_ENTITLEMENT_SIGNING_PRIVATE_KEY while
they are blank.
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
}
# Traefik is the only container that needs DNS-01 credentials, and it must not
# receive the operator .env (that holds CP_ADMIN_KEY and the entitlement
# signing private key). Non-Cloudflare providers put their credential
# variables here; compose injects the file into the traefik container alone.
ensure_dns_credentials_file() {
local creds_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/dns-credentials.env"
if [[ -f "${creds_path}" ]]; then
chmod 0600 "${creds_path}" || true
return 0
fi
cat > "${creds_path}" <<'EOF'
# Credential variables for the ACME DNS-01 provider, injected only into the
# traefik container. With the default ACME_DNS_PROVIDER=cloudflare this file
# stays empty; CF_DNS_API_TOKEN in .env is passed through directly. For any
# other provider, set ACME_DNS_PROVIDER in .env to the Traefik dnsChallenge
# provider name and put that provider's variables here, e.g. for route53:
# AWS_ACCESS_KEY_ID=...
# AWS_SECRET_ACCESS_KEY=...
# AWS_REGION=...
EOF
chmod 0600 "${creds_path}"
}
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 CP_ENV TRAEFIK_IMAGE DOCKER_SOCKET_PROXY_IMAGE CONTROL_PLANE_IMAGE CP_ADMIN_KEY CP_PULSE_IMAGE 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 CP_ENTITLEMENT_SIGNING_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
local dns_provider creds_path
dns_provider="$(env_value ACME_DNS_PROVIDER "${env_path}")"
dns_provider="${dns_provider:-cloudflare}"
if [[ "${dns_provider}" == "cloudflare" ]]; then
if [[ -z "$(env_value CF_DNS_API_TOKEN "${env_path}")" ]]; then
die "CF_DNS_API_TOKEN is required with the default ACME_DNS_PROVIDER=cloudflare; for another provider set ACME_DNS_PROVIDER to a Traefik dnsChallenge provider name and put its credential variables in dns-credentials.env"
fi
else
creds_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/dns-credentials.env"
if [[ ! -f "${creds_path}" ]] || ! grep -Eq '^[A-Za-z_][A-Za-z0-9_]*=.+' "${creds_path}"; then
die "ACME_DNS_PROVIDER=${dns_provider}: put that provider's credential variables in ${creds_path} (see Traefik's dnsChallenge provider table for the variable names)"
fi
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 path_var path_value
for path_var in PULSE_PROVIDER_MSP_DATA_DIR PULSE_PROVIDER_MSP_DOCKER_SOCKET PULSE_PROVIDER_MSP_ROOT_SPACECHECK_DIR PULSE_PROVIDER_MSP_DOCKER_SPACECHECK_DIR; do
path_value="$(env_value "${path_var}" "${env_path}")"
if [[ "${path_value}" != /* ]]; then
die "${path_var} must be an absolute path"
fi
done
if [[ ! -S "$(env_value PULSE_PROVIDER_MSP_DOCKER_SOCKET "${env_path}")" ]]; then
die "PULSE_PROVIDER_MSP_DOCKER_SOCKET must point to a reachable Docker socket"
fi
local image_ref
for k in TRAEFIK_IMAGE DOCKER_SOCKET_PROXY_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 admin_key trial_key trusted_cidrs docker_subnet
admin_key="$(env_value CP_ADMIN_KEY "${env_path}")"
if [[ "${#admin_key}" -lt 32 ]]; then
die "CP_ADMIN_KEY must be at least 32 characters"
fi
trial_key="$(env_value CP_ENTITLEMENT_SIGNING_PRIVATE_KEY "${env_path}")"
if ! printf '%s' "${trial_key}" | base64 -d >/dev/null 2>&1; then
die "CP_ENTITLEMENT_SIGNING_PRIVATE_KEY must be valid base64"
fi
docker_subnet="$(env_value PULSE_PROVIDER_MSP_DOCKER_SUBNET "${env_path}")"
trusted_cidrs="$(env_value CP_TRUSTED_PROXY_CIDRS "${env_path}" | tr -d '[:space:]')"
if [[ ",${trusted_cidrs}," != *",${docker_subnet},"* ]]; then
die "CP_TRUSTED_PROXY_CIDRS must include PULSE_PROVIDER_MSP_DOCKER_SUBNET (${docker_subnet})"
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
# An empty CP_PROVIDER_MSP_LICENSE_FILE is evaluation mode, not a mistake.
#
# This used to be mandatory, which meant nobody could start the stack, create
# a client workspace, or see the portal until they had emailed for a licence
# and waited for a human to mint one. That put a round-trip with us in front
# of the first screen, and an isolation guarantee is the one claim a provider
# cannot evaluate from a screenshot.
#
# Unlicensed runs on msp_eval (2 client workspaces). Paid caps come from a
# valid licence, never from local configuration.
local license_file
license_file="$(env_value CP_PROVIDER_MSP_LICENSE_FILE "${env_path}")"
if [[ -z "${license_file}" ]]; then
log "no CP_PROVIDER_MSP_LICENSE_FILE set: evaluation mode, 2 client workspaces"
return 0
fi
if [[ "${license_file}" != /* ]]; then
license_file="${PULSE_PROVIDER_MSP_INSTALL_DIR}/${license_file}"
fi
if [[ ! -f "${license_file}" ]]; then
die "CP_PROVIDER_MSP_LICENSE_FILE is set but does not exist: ${license_file}
Leave it blank to run in evaluation mode (2 client workspaces), or place an
already issued license at that path. For a custom license, print the platform's
lease signing public key with ./setup.sh --print-lease-signing-public-key;
the license must bind that key or the control plane will refuse to start."
fi
}
validate_compose_config() {
log "validating compose config"
local license_file
license_file="$(env_value CP_PROVIDER_MSP_LICENSE_FILE "${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env")"
if [[ "${1:-}" == "--allow-missing-evaluation-license" && -z "${license_file}" ]]; then
# Compose requires a non-empty secret source even for a config-only
# parse. Use a non-secret placeholder for this pre-issuance validation;
# the normal validation after ensure_eval_license uses the real file.
(cd "${PULSE_PROVIDER_MSP_INSTALL_DIR}" && CP_PROVIDER_MSP_LICENSE_FILE=/dev/null docker compose config --quiet)
return 0
fi
(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"
local env_path="${PULSE_PROVIDER_MSP_INSTALL_DIR}/.env"
local key image_ref
for key in TRAEFIK_IMAGE DOCKER_SOCKET_PROXY_IMAGE CONTROL_PLANE_IMAGE CP_PULSE_IMAGE; do
image_ref="$(env_value "${key}" "${env_path}")"
[[ "${image_ref}" == *@sha256:* ]] || die "${key} is not digest-pinned before image pull"
docker pull "${image_ref}"
done
}
start_provider_services() {
# Leave the platform running. The next steps setup prints (bootstrap, then
# the portal sign-in link) only work against a running control plane; a
# setup that stopped at "prepared" handed a first-time provider a sign-in
# link that answered 404 until something else happened to start it.
log "starting provider MSP services"
(cd "${PULSE_PROVIDER_MSP_INSTALL_DIR}" && docker compose up -d traefik docker-socket-proxy control-plane)
local attempt
for attempt in $(seq 1 30); do
if (cd "${PULSE_PROVIDER_MSP_INSTALL_DIR}" && docker compose ps --services --status running 2>/dev/null) | grep -qx control-plane; then
return 0
fi
sleep 2
done
die "control plane did not reach running state; inspect: cd ${PULSE_PROVIDER_MSP_INSTALL_DIR} && docker compose logs 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 is running.
Paths:
- Deploy dir: ${PULSE_PROVIDER_MSP_INSTALL_DIR}
- Data dir: ${data_dir}
- Network: ${network}
Next step: create your operator account (prints your portal sign-in link):
cd ${PULSE_PROVIDER_MSP_INSTALL_DIR}
docker compose run --rm control-plane provider-msp bootstrap \\
--account-name "Example MSP" --owner-email owner@example.com
Prove the platform before the first real client:
./run-install-proof.sh --account-name "Example MSP" --owner-email owner@example.com
Portal (after bootstrap):
https://${domain}/portal
Plan: the evaluation covers two clients. Open Plan in the portal to see whether
a paid upgrade is available. Keep within two clients until Plan confirms a
higher active limit. A paid plan exposes Manage billing for changes or
cancellation.
Day 2: portal sessions last 7 days. Re-run the bootstrap command above any
time to print a fresh owner sign-in link, or use
docker compose run --rm control-plane provider-msp portal-link --email you@example.com
for any invited teammate. Set RESEND_API_KEY in .env to enable emailed
sign-in links instead.
EOF
}
main() {
need_root
if [[ "${1:-}" == "--print-lease-signing-public-key" ]]; then
ensure_env_file
ensure_generated_secrets
derive_lease_signing_public_key
printf '\n'
exit 0
fi
log "starting provider MSP first-time setup"
apt_install apt-transport-https
install_docker_ce
install_ops_tools
install_deploy_bundle
ensure_env_file
ensure_dns_credentials_file
ensure_generated_secrets
# After install_docker_ce, which provides the buildx used to read the
# registry, and before validation, which requires the pins to be set.
ensure_image_pins
validate_env_file
create_data_dirs
ensure_docker_network
block_container_metadata_service
validate_compose_config --allow-missing-evaluation-license
pull_provider_images
# Issue the evaluation only after the host is configured and the immutable
# images are reachable. This makes an issued evaluation a useful activation
# signal rather than a record created before setup can succeed.
ensure_eval_license
validate_compose_config
start_provider_services
run_install_proof_if_requested
print_summary
}
main "$@"