mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 12:47:49 +00:00
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
858 lines
34 KiB
Bash
Executable file
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 "$@"
|