diff --git a/build/ansible/roles/nginx/files/install-pmm-client.sh b/build/ansible/roles/nginx/files/install-pmm-client.sh new file mode 100755 index 00000000000..fee1ced7bc1 --- /dev/null +++ b/build/ansible/roles/nginx/files/install-pmm-client.sh @@ -0,0 +1,673 @@ +#!/usr/bin/env bash + +set -euo pipefail + +log() { + echo "[install-pmm-client] $*" +} + +error() { + echo "[install-pmm-client] ERROR: $*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Usage: install-pmm-client.sh [options] + +Global options: + --pmm-server-url URL PMM server URL (supports service_token userinfo) + --pmm-server-insecure-tls Use --server-insecure-tls for pmm-admin config + --tech TECH One of: mysql, postgresql, mongodb, valkey + --node-name NAME Node name for pmm-admin config + --node-address ADDRESS Node address for pmm-admin config + --force Pass --force to pmm-admin config (removes existing node name and its services on the server, then registers again) + +Generic DB options (mapped per technology): + --db-user USER + --db-password PASSWORD + --db-host HOST + --db-port PORT + --db-name NAME DB name for PostgreSQL + --db-address HOST:PORT Explicit service address + --db-service-name NAME PMM service name + --db-auth-db NAME MongoDB auth database + --db-socket PATH Socket path for MySQL/PostgreSQL/MongoDB/Valkey + +Environment variables are also supported. +Priority is: flags > env vars > interactive prompt. +When stdin is a terminal, database prompts are skipped if credentials are already +set from flags or environment (DB_USER / DB_PASSWORD and per-tech MYSQL_*, +POSTGRESQL_* / … after apply_generic_inputs). Use sudo -E bash … when running +as root so your exports reach the script. + +pmm-agent runtime knobs (env only): + PMM_AGENT_CONFIG_FILE Path to pmm-agent.yaml (default: /usr/local/percona/pmm/config/pmm-agent.yaml) + PMM_AGENT_LISTEN_HOST Host the local API binds to (default: 127.0.0.1) + PMM_AGENT_LISTEN_PORT Port the local API binds to (default: 7777) + PMM_AGENT_LOG_FILE Log file when started without systemd (default: /var/log/pmm-agent.log) + PMM_AGENT_START_TIMEOUT_SECS Seconds to wait for the local API after start (default: 30) +EOF +} + +PMM_SERVER_URL="${PMM_SERVER_URL:-}" +PMM_SERVER_INSECURE_TLS="${PMM_SERVER_INSECURE_TLS:-0}" +TECH="${TECH:-}" +NODE_NAME="${NODE_NAME:-}" +NODE_ADDRESS="${NODE_ADDRESS:-}" +PMM_CONFIG_FORCE="${PMM_CONFIG_FORCE:-0}" + +DB_USER="${DB_USER:-}" +DB_PASSWORD="${DB_PASSWORD:-}" +DB_HOST="${DB_HOST:-}" +DB_PORT="${DB_PORT:-}" +DB_NAME="${DB_NAME:-}" +DB_ADDRESS="${DB_ADDRESS:-}" +DB_SERVICE_NAME="${DB_SERVICE_NAME:-}" +DB_AUTH_DB="${DB_AUTH_DB:-}" +DB_SOCKET="${DB_SOCKET:-}" + +MYSQL_USERNAME="${MYSQL_USERNAME:-}" +MYSQL_PASSWORD="${MYSQL_PASSWORD:-}" +MYSQL_HOST="${MYSQL_HOST:-}" +MYSQL_PORT="${MYSQL_PORT:-}" +MYSQL_ADDRESS="${MYSQL_ADDRESS:-}" +MYSQL_SERVICE_NAME="${MYSQL_SERVICE_NAME:-}" +MYSQL_SOCKET="${MYSQL_SOCKET:-}" + +POSTGRESQL_USERNAME="${POSTGRESQL_USERNAME:-}" +POSTGRESQL_PASSWORD="${POSTGRESQL_PASSWORD:-}" +POSTGRESQL_HOST="${POSTGRESQL_HOST:-}" +POSTGRESQL_PORT="${POSTGRESQL_PORT:-}" +POSTGRESQL_ADDRESS="${POSTGRESQL_ADDRESS:-}" +POSTGRESQL_SERVICE_NAME="${POSTGRESQL_SERVICE_NAME:-}" +POSTGRESQL_DATABASE="${POSTGRESQL_DATABASE:-}" +POSTGRESQL_SOCKET="${POSTGRESQL_SOCKET:-}" + +MONGODB_USERNAME="${MONGODB_USERNAME:-}" +MONGODB_PASSWORD="${MONGODB_PASSWORD:-}" +MONGODB_HOST="${MONGODB_HOST:-}" +MONGODB_PORT="${MONGODB_PORT:-}" +MONGODB_ADDRESS="${MONGODB_ADDRESS:-}" +MONGODB_SERVICE_NAME="${MONGODB_SERVICE_NAME:-}" +MONGODB_AUTH_DB="${MONGODB_AUTH_DB:-}" +MONGODB_SOCKET="${MONGODB_SOCKET:-}" + +VALKEY_USERNAME="${VALKEY_USERNAME:-}" +VALKEY_PASSWORD="${VALKEY_PASSWORD:-}" +VALKEY_HOST="${VALKEY_HOST:-}" +VALKEY_PORT="${VALKEY_PORT:-}" +VALKEY_ADDRESS="${VALKEY_ADDRESS:-}" +VALKEY_SERVICE_NAME="${VALKEY_SERVICE_NAME:-}" +VALKEY_SOCKET="${VALKEY_SOCKET:-}" + +# pmm-agent runtime knobs. Defaults match the Debian/RPM package layout. +# Override via env if the package places things elsewhere or you need to +# bind the local API on a non-default host/port. +PMM_AGENT_CONFIG_FILE="${PMM_AGENT_CONFIG_FILE:-/usr/local/percona/pmm/config/pmm-agent.yaml}" +PMM_AGENT_LISTEN_HOST="${PMM_AGENT_LISTEN_HOST:-127.0.0.1}" +PMM_AGENT_LISTEN_PORT="${PMM_AGENT_LISTEN_PORT:-7777}" +PMM_AGENT_LOG_FILE="${PMM_AGENT_LOG_FILE:-/var/log/pmm-agent.log}" +PMM_AGENT_START_TIMEOUT_SECS="${PMM_AGENT_START_TIMEOUT_SECS:-30}" + +while [ $# -gt 0 ]; do + case "$1" in + --help|-h) + usage + exit 0 + ;; + --pmm-server-url) + PMM_SERVER_URL="${2:-}" + shift 2 + ;; + --pmm-server-insecure-tls) + PMM_SERVER_INSECURE_TLS=1 + shift + ;; + --tech) + TECH="${2:-}" + shift 2 + ;; + --node-name) + NODE_NAME="${2:-}" + shift 2 + ;; + --node-address) + NODE_ADDRESS="${2:-}" + shift 2 + ;; + --force) + PMM_CONFIG_FORCE=1 + shift + ;; + --db-user) + DB_USER="${2:-}" + shift 2 + ;; + --db-password) + DB_PASSWORD="${2:-}" + shift 2 + ;; + --db-host) + DB_HOST="${2:-}" + shift 2 + ;; + --db-port) + DB_PORT="${2:-}" + shift 2 + ;; + --db-name) + DB_NAME="${2:-}" + shift 2 + ;; + --db-address) + DB_ADDRESS="${2:-}" + shift 2 + ;; + --db-service-name) + DB_SERVICE_NAME="${2:-}" + shift 2 + ;; + --db-auth-db) + DB_AUTH_DB="${2:-}" + shift 2 + ;; + --db-socket) + DB_SOCKET="${2:-}" + shift 2 + ;; + *) + error "Unknown option: $1. Use --help for usage." + ;; + esac +done + +require_root() { + if [ "${EUID}" -ne 0 ]; then + error "Run this script as root (for package installation). Example: curl -fsSLk ... | sudo -E env ... bash -s --" + fi +} + +prompt_if_empty() { + local var_name="$1" + local prompt_label="$2" + local secret="${3:-0}" + local hint="${4:-}" + local value="${!var_name:-}" + + if [ -n "${value}" ]; then + return + fi + + if [ "${secret}" = "1" ]; then + read -r -s -p "${prompt_label}: " value + echo + else + read -r -p "${prompt_label}: " value + fi + + if [ -z "${value}" ]; then + if [ -n "${hint}" ]; then + error "${prompt_label} is required. ${hint}" + else + error "${prompt_label} is required." + fi + fi + + printf -v "${var_name}" '%s' "${value}" +} + +detect_os_family() { + if [ -f /etc/os-release ]; then + # shellcheck source=/dev/null + . /etc/os-release + case "${ID:-}" in + debian|ubuntu) + echo "debian" + return + ;; + rhel|ol|amzn|rocky|almalinux|centos|fedora) + echo "el" + return + ;; + esac + fi + if [ -f /etc/redhat-release ] || [ -f /etc/oracle-release ]; then + echo "el" + return + fi + if [ -f /etc/debian_version ]; then + echo "debian" + return + fi + error "Unsupported OS. Supported 64-bit Linux: Debian, Ubuntu (DEB) and RHEL, Oracle Linux, Amazon Linux (RPM)." +} + +install_percona_repo_el() { + if command -v percona-release >/dev/null 2>&1; then + return + fi + if command -v dnf >/dev/null 2>&1; then + dnf install -y https://repo.percona.com/yum/percona-release-latest.noarch.rpm + elif command -v yum >/dev/null 2>&1; then + yum install -y https://repo.percona.com/yum/percona-release-latest.noarch.rpm + else + error "Neither dnf nor yum was found." + fi +} + +install_percona_repo_debian() { + if command -v percona-release >/dev/null 2>&1; then + return + fi + apt-get update -y + apt-get install -y curl gnupg lsb-release + local deb_path="/tmp/percona-release_latest.generic_all.deb" + curl -fsSL -o "${deb_path}" "https://repo.percona.com/apt/percona-release_latest.generic_all.deb" + apt-get install -y "${deb_path}" + rm -f "${deb_path}" +} + +install_pmm_client() { + if command -v pmm-admin >/dev/null 2>&1; then + log "pmm-admin already installed; skipping package install." + return + fi + + local os_family + os_family="$(detect_os_family)" + log "Detected OS family: ${os_family}" + + if [ "${os_family}" = "el" ]; then + install_percona_repo_el + percona-release enable pmm3-client release || true + if command -v dnf >/dev/null 2>&1; then + dnf install -y pmm-client + else + yum install -y pmm-client + fi + return + fi + + install_percona_repo_debian + percona-release enable pmm3-client release || true + apt-get update -y + apt-get install -y pmm-client +} + +# Returns 0 if a real systemd is the init (i.e. systemctl can actually start units). +# Mere presence of systemctl is not enough — Docker images often ship the binary +# without PID 1 being systemd, and `systemctl start` then no-ops or fails. +systemd_is_running() { + [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1 +} + +# Cheap TCP probe via bash builtins; no curl/nc dependency. We only need to know +# the local API socket is bound — pmm-admin will do the actual HTTP handshake. +# Anything else listening on that port (very unlikely on 7777) would falsely report +# success; that case fails clearly at the next pmm-admin step. +pmm_agent_listening() { + (exec 3<>"/dev/tcp/${PMM_AGENT_LISTEN_HOST}/${PMM_AGENT_LISTEN_PORT}") >/dev/null 2>&1 +} + +wait_for_pmm_agent() { + local i=0 + while [ "$i" -lt "$PMM_AGENT_START_TIMEOUT_SECS" ]; do + if pmm_agent_listening; then + return 0 + fi + sleep 1 + i=$((i + 1)) + done + return 1 +} + +# pmm-agent refuses to start without a config file. The Debian/RPM packages +# create an empty 0660 file at install time; recreate it if something deleted it +# (e.g. after a manual cleanup) so the daemon at least has a path to write to. +# Resolve the local node's hostname without assuming the `hostname(1)` binary +# is installed. Minimal RHEL/UBI/Alpine images often ship without it, and a +# `$(hostname)` call there fails with "command not found", which under +# `set -euo pipefail` aborts the whole script. Order: bash's $HOSTNAME (set +# automatically via gethostname() syscall) → uname -n → /etc/hostname → "node". +detect_node_hostname() { + if [ -n "${HOSTNAME:-}" ]; then + printf '%s' "${HOSTNAME}" + return + fi + if command -v hostname >/dev/null 2>&1; then + hostname + return + fi + if command -v uname >/dev/null 2>&1; then + uname -n + return + fi + if [ -r /etc/hostname ]; then + head -n 1 /etc/hostname + return + fi + printf 'node' +} + +ensure_pmm_agent_config_file() { + local dir + dir="$(dirname "${PMM_AGENT_CONFIG_FILE}")" + if [ ! -d "${dir}" ]; then + mkdir -p "${dir}" + fi + if [ ! -e "${PMM_AGENT_CONFIG_FILE}" ]; then + : > "${PMM_AGENT_CONFIG_FILE}" + chmod 0660 "${PMM_AGENT_CONFIG_FILE}" || true + log "Created empty pmm-agent config: ${PMM_AGENT_CONFIG_FILE}" + fi +} + +start_pmm_agent_systemd() { + if ! systemctl list-unit-files pmm-agent.service >/dev/null 2>&1; then + return 1 + fi + log "Starting pmm-agent via systemd..." + systemctl daemon-reload >/dev/null 2>&1 || true + systemctl enable --now pmm-agent.service +} + +start_pmm_agent_nohup() { + if ! command -v pmm-agent >/dev/null 2>&1; then + error "pmm-agent binary not found in PATH; cannot start it manually." + fi + mkdir -p "$(dirname "${PMM_AGENT_LOG_FILE}")" 2>/dev/null || true + + # Drop privileges to the pmm-agent system user when it exists (created by the + # package's postinst). The systemd unit runs as that user too, so this keeps + # the nohup fallback from being a privilege regression vs. systemd. If the + # user is missing (very minimal images, broken postinst), fall back to root — + # the agent still works, just with a wider blast radius if it's ever exploited. + local runner=() + if id -u pmm-agent >/dev/null 2>&1 && command -v runuser >/dev/null 2>&1; then + runner=(runuser -u pmm-agent --) + log "Starting pmm-agent as user pmm-agent (no usable systemd); logging to ${PMM_AGENT_LOG_FILE}" + else + log "Starting pmm-agent as root (no pmm-agent user or no runuser binary); logging to ${PMM_AGENT_LOG_FILE}" + fi + + nohup "${runner[@]}" pmm-agent --config-file="${PMM_AGENT_CONFIG_FILE}" \ + >>"${PMM_AGENT_LOG_FILE}" 2>&1 & + disown 2>/dev/null || true +} + +# Make sure pmm-agent is up and listening on its local API before we hand off to +# pmm-admin config/add. The script previously assumed the package's postinst had +# already started the daemon via systemd; that breaks in containers (no systemd) +# and on hosts where the service is masked or stopped. +ensure_pmm_agent_running() { + if pmm_agent_listening; then + log "pmm-agent already listening on ${PMM_AGENT_LISTEN_HOST}:${PMM_AGENT_LISTEN_PORT}." + return + fi + + log "pmm-agent is not running; attempting to start it." + ensure_pmm_agent_config_file + + local started=0 + if systemd_is_running; then + if start_pmm_agent_systemd; then + started=1 + else + log "No pmm-agent.service unit found; falling back to nohup." + fi + fi + + if [ "${started}" -eq 0 ]; then + start_pmm_agent_nohup + fi + + if ! wait_for_pmm_agent; then + log "pmm-agent did not bind ${PMM_AGENT_LISTEN_HOST}:${PMM_AGENT_LISTEN_PORT} within ${PMM_AGENT_START_TIMEOUT_SECS}s." + if [ -f "${PMM_AGENT_LOG_FILE}" ]; then + log "Last 20 lines of ${PMM_AGENT_LOG_FILE}:" + tail -n 20 "${PMM_AGENT_LOG_FILE}" >&2 || true + elif systemd_is_running; then + log "Try: journalctl -u pmm-agent -n 50 --no-pager" + fi + error "pmm-agent failed to start." + fi + + log "pmm-agent is up on ${PMM_AGENT_LISTEN_HOST}:${PMM_AGENT_LISTEN_PORT}." +} + +# When stdin is not a terminal (e.g. curl ... | bash), prompts cannot be used for DB +# credentials. Fail before pmm-admin config so we do not register the node and then fail on add. +# Caller must have already invoked apply_generic_inputs. +# +# Contract with the PMM UI's "Prompt on node" credentials mode: +# The UI deliberately renders a TWO-STEP command in that mode: +# 1) curl -fsSL[k] -o /tmp/install-pmm-client.sh '' +# 2) sudo -E bash /tmp/install-pmm-client.sh --pmm-server-url ... --tech ... [...] +# Step 2 reads the script from disk (not from a pipe), so stdin stays attached +# to the user's TTY through sudo, [ -t 0 ] is true, and prompt_if_empty / +# read -r -s in add_mysql / add_postgresql / add_mongodb / add_valkey can ask +# for DB user and password interactively — unless DB_USER / DB_PASSWORD (or +# per-tech MYSQL_* / …) are already set; sudo -E preserves those exports. +# This guard therefore never trips +# when the user followed the UI's prompt-mode command — it only protects the +# curl | bash pipeline from registering a half-configured node. +require_db_creds_before_config_if_noninteractive() { + if [ -t 0 ]; then + return 0 + fi + + local hint='This install is non-interactive (stdin is not a terminal, e.g. curl ... | bash), so database credentials cannot be prompted. Either pass them up front (--db-user/--db-password or DB_USER/DB_PASSWORD; with sudo env, use sudo -E to preserve exports), or switch to the UI'\''s "Prompt on node" mode which renders a download-then-run command: curl -fsSLk -o /tmp/install-pmm-client.sh '\'''\''; sudo -E bash /tmp/install-pmm-client.sh --pmm-server-url ... --tech ...' + + case "${TECH}" in + mysql) + if [ -z "${MYSQL_USERNAME}" ] || [ -z "${MYSQL_PASSWORD}" ]; then + error "MySQL username and password are required for non-interactive runs. ${hint}" + fi + ;; + postgresql) + if [ -z "${POSTGRESQL_USERNAME}" ] || [ -z "${POSTGRESQL_PASSWORD}" ]; then + error "PostgreSQL username and password are required for non-interactive runs. ${hint}" + fi + ;; + mongodb) + if [ -z "${MONGODB_USERNAME}" ] || [ -z "${MONGODB_PASSWORD}" ]; then + error "MongoDB username and password are required for non-interactive runs. ${hint}" + fi + ;; + valkey) + if [ -z "${VALKEY_PASSWORD}" ]; then + error "Valkey password is required for non-interactive runs (use --db-password or DB_PASSWORD / VALKEY_PASSWORD). ${hint}" + fi + ;; + esac +} + +configure_pmm_agent() { + prompt_if_empty PMM_SERVER_URL "PMM server URL (example: https://service_token:GLSA_TOKEN@pmm.example.com:443)" 1 + prompt_if_empty TECH "Technology to add (mysql/postgresql/mongodb/valkey)" + + require_db_creds_before_config_if_noninteractive + + local config_cmd=(pmm-admin config "--server-url=${PMM_SERVER_URL}") + if [ "${PMM_SERVER_INSECURE_TLS}" = "1" ] || [ "${PMM_SERVER_INSECURE_TLS}" = "true" ]; then + config_cmd+=(--server-insecure-tls) + fi + # pmm-admin config positionals are [] [] []. + # NODE_NAME without NODE_ADDRESS would shift "generic" into the address slot. + if [ -n "${NODE_NAME}" ]; then + local node_address="${NODE_ADDRESS:-$(detect_node_hostname)}" + config_cmd+=("${node_address}" "generic" "${NODE_NAME}") + elif [ -n "${NODE_ADDRESS}" ]; then + config_cmd+=("${NODE_ADDRESS}") + fi + if [ "${PMM_CONFIG_FORCE}" = "1" ] || [ "${PMM_CONFIG_FORCE}" = "true" ]; then + config_cmd+=(--force) + fi + + log "Running pmm-admin config..." + "${config_cmd[@]}" +} + +apply_generic_inputs() { + MYSQL_USERNAME="${MYSQL_USERNAME:-${DB_USER}}" + MYSQL_PASSWORD="${MYSQL_PASSWORD:-${DB_PASSWORD}}" + MYSQL_HOST="${MYSQL_HOST:-${DB_HOST}}" + MYSQL_PORT="${MYSQL_PORT:-${DB_PORT}}" + MYSQL_ADDRESS="${MYSQL_ADDRESS:-${DB_ADDRESS}}" + MYSQL_SERVICE_NAME="${MYSQL_SERVICE_NAME:-${DB_SERVICE_NAME}}" + MYSQL_SOCKET="${MYSQL_SOCKET:-${DB_SOCKET}}" + + POSTGRESQL_USERNAME="${POSTGRESQL_USERNAME:-${DB_USER}}" + POSTGRESQL_PASSWORD="${POSTGRESQL_PASSWORD:-${DB_PASSWORD}}" + POSTGRESQL_HOST="${POSTGRESQL_HOST:-${DB_HOST}}" + POSTGRESQL_PORT="${POSTGRESQL_PORT:-${DB_PORT}}" + POSTGRESQL_ADDRESS="${POSTGRESQL_ADDRESS:-${DB_ADDRESS}}" + POSTGRESQL_SERVICE_NAME="${POSTGRESQL_SERVICE_NAME:-${DB_SERVICE_NAME}}" + POSTGRESQL_DATABASE="${POSTGRESQL_DATABASE:-${DB_NAME}}" + POSTGRESQL_SOCKET="${POSTGRESQL_SOCKET:-${DB_SOCKET}}" + + MONGODB_USERNAME="${MONGODB_USERNAME:-${DB_USER}}" + MONGODB_PASSWORD="${MONGODB_PASSWORD:-${DB_PASSWORD}}" + MONGODB_HOST="${MONGODB_HOST:-${DB_HOST}}" + MONGODB_PORT="${MONGODB_PORT:-${DB_PORT}}" + MONGODB_ADDRESS="${MONGODB_ADDRESS:-${DB_ADDRESS}}" + MONGODB_SERVICE_NAME="${MONGODB_SERVICE_NAME:-${DB_SERVICE_NAME}}" + MONGODB_AUTH_DB="${MONGODB_AUTH_DB:-${DB_AUTH_DB}}" + MONGODB_SOCKET="${MONGODB_SOCKET:-${DB_SOCKET}}" + + VALKEY_USERNAME="${VALKEY_USERNAME:-${DB_USER}}" + VALKEY_PASSWORD="${VALKEY_PASSWORD:-${DB_PASSWORD}}" + VALKEY_HOST="${VALKEY_HOST:-${DB_HOST}}" + VALKEY_PORT="${VALKEY_PORT:-${DB_PORT}}" + VALKEY_ADDRESS="${VALKEY_ADDRESS:-${DB_ADDRESS}}" + VALKEY_SERVICE_NAME="${VALKEY_SERVICE_NAME:-${DB_SERVICE_NAME}}" + VALKEY_SOCKET="${VALKEY_SOCKET:-${DB_SOCKET}}" +} + +add_mysql() { + local db_cred_hint='Use --db-user and --db-password, or set DB_USER and DB_PASSWORD (MYSQL_* overrides if set). If you use sudo env, list DB_USER and DB_PASSWORD there; exports in your shell are not passed to the script.' + prompt_if_empty MYSQL_USERNAME "MySQL username" 0 "${db_cred_hint}" + prompt_if_empty MYSQL_PASSWORD "MySQL password" 1 "${db_cred_hint}" + MYSQL_ADDRESS="${MYSQL_ADDRESS:-${MYSQL_HOST:-127.0.0.1}:${MYSQL_PORT:-3306}}" + MYSQL_SERVICE_NAME="${MYSQL_SERVICE_NAME:-$(detect_node_hostname)-mysql}" + local cmd=(pmm-admin add mysql "${MYSQL_SERVICE_NAME}" "${MYSQL_ADDRESS}" "--username=${MYSQL_USERNAME}" "--password=${MYSQL_PASSWORD}") + if [ -n "${MYSQL_SOCKET}" ]; then + cmd+=("--socket=${MYSQL_SOCKET}") + fi + log "Running pmm-admin add mysql..." + "${cmd[@]}" +} + +add_postgresql() { + local db_cred_hint='Use --db-user and --db-password, or set DB_USER and DB_PASSWORD (POSTGRESQL_* overrides if set). If you use sudo env, list DB_USER and DB_PASSWORD there; exports in your shell are not passed to the script.' + prompt_if_empty POSTGRESQL_USERNAME "PostgreSQL username" 0 "${db_cred_hint}" + prompt_if_empty POSTGRESQL_PASSWORD "PostgreSQL password" 1 "${db_cred_hint}" + POSTGRESQL_ADDRESS="${POSTGRESQL_ADDRESS:-${POSTGRESQL_HOST:-127.0.0.1}:${POSTGRESQL_PORT:-5432}}" + POSTGRESQL_SERVICE_NAME="${POSTGRESQL_SERVICE_NAME:-$(detect_node_hostname)-postgresql}" + local cmd=(pmm-admin add postgresql "${POSTGRESQL_SERVICE_NAME}" "${POSTGRESQL_ADDRESS}" "--username=${POSTGRESQL_USERNAME}" "--password=${POSTGRESQL_PASSWORD}") + if [ -n "${POSTGRESQL_DATABASE}" ]; then + cmd+=("--database=${POSTGRESQL_DATABASE}") + fi + if [ -n "${POSTGRESQL_SOCKET}" ]; then + cmd+=("--socket=${POSTGRESQL_SOCKET}") + fi + log "Running pmm-admin add postgresql..." + "${cmd[@]}" +} + +add_mongodb() { + local db_cred_hint='Use --db-user and --db-password, or set DB_USER and DB_PASSWORD (MONGODB_* overrides if set). If you use sudo env, list DB_USER and DB_PASSWORD there; exports in your shell are not passed to the script.' + prompt_if_empty MONGODB_USERNAME "MongoDB username" 0 "${db_cred_hint}" + prompt_if_empty MONGODB_PASSWORD "MongoDB password" 1 "${db_cred_hint}" + MONGODB_ADDRESS="${MONGODB_ADDRESS:-${MONGODB_HOST:-127.0.0.1}:${MONGODB_PORT:-27017}}" + MONGODB_SERVICE_NAME="${MONGODB_SERVICE_NAME:-$(detect_node_hostname)-mongodb}" + local cmd=(pmm-admin add mongodb "${MONGODB_SERVICE_NAME}" "${MONGODB_ADDRESS}" "--username=${MONGODB_USERNAME}" "--password=${MONGODB_PASSWORD}") + if [ -n "${MONGODB_AUTH_DB}" ]; then + cmd+=("--authentication-database=${MONGODB_AUTH_DB}") + fi + if [ -n "${MONGODB_SOCKET}" ]; then + cmd+=("--socket=${MONGODB_SOCKET}") + fi + log "Running pmm-admin add mongodb..." + "${cmd[@]}" +} + +add_valkey() { + local db_cred_hint='Use --db-password or DB_PASSWORD (VALKEY_PASSWORD overrides if set). If you use sudo env, list DB_PASSWORD there; exports in your shell are not passed to the script.' + prompt_if_empty VALKEY_PASSWORD "Valkey password" 1 "${db_cred_hint}" + VALKEY_ADDRESS="${VALKEY_ADDRESS:-${VALKEY_HOST:-127.0.0.1}:${VALKEY_PORT:-6379}}" + VALKEY_SERVICE_NAME="${VALKEY_SERVICE_NAME:-$(detect_node_hostname)-valkey}" + local cmd=(pmm-admin add valkey "${VALKEY_SERVICE_NAME}" "${VALKEY_ADDRESS}" "--password=${VALKEY_PASSWORD}") + if [ -n "${VALKEY_USERNAME}" ]; then + cmd+=("--username=${VALKEY_USERNAME}") + fi + if [ -n "${VALKEY_SOCKET}" ]; then + cmd+=("--socket=${VALKEY_SOCKET}") + fi + log "Running pmm-admin add valkey..." + "${cmd[@]}" +} + +add_service() { + # IMPORTANT: keep this list in sync with: + # - installTokenTechnologies in managed/services/management/install_token.go + # - the Technology union in ui/apps/pmm/src/pages/install-client/InstallClientPage.utils.ts + # If you add a tech here, also add a matching add_ function above and the require_* + # branch in require_db_creds_before_config_if_noninteractive. + case "${TECH}" in + mysql) + add_mysql + ;; + postgresql) + add_postgresql + ;; + mongodb) + add_mongodb + ;; + valkey) + add_valkey + ;; + *) + error "Unsupported TECH '${TECH}'. Supported values: mysql, postgresql, mongodb, valkey." + ;; + esac +} + +# Print a tailored recovery hint when `pmm-admin add` fails after `pmm-admin config` +# has already registered the node. The most common cause we see in the field is +# wrong DB credentials; the second most common is leftover state from a previous +# attempt. Either way the user wants `--force` on the next run + corrected creds. +report_add_service_failure() { + local exit_code="$1" + echo >&2 + log "ERROR: 'pmm-admin add ${TECH}' failed (exit ${exit_code}) after the node was already registered with PMM Server." + log " The node is now visible on PMM Server but no service is attached to it." + log " Most common causes:" + log " * Wrong DB credentials → fix DB_USER / DB_PASSWORD (or --db-user / --db-password) and re-run." + log " * Service already attached from a prior attempt → re-run with --force (or PMM_CONFIG_FORCE=1)" + log " which removes the previous node registration and its services on the server before re-registering." + log " For MongoDB also check --db-auth-db / DB_AUTH_DB; for PostgreSQL check --db-name / DB_NAME." + exit "${exit_code}" +} + +main() { + require_root + install_pmm_client + apply_generic_inputs + ensure_pmm_agent_running + configure_pmm_agent + # Disable -e for the add step so we can intercept its non-zero exit, print a + # helpful recovery message, and propagate the original status. `set -E` would + # work too but only on bash >= 4 and changes broader trap semantics. + set +e + add_service + local rc=$? + set -e + if [ "${rc}" -ne 0 ]; then + report_add_service_failure "${rc}" + fi + log "PMM client setup completed successfully." +} + +main "$@" diff --git a/build/ansible/roles/nginx/tasks/main.yml b/build/ansible/roles/nginx/tasks/main.yml index 135e4f326e9..0118fc1a9cb 100644 --- a/build/ansible/roles/nginx/tasks/main.yml +++ b/build/ansible/roles/nginx/tasks/main.yml @@ -92,3 +92,11 @@ group: root owner: pmm mode: 0644 + +- name: Copy one-step PMM client installer script + copy: + src: install-pmm-client.sh + dest: /usr/share/pmm-server/static/install-pmm-client.sh + group: root + owner: pmm + mode: 0755 diff --git a/documentation/docs/install-pmm/install-pmm-client/one-step-ui-install.md b/documentation/docs/install-pmm/install-pmm-client/one-step-ui-install.md new file mode 100644 index 00000000000..cf4dd2dfb3d --- /dev/null +++ b/documentation/docs/install-pmm/install-pmm-client/one-step-ui-install.md @@ -0,0 +1,79 @@ +# One-step PMM Client install from UI + +Use the **Install PMM Client** wizard to generate a single command that installs `pmm-client`, registers the node with PMM Server, and adds one monitored service. + +## Before you start + +- PMM Server must be reachable from the target node (default port `443`; whatever you set in **PMM host** is used in `PMM_SERVER_URL`). +- The node user running the command needs `sudo` access (or run it as `root`, e.g. inside a container). +- A short-lived service token is minted from the UI on demand — you do not need to provision one beforehand. The Grafana **Install PMM Client** service account is **Admin** org role and **expires 15 minutes after generation**; treat the URL like a password. + +## Generate the command + +1. In PMM UI, open **Inventory → Install PMM Client**. +2. Choose technology: `MySQL`, `PostgreSQL`, `MongoDB`, or `Valkey`. +3. Click **Generate short-lived token**. The countdown chip shows the remaining lifetime. +4. Select the credentials mode: + - **Prompt on node (downloads script first, asks for DB user/password)** (**default**): the wizard renders a **two-step** command — `curl … -o /tmp/install-pmm-client.sh ''` followed by `sudo -E bash /tmp/install-pmm-client.sh …`. Reading the script from disk (instead of piping it from `curl`) keeps stdin attached to your terminal, so the script can prompt you for the DB user and password. **`sudo -E`** preserves your environment into the root shell: if `DB_USER` / `DB_PASSWORD` (or per-tech `MYSQL_*`, `POSTGRESQL_*`, …) are already exported, the script uses them and **does not prompt**. Use this when you do not want credentials in the copied command line or process list from flags alone. + - **Include env variables (recommended for `curl | bash`)**: credentials are passed in the environment of the spawned shell. Use this when you want the classic one-liner pipeline. + - **Pass as script flags**: credentials are passed as `--db-*` script arguments instead of env vars. Same security profile as env mode, just a different surface. +5. Fill in the optional fields you need (node name/address, DB host/port, service name, MongoDB auth DB, PostgreSQL database). +6. Copy the generated command and run it on the target node before the token expires. + +Example (env mode, matches what the wizard renders): + +```bash +curl -fsSLk 'https:///pmm-static/install-pmm-client.sh' | sudo -E env \ + PMM_SERVER_URL='https://service_token:@' \ + TECH='mysql' \ + DB_USER='pmm' \ + DB_PASSWORD='secret' \ + bash -s -- \ + --pmm-server-insecure-tls +``` + +Example (prompt mode — credentials never appear in the rendered command): + +```bash +curl -fsSLk -o '/tmp/install-pmm-client.sh' 'https:///pmm-static/install-pmm-client.sh' +sudo -E bash '/tmp/install-pmm-client.sh' \ + --pmm-server-url 'https://service_token:@' \ + --tech 'mysql' \ + --pmm-server-insecure-tls +``` + +The second line runs `bash` against a file (not against a pipe), so `sudo` keeps stdin connected to your terminal. The script then asks twice — once for the DB user, once for the DB password (silent input) — before running `pmm-admin add`. Optional fields you fill in the wizard (host, port, service name, MongoDB auth DB, PostgreSQL database) are still passed as `--db-*` flags so you only have to type two things. + +Notes on the rendered command: + +- `curl -fsSLk` (with `-k`) is emitted only when **Use insecure TLS** is on; with a properly signed PMM Server certificate the wizard drops the `-k`. +- TLS-skip on the PMM Server side is controlled by the `--pmm-server-insecure-tls` script flag (passed after `bash -s --`, or as an argument to `bash ` in prompt mode). The script also accepts `PMM_SERVER_INSECURE_TLS=1` as an env var if you build the command by hand. +- `sudo -E env VAR=... bash -s --` is the standard shape for env/flags modes; `-E` preserves your shell's exports while the explicit `VAR=...` list gets handed to `bash`'s environment (and therefore to the script). +- Prompt mode uses `sudo -E bash ...` instead of `sudo -E env … bash -s --`: there is no inline env block in the copied command, but `-E` still forwards your shell exports (e.g. `DB_USER` / `DB_PASSWORD`) so credentials can be supplied without prompts or appearing in the command string. Stdin stays on your TTY the same way as plain `sudo bash`. + +## What the script does + +The script available at `/pmm-static/install-pmm-client.sh` performs: + +1. Installs `pmm-client` using the OS package manager (RHEL-compatible or Debian-compatible hosts). +2. Ensures `pmm-agent` is running (starts it via `systemd` when available, otherwise `nohup` in the background). +3. Runs `pmm-admin config` against your PMM server to register the node and persist the agent identity. +4. Runs `pmm-admin add ` using your selected options. + +## Security notes + +- Generated tokens are tied to Grafana service accounts minted as **Admin** org role and live for **15 minutes** — generate, run, done. There is no way to extend the lifetime from the UI. +- Env mode and flags mode put credentials into the shell command line and the spawned process environment. On a shared node, that may be visible in `ps`/`/proc` to other users for a moment. **Prompt mode** avoids this entirely: the rendered command never contains the DB user or password, and the script reads them straight from your terminal once it is running on the node. +- Avoid copy-pasting the command into chat/issue trackers; the embedded service token is a credential. (In prompt mode the DB credentials are not in the command, but the PMM service token still is.) + +## Troubleshooting + +- **curl / browser returns `404`** on URLs like `/graph/…` — PMM Web UI lives under **`/pmm-ui/`**. Use paths such as `/pmm-ui/graph/inventory/nodes`, not `/graph/inventory/nodes`. This matches what the browser loads (see address bar vs. truncated copy-pastes). + +- **TLS handshake errors against PMM Server** — turn on **Use insecure TLS** in the wizard (sets the `--pmm-server-insecure-tls` script flag). The wizard also adds `-k` to `curl` so the script download itself succeeds. +- **Package install fails** — verify outbound access to the Percona repositories (`repo.percona.com`). +- **`pmm-admin add` fails (auth, name conflict, etc.)** — the node was already registered by `pmm-admin config`. Re-run with **Force re-register node** enabled (this passes `--force` to `pmm-admin config`, which removes the previous node and its services on the server before re-registering). You may also have to fix the database credentials before retrying. +- **`pmm-agent is not running`** — happens in containers without `systemd`. The script auto-starts it via `nohup` and writes logs to `/var/log/pmm-agent.log`; check there. +- **`hostname: command not found`** — only on extremely minimal images; the script falls back to `$HOSTNAME`/`uname -n`/`/etc/hostname` and finally `node`. +- **Prompt mode does not actually prompt** — the script's noninteractive guard fires when stdin is not a TTY, e.g. when the prompt-mode command is invoked through `ssh host ''` (no allocated TTY) or through automation. Run it from an interactive shell on the node, or use **Include env variables** / **Pass as script flags** mode instead. +- **Cleanup after prompt mode** — the downloaded script lives at `/tmp/install-pmm-client.sh` after a successful install. It is harmless (no embedded secrets), but if you want it gone: `rm -f /tmp/install-pmm-client.sh`. diff --git a/documentation/mkdocs-base.yml b/documentation/mkdocs-base.yml index a8f5d0b2663..97a58aa3c9e 100644 --- a/documentation/mkdocs-base.yml +++ b/documentation/mkdocs-base.yml @@ -193,6 +193,7 @@ nav: - Install PMM Client: - Client installation overview: install-pmm/install-pmm-client/index.md - install-pmm/install-pmm-client/prerequisites.md + - One-step install from UI: install-pmm/install-pmm-client/one-step-ui-install.md - Deployment options: - Install with Package Manager: install-pmm/install-pmm-client/package_manager.md - Install from binaries: install-pmm/install-pmm-client/binary_package.md diff --git a/ui/apps/pmm/src/api/installToken.ts b/ui/apps/pmm/src/api/installToken.ts new file mode 100644 index 00000000000..f55fa5ad1fa --- /dev/null +++ b/ui/apps/pmm/src/api/installToken.ts @@ -0,0 +1,111 @@ +import { grafanaApi } from './api'; + +export interface CreateNodeInstallTokenResponse { + token: string; + expiresAt: string; +} + +// Hard cap mirrors the previous server-side cap (15 min). Tokens longer than this +// shouldn't be in someone's terminal scrollback — re-run "Generate token" instead. +const MAX_TTL_SECONDS = 15 * 60; +const DEFAULT_TTL_SECONDS = 15 * 60; + +const SUPPORTED_TECHNOLOGIES = new Set([ + 'mysql', + 'postgresql', + 'mongodb', + 'valkey', +]); + +// Shared SA per technology, created lazily on first use. Same naming scheme the +// removed backend endpoint used so previously-minted SAs are still reusable. +const SA_NAME_PREFIX = 'pmm-install-sa'; +const TOKEN_NAME_PREFIX = 'pmm-install-st'; + +interface GrafanaServiceAccount { + id: number; + name: string; +} + +interface GrafanaServiceAccountSearch { + totalCount: number; + serviceAccounts: GrafanaServiceAccount[]; +} + +interface GrafanaTokenResponse { + id: number; + name: string; + key: string; +} + +/** + * Mints a short-lived Grafana service-account token for a PMM Client install command. + * + * Implementation note: this calls Grafana's serviceaccounts API directly through the + * `/graph/api/` reverse proxy. The user must already be authenticated as a Grafana + * Admin (Grafana rejects the create/mint requests with 403 otherwise) — that's the + * same trust boundary the old backend endpoint had, just one hop shorter. + */ +export async function createNodeInstallToken( + technology: string, + ttlSeconds = 0 +): Promise { + if (!SUPPORTED_TECHNOLOGIES.has(technology)) { + throw new Error(`unsupported technology "${technology}"`); + } + + let ttl = ttlSeconds > 0 ? ttlSeconds : DEFAULT_TTL_SECONDS; + if (ttl > MAX_TTL_SECONDS) { + ttl = MAX_TTL_SECONDS; + } + + const saName = `${SA_NAME_PREFIX}-${technology}`; + + let saId = await findServiceAccountIdByName(saName); + if (saId === null) { + saId = await createServiceAccount(saName); + } + + // UUID-suffixed token name keeps concurrent calls from colliding on Grafana's + // per-SA unique-name constraint (Grafana returns 409 otherwise). + const tokenName = `${TOKEN_NAME_PREFIX}-${technology}-${crypto.randomUUID()}`; + const key = await mintToken(saId, tokenName, ttl); + + return { + token: key, + expiresAt: new Date(Date.now() + ttl * 1000).toISOString(), + }; +} + +async function findServiceAccountIdByName(name: string): Promise { + const res = await grafanaApi.get( + '/serviceaccounts/search', + { params: { query: name } } + ); + const match = res.data.serviceAccounts?.find((sa) => sa.name === name); + return match ? match.id : null; +} + +async function createServiceAccount(name: string): Promise { + // Admin role is required for `pmm-admin config`/inventory writes in real PMM setups. + const res = await grafanaApi.post('/serviceaccounts', { + name, + role: 'Admin', + isDisabled: false, + }); + return res.data.id; +} + +async function mintToken( + serviceAccountId: number, + tokenName: string, + ttlSeconds: number +): Promise { + // Only `name` + `secondsToLive` — extra fields (`role`) have been observed to + // make some Grafana versions ignore `secondsToLive` and fall back to a long default. + const res = await grafanaApi.post( + `/serviceaccounts/${serviceAccountId}/tokens`, + { name: tokenName, secondsToLive: ttlSeconds } + ); + return res.data.key; +} diff --git a/ui/apps/pmm/src/contexts/navigation/navigation.constants.ts b/ui/apps/pmm/src/contexts/navigation/navigation.constants.ts index 0399e2b8ca3..81e6315944a 100644 --- a/ui/apps/pmm/src/contexts/navigation/navigation.constants.ts +++ b/ui/apps/pmm/src/contexts/navigation/navigation.constants.ts @@ -573,6 +573,17 @@ export const NAV_INVENTORY: NavItem = { url: `${PMM_NEW_NAV_GRAFANA_PATH}/inventory/nodes`, matches: ['*'], }, + { + id: 'install-pmm-client', + text: 'Install PMM Client', + url: `${PMM_NEW_NAV_PATH}/install-client`, + matches: ['*'], + badge: { + label: 'Preview', + color: 'default', + variant: 'filled', + }, + }, ], }; diff --git a/ui/apps/pmm/src/pages/install-client/InstallClientPage.tsx b/ui/apps/pmm/src/pages/install-client/InstallClientPage.tsx new file mode 100644 index 00000000000..c81d9b78ca6 --- /dev/null +++ b/ui/apps/pmm/src/pages/install-client/InstallClientPage.tsx @@ -0,0 +1,403 @@ +import axios from 'axios'; +import { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + FormControl, + FormControlLabel, + FormHelperText, + InputLabel, + MenuItem, + Select, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import AccessTimeOutlinedIcon from '@mui/icons-material/AccessTimeOutlined'; +import { Page } from 'components/page'; +import { createNodeInstallToken } from 'api/installToken'; +import { + buildInstallCommand, + buildPmmServerURL, + CredentialsMode, + formatExpiresIn, + Technology, +} from './InstallClientPage.utils'; + +export const InstallClientPage = () => { + const [technology, setTechnology] = useState('mysql'); + const [credentialsMode, setCredentialsMode] = useState('prompt'); + const [token, setToken] = useState(''); + const [pmmHost, setPmmHost] = useState(() => window.location.host); + const [insecureTLS, setInsecureTLS] = useState(true); + const [registerForce, setRegisterForce] = useState(false); + const [nodeName, setNodeName] = useState(''); + const [nodeAddress, setNodeAddress] = useState(''); + const [dbUser, setDbUser] = useState(''); + const [dbPassword, setDbPassword] = useState(''); + const [dbHost, setDbHost] = useState(''); + const [dbPort, setDbPort] = useState(''); + const [dbName, setDbName] = useState(''); + const [dbAuthDB, setDbAuthDB] = useState(''); + const [dbServiceName, setDbServiceName] = useState(''); + const [copied, setCopied] = useState(false); + const [genLoading, setGenLoading] = useState(false); + const [genError, setGenError] = useState(null); + const [tokenExpiresAt, setTokenExpiresAt] = useState(null); + const [now, setNow] = useState(() => Date.now()); + + // Tick once a second while a token is live, so the countdown chip refreshes. + // Stops as soon as expiresAt is null (e.g. user cleared the field manually). + useEffect(() => { + if (!tokenExpiresAt) return undefined; + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, [tokenExpiresAt]); + + const secondsLeft = tokenExpiresAt + ? Math.max(0, Math.floor((tokenExpiresAt.getTime() - now) / 1000)) + : 0; + const isExpired = !!tokenExpiresAt && secondsLeft <= 0; + + // When the timer hits zero, drop the secret so the rendered command falls + // back to the placeholder. We deliberately keep `tokenExpiresAt` set so the + // chip can still show "Expired — regenerate" until the user acts. + useEffect(() => { + if (isExpired && token) { + setToken(''); + } + }, [isExpired, token]); + + const installerUrl = useMemo( + () => `${window.location.origin}/pmm-static/install-pmm-client.sh`, + [] + ); + + const serverURL = useMemo(() => buildPmmServerURL(pmmHost, token), [pmmHost, token]); + + const command = useMemo( + () => + buildInstallCommand({ + installerUrl, + technology, + credentialsMode, + serverURL, + insecureTLS, + registerForce, + nodeName, + nodeAddress, + dbUser, + dbPassword, + dbHost, + dbPort, + dbName, + dbAuthDB, + dbServiceName, + }), + [ + credentialsMode, + dbAuthDB, + dbHost, + dbName, + dbPassword, + dbPort, + dbServiceName, + dbUser, + insecureTLS, + installerUrl, + nodeAddress, + nodeName, + registerForce, + serverURL, + technology, + ] + ); + + const handleCopy = async () => { + await navigator.clipboard.writeText(command); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + }; + + const handleGenerateToken = async () => { + setGenError(null); + setGenLoading(true); + try { + const res = await createNodeInstallToken(technology, 0); + setToken(res.token); + // installToken.ts always returns expiresAt; the fallback is just defensive + // belt-and-braces in case of a future refactor. + const expires = res.expiresAt + ? new Date(res.expiresAt) + : new Date(Date.now() + 15 * 60 * 1000); + setTokenExpiresAt(expires); + setNow(Date.now()); + } catch (e: unknown) { + let msg = 'Failed to create token'; + if (axios.isAxiosError(e)) { + const data = e.response?.data as { message?: string } | undefined; + msg = data?.message ?? e.message; + } + setGenError(msg); + } finally { + setGenLoading(false); + } + }; + + return ( + + + + + + + Choose installation options, then copy and run the generated command on your database + node. Include env variables and Pass as script flags use the usual{' '} + curl … | bash form. Prompt on node renders a two-step command + that downloads the script to /tmp/install-pmm-client.sh first, then runs + it with sudo -E bash so it can prompt you for the DB user and password on + the node (or skip prompts if you already exported DB_USER /{' '} + DB_PASSWORD-E keeps them visible to the script). + + + Generated tokens are Grafana Admin–role on the minted install service account and valid for 15 minutes{' '} + — treat the URL like a password. + + + + + Technology + + + + + Credentials mode + + + + In prompt mode the rendered command is a two-liner: curl -o downloads + the script to /tmp/install-pmm-client.sh, then{' '} + sudo -E bash runs it on a TTY so it can ask for the DB user and password, + or use credentials you already exported (DB_USER, DB_PASSWORD, or + per-tech MYSQL_* / …) without prompts. + + + + + + setPmmHost(e.target.value)} + helperText="Hostname or hostname:port for PMM_SERVER_URL (defaults to this page if empty)" + /> + { + setToken(e.target.value); + // User edited the field manually — drop the expiry so we + // stop ticking against a token they overrode. + setTokenExpiresAt(null); + }} + error={isExpired} + helperText={ + isExpired + ? 'Token expired. Click Regenerate to mint a new one.' + : 'Used only to render command locally in browser. Generated tokens auto-expire 15 min after creation.' + } + /> + + + + {tokenExpiresAt && !genLoading && ( + } + label={ + isExpired + ? 'Expired — regenerate' + : `Expires in ${formatExpiresIn(secondsLeft)}` + } + color={isExpired ? 'error' : 'success'} + variant="outlined" + size="medium" + /> + )} + {genError && ( + + {genError} + + )} + + + Tokens are valid for 15 minutes after generation. Run the command on your node before + then. + + + + setNodeName(e.target.value)} + /> + setNodeAddress(e.target.value)} + /> + + + {credentialsMode === 'prompt' ? ( + + DB user and password will be requested when the script runs on the node. + + ) : ( + + setDbUser(e.target.value)} + /> + setDbPassword(e.target.value)} + /> + + )} + + + setDbHost(e.target.value)} + /> + setDbPort(e.target.value)} + /> + setDbServiceName(e.target.value)} + /> + + + {technology === 'postgresql' && ( + setDbName(e.target.value)} + /> + )} + {technology === 'mongodb' && ( + setDbAuthDB(e.target.value)} + /> + )} + + + setInsecureTLS(e.target.checked)} + /> + } + label="Use insecure TLS" + /> + setRegisterForce(e.target.checked)} + /> + } + label="Force re-register node" + /> + + + + + + + + + Generated command + + + + + {copied && Command copied.} + + + + + ); +}; diff --git a/ui/apps/pmm/src/pages/install-client/InstallClientPage.utils.test.ts b/ui/apps/pmm/src/pages/install-client/InstallClientPage.utils.test.ts new file mode 100644 index 00000000000..2ca5ab9e5e9 --- /dev/null +++ b/ui/apps/pmm/src/pages/install-client/InstallClientPage.utils.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, test } from 'vitest'; +import { + buildInstallCommand, + buildPmmServerURL, + formatExpiresIn, + InstallCommandOptions, +} from './InstallClientPage.utils'; + +// Default to 'env' mode so most existing assertions exercise the curl|bash +// pipeline; prompt-mode tests opt in explicitly via `credentialsMode: 'prompt'`. +// This avoids surprises when shared assertions (curl flags, --pmm-server-insecure-tls) +// happen to also pass in prompt mode but for entirely different reasons. +const baseOptions: InstallCommandOptions = { + installerUrl: 'https://pmm.example.com/pmm-static/install-pmm-client.sh', + technology: 'mysql', + credentialsMode: 'env', + serverURL: 'https://service_token:GLSA@pmm.example.com:443', + insecureTLS: true, + registerForce: false, + nodeName: '', + nodeAddress: '', + dbUser: '', + dbPassword: '', + dbHost: '', + dbPort: '', + dbName: '', + dbAuthDB: '', + dbServiceName: '', +}; + +const optionsWithDb: InstallCommandOptions = { + ...baseOptions, + dbUser: 'pmm', + dbPassword: 'secret', + dbHost: '127.0.0.1', + dbPort: '3306', + dbServiceName: 'node-mysql', +}; + +describe('buildPmmServerURL', () => { + test('uses placeholder when token empty', () => { + expect(buildPmmServerURL('pmm.example.com:8443', '')).toBe( + 'https://service_token:@pmm.example.com:8443' + ); + }); + + test('percent-encodes token in userinfo', () => { + expect(buildPmmServerURL('h:1', 'a:b@c')).toBe( + 'https://service_token:a%3Ab%40c@h:1' + ); + }); +}); + +describe('buildInstallCommand', () => { + test('env mode renders curl|bash with PMM_SERVER_URL and TECH', () => { + const cmd = buildInstallCommand(baseOptions); + expect(cmd).toContain("TECH='mysql'"); + expect(cmd).toContain("PMM_SERVER_URL='https://service_token:GLSA@pmm.example.com:443'"); + expect(cmd).toContain('sudo -E env'); + expect(cmd).toContain('curl -fsSLk'); + expect(cmd).toContain('bash -s --'); + expect(cmd).toContain('--pmm-server-insecure-tls'); + expect(cmd).not.toContain('DB_USER='); + expect(cmd).not.toContain('DB_PASSWORD='); + }); + + test('omits insecure TLS flag and drops curl -k when disabled', () => { + const cmd = buildInstallCommand({ ...baseOptions, insecureTLS: false }); + expect(cmd).not.toContain('--pmm-server-insecure-tls'); + expect(cmd).toContain('curl -fsSL '); + expect(cmd).not.toContain('curl -fsSLk'); + expect(cmd).toContain('bash -s --'); + }); + + test('uses curl -fsSLk when insecure TLS is on', () => { + const cmd = buildInstallCommand({ ...baseOptions, insecureTLS: true }); + expect(cmd).toContain('curl -fsSLk '); + expect(cmd).toContain('--pmm-server-insecure-tls'); + }); + + test('flags mode also respects the insecure TLS toggle for curl', () => { + const secure = buildInstallCommand({ + ...optionsWithDb, + credentialsMode: 'flags', + insecureTLS: false, + }); + const insecure = buildInstallCommand({ + ...optionsWithDb, + credentialsMode: 'flags', + insecureTLS: true, + }); + expect(secure).toContain('curl -fsSL '); + expect(secure).not.toContain('curl -fsSLk'); + expect(secure).not.toContain('--pmm-server-insecure-tls'); + expect(insecure).toContain('curl -fsSLk '); + expect(insecure).toContain('--pmm-server-insecure-tls'); + }); + + test('includes DB credentials in env mode', () => { + const cmd = buildInstallCommand({ + ...optionsWithDb, + credentialsMode: 'env', + }); + expect(cmd).toContain("DB_USER='pmm'"); + expect(cmd).toContain("DB_PASSWORD='secret'"); + expect(cmd).toContain("DB_HOST='127.0.0.1'"); + }); + + test('uses flags mode and includes db args', () => { + const cmd = buildInstallCommand({ + ...optionsWithDb, + credentialsMode: 'flags', + technology: 'postgresql', + dbName: 'postgres', + }); + expect(cmd).toContain('sudo -E bash -s --'); + expect(cmd).toContain('curl -fsSLk'); + expect(cmd).toContain("--pmm-server-url 'https://service_token:GLSA@pmm.example.com:443'"); + expect(cmd).toContain("--tech 'postgresql'"); + expect(cmd).toContain("--db-password 'secret'"); + expect(cmd).toContain("--db-name 'postgres'"); + expect(cmd).toContain('--pmm-server-insecure-tls'); + }); + + test('includes mongodb auth db only for mongodb', () => { + const mongodb = buildInstallCommand({ + ...optionsWithDb, + credentialsMode: 'env', + technology: 'mongodb', + dbAuthDB: 'admin', + }); + const mysql = buildInstallCommand({ + ...optionsWithDb, + credentialsMode: 'env', + technology: 'mysql', + dbAuthDB: 'admin', + }); + + expect(mongodb).toContain("DB_AUTH_DB='admin'"); + expect(mysql).not.toContain('DB_AUTH_DB='); + }); + + test('supports valkey technology', () => { + const cmd = buildInstallCommand({ + ...baseOptions, + technology: 'valkey', + }); + expect(cmd).toContain("TECH='valkey'"); + }); +}); + +describe('buildInstallCommand prompt mode', () => { + const promptBase: InstallCommandOptions = { + ...baseOptions, + credentialsMode: 'prompt', + }; + + test('renders a two-line curl-then-sudo-E-bash command', () => { + const cmd = buildInstallCommand(promptBase); + const lines = cmd.split('\n'); + expect(lines[0]).toContain( + "curl -fsSLk -o '/tmp/install-pmm-client.sh' 'https://pmm.example.com/pmm-static/install-pmm-client.sh'" + ); + expect(lines[1]).toMatch(/^sudo -E bash '\/tmp\/install-pmm-client\.sh' \\$/); + expect(cmd).not.toContain('curl |'); + expect(cmd).not.toContain('bash -s --'); + expect(cmd).not.toContain('sudo -E env'); + }); + + test('never emits DB credentials, even when fields are filled', () => { + const cmd = buildInstallCommand({ + ...promptBase, + dbUser: 'pmm', + dbPassword: 'secret', + }); + expect(cmd).not.toContain('DB_USER='); + expect(cmd).not.toContain('DB_PASSWORD='); + expect(cmd).not.toContain('--db-user'); + expect(cmd).not.toContain('--db-password'); + expect(cmd).not.toContain("'pmm'"); + expect(cmd).not.toContain("'secret'"); + }); + + test('emits non-credential DB fields as flags', () => { + const cmd = buildInstallCommand({ + ...promptBase, + dbHost: '127.0.0.1', + dbPort: '3306', + dbServiceName: 'node-mysql', + nodeName: 'node-1', + nodeAddress: '10.0.0.1', + }); + expect(cmd).toContain("--db-host '127.0.0.1'"); + expect(cmd).toContain("--db-port '3306'"); + expect(cmd).toContain("--db-service-name 'node-mysql'"); + expect(cmd).toContain("--node-name 'node-1'"); + expect(cmd).toContain("--node-address '10.0.0.1'"); + }); + + test('emits --db-name only for postgresql', () => { + const pg = buildInstallCommand({ + ...promptBase, + technology: 'postgresql', + dbName: 'postgres', + }); + const mysql = buildInstallCommand({ + ...promptBase, + technology: 'mysql', + dbName: 'postgres', + }); + expect(pg).toContain("--db-name 'postgres'"); + expect(mysql).not.toContain('--db-name'); + }); + + test('emits --db-auth-db only for mongodb', () => { + const mongo = buildInstallCommand({ + ...promptBase, + technology: 'mongodb', + dbAuthDB: 'admin', + }); + const mysql = buildInstallCommand({ + ...promptBase, + technology: 'mysql', + dbAuthDB: 'admin', + }); + expect(mongo).toContain("--db-auth-db 'admin'"); + expect(mysql).not.toContain('--db-auth-db'); + }); + + test('respects insecureTLS toggle for both curl and the script', () => { + const secure = buildInstallCommand({ ...promptBase, insecureTLS: false }); + const insecure = buildInstallCommand({ ...promptBase, insecureTLS: true }); + + expect(secure).toContain('curl -fsSL '); + expect(secure).not.toContain('curl -fsSLk'); + expect(secure).not.toContain('--pmm-server-insecure-tls'); + + expect(insecure).toContain('curl -fsSLk '); + expect(insecure).toContain('--pmm-server-insecure-tls'); + }); + + test('emits --pmm-server-url and --tech', () => { + const cmd = buildInstallCommand(promptBase); + expect(cmd).toContain( + "--pmm-server-url 'https://service_token:GLSA@pmm.example.com:443'" + ); + expect(cmd).toContain("--tech 'mysql'"); + }); + + test('emits --force when registerForce is on', () => { + const off = buildInstallCommand({ ...promptBase, registerForce: false }); + const on = buildInstallCommand({ ...promptBase, registerForce: true }); + expect(off).not.toContain('--force'); + expect(on).toContain('--force'); + }); +}); + +describe('formatExpiresIn', () => { + test('formats whole minutes with zero seconds', () => { + expect(formatExpiresIn(15 * 60)).toBe('15:00'); + expect(formatExpiresIn(60)).toBe('1:00'); + }); + + test('zero-pads seconds', () => { + expect(formatExpiresIn(125)).toBe('2:05'); + expect(formatExpiresIn(9)).toBe('0:09'); + }); + + test('handles boundary values', () => { + expect(formatExpiresIn(0)).toBe('0:00'); + expect(formatExpiresIn(1)).toBe('0:01'); + expect(formatExpiresIn(59)).toBe('0:59'); + expect(formatExpiresIn(60)).toBe('1:00'); + }); + + test('floors fractional seconds', () => { + expect(formatExpiresIn(59.9)).toBe('0:59'); + expect(formatExpiresIn(120.4)).toBe('2:00'); + }); + + test('clamps negatives to 0:00 (already expired)', () => { + expect(formatExpiresIn(-1)).toBe('0:00'); + expect(formatExpiresIn(-9999)).toBe('0:00'); + }); +}); diff --git a/ui/apps/pmm/src/pages/install-client/InstallClientPage.utils.ts b/ui/apps/pmm/src/pages/install-client/InstallClientPage.utils.ts new file mode 100644 index 00000000000..a9ecb6189f7 --- /dev/null +++ b/ui/apps/pmm/src/pages/install-client/InstallClientPage.utils.ts @@ -0,0 +1,234 @@ +// IMPORTANT: keep this list in sync with `SUPPORTED_TECHNOLOGIES` in +// api/installToken.ts — adding a tech here without adding it there gets you +// a client-side "unsupported technology" error when generating a token. +export type Technology = 'mysql' | 'postgresql' | 'mongodb' | 'valkey'; +export type CredentialsMode = 'prompt' | 'env' | 'flags'; + +/** + * Formats the remaining lifetime of an install token as MM:SS. + * Negative inputs (already expired) are clamped to "0:00" so callers can + * branch on isExpired separately without seeing odd negative timers. + */ +export const formatExpiresIn = (secondsLeft: number): string => { + const safe = Math.max(0, Math.floor(secondsLeft)); + const minutes = Math.floor(safe / 60); + const seconds = safe % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +}; + +/** + * Builds PMM_SERVER_URL for install scripts. Token is percent-encoded in the userinfo. + * `pmmHost` is hostname or hostname:port (defaults to current page host when empty). + */ +export function buildPmmServerURL(pmmHost: string, token: string): string { + const authority = + pmmHost.trim() || + (typeof window !== 'undefined' ? window.location.host : 'localhost'); + const t = token.trim(); + if (!t) { + return `https://service_token:@${authority}`; + } + return `https://service_token:${encodeURIComponent(t)}@${authority}`; +} + +export interface InstallCommandOptions { + installerUrl: string; + technology: Technology; + credentialsMode: CredentialsMode; + serverURL: string; + insecureTLS: boolean; + registerForce: boolean; + nodeName: string; + nodeAddress: string; + dbUser: string; + dbPassword: string; + dbHost: string; + dbPort: string; + dbName: string; + dbAuthDB: string; + dbServiceName: string; +} + +export const shellEscape = (value: string): string => + `'${value.replace(/'/g, `'\\''`)}'`; + +// Where prompt mode tells the user to drop the downloaded script. /tmp is the +// only path we promise: it is universally writable and `bash ` works even +// when /tmp is mounted noexec (no exec bit needed, only read). Documented in +// one-step-ui-install.md; do not change without updating docs and tests. +const DOWNLOADED_SCRIPT_PATH = '/tmp/install-pmm-client.sh'; + +const curlDownloadFlags = (insecureTLS: boolean): string => + insecureTLS ? '-fsSLk' : '-fsSL'; + +// buildPromptModeCommand renders a two-step "download then sudo -E bash" command +// so the install script gets a real TTY on stdin. This is the only mode where +// `read -r -s` in install-pmm-client.sh can ask for DB user/password, so this +// branch must NEVER emit DB_USER / DB_PASSWORD or --db-user / --db-password — +// the script will prompt for them. Other optional fields (host, port, service +// name, MongoDB auth DB, PostgreSQL database) are still passed as flags so the +// user only types two things. +const buildPromptModeCommand = (opts: InstallCommandOptions): string => { + const curl = `curl ${curlDownloadFlags(opts.insecureTLS)} -o ${shellEscape( + DOWNLOADED_SCRIPT_PATH + )} ${shellEscape(opts.installerUrl)}`; + + const flags: string[] = [ + `--pmm-server-url ${shellEscape(opts.serverURL)}`, + `--tech ${shellEscape(opts.technology)}`, + ]; + if (opts.insecureTLS) { + flags.push('--pmm-server-insecure-tls'); + } + if (opts.registerForce) { + flags.push('--force'); + } + if (opts.nodeName.trim()) { + flags.push(`--node-name ${shellEscape(opts.nodeName.trim())}`); + } + if (opts.nodeAddress.trim()) { + flags.push(`--node-address ${shellEscape(opts.nodeAddress.trim())}`); + } + if (opts.dbHost.trim()) { + flags.push(`--db-host ${shellEscape(opts.dbHost.trim())}`); + } + if (opts.dbPort.trim()) { + flags.push(`--db-port ${shellEscape(opts.dbPort.trim())}`); + } + if (opts.dbServiceName.trim()) { + flags.push(`--db-service-name ${shellEscape(opts.dbServiceName.trim())}`); + } + if (opts.dbName.trim() && opts.technology === 'postgresql') { + flags.push(`--db-name ${shellEscape(opts.dbName.trim())}`); + } + if (opts.dbAuthDB.trim() && opts.technology === 'mongodb') { + flags.push(`--db-auth-db ${shellEscape(opts.dbAuthDB.trim())}`); + } + + // `sudo -E bash ` keeps stdin on the caller's TTY (same as plain sudo bash) + // while preserving the user's environment. install-pmm-client.sh maps + // DB_USER / DB_PASSWORD (and MYSQL_* / POSTGRESQL_* / …) before prompts; if + // those are already set, `prompt_if_empty` skips — so exports survive sudo. + return [ + curl, + `sudo -E bash ${shellEscape(DOWNLOADED_SCRIPT_PATH)} \\`, + ` ${flags.join(' \\\n ')}`, + ].join('\n'); +}; + +export const buildInstallCommand = (opts: InstallCommandOptions): string => { + if (opts.credentialsMode === 'prompt') { + return buildPromptModeCommand(opts); + } + + // -k is for the PMM Server certificate; emit it only when the user opted into + // insecure TLS. With a properly signed cert we want curl to verify normally + // (otherwise we'd be silently downgrading the security of every install). + const curl = `curl ${curlDownloadFlags(opts.insecureTLS)} ${shellEscape(opts.installerUrl)}`; + + const envVars: string[] = [ + `PMM_SERVER_URL=${shellEscape(opts.serverURL)}`, + `TECH=${shellEscape(opts.technology)}`, + ]; + + if (opts.nodeName.trim()) { + envVars.push(`NODE_NAME=${shellEscape(opts.nodeName.trim())}`); + } + if (opts.nodeAddress.trim()) { + envVars.push(`NODE_ADDRESS=${shellEscape(opts.nodeAddress.trim())}`); + } + + /** Passed after \`bash -s --\` (matches install-pmm-client.sh). */ + const scriptFlags: string[] = []; + if (opts.insecureTLS) { + scriptFlags.push('--pmm-server-insecure-tls'); + } + if (opts.registerForce) { + scriptFlags.push('--force'); + } + + if (opts.credentialsMode === 'env') { + if (opts.dbUser.trim()) { + envVars.push(`DB_USER=${shellEscape(opts.dbUser.trim())}`); + } + if (opts.dbPassword.trim()) { + envVars.push(`DB_PASSWORD=${shellEscape(opts.dbPassword.trim())}`); + } + if (opts.dbHost.trim()) { + envVars.push(`DB_HOST=${shellEscape(opts.dbHost.trim())}`); + } + if (opts.dbPort.trim()) { + envVars.push(`DB_PORT=${shellEscape(opts.dbPort.trim())}`); + } + if (opts.dbServiceName.trim()) { + envVars.push(`DB_SERVICE_NAME=${shellEscape(opts.dbServiceName.trim())}`); + } + if (opts.dbName.trim() && opts.technology === 'postgresql') { + envVars.push(`DB_NAME=${shellEscape(opts.dbName.trim())}`); + } + if (opts.dbAuthDB.trim() && opts.technology === 'mongodb') { + envVars.push(`DB_AUTH_DB=${shellEscape(opts.dbAuthDB.trim())}`); + } + } + + if (opts.credentialsMode === 'flags') { + const flags: string[] = [ + `--pmm-server-url ${shellEscape(opts.serverURL)}`, + `--tech ${shellEscape(opts.technology)}`, + ]; + + if (opts.nodeName.trim()) { + flags.push(`--node-name ${shellEscape(opts.nodeName.trim())}`); + } + if (opts.nodeAddress.trim()) { + flags.push(`--node-address ${shellEscape(opts.nodeAddress.trim())}`); + } + if (opts.insecureTLS) { + flags.push('--pmm-server-insecure-tls'); + } + if (opts.registerForce) { + flags.push('--force'); + } + if (opts.dbUser.trim()) { + flags.push(`--db-user ${shellEscape(opts.dbUser.trim())}`); + } + if (opts.dbPassword.trim()) { + flags.push(`--db-password ${shellEscape(opts.dbPassword.trim())}`); + } + if (opts.dbHost.trim()) { + flags.push(`--db-host ${shellEscape(opts.dbHost.trim())}`); + } + if (opts.dbPort.trim()) { + flags.push(`--db-port ${shellEscape(opts.dbPort.trim())}`); + } + if (opts.dbServiceName.trim()) { + flags.push(`--db-service-name ${shellEscape(opts.dbServiceName.trim())}`); + } + if (opts.dbName.trim() && opts.technology === 'postgresql') { + flags.push(`--db-name ${shellEscape(opts.dbName.trim())}`); + } + if (opts.dbAuthDB.trim() && opts.technology === 'mongodb') { + flags.push(`--db-auth-db ${shellEscape(opts.dbAuthDB.trim())}`); + } + + return [ + `${curl} | sudo -E bash -s -- \\`, + ` ${flags.join(' \\\n ')}`, + ].join('\n'); + } + + const lines: string[] = [`${curl} | sudo -E env \\`]; + envVars.forEach((item) => { + lines.push(` ${item} \\`); + }); + if (scriptFlags.length === 0) { + lines.push('bash -s --'); + } else { + lines.push('bash -s -- \\'); + scriptFlags.forEach((flag, index) => { + const isLast = index === scriptFlags.length - 1; + lines.push(isLast ? ` ${flag}` : ` ${flag} \\`); + }); + } + return lines.join('\n'); +}; diff --git a/ui/apps/pmm/src/pages/install-client/index.ts b/ui/apps/pmm/src/pages/install-client/index.ts new file mode 100644 index 00000000000..a1783cf07d6 --- /dev/null +++ b/ui/apps/pmm/src/pages/install-client/index.ts @@ -0,0 +1 @@ +export * from './InstallClientPage'; diff --git a/ui/apps/pmm/src/router.tsx b/ui/apps/pmm/src/router.tsx index 12cdba5d678..f59cc38de0b 100644 --- a/ui/apps/pmm/src/router.tsx +++ b/ui/apps/pmm/src/router.tsx @@ -13,6 +13,7 @@ import { RealtimeSessionsPage } from 'pages/rta/sessions'; import { Redirect, SettingsRedirect } from 'components/redirect'; import RealtimeOverviewPage from 'pages/rta/overview/RealtimeOverview'; import RealtimeTab from 'pages/rta/tab/RealtimeTab'; +import { InstallClientPage } from 'pages/install-client'; const router = createBrowserRouter( [ @@ -40,6 +41,10 @@ const router = createBrowserRouter( path: 'help', element: , }, + { + path: 'install-client', + element: , + }, { path: 'settings/:tab?', element: ,