From f68d0ae0791efe1735074034603243c2f5655c92 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 2 Jun 2026 15:18:11 +0100 Subject: [PATCH] Add provider MSP first-time setup artifact --- deploy/provider-msp/.env.example | 10 + deploy/provider-msp/docker-compose.yml | 8 +- deploy/provider-msp/setup.sh | 420 ++++++++++++++++++ .../subsystems/deployment-installability.md | 9 + .../installtests/provider_msp_deploy_test.go | 45 +- 5 files changed, 486 insertions(+), 6 deletions(-) create mode 100755 deploy/provider-msp/setup.sh diff --git a/deploy/provider-msp/.env.example b/deploy/provider-msp/.env.example index 01f78533b..ad3e2a768 100644 --- a/deploy/provider-msp/.env.example +++ b/deploy/provider-msp/.env.example @@ -13,6 +13,8 @@ CP_PULSE_IMAGE=ghcr.io/rcourtman/pulse@sha256: # 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" \ diff --git a/deploy/provider-msp/docker-compose.yml b/deploy/provider-msp/docker-compose.yml index 8416322ac..34f20a916 100644 --- a/deploy/provider-msp/docker-compose.yml +++ b/deploy/provider-msp/docker-compose.yml @@ -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: diff --git a/deploy/provider-msp/setup.sh b/deploy/provider-msp/setup.sh new file mode 100755 index 000000000..025fee258 --- /dev/null +++ b/deploy/provider-msp/setup.sh @@ -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 </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 <"* ]]; 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 <