mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-12 14:07:28 +00:00
Fixes #755. Adds interactive pauses and graphical popups (where available) to installer scripts when critical errors occur, ensuring troubleshooting guides are readable. Also clarifies 'build from source' instructions.
2157 lines
56 KiB
Bash
Executable file
2157 lines
56 KiB
Bash
Executable file
# Generated file. Do not edit.
|
||
# Bundled on: 2025-11-25T11:15:48Z
|
||
# Manifest: scripts/bundle.manifest
|
||
|
||
# === Begin: scripts/lib/common.sh ===
|
||
#!/usr/bin/env bash
|
||
#
|
||
# Shared common utilities for Pulse shell scripts.
|
||
# Provides logging, privilege escalation, command execution helpers, and cleanup management.
|
||
|
||
set -o errexit
|
||
set -o nounset
|
||
set -o pipefail
|
||
|
||
shopt -s extglob
|
||
|
||
declare -gA COMMON__LOG_LEVELS=(
|
||
[debug]=10
|
||
[info]=20
|
||
[warn]=30
|
||
[error]=40
|
||
)
|
||
|
||
declare -g COMMON__SCRIPT_PATH=""
|
||
declare -g COMMON__SCRIPT_DIR=""
|
||
declare -g COMMON__ORIGINAL_ARGS=()
|
||
|
||
declare -g COMMON__LOG_LEVEL="info"
|
||
declare -g COMMON__LOG_LEVEL_NUM=20
|
||
declare -g COMMON__DEBUG=false
|
||
declare -g COMMON__COLOR_ENABLED=true
|
||
|
||
declare -g COMMON__DRY_RUN=false
|
||
|
||
declare -ag COMMON__CLEANUP_DESCRIPTIONS=()
|
||
declare -ag COMMON__CLEANUP_COMMANDS=()
|
||
declare -g COMMON__CLEANUP_REGISTERED=false
|
||
|
||
# shellcheck disable=SC2034
|
||
declare -g COMMON__ANSI_RESET=""
|
||
# shellcheck disable=SC2034
|
||
declare -g COMMON__ANSI_INFO=""
|
||
# shellcheck disable=SC2034
|
||
declare -g COMMON__ANSI_WARN=""
|
||
# shellcheck disable=SC2034
|
||
declare -g COMMON__ANSI_ERROR=""
|
||
# shellcheck disable=SC2034
|
||
declare -g COMMON__ANSI_DEBUG=""
|
||
|
||
# common::init
|
||
# Initializes the common library. Call at the top of every script.
|
||
# Sets script metadata, log level, color handling, traps, and stores original args.
|
||
common::init() {
|
||
COMMON__SCRIPT_PATH="$(common::__resolve_script_path)"
|
||
COMMON__SCRIPT_DIR="$(dirname "${COMMON__SCRIPT_PATH}")"
|
||
COMMON__ORIGINAL_ARGS=("$@")
|
||
|
||
common::__configure_color
|
||
common::__configure_log_level
|
||
|
||
if [[ "${COMMON__DEBUG}" == true ]]; then
|
||
common::log_debug "Debug mode enabled"
|
||
fi
|
||
|
||
if [[ "${COMMON__CLEANUP_REGISTERED}" == false ]]; then
|
||
trap common::cleanup_run EXIT
|
||
COMMON__CLEANUP_REGISTERED=true
|
||
fi
|
||
}
|
||
|
||
# common::log_info "message"
|
||
# Logs informational messages. Printed to stdout.
|
||
common::log_info() {
|
||
common::__log "info" "$@"
|
||
}
|
||
|
||
# common::log_warn "message"
|
||
# Logs warning messages. Printed to stderr.
|
||
common::log_warn() {
|
||
common::__log "warn" "$@"
|
||
}
|
||
|
||
# common::log_error "message"
|
||
# Logs error messages. Printed to stderr.
|
||
common::log_error() {
|
||
common::__log "error" "$@"
|
||
}
|
||
|
||
# common::log_debug "message"
|
||
# Logs debug messages when log level is debug. Printed to stderr.
|
||
common::log_debug() {
|
||
common::__log "debug" "$@"
|
||
}
|
||
|
||
# common::fail "message" [--code N]
|
||
# Logs an error message and exits with provided code (default 1).
|
||
common::fail() {
|
||
local exit_code=1
|
||
local message_parts=()
|
||
while (($#)); do
|
||
case "$1" in
|
||
--code)
|
||
shift
|
||
exit_code="${1:-1}"
|
||
;;
|
||
*)
|
||
message_parts+=("$1")
|
||
;;
|
||
esac
|
||
shift || break
|
||
done
|
||
local message="${message_parts[*]}"
|
||
common::log_error "${message}"
|
||
|
||
if common::is_interactive; then
|
||
echo ""
|
||
if [[ -t 0 ]]; then
|
||
read -p "Press Enter to exit..."
|
||
elif [[ -e /dev/tty ]]; then
|
||
read -p "Press Enter to exit..." < /dev/tty
|
||
fi
|
||
fi
|
||
|
||
exit "${exit_code}"
|
||
}
|
||
|
||
# common::require_command cmd1 [cmd2 ...]
|
||
# Verifies that required commands exist. Fails if any are missing.
|
||
common::require_command() {
|
||
local missing=()
|
||
local cmd
|
||
for cmd in "$@"; do
|
||
if ! command -v "${cmd}" >/dev/null 2>&1; then
|
||
missing+=("${cmd}")
|
||
fi
|
||
done
|
||
|
||
if ((${#missing[@]} > 0)); then
|
||
common::fail "Missing required command(s): ${missing[*]}"
|
||
fi
|
||
}
|
||
|
||
# common::is_interactive
|
||
# Returns success when running in an interactive TTY or PULSE_FORCE_INTERACTIVE=1.
|
||
common::is_interactive() {
|
||
if [[ "${PULSE_FORCE_INTERACTIVE:-0}" == "1" ]]; then
|
||
return 0
|
||
fi
|
||
[[ -t 0 && -t 1 ]]
|
||
}
|
||
|
||
# common::ensure_root [--allow-sudo] [--args "${COMMON__ORIGINAL_ARGS[@]}"]
|
||
# Ensures the script is running with root privileges. Optionally re-execs with sudo.
|
||
common::ensure_root() {
|
||
local allow_sudo=false
|
||
local reexec_args=()
|
||
|
||
while (($#)); do
|
||
case "$1" in
|
||
--allow-sudo)
|
||
allow_sudo=true
|
||
;;
|
||
--args)
|
||
shift
|
||
reexec_args=("$@")
|
||
break
|
||
;;
|
||
*)
|
||
common::log_warn "Unknown argument to common::ensure_root: $1"
|
||
;;
|
||
esac
|
||
shift || break
|
||
done
|
||
|
||
if [[ "${EUID}" -eq 0 ]]; then
|
||
return 0
|
||
fi
|
||
|
||
if [[ "${allow_sudo}" == true ]]; then
|
||
if common::is_interactive; then
|
||
common::log_info "Escalating privileges with sudo..."
|
||
common::sudo_exec "${COMMON__SCRIPT_PATH}" "${reexec_args[@]}"
|
||
exit 0
|
||
fi
|
||
common::fail "Root privileges required; rerun with sudo or as root."
|
||
fi
|
||
|
||
common::fail "Root privileges required."
|
||
}
|
||
|
||
# common::sudo_exec command [args...]
|
||
# Executes a command via sudo, providing user guidance on failure.
|
||
common::sudo_exec() {
|
||
local sudo_cmd="${PULSE_SUDO_CMD:-sudo}"
|
||
if command -v "${sudo_cmd}" >/dev/null 2>&1; then
|
||
exec "${sudo_cmd}" -- "$@"
|
||
fi
|
||
|
||
cat <<'EOF' >&2
|
||
Unable to escalate privileges automatically because sudo is unavailable.
|
||
Please install sudo or rerun this script as root.
|
||
EOF
|
||
exit 1
|
||
}
|
||
|
||
# common::run [--label desc] [--retries N] [--backoff "1 2 4"] command [args...]
|
||
# Executes a command, respecting dry-run mode and retry policies.
|
||
common::run() {
|
||
local label=""
|
||
local retries=1
|
||
local backoff=()
|
||
local -a cmd=()
|
||
|
||
while (($#)); do
|
||
case "$1" in
|
||
--label)
|
||
label="$2"
|
||
shift 2
|
||
continue
|
||
;;
|
||
--retries)
|
||
retries="$2"
|
||
shift 2
|
||
continue
|
||
;;
|
||
--backoff)
|
||
read -r -a backoff <<<"$2"
|
||
shift 2
|
||
continue
|
||
;;
|
||
--)
|
||
shift
|
||
break
|
||
;;
|
||
-*)
|
||
common::log_warn "Unknown flag for common::run: $1"
|
||
shift
|
||
continue
|
||
;;
|
||
*)
|
||
break
|
||
;;
|
||
esac
|
||
done
|
||
|
||
cmd=("$@")
|
||
[[ -z "${label}" ]] && label="${cmd[*]}"
|
||
|
||
if [[ "${COMMON__DRY_RUN}" == true ]]; then
|
||
common::log_info "[dry-run] ${label}"
|
||
return 0
|
||
fi
|
||
|
||
local attempt=1
|
||
local exit_code=0
|
||
|
||
while (( attempt <= retries )); do
|
||
if "${cmd[@]}"; then
|
||
return 0
|
||
fi
|
||
|
||
exit_code=$?
|
||
if (( attempt == retries )); then
|
||
common::log_error "Command failed (${exit_code}): ${label}"
|
||
return "${exit_code}"
|
||
fi
|
||
|
||
local sleep_time="${backoff[$((attempt - 1))]:-1}"
|
||
common::log_warn "Command failed (${exit_code}): ${label} — retrying in ${sleep_time}s (attempt ${attempt}/${retries})"
|
||
sleep "${sleep_time}"
|
||
((attempt++))
|
||
done
|
||
|
||
return "${exit_code}"
|
||
}
|
||
|
||
# common::run_capture [--label desc] command [args...]
|
||
# Executes a command and captures stdout. Respects dry-run mode.
|
||
common::run_capture() {
|
||
local label=""
|
||
|
||
while (($#)); do
|
||
case "$1" in
|
||
--label)
|
||
label="$2"
|
||
shift 2
|
||
continue
|
||
;;
|
||
--)
|
||
shift
|
||
break
|
||
;;
|
||
-*)
|
||
common::log_warn "Unknown flag for common::run_capture: $1"
|
||
shift
|
||
continue
|
||
;;
|
||
*)
|
||
break
|
||
;;
|
||
esac
|
||
done
|
||
|
||
local -a cmd=("$@")
|
||
[[ -z "${label}" ]] && label="${cmd[*]}"
|
||
|
||
if [[ "${COMMON__DRY_RUN}" == true ]]; then
|
||
common::log_info "[dry-run] ${label}"
|
||
echo ""
|
||
return 0
|
||
fi
|
||
|
||
"${cmd[@]}"
|
||
}
|
||
|
||
# common::temp_dir <var> [--prefix name]
|
||
# Creates a temporary directory tracked for cleanup and assigns it to <var>.
|
||
common::temp_dir() {
|
||
local var_name=""
|
||
local prefix="pulse-"
|
||
|
||
if (($#)) && [[ $1 != --* ]]; then
|
||
var_name="$1"
|
||
shift
|
||
fi
|
||
|
||
while (($#)); do
|
||
case "$1" in
|
||
--prefix)
|
||
prefix="$2"
|
||
shift 2
|
||
continue
|
||
;;
|
||
*)
|
||
common::log_warn "Unknown argument to common::temp_dir: $1"
|
||
shift
|
||
continue
|
||
;;
|
||
esac
|
||
done
|
||
|
||
local dir
|
||
dir="$(mktemp -d -t "${prefix}XXXXXX")"
|
||
if (( "${BASH_SUBSHELL:-0}" > 0 )); then
|
||
common::log_warn "common::temp_dir invoked in subshell; cleanup handlers will not be registered automatically for ${dir}"
|
||
else
|
||
common::cleanup_push "Remove temp dir ${dir}" "rm -rf ${dir@Q}"
|
||
fi
|
||
|
||
if [[ -n "${var_name}" ]]; then
|
||
printf -v "${var_name}" '%s' "${dir}"
|
||
else
|
||
printf '%s\n' "${dir}"
|
||
fi
|
||
}
|
||
|
||
# common::cleanup_push "description" "command"
|
||
# Adds a cleanup handler executed in LIFO order on exit.
|
||
common::cleanup_push() {
|
||
local description="${1:-}"
|
||
local command="${2:-}"
|
||
|
||
if [[ -z "${command}" ]]; then
|
||
common::log_warn "Ignoring cleanup handler without command"
|
||
return
|
||
fi
|
||
|
||
COMMON__CLEANUP_DESCRIPTIONS+=("${description}")
|
||
COMMON__CLEANUP_COMMANDS+=("${command}")
|
||
}
|
||
|
||
# common::cleanup_run
|
||
# Executes registered cleanup handlers. Called automatically via EXIT trap.
|
||
common::cleanup_run() {
|
||
local idx=$(( ${#COMMON__CLEANUP_COMMANDS[@]} - 1 ))
|
||
while (( idx >= 0 )); do
|
||
local command="${COMMON__CLEANUP_COMMANDS[$idx]}"
|
||
local description="${COMMON__CLEANUP_DESCRIPTIONS[$idx]}"
|
||
if [[ -n "${description}" ]]; then
|
||
common::log_debug "Running cleanup: ${description}"
|
||
fi
|
||
eval "${command}"
|
||
((idx--))
|
||
done
|
||
|
||
COMMON__CLEANUP_COMMANDS=()
|
||
COMMON__CLEANUP_DESCRIPTIONS=()
|
||
}
|
||
|
||
# common::set_dry_run true|false
|
||
# Enables or disables global dry-run mode.
|
||
common::set_dry_run() {
|
||
local flag="${1:-false}"
|
||
case "${flag}" in
|
||
true|1|yes)
|
||
COMMON__DRY_RUN=true
|
||
;;
|
||
false|0|no)
|
||
COMMON__DRY_RUN=false
|
||
;;
|
||
*)
|
||
common::log_warn "Unknown dry-run flag: ${flag}"
|
||
COMMON__DRY_RUN=true
|
||
;;
|
||
esac
|
||
}
|
||
|
||
# common::is_dry_run
|
||
# Returns success when dry-run mode is active.
|
||
common::is_dry_run() {
|
||
[[ "${COMMON__DRY_RUN}" == true ]]
|
||
}
|
||
|
||
# Internal helper: configure color output.
|
||
common::__configure_color() {
|
||
if [[ "${PULSE_NO_COLOR:-0}" == "1" || ! -t 1 ]]; then
|
||
COMMON__COLOR_ENABLED=false
|
||
fi
|
||
|
||
if [[ "${COMMON__COLOR_ENABLED}" == true ]]; then
|
||
COMMON__ANSI_RESET=$'\033[0m'
|
||
COMMON__ANSI_INFO=$'\033[1;34m'
|
||
COMMON__ANSI_WARN=$'\033[1;33m'
|
||
COMMON__ANSI_ERROR=$'\033[1;31m'
|
||
COMMON__ANSI_DEBUG=$'\033[1;35m'
|
||
else
|
||
COMMON__ANSI_RESET=""
|
||
COMMON__ANSI_INFO=""
|
||
COMMON__ANSI_WARN=""
|
||
COMMON__ANSI_ERROR=""
|
||
COMMON__ANSI_DEBUG=""
|
||
fi
|
||
}
|
||
|
||
# Internal helper: set log level and debug flag.
|
||
common::__configure_log_level() {
|
||
if [[ "${PULSE_DEBUG:-0}" == "1" ]]; then
|
||
COMMON__DEBUG=true
|
||
COMMON__LOG_LEVEL="debug"
|
||
else
|
||
COMMON__LOG_LEVEL="${PULSE_LOG_LEVEL:-info}"
|
||
fi
|
||
|
||
COMMON__LOG_LEVEL="${COMMON__LOG_LEVEL,,}"
|
||
COMMON__LOG_LEVEL_NUM="${COMMON__LOG_LEVELS[${COMMON__LOG_LEVEL}]:-20}"
|
||
}
|
||
|
||
# Internal helper: generic logger.
|
||
common::__log() {
|
||
local level="$1"
|
||
shift
|
||
local message="$*"
|
||
|
||
local level_num="${COMMON__LOG_LEVELS[${level}]:-20}"
|
||
if (( level_num < COMMON__LOG_LEVEL_NUM )); then
|
||
return 0
|
||
fi
|
||
|
||
local timestamp
|
||
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||
local color=""
|
||
|
||
case "${level}" in
|
||
info) color="${COMMON__ANSI_INFO}" ;;
|
||
warn) color="${COMMON__ANSI_WARN}" ;;
|
||
error) color="${COMMON__ANSI_ERROR}" ;;
|
||
debug) color="${COMMON__ANSI_DEBUG}" ;;
|
||
esac
|
||
|
||
local formatted="[$timestamp] [${level^^}] ${message}"
|
||
if [[ "${level}" == "info" ]]; then
|
||
printf '%s%s%s\n' "${color}" "${formatted}" "${COMMON__ANSI_RESET}"
|
||
else
|
||
printf '%s%s%s\n' "${color}" "${formatted}" "${COMMON__ANSI_RESET}" >&2
|
||
fi
|
||
}
|
||
|
||
# Internal helper: determine absolute script path.
|
||
common::__resolve_script_path() {
|
||
local source="${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}"
|
||
if [[ -z "${source}" ]]; then
|
||
pwd
|
||
return
|
||
fi
|
||
|
||
if [[ "${source}" == /* ]]; then
|
||
printf '%s\n' "${source}"
|
||
return
|
||
fi
|
||
|
||
printf '%s\n' "$(cd "$(dirname "${source}")" && pwd)/$(basename "${source}")"
|
||
}
|
||
# === End: scripts/lib/common.sh ===
|
||
|
||
# === Begin: scripts/lib/systemd.sh ===
|
||
#!/usr/bin/env bash
|
||
#
|
||
# Systemd management helpers for Pulse shell scripts.
|
||
|
||
set -euo pipefail
|
||
|
||
# Ensure the common library is available when sourced directly.
|
||
if ! declare -F common::log_info >/dev/null 2>&1; then
|
||
# shellcheck disable=SC1091
|
||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh"
|
||
fi
|
||
|
||
declare -g SYSTEMD_TIMEOUT_SEC="${SYSTEMD_TIMEOUT_SEC:-20}"
|
||
|
||
# systemd::safe_systemctl args...
|
||
# Executes systemctl with sensible defaults and optional timeout guards.
|
||
systemd::safe_systemctl() {
|
||
if ! command -v systemctl >/dev/null 2>&1; then
|
||
common::fail "systemctl not available on this host"
|
||
fi
|
||
|
||
local -a cmd
|
||
systemd::__build_cmd cmd "$@"
|
||
systemd::__wrap_timeout cmd
|
||
|
||
local label="systemctl $*"
|
||
common::run --label "${label}" -- "${cmd[@]}"
|
||
}
|
||
|
||
# systemd::detect_service_name [service...]
|
||
# Returns the first existing service name from the provided list.
|
||
systemd::detect_service_name() {
|
||
local -a candidates=("$@")
|
||
if ((${#candidates[@]} == 0)); then
|
||
candidates=(pulse.service pulse-backend.service pulse-docker-agent.service pulse-hot-dev.service)
|
||
fi
|
||
|
||
local name
|
||
for name in "${candidates[@]}"; do
|
||
if systemd::service_exists "${name}"; then
|
||
printf '%s\n' "$(systemd::__normalize_unit "${name}")"
|
||
return 0
|
||
fi
|
||
done
|
||
return 1
|
||
}
|
||
|
||
# systemd::service_exists service
|
||
# Returns success if the given service unit exists.
|
||
systemd::service_exists() {
|
||
local unit
|
||
unit="$(systemd::__normalize_unit "${1:-}")" || return 1
|
||
|
||
if command -v systemctl >/dev/null 2>&1 && ! common::is_dry_run; then
|
||
local -a cmd
|
||
systemd::__build_cmd cmd "list-unit-files" "${unit}"
|
||
systemd::__wrap_timeout cmd
|
||
local output=""
|
||
if output="$("${cmd[@]}" 2>/dev/null)"; then
|
||
[[ "${output}" =~ ^${unit}[[:space:]] ]] && return 0
|
||
fi
|
||
fi
|
||
|
||
local paths=(
|
||
"/etc/systemd/system/${unit}"
|
||
"/lib/systemd/system/${unit}"
|
||
"/usr/lib/systemd/system/${unit}"
|
||
)
|
||
local path
|
||
for path in "${paths[@]}"; do
|
||
if [[ -f "${path}" ]]; then
|
||
return 0
|
||
fi
|
||
done
|
||
return 1
|
||
}
|
||
|
||
# systemd::is_active service
|
||
# Checks whether the given service is active.
|
||
systemd::is_active() {
|
||
local unit
|
||
unit="$(systemd::__normalize_unit "${1:-}")" || return 1
|
||
|
||
if common::is_dry_run; then
|
||
return 1
|
||
fi
|
||
|
||
if ! command -v systemctl >/dev/null 2>&1; then
|
||
return 1
|
||
fi
|
||
|
||
local -a cmd
|
||
systemd::__build_cmd cmd "is-active" "--quiet" "${unit}"
|
||
systemd::__wrap_timeout cmd
|
||
if "${cmd[@]}" >/dev/null 2>&1; then
|
||
return 0
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
# systemd::create_service /path/to/unit.service
|
||
# Reads unit file content from stdin and writes it to the supplied path.
|
||
systemd::create_service() {
|
||
local target="${1:-}"
|
||
local mode="${2:-0644}"
|
||
if [[ -z "${target}" ]]; then
|
||
common::fail "systemd::create_service requires a target path"
|
||
fi
|
||
|
||
local content
|
||
content="$(cat)"
|
||
|
||
if common::is_dry_run; then
|
||
common::log_info "[dry-run] Would write systemd unit ${target}"
|
||
common::log_debug "${content}"
|
||
return 0
|
||
fi
|
||
|
||
mkdir -p "$(dirname "${target}")"
|
||
printf '%s' "${content}" > "${target}"
|
||
chmod "${mode}" "${target}"
|
||
common::log_info "Wrote unit file: ${target}"
|
||
}
|
||
|
||
# systemd::enable_and_start service
|
||
# Reloads systemd, enables, and starts the given service.
|
||
systemd::enable_and_start() {
|
||
local unit
|
||
unit="$(systemd::__normalize_unit "${1:-}")" || common::fail "Invalid systemd unit name"
|
||
|
||
systemd::safe_systemctl daemon-reload
|
||
systemd::safe_systemctl enable "${unit}"
|
||
systemd::safe_systemctl start "${unit}"
|
||
}
|
||
|
||
# systemd::restart service
|
||
# Safely restarts the given service.
|
||
systemd::restart() {
|
||
local unit
|
||
unit="$(systemd::__normalize_unit "${1:-}")" || common::fail "Invalid systemd unit name"
|
||
systemd::safe_systemctl restart "${unit}"
|
||
}
|
||
|
||
# Internal: build systemctl command array.
|
||
systemd::__build_cmd() {
|
||
local -n ref=$1
|
||
shift
|
||
ref=("systemctl" "--no-ask-password" "--no-pager")
|
||
ref+=("$@")
|
||
}
|
||
|
||
# Internal: wrap command with timeout if necessary.
|
||
systemd::__wrap_timeout() {
|
||
local -n ref=$1
|
||
if ! command -v timeout >/dev/null 2>&1; then
|
||
return
|
||
fi
|
||
if systemd::__should_timeout; then
|
||
local -a wrapped=("timeout" "${SYSTEMD_TIMEOUT_SEC}s")
|
||
wrapped+=("${ref[@]}")
|
||
ref=("${wrapped[@]}")
|
||
fi
|
||
}
|
||
|
||
# Internal: determine if we are in a container environment.
|
||
systemd::__should_timeout() {
|
||
if [[ -f /run/systemd/container ]]; then
|
||
return 0
|
||
fi
|
||
if command -v systemd-detect-virt >/dev/null 2>&1; then
|
||
if systemd-detect-virt --quiet --container; then
|
||
return 0
|
||
fi
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
# Internal: normalize service names to include .service suffix.
|
||
systemd::__normalize_unit() {
|
||
local unit="${1:-}"
|
||
if [[ -z "${unit}" ]]; then
|
||
return 1
|
||
fi
|
||
if [[ "${unit}" != *.service ]]; then
|
||
unit="${unit}.service"
|
||
fi
|
||
printf '%s\n' "${unit}"
|
||
}
|
||
# === End: scripts/lib/systemd.sh ===
|
||
|
||
# === Begin: scripts/lib/http.sh ===
|
||
#!/usr/bin/env bash
|
||
#
|
||
# HTTP helpers for Pulse shell scripts (downloads, API calls, GitHub queries).
|
||
|
||
set -euo pipefail
|
||
|
||
# Ensure common library is loaded when sourced directly.
|
||
if ! declare -F common::log_info >/dev/null 2>&1; then
|
||
# shellcheck disable=SC1091
|
||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh"
|
||
fi
|
||
|
||
declare -g HTTP_DEFAULT_RETRIES="${HTTP_DEFAULT_RETRIES:-3}"
|
||
declare -g HTTP_DEFAULT_BACKOFF="${HTTP_DEFAULT_BACKOFF:-1 3 5}"
|
||
|
||
# http::detect_download_tool
|
||
# Emits the preferred download tool (curl/wget) or fails if neither exist.
|
||
http::detect_download_tool() {
|
||
if command -v curl >/dev/null 2>&1; then
|
||
printf 'curl\n'
|
||
return 0
|
||
fi
|
||
if command -v wget >/dev/null 2>&1; then
|
||
printf 'wget\n'
|
||
return 0
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
# http::download --url URL --output PATH [--insecure] [--quiet] [--header "Name: value"]
|
||
# [--retries N] [--backoff "1 3 5"]
|
||
# Downloads the specified URL to PATH using curl or wget.
|
||
http::download() {
|
||
local url=""
|
||
local output=""
|
||
local insecure=false
|
||
local quiet=false
|
||
local retries="${HTTP_DEFAULT_RETRIES}"
|
||
local backoff="${HTTP_DEFAULT_BACKOFF}"
|
||
local -a headers=()
|
||
|
||
while (($#)); do
|
||
case "$1" in
|
||
--url)
|
||
url="$2"
|
||
shift 2
|
||
;;
|
||
--output)
|
||
output="$2"
|
||
shift 2
|
||
;;
|
||
--insecure)
|
||
insecure=true
|
||
shift
|
||
;;
|
||
--quiet)
|
||
quiet=true
|
||
shift
|
||
;;
|
||
--header)
|
||
headers+=("$2")
|
||
shift 2
|
||
;;
|
||
--retries)
|
||
retries="$2"
|
||
shift 2
|
||
;;
|
||
--backoff)
|
||
backoff="$2"
|
||
shift 2
|
||
;;
|
||
*)
|
||
common::log_warn "Unknown flag for http::download: $1"
|
||
shift
|
||
;;
|
||
esac
|
||
done
|
||
|
||
if [[ -z "${url}" || -z "${output}" ]]; then
|
||
common::fail "http::download requires --url and --output"
|
||
fi
|
||
|
||
if common::is_dry_run; then
|
||
common::log_info "[dry-run] Download ${url} -> ${output}"
|
||
return 0
|
||
fi
|
||
|
||
local tool
|
||
if ! tool="$(http::detect_download_tool)"; then
|
||
common::fail "No download tool available (install curl or wget)"
|
||
fi
|
||
|
||
mkdir -p "$(dirname "${output}")"
|
||
|
||
local -a cmd
|
||
if [[ "${tool}" == "curl" ]]; then
|
||
cmd=(curl -fL --connect-timeout 15)
|
||
if [[ "${quiet}" == true ]]; then
|
||
cmd+=(-sS)
|
||
else
|
||
cmd+=(--progress-bar)
|
||
fi
|
||
if [[ "${insecure}" == true ]]; then
|
||
cmd+=(-k)
|
||
fi
|
||
local header
|
||
for header in "${headers[@]}"; do
|
||
cmd+=(-H "${header}")
|
||
done
|
||
cmd+=(-o "${output}" "${url}")
|
||
else
|
||
cmd=(wget --tries=3)
|
||
if [[ "${quiet}" == true ]]; then
|
||
cmd+=(-q)
|
||
else
|
||
cmd+=(--progress=bar:force)
|
||
fi
|
||
if [[ "${insecure}" == true ]]; then
|
||
cmd+=(--no-check-certificate)
|
||
fi
|
||
local header
|
||
for header in "${headers[@]}"; do
|
||
cmd+=(--header="${header}")
|
||
done
|
||
cmd+=(-O "${output}" "${url}")
|
||
fi
|
||
|
||
local -a run_args=(--label "download ${url}")
|
||
[[ -n "${retries}" ]] && run_args+=(--retries "${retries}")
|
||
[[ -n "${backoff}" ]] && run_args+=(--backoff "${backoff}")
|
||
|
||
common::run "${run_args[@]}" -- "${cmd[@]}"
|
||
}
|
||
|
||
# http::api_call --url URL [--method METHOD] [--token TOKEN] [--bearer TOKEN]
|
||
# [--body DATA] [--header "Name: value"] [--insecure]
|
||
# Performs an API request and prints the response body.
|
||
http::api_call() {
|
||
local url=""
|
||
local method="GET"
|
||
local token=""
|
||
local bearer=""
|
||
local body=""
|
||
local insecure=false
|
||
local -a headers=()
|
||
|
||
while (($#)); do
|
||
case "$1" in
|
||
--url)
|
||
url="$2"
|
||
shift 2
|
||
;;
|
||
--method)
|
||
method="$2"
|
||
shift 2
|
||
;;
|
||
--token)
|
||
token="$2"
|
||
shift 2
|
||
;;
|
||
--bearer)
|
||
bearer="$2"
|
||
shift 2
|
||
;;
|
||
--body)
|
||
body="$2"
|
||
shift 2
|
||
;;
|
||
--header)
|
||
headers+=("$2")
|
||
shift 2
|
||
;;
|
||
--insecure)
|
||
insecure=true
|
||
shift
|
||
;;
|
||
*)
|
||
common::log_warn "Unknown flag for http::api_call: $1"
|
||
shift
|
||
;;
|
||
esac
|
||
done
|
||
|
||
if [[ -z "${url}" ]]; then
|
||
common::fail "http::api_call requires --url"
|
||
fi
|
||
|
||
if common::is_dry_run; then
|
||
common::log_info "[dry-run] API ${method} ${url}"
|
||
return 0
|
||
fi
|
||
|
||
local tool
|
||
if ! tool="$(http::detect_download_tool)"; then
|
||
common::fail "No HTTP client available (install curl or wget)"
|
||
fi
|
||
|
||
local -a cmd
|
||
if [[ "${tool}" == "curl" ]]; then
|
||
cmd=(curl -fsSL)
|
||
[[ "${insecure}" == true ]] && cmd+=(-k)
|
||
[[ -n "${method}" ]] && cmd+=(-X "${method}")
|
||
if [[ -n "${body}" ]]; then
|
||
cmd+=(-d "${body}")
|
||
fi
|
||
if [[ -n "${token}" ]]; then
|
||
headers+=("X-API-Token: ${token}")
|
||
fi
|
||
if [[ -n "${bearer}" ]]; then
|
||
headers+=("Authorization: Bearer ${bearer}")
|
||
fi
|
||
local header
|
||
for header in "${headers[@]}"; do
|
||
cmd+=(-H "${header}")
|
||
done
|
||
cmd+=("${url}")
|
||
else
|
||
cmd=(wget -qO-)
|
||
[[ "${insecure}" == true ]] && cmd+=(--no-check-certificate)
|
||
[[ -n "${method}" ]] && cmd+=(--method="${method}")
|
||
if [[ -n "${body}" ]]; then
|
||
cmd+=(--body-data="${body}")
|
||
fi
|
||
if [[ -n "${token}" ]]; then
|
||
cmd+=(--header="X-API-Token: ${token}")
|
||
fi
|
||
if [[ -n "${bearer}" ]]; then
|
||
cmd+=(--header="Authorization: Bearer ${bearer}")
|
||
fi
|
||
local header
|
||
for header in "${headers[@]}"; do
|
||
cmd+=(--header="${header}")
|
||
done
|
||
cmd+=("${url}")
|
||
fi
|
||
|
||
common::run_capture --label "api ${method} ${url}" -- "${cmd[@]}"
|
||
}
|
||
|
||
# http::get_github_latest_release owner/repo
|
||
# Echoes the latest release tag for a GitHub repository.
|
||
http::get_github_latest_release() {
|
||
local repo="${1:-}"
|
||
if [[ -z "${repo}" ]]; then
|
||
common::fail "http::get_github_latest_release requires owner/repo argument"
|
||
fi
|
||
|
||
local response
|
||
response="$(http::api_call --url "https://api.github.com/repos/${repo}/releases/latest" --header "Accept: application/vnd.github+json" 2>/dev/null || true)"
|
||
if [[ -z "${response}" ]]; then
|
||
return 1
|
||
fi
|
||
|
||
if [[ "${response}" =~ \"tag_name\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then
|
||
printf '%s\n' "${BASH_REMATCH[1]}"
|
||
return 0
|
||
fi
|
||
|
||
if [[ "${response}" =~ \"name\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then
|
||
printf '%s\n' "${BASH_REMATCH[1]}"
|
||
return 0
|
||
fi
|
||
|
||
common::log_warn "Unable to parse GitHub release tag for ${repo}"
|
||
return 1
|
||
}
|
||
|
||
# http::parse_bool value
|
||
# Parses truthy/falsy strings and prints canonical true/false.
|
||
http::parse_bool() {
|
||
local input="${1:-}"
|
||
local lowered="${input,,}"
|
||
case "${lowered}" in
|
||
1|true|yes|y|on)
|
||
printf 'true\n'
|
||
return 0
|
||
;;
|
||
0|false|no|n|off|"")
|
||
printf 'false\n'
|
||
return 0
|
||
;;
|
||
esac
|
||
return 1
|
||
}
|
||
# === End: scripts/lib/http.sh ===
|
||
|
||
# === Begin: scripts/install-docker-agent-v2.sh ===
|
||
#!/usr/bin/env bash
|
||
#
|
||
# Pulse Docker Agent Installer/Uninstaller (v2)
|
||
# Refactored to leverage shared script libraries.
|
||
|
||
set -euo pipefail
|
||
|
||
LIB_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
LIB_DIR="${LIB_ROOT}/lib"
|
||
if [[ -f "${LIB_DIR}/common.sh" ]]; then
|
||
# shellcheck disable=SC1090
|
||
source "${LIB_DIR}/common.sh"
|
||
# shellcheck disable=SC1090
|
||
source "${LIB_DIR}/systemd.sh"
|
||
# shellcheck disable=SC1090
|
||
source "${LIB_DIR}/http.sh"
|
||
fi
|
||
|
||
common::init "$@"
|
||
|
||
log_info() {
|
||
common::log_info "$1"
|
||
}
|
||
|
||
log_warn() {
|
||
common::log_warn "$1"
|
||
}
|
||
|
||
log_error() {
|
||
common::log_error "$1"
|
||
}
|
||
|
||
log_success() {
|
||
common::log_info "[ OK ] $1"
|
||
}
|
||
|
||
trim() {
|
||
local value="$1"
|
||
value="${value#"${value%%[![:space:]]*}"}"
|
||
value="${value%"${value##*[![:space:]]}"}"
|
||
printf '%s' "$value"
|
||
}
|
||
|
||
determine_agent_identifier() {
|
||
local agent_id=""
|
||
|
||
if command -v docker &> /dev/null; then
|
||
agent_id=$(docker info --format '{{.ID}}' 2>/dev/null | head -n1 | tr -d '[:space:]')
|
||
fi
|
||
|
||
if [[ -z "$agent_id" ]] && [[ -r /etc/machine-id ]]; then
|
||
agent_id=$(tr -d '[:space:]' < /etc/machine-id)
|
||
fi
|
||
|
||
if [[ -z "$agent_id" ]]; then
|
||
agent_id=$(hostname 2>/dev/null | tr -d '[:space:]')
|
||
fi
|
||
|
||
printf '%s' "$agent_id"
|
||
}
|
||
|
||
log_header() {
|
||
printf '\n== %s ==\n' "$1"
|
||
}
|
||
|
||
quote_shell_arg() {
|
||
local value="$1"
|
||
value=${value//\'/\'\\\'\'}
|
||
printf "'%s'" "$value"
|
||
}
|
||
|
||
parse_bool() {
|
||
local result
|
||
if result="$(http::parse_bool "${1:-}")"; then
|
||
PARSED_BOOL="$result"
|
||
return 0
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
parse_target_spec() {
|
||
local spec="$1"
|
||
local raw_url raw_token raw_insecure
|
||
|
||
IFS='|' read -r raw_url raw_token raw_insecure <<< "$spec"
|
||
raw_url=$(trim "$raw_url")
|
||
raw_token=$(trim "$raw_token")
|
||
raw_insecure=$(trim "$raw_insecure")
|
||
|
||
if [[ -z "$raw_url" || -z "$raw_token" ]]; then
|
||
echo "Error: invalid target spec \"$spec\". Expected format url|token[|insecure]." >&2
|
||
return 1
|
||
fi
|
||
|
||
PARSED_TARGET_URL="${raw_url%/}"
|
||
PARSED_TARGET_TOKEN="$raw_token"
|
||
|
||
if [[ -n "$raw_insecure" ]]; then
|
||
if ! parse_bool "$raw_insecure"; then
|
||
echo "Error: invalid insecure flag \"$raw_insecure\" in target spec \"$spec\"." >&2
|
||
return 1
|
||
fi
|
||
PARSED_TARGET_INSECURE="$PARSED_BOOL"
|
||
else
|
||
PARSED_TARGET_INSECURE="false"
|
||
fi
|
||
|
||
return 0
|
||
}
|
||
|
||
split_targets_from_env() {
|
||
local value="$1"
|
||
if [[ -z "$value" ]]; then
|
||
return 0
|
||
fi
|
||
|
||
value="${value//$'\n'/;}"
|
||
IFS=';' read -ra __env_targets <<< "$value"
|
||
for entry in "${__env_targets[@]}"; do
|
||
local trimmed
|
||
trimmed=$(trim "$entry")
|
||
if [[ -n "$trimmed" ]]; then
|
||
printf '%s\n' "$trimmed"
|
||
fi
|
||
done
|
||
}
|
||
|
||
# Early runtime detection to hand off Podman installs to the container-aware script.
|
||
ORIGINAL_ARGS=("$@")
|
||
DETECTED_RUNTIME="${PULSE_RUNTIME:-}"
|
||
if [[ -z "$DETECTED_RUNTIME" ]]; then
|
||
idx=0
|
||
total_args=${#ORIGINAL_ARGS[@]}
|
||
while [[ $idx -lt $total_args ]]; do
|
||
arg="${ORIGINAL_ARGS[$idx]}"
|
||
case "$arg" in
|
||
--runtime)
|
||
if (( idx + 1 < total_args )); then
|
||
DETECTED_RUNTIME="${ORIGINAL_ARGS[$((idx + 1))]}"
|
||
fi
|
||
((idx += 2))
|
||
continue
|
||
;;
|
||
--runtime=*)
|
||
DETECTED_RUNTIME="${arg#--runtime=}"
|
||
;;
|
||
esac
|
||
((idx += 1))
|
||
done
|
||
unset total_args
|
||
fi
|
||
|
||
if [[ -n "$DETECTED_RUNTIME" ]]; then
|
||
runtime_lower=$(printf '%s' "$DETECTED_RUNTIME" | tr '[:upper:]' '[:lower:]')
|
||
if [[ "$runtime_lower" == "podman" ]]; then
|
||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
if [[ -f "${SCRIPT_DIR}/install-container-agent.sh" ]]; then
|
||
exec "${SCRIPT_DIR}/install-container-agent.sh" "${ORIGINAL_ARGS[@]}"
|
||
fi
|
||
common::log_error "Podman runtime requested but install-container-agent.sh not found."
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
extract_targets_from_service() {
|
||
local file="$1"
|
||
[[ ! -f "$file" ]] && return
|
||
|
||
local line value
|
||
|
||
# Prefer explicit multi-target configuration if present
|
||
line=$(grep -m1 'PULSE_TARGETS=' "$file" 2>/dev/null || true)
|
||
if [[ -n "$line" ]]; then
|
||
value=$(printf '%s\n' "$line" | sed -n 's/.*PULSE_TARGETS=\([^"]*\).*/\1/p')
|
||
if [[ -n "$value" ]]; then
|
||
IFS=';' read -ra __service_targets <<< "$value"
|
||
for entry in "${__service_targets[@]}"; do
|
||
entry=$(trim "$entry")
|
||
if [[ -n "$entry" ]]; then
|
||
printf '%s\n' "$entry"
|
||
fi
|
||
done
|
||
fi
|
||
return
|
||
fi
|
||
|
||
local url=""
|
||
local token=""
|
||
local insecure="false"
|
||
|
||
line=$(grep -m1 'PULSE_URL=' "$file" 2>/dev/null || true)
|
||
if [[ -n "$line" ]]; then
|
||
value="${line#*PULSE_URL=}"
|
||
value="${value%\"*}"
|
||
url=$(trim "$value")
|
||
fi
|
||
|
||
line=$(grep -m1 'PULSE_TOKEN=' "$file" 2>/dev/null || true)
|
||
if [[ -n "$line" ]]; then
|
||
value="${line#*PULSE_TOKEN=}"
|
||
value="${value%\"*}"
|
||
token=$(trim "$value")
|
||
fi
|
||
|
||
line=$(grep -m1 'PULSE_INSECURE_SKIP_VERIFY=' "$file" 2>/dev/null || true)
|
||
if [[ -n "$line" ]]; then
|
||
value="${line#*PULSE_INSECURE_SKIP_VERIFY=}"
|
||
value="${value%\"*}"
|
||
if parse_bool "$value"; then
|
||
insecure="$PARSED_BOOL"
|
||
fi
|
||
fi
|
||
|
||
local exec_line
|
||
exec_line=$(grep -m1 '^ExecStart=' "$file" 2>/dev/null || true)
|
||
if [[ -n "$exec_line" ]]; then
|
||
if [[ -z "$url" ]]; then
|
||
if [[ "$exec_line" =~ --url[[:space:]]+\"([^\"]+)\" ]]; then
|
||
url="${BASH_REMATCH[1]}"
|
||
elif [[ "$exec_line" =~ --url[[:space:]]+([^[:space:]]+) ]]; then
|
||
url="${BASH_REMATCH[1]}"
|
||
fi
|
||
fi
|
||
if [[ -z "$token" ]]; then
|
||
if [[ "$exec_line" =~ --token[[:space:]]+\"([^\"]+)\" ]]; then
|
||
token="${BASH_REMATCH[1]}"
|
||
elif [[ "$exec_line" =~ --token[[:space:]]+([^[:space:]]+) ]]; then
|
||
token="${BASH_REMATCH[1]}"
|
||
fi
|
||
fi
|
||
if [[ "$insecure" != "true" && "$exec_line" == *"--insecure"* ]]; then
|
||
insecure="true"
|
||
fi
|
||
fi
|
||
|
||
url=$(trim "$url")
|
||
token=$(trim "$token")
|
||
|
||
if [[ -n "$url" && -n "$token" ]]; then
|
||
printf '%s|%s|%s\n' "$url" "$token" "$insecure"
|
||
fi
|
||
}
|
||
|
||
detect_agent_path_from_service() {
|
||
if [[ -n "$SERVICE_PATH" && -f "$SERVICE_PATH" ]]; then
|
||
local exec_line
|
||
exec_line=$(grep -m1 '^ExecStart=' "$SERVICE_PATH" 2>/dev/null || true)
|
||
if [[ -n "$exec_line" ]]; then
|
||
local value="${exec_line#ExecStart=}"
|
||
value=$(trim "$value")
|
||
if [[ -n "$value" ]]; then
|
||
printf '%s' "${value%%[[:space:]]*}"
|
||
return
|
||
fi
|
||
fi
|
||
fi
|
||
}
|
||
|
||
detect_agent_path_from_unraid() {
|
||
if [[ -n "$UNRAID_STARTUP" && -f "$UNRAID_STARTUP" ]]; then
|
||
local match
|
||
match=$(grep -m1 -o '/[^[:space:]]*pulse-docker-agent' "$UNRAID_STARTUP" 2>/dev/null || true)
|
||
if [[ -n "$match" ]]; then
|
||
printf '%s' "$match"
|
||
return
|
||
fi
|
||
fi
|
||
}
|
||
|
||
detect_existing_agent_path() {
|
||
local path
|
||
|
||
path=$(detect_agent_path_from_service)
|
||
if [[ -n "$path" ]]; then
|
||
printf '%s' "$path"
|
||
return
|
||
fi
|
||
|
||
path=$(detect_agent_path_from_unraid)
|
||
if [[ -n "$path" ]]; then
|
||
printf '%s' "$path"
|
||
return
|
||
fi
|
||
|
||
if command -v pulse-docker-agent >/dev/null 2>&1; then
|
||
path=$(command -v pulse-docker-agent)
|
||
if [[ -n "$path" ]]; then
|
||
printf '%s' "$path"
|
||
return
|
||
fi
|
||
fi
|
||
}
|
||
|
||
ensure_agent_path_writable() {
|
||
local file_path="$1"
|
||
local dir="${file_path%/*}"
|
||
|
||
if [[ -z "$dir" || "$file_path" != /* ]]; then
|
||
return 1
|
||
fi
|
||
|
||
if [[ ! -d "$dir" ]]; then
|
||
if ! mkdir -p "$dir" 2>/dev/null; then
|
||
return 1
|
||
fi
|
||
fi
|
||
|
||
local test_file="$dir/.pulse-agent-write-test-$$"
|
||
if ! touch "$test_file" 2>/dev/null; then
|
||
return 1
|
||
fi
|
||
rm -f "$test_file" 2>/dev/null || true
|
||
return 0
|
||
}
|
||
|
||
select_agent_path_for_install() {
|
||
local candidates=()
|
||
declare -A seen=()
|
||
local selected=""
|
||
local default_attempted="false"
|
||
local default_failed="false"
|
||
|
||
if [[ -n "$AGENT_PATH_OVERRIDE" ]]; then
|
||
candidates+=("$AGENT_PATH_OVERRIDE")
|
||
fi
|
||
|
||
if [[ -n "$EXISTING_AGENT_PATH" ]]; then
|
||
candidates+=("$EXISTING_AGENT_PATH")
|
||
fi
|
||
|
||
candidates+=("$DEFAULT_AGENT_PATH")
|
||
for fallback in "${AGENT_FALLBACK_PATHS[@]}"; do
|
||
candidates+=("$fallback")
|
||
done
|
||
|
||
for candidate in "${candidates[@]}"; do
|
||
candidate=$(trim "$candidate")
|
||
if [[ -z "$candidate" || "$candidate" != /* ]]; then
|
||
continue
|
||
fi
|
||
if [[ -n "${seen[$candidate]:-}" ]]; then
|
||
continue
|
||
fi
|
||
seen["$candidate"]=1
|
||
|
||
if [[ "$candidate" == "$DEFAULT_AGENT_PATH" ]]; then
|
||
default_attempted="true"
|
||
fi
|
||
|
||
if ensure_agent_path_writable "$candidate"; then
|
||
selected="$candidate"
|
||
if [[ "$candidate" == "$DEFAULT_AGENT_PATH" ]]; then
|
||
DEFAULT_AGENT_PATH_WRITABLE="true"
|
||
else
|
||
if [[ "$default_attempted" == "true" && "$default_failed" == "true" && "$OVERRIDE_SPECIFIED" == "false" ]]; then
|
||
AGENT_PATH_NOTE="Note: Detected that $DEFAULT_AGENT_PATH is not writable. Using fallback path: $candidate"
|
||
fi
|
||
fi
|
||
break
|
||
else
|
||
if [[ "$candidate" == "$DEFAULT_AGENT_PATH" ]]; then
|
||
default_failed="true"
|
||
DEFAULT_AGENT_PATH_WRITABLE="false"
|
||
fi
|
||
fi
|
||
done
|
||
|
||
if [[ -z "$selected" ]]; then
|
||
echo "Error: Could not find a writable location for the agent binary." >&2
|
||
if [[ "$OVERRIDE_SPECIFIED" == "true" ]]; then
|
||
echo "Provided agent path: $AGENT_PATH_OVERRIDE" >&2
|
||
fi
|
||
exit 1
|
||
fi
|
||
|
||
printf '%s' "$selected"
|
||
}
|
||
|
||
resolve_agent_path_for_uninstall() {
|
||
if [[ -n "$AGENT_PATH_OVERRIDE" ]]; then
|
||
printf '%s' "$AGENT_PATH_OVERRIDE"
|
||
return
|
||
fi
|
||
|
||
local existing_path
|
||
existing_path=$(detect_existing_agent_path)
|
||
if [[ -n "$existing_path" ]]; then
|
||
printf '%s' "$existing_path"
|
||
return
|
||
fi
|
||
|
||
printf '%s' "$DEFAULT_AGENT_PATH"
|
||
}
|
||
|
||
# Pulse Docker Agent Installer/Uninstaller
|
||
# Install (single target):
|
||
# curl -fSL http://pulse.example.com/install-docker-agent.sh -o /tmp/pulse-install-docker-agent.sh && \
|
||
# sudo bash /tmp/pulse-install-docker-agent.sh --url http://pulse.example.com --token <api-token> && \
|
||
# rm -f /tmp/pulse-install-docker-agent.sh
|
||
# Install (multi-target fan-out):
|
||
# curl -fSL http://pulse.example.com/install-docker-agent.sh -o /tmp/pulse-install-docker-agent.sh && \
|
||
# sudo bash /tmp/pulse-install-docker-agent.sh -- \
|
||
# --target https://pulse.example.com|<api-token> \
|
||
# --target https://pulse-dr.example.com|<api-token> && \
|
||
# rm -f /tmp/pulse-install-docker-agent.sh
|
||
# Uninstall:
|
||
# curl -fSL http://pulse.example.com/install-docker-agent.sh -o /tmp/pulse-install-docker-agent.sh && \
|
||
# sudo bash /tmp/pulse-install-docker-agent.sh --uninstall && \
|
||
# rm -f /tmp/pulse-install-docker-agent.sh
|
||
|
||
PULSE_URL=""
|
||
DEFAULT_AGENT_PATH="/usr/local/bin/pulse-docker-agent"
|
||
AGENT_FALLBACK_PATHS=(
|
||
"/opt/pulse/bin/pulse-docker-agent"
|
||
"/opt/bin/pulse-docker-agent"
|
||
"/var/lib/pulse/bin/pulse-docker-agent"
|
||
)
|
||
AGENT_PATH_OVERRIDE="${PULSE_AGENT_PATH:-}"
|
||
OVERRIDE_SPECIFIED="false"
|
||
if [[ -n "$AGENT_PATH_OVERRIDE" ]]; then
|
||
OVERRIDE_SPECIFIED="true"
|
||
fi
|
||
AGENT_PATH_NOTE=""
|
||
DEFAULT_AGENT_PATH_WRITABLE="unknown"
|
||
EXISTING_AGENT_PATH=""
|
||
AGENT_PATH=""
|
||
SERVICE_PATH="/etc/systemd/system/pulse-docker-agent.service"
|
||
UNRAID_STARTUP="/boot/config/go.d/pulse-docker-agent.sh"
|
||
LOG_PATH="/var/log/pulse-docker-agent.log"
|
||
INTERVAL="30s"
|
||
UNINSTALL=false
|
||
TOKEN="${PULSE_TOKEN:-}"
|
||
DOWNLOAD_ARCH=""
|
||
TARGET_SPECS=()
|
||
PULSE_TARGETS_ENV="${PULSE_TARGETS:-}"
|
||
DEFAULT_INSECURE="$(trim "${PULSE_INSECURE_SKIP_VERIFY:-}")"
|
||
PRIMARY_URL=""
|
||
PRIMARY_TOKEN=""
|
||
PRIMARY_INSECURE="false"
|
||
JOINED_TARGETS=""
|
||
ORIGINAL_ARGS=("$@")
|
||
|
||
# Parse arguments
|
||
while [[ $# -gt 0 ]]; do
|
||
case $1 in
|
||
--url)
|
||
PULSE_URL="$2"
|
||
shift 2
|
||
;;
|
||
--interval)
|
||
INTERVAL="$2"
|
||
shift 2
|
||
;;
|
||
--uninstall)
|
||
UNINSTALL=true
|
||
shift
|
||
;;
|
||
--token)
|
||
TOKEN="$2"
|
||
shift 2
|
||
;;
|
||
--target)
|
||
TARGET_SPECS+=("$2")
|
||
shift 2
|
||
;;
|
||
--agent-path)
|
||
AGENT_PATH_OVERRIDE="$2"
|
||
OVERRIDE_SPECIFIED="true"
|
||
shift 2
|
||
;;
|
||
--dry-run)
|
||
common::set_dry_run true
|
||
shift
|
||
;;
|
||
*)
|
||
echo "Unknown option: $1"
|
||
echo "Usage: $0 --url <Pulse URL> --token <API token> [--interval 30s]"
|
||
echo " $0 --agent-path /custom/path/pulse-docker-agent"
|
||
echo " $0 --uninstall"
|
||
exit 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# Normalize PULSE_URL - strip trailing slashes to prevent double-slash issues
|
||
PULSE_URL="${PULSE_URL%/}"
|
||
|
||
if ! common::is_dry_run; then
|
||
common::ensure_root --allow-sudo --args "${ORIGINAL_ARGS[@]}"
|
||
else
|
||
common::log_debug "Skipping privilege escalation due to dry-run mode"
|
||
fi
|
||
|
||
AGENT_PATH_OVERRIDE=$(trim "$AGENT_PATH_OVERRIDE")
|
||
if [[ -z "$AGENT_PATH_OVERRIDE" ]]; then
|
||
OVERRIDE_SPECIFIED="false"
|
||
fi
|
||
|
||
if [[ -n "$AGENT_PATH_OVERRIDE" && "$AGENT_PATH_OVERRIDE" != /* ]]; then
|
||
echo "Error: --agent-path must be an absolute path." >&2
|
||
exit 1
|
||
fi
|
||
|
||
EXISTING_AGENT_PATH=$(detect_existing_agent_path)
|
||
|
||
if [[ "$UNINSTALL" = true ]]; then
|
||
AGENT_PATH=$(resolve_agent_path_for_uninstall)
|
||
else
|
||
AGENT_PATH=$(select_agent_path_for_install)
|
||
fi
|
||
|
||
# Handle uninstall
|
||
if [ "$UNINSTALL" = true ]; then
|
||
echo "==================================="
|
||
echo "Pulse Docker Agent Uninstaller"
|
||
echo "==================================="
|
||
echo ""
|
||
|
||
# Stop and disable systemd service
|
||
if command -v systemctl &> /dev/null && [ -f "$SERVICE_PATH" ]; then
|
||
echo "Stopping systemd service..."
|
||
systemctl stop pulse-docker-agent 2>/dev/null || true
|
||
systemctl disable pulse-docker-agent 2>/dev/null || true
|
||
rm -f "$SERVICE_PATH"
|
||
systemctl daemon-reload
|
||
echo "✓ Systemd service removed"
|
||
fi
|
||
|
||
# Stop running agent process
|
||
if pgrep -f pulse-docker-agent > /dev/null; then
|
||
echo "Stopping agent process..."
|
||
pkill -f pulse-docker-agent || true
|
||
sleep 1
|
||
echo "✓ Agent process stopped"
|
||
fi
|
||
|
||
# Remove binary
|
||
if [ -f "$AGENT_PATH" ]; then
|
||
rm -f "$AGENT_PATH"
|
||
echo "✓ Agent binary removed"
|
||
fi
|
||
|
||
# Remove Unraid startup script
|
||
if [ -f "$UNRAID_STARTUP" ]; then
|
||
rm -f "$UNRAID_STARTUP"
|
||
echo "✓ Unraid startup script removed"
|
||
fi
|
||
|
||
# Remove Synology Upstart config
|
||
if [ -f "/etc/init/pulse-docker-agent.conf" ]; then
|
||
initctl stop pulse-docker-agent 2>/dev/null || true
|
||
rm -f "/etc/init/pulse-docker-agent.conf"
|
||
echo "✓ Synology Upstart config removed"
|
||
fi
|
||
|
||
# Stop and remove launchd service (macOS)
|
||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||
LAUNCHD_PLIST="/Library/LaunchDaemons/com.pulse.docker-agent.plist"
|
||
if [[ -f "$LAUNCHD_PLIST" ]]; then
|
||
echo "Stopping launchd service..."
|
||
launchctl unload "$LAUNCHD_PLIST" 2>/dev/null || true
|
||
rm -f "$LAUNCHD_PLIST"
|
||
echo "✓ Launchd service removed"
|
||
fi
|
||
fi
|
||
|
||
# Remove log file
|
||
if [ -f "$LOG_PATH" ]; then
|
||
rm -f "$LOG_PATH"
|
||
echo "✓ Log file removed"
|
||
fi
|
||
|
||
echo ""
|
||
echo "==================================="
|
||
echo "✓ Uninstall complete!"
|
||
echo "==================================="
|
||
echo ""
|
||
echo "The Pulse Docker agent has been removed from this system."
|
||
echo ""
|
||
exit 0
|
||
fi
|
||
|
||
# Validate target configuration for install
|
||
if [[ "$UNINSTALL" != true ]]; then
|
||
declare -a RAW_TARGETS=()
|
||
|
||
if [[ ${#TARGET_SPECS[@]} -gt 0 ]]; then
|
||
RAW_TARGETS+=("${TARGET_SPECS[@]}")
|
||
fi
|
||
|
||
if [[ -n "$PULSE_TARGETS_ENV" ]]; then
|
||
mapfile -t ENV_TARGETS < <(split_targets_from_env "$PULSE_TARGETS_ENV")
|
||
if [[ ${#ENV_TARGETS[@]} -gt 0 ]]; then
|
||
RAW_TARGETS+=("${ENV_TARGETS[@]}")
|
||
fi
|
||
fi
|
||
|
||
TOKEN=$(trim "$TOKEN")
|
||
PULSE_URL=$(trim "$PULSE_URL")
|
||
|
||
if [[ ${#RAW_TARGETS[@]} -eq 0 ]]; then
|
||
if [[ -z "$PULSE_URL" || -z "$TOKEN" ]]; then
|
||
echo "Error: Provide --target / PULSE_TARGETS or legacy --url and --token values."
|
||
echo ""
|
||
echo "Usage:"
|
||
echo " Install: $0 --target https://pulse.example.com|<api-token> [--target ...] [--interval 30s]"
|
||
echo " Legacy: $0 --url http://pulse.example.com --token <api-token> [--interval 30s]"
|
||
echo " Uninstall: $0 --uninstall"
|
||
exit 1
|
||
fi
|
||
|
||
if [[ -n "$DEFAULT_INSECURE" ]]; then
|
||
if ! parse_bool "$DEFAULT_INSECURE"; then
|
||
echo "Error: invalid PULSE_INSECURE_SKIP_VERIFY value \"$DEFAULT_INSECURE\"." >&2
|
||
exit 1
|
||
fi
|
||
PRIMARY_INSECURE="$PARSED_BOOL"
|
||
else
|
||
PRIMARY_INSECURE="false"
|
||
fi
|
||
|
||
RAW_TARGETS+=("${PULSE_URL%/}|$TOKEN|$PRIMARY_INSECURE")
|
||
fi
|
||
|
||
if [[ -f "$SERVICE_PATH" && ${#RAW_TARGETS[@]} -eq 0 ]]; then
|
||
mapfile -t EXISTING_TARGETS < <(extract_targets_from_service "$SERVICE_PATH")
|
||
if [[ ${#EXISTING_TARGETS[@]} -gt 0 ]]; then
|
||
RAW_TARGETS+=("${EXISTING_TARGETS[@]}")
|
||
fi
|
||
fi
|
||
|
||
declare -A SEEN_TARGETS=()
|
||
TARGETS=()
|
||
|
||
for spec in "${RAW_TARGETS[@]}"; do
|
||
if ! parse_target_spec "$spec"; then
|
||
exit 1
|
||
fi
|
||
|
||
local_normalized="${PARSED_TARGET_URL}|${PARSED_TARGET_TOKEN}|${PARSED_TARGET_INSECURE}"
|
||
|
||
if [[ -z "$PRIMARY_URL" ]]; then
|
||
PRIMARY_URL="$PARSED_TARGET_URL"
|
||
PRIMARY_TOKEN="$PARSED_TARGET_TOKEN"
|
||
PRIMARY_INSECURE="$PARSED_TARGET_INSECURE"
|
||
fi
|
||
|
||
if [[ -n "${SEEN_TARGETS[$local_normalized]:-}" ]]; then
|
||
continue
|
||
fi
|
||
|
||
SEEN_TARGETS[$local_normalized]=1
|
||
TARGETS+=("$local_normalized")
|
||
done
|
||
|
||
if [[ ${#TARGETS[@]} -eq 0 ]]; then
|
||
echo "Error: no valid Pulse targets provided." >&2
|
||
exit 1
|
||
fi
|
||
|
||
JOINED_TARGETS=$(printf "%s;" "${TARGETS[@]}")
|
||
JOINED_TARGETS="${JOINED_TARGETS%;}"
|
||
|
||
# Backwards compatibility for older agent versions
|
||
PULSE_URL="$PRIMARY_URL"
|
||
TOKEN="$PRIMARY_TOKEN"
|
||
fi
|
||
|
||
log_header "Pulse Docker Agent Installer"
|
||
if [[ "$UNINSTALL" != true ]]; then
|
||
AGENT_IDENTIFIER=$(determine_agent_identifier)
|
||
else
|
||
AGENT_IDENTIFIER=""
|
||
fi
|
||
if [[ -n "$AGENT_PATH_NOTE" ]]; then
|
||
log_warn "$AGENT_PATH_NOTE"
|
||
fi
|
||
log_info "Primary Pulse URL : $PRIMARY_URL"
|
||
if [[ ${#TARGETS[@]} -gt 1 ]]; then
|
||
log_info "Additional targets : $(( ${#TARGETS[@]} - 1 ))"
|
||
fi
|
||
log_info "Install path : $AGENT_PATH"
|
||
log_info "Log directory : /var/log/pulse-docker-agent"
|
||
log_info "Reporting interval: $INTERVAL"
|
||
if [[ "$UNINSTALL" != true ]]; then
|
||
log_info "API token : provided"
|
||
if [[ -n "$AGENT_IDENTIFIER" ]]; then
|
||
log_info "Docker host ID : $AGENT_IDENTIFIER"
|
||
fi
|
||
log_info "Targets:"
|
||
for spec in "${TARGETS[@]}"; do
|
||
IFS='|' read -r target_url _ target_insecure <<< "$spec"
|
||
if [[ "$target_insecure" == "true" ]]; then
|
||
log_info " • $target_url (skip TLS verify)"
|
||
else
|
||
log_info " • $target_url"
|
||
fi
|
||
done
|
||
fi
|
||
printf '\n'
|
||
|
||
# Detect architecture for download
|
||
if [[ "$UNINSTALL" != true ]]; then
|
||
ARCH=$(uname -m)
|
||
case "$ARCH" in
|
||
x86_64|amd64)
|
||
DOWNLOAD_ARCH="linux-amd64"
|
||
;;
|
||
aarch64|arm64)
|
||
DOWNLOAD_ARCH="linux-arm64"
|
||
;;
|
||
armv7l|armhf|armv7)
|
||
DOWNLOAD_ARCH="linux-armv7"
|
||
;;
|
||
*)
|
||
DOWNLOAD_ARCH=""
|
||
log_warn "Unknown architecture '$ARCH'. Falling back to default agent binary."
|
||
;;
|
||
esac
|
||
fi
|
||
|
||
# Check if Docker is installed
|
||
if ! command -v docker &> /dev/null; then
|
||
log_warn 'Docker not found. The agent requires Docker to be installed.'
|
||
if common::is_dry_run; then
|
||
log_warn 'Dry-run mode: skipping Docker enforcement.'
|
||
else
|
||
read -p "Continue anyway? (y/N) " -n 1 -r
|
||
echo
|
||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||
exit 1
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
if [[ "$UNINSTALL" != true ]] && command -v systemctl >/dev/null 2>&1; then
|
||
if systemd::service_exists "pulse-docker-agent"; then
|
||
if systemd::is_active "pulse-docker-agent"; then
|
||
systemd::safe_systemctl stop "pulse-docker-agent"
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# Download agent binary
|
||
log_info "Downloading agent binary"
|
||
DOWNLOAD_URL_BASE="$PRIMARY_URL/download/pulse-docker-agent"
|
||
DOWNLOAD_URL="$DOWNLOAD_URL_BASE"
|
||
if [[ -n "$DOWNLOAD_ARCH" ]]; then
|
||
DOWNLOAD_URL="$DOWNLOAD_URL?arch=$DOWNLOAD_ARCH"
|
||
fi
|
||
|
||
download_agent_binary() {
|
||
local primary_url="$1"
|
||
local fallback_url="$2"
|
||
local -a download_args=(--url "${primary_url}" --output "${AGENT_PATH}" --retries 3 --backoff "1 3 5")
|
||
if [[ "$PRIMARY_INSECURE" == "true" ]]; then
|
||
download_args+=(--insecure)
|
||
fi
|
||
|
||
if http::download "${download_args[@]}"; then
|
||
AGENT_DOWNLOAD_SOURCE="${primary_url}"
|
||
return 0
|
||
fi
|
||
|
||
if ! common::is_dry_run; then
|
||
rm -f "$AGENT_PATH" 2>/dev/null || true
|
||
fi
|
||
|
||
if [[ "${fallback_url}" != "${primary_url}" && -n "${fallback_url}" ]]; then
|
||
log_info 'Falling back to server default agent binary'
|
||
download_args=(--url "${fallback_url}" --output "${AGENT_PATH}" --retries 3 --backoff "1 3 5")
|
||
if [[ "$PRIMARY_INSECURE" == "true" ]]; then
|
||
download_args+=(--insecure)
|
||
fi
|
||
if http::download "${download_args[@]}"; then
|
||
AGENT_DOWNLOAD_SOURCE="${fallback_url}"
|
||
return 0
|
||
fi
|
||
|
||
if ! common::is_dry_run; then
|
||
rm -f "$AGENT_PATH" 2>/dev/null || true
|
||
fi
|
||
fi
|
||
|
||
return 1
|
||
}
|
||
|
||
unset AGENT_DOWNLOAD_SOURCE
|
||
|
||
if download_agent_binary "$DOWNLOAD_URL" "$DOWNLOAD_URL_BASE"; then
|
||
:
|
||
else
|
||
log_warn 'Failed to download agent binary'
|
||
log_warn "Ensure the Pulse server is reachable at $PRIMARY_URL"
|
||
|
||
if common::is_interactive; then
|
||
echo ""
|
||
if [[ -t 0 ]]; then
|
||
read -p "Press Enter to exit..."
|
||
elif [[ -e /dev/tty ]]; then
|
||
read -p "Press Enter to exit..." < /dev/tty
|
||
fi
|
||
fi
|
||
exit 1
|
||
fi
|
||
|
||
fetch_checksum_header() {
|
||
local url="$1"
|
||
local header=""
|
||
|
||
if command -v curl &> /dev/null; then
|
||
local curl_args=(-fsSI "$url")
|
||
if [[ "$PRIMARY_INSECURE" == "true" ]]; then
|
||
curl_args=(-k "${curl_args[@]}")
|
||
fi
|
||
header=$(curl "${curl_args[@]}" 2>/dev/null || true)
|
||
elif command -v wget &> /dev/null; then
|
||
local tmp
|
||
tmp=$(mktemp)
|
||
if [[ "$PRIMARY_INSECURE" == "true" ]]; then
|
||
wget --spider --no-check-certificate --server-response "$url" >/dev/null 2>"$tmp" || true
|
||
else
|
||
wget --spider --server-response "$url" >/dev/null 2>"$tmp" || true
|
||
fi
|
||
header=$(cat "$tmp" 2>/dev/null || true)
|
||
rm -f "$tmp"
|
||
fi
|
||
|
||
if [[ -z "$header" ]]; then
|
||
return 1
|
||
fi
|
||
|
||
local checksum_line
|
||
checksum_line=$(printf '%s\n' "$header" | grep -i "^ *X-Checksum-Sha256:" | head -n 1)
|
||
if [[ -z "$checksum_line" ]]; then
|
||
return 1
|
||
fi
|
||
|
||
local value
|
||
value=$(printf '%s\n' "$checksum_line" | awk -F':' '{sub(/^[[:space:]]*/,"",$2); sub(/[[:space:]]*$/,"",$2); print $2}')
|
||
value=$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')
|
||
# Extra trim to be safe
|
||
value=$(trim "$value")
|
||
|
||
if [[ -z "$value" ]]; then
|
||
return 1
|
||
fi
|
||
|
||
FETCHED_CHECKSUM="$value"
|
||
return 0
|
||
}
|
||
|
||
calculate_sha256() {
|
||
local file="$1"
|
||
local hash=""
|
||
|
||
if command -v sha256sum &> /dev/null; then
|
||
hash=$(sha256sum "$file" | awk '{print $1}')
|
||
elif command -v shasum &> /dev/null; then
|
||
hash=$(shasum -a 256 "$file" | awk '{print $1}')
|
||
fi
|
||
|
||
if [[ -z "$hash" ]]; then
|
||
return 1
|
||
fi
|
||
|
||
CALCULATED_CHECKSUM=$(printf '%s' "$hash" | tr '[:upper:]' '[:lower:]')
|
||
return 0
|
||
}
|
||
|
||
verify_agent_checksum() {
|
||
local url="$1"
|
||
|
||
if common::is_dry_run; then
|
||
log_info '[dry-run] Skipping checksum verification'
|
||
return 0
|
||
fi
|
||
|
||
if ! fetch_checksum_header "$url"; then
|
||
log_warn 'Agent download did not include X-Checksum-Sha256 header; skipping verification'
|
||
return 0
|
||
fi
|
||
|
||
if ! calculate_sha256 "$AGENT_PATH"; then
|
||
log_warn 'Unable to calculate sha256 checksum locally; skipping verification'
|
||
return 0
|
||
fi
|
||
|
||
local clean_fetched
|
||
clean_fetched=$(echo "$FETCHED_CHECKSUM" | tr -d '[:space:]')
|
||
local clean_calculated
|
||
clean_calculated=$(echo "$CALCULATED_CHECKSUM" | tr -d '[:space:]')
|
||
|
||
if [[ "$clean_fetched" != "$clean_calculated" ]]; then
|
||
rm -f "$AGENT_PATH"
|
||
log_error "Checksum mismatch."
|
||
log_error " Expected: '$clean_fetched' (from header)"
|
||
log_error " Actual: '$clean_calculated' (calculated)"
|
||
return 1
|
||
fi
|
||
|
||
log_success 'Checksum verified for agent binary'
|
||
unset FETCHED_CHECKSUM CALCULATED_CHECKSUM
|
||
return 0
|
||
}
|
||
|
||
if [[ -n "${AGENT_DOWNLOAD_SOURCE:-}" ]]; then
|
||
if ! verify_agent_checksum "$AGENT_DOWNLOAD_SOURCE"; then
|
||
log_error 'Agent download failed checksum verification'
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
if ! common::is_dry_run; then
|
||
chmod 0755 "$AGENT_PATH"
|
||
fi
|
||
log_success "Agent binary installed"
|
||
|
||
allow_reenroll_if_needed() {
|
||
local host_id="$1"
|
||
if [[ -z "$host_id" || -z "$PRIMARY_TOKEN" || -z "$PRIMARY_URL" ]]; then
|
||
return 0
|
||
fi
|
||
|
||
local endpoint="$PRIMARY_URL/api/agents/docker/hosts/${host_id}/allow-reenroll"
|
||
local -a api_args=(--url "${endpoint}" --method POST --token "${PRIMARY_TOKEN}")
|
||
if [[ "$PRIMARY_INSECURE" == "true" ]]; then
|
||
api_args+=(--insecure)
|
||
fi
|
||
|
||
if http::api_call "${api_args[@]}" >/dev/null 2>&1; then
|
||
log_success "Cleared any previous stop block for host"
|
||
else
|
||
log_warn "Unable to confirm removal block clearance (continuing)"
|
||
fi
|
||
|
||
return 0
|
||
}
|
||
|
||
allow_reenroll_if_needed "$AGENT_IDENTIFIER"
|
||
|
||
# Check if systemd is available
|
||
if ! command -v systemctl &> /dev/null || [ ! -d /etc/systemd/system ]; then
|
||
printf '\n%s\n' '-- Systemd not detected; configuring alternative startup --'
|
||
|
||
# Check if this is Unraid (has /boot/config directory)
|
||
if [ -d /boot/config ]; then
|
||
log_info 'Detected Unraid environment'
|
||
|
||
mkdir -p /boot/config/go.d
|
||
STARTUP_SCRIPT="/boot/config/go.d/pulse-docker-agent.sh"
|
||
cat > "$STARTUP_SCRIPT" <<EOF
|
||
#!/bin/bash
|
||
# Pulse Docker Agent - Auto-start script
|
||
sleep 10 # Wait for Docker to be ready
|
||
PULSE_URL="$PRIMARY_URL" PULSE_TOKEN="$PRIMARY_TOKEN" PULSE_TARGETS="$JOINED_TARGETS" PULSE_INSECURE_SKIP_VERIFY="$PRIMARY_INSECURE" $AGENT_PATH --url "$PRIMARY_URL" --interval "$INTERVAL"$NO_AUTO_UPDATE_FLAG > /var/log/pulse-docker-agent.log 2>&1 &
|
||
EOF
|
||
|
||
chmod +x "$STARTUP_SCRIPT"
|
||
log_success "Created startup script: $STARTUP_SCRIPT"
|
||
|
||
log_info 'Starting agent'
|
||
PULSE_URL="$PRIMARY_URL" PULSE_TOKEN="$PRIMARY_TOKEN" PULSE_TARGETS="$JOINED_TARGETS" PULSE_INSECURE_SKIP_VERIFY="$PRIMARY_INSECURE" $AGENT_PATH --url "$PRIMARY_URL" --interval "$INTERVAL"$NO_AUTO_UPDATE_FLAG > /var/log/pulse-docker-agent.log 2>&1 &
|
||
|
||
log_header 'Installation complete'
|
||
log_info 'Agent started via Unraid go.d hook'
|
||
log_info 'Log file : /var/log/pulse-docker-agent.log'
|
||
log_info 'Host visible in Pulse: ~30 seconds'
|
||
exit 0
|
||
fi
|
||
|
||
# Check if this is Synology DSM (has /usr/syno/etc/rc.sysv)
|
||
if [ -d /usr/syno/etc/rc.sysv ]; then
|
||
log_info 'Detected Synology DSM environment'
|
||
|
||
UPSTART_CONF="/etc/init/pulse-docker-agent.conf"
|
||
|
||
cat > "$UPSTART_CONF" <<EOF
|
||
description "Pulse Docker Agent"
|
||
author "Pulse"
|
||
|
||
start on syno.network.ready
|
||
stop on runlevel [06]
|
||
|
||
respawn
|
||
respawn limit 5 10
|
||
|
||
env PULSE_URL="$PRIMARY_URL"
|
||
env PULSE_TOKEN="$PRIMARY_TOKEN"
|
||
env PULSE_TARGETS="$JOINED_TARGETS"
|
||
env PULSE_INSECURE_SKIP_VERIFY="$PRIMARY_INSECURE"
|
||
|
||
exec $AGENT_PATH --url "$PRIMARY_URL" --interval "$INTERVAL"$NO_AUTO_UPDATE_FLAG
|
||
EOF
|
||
|
||
log_success "Created Upstart config: $UPSTART_CONF"
|
||
|
||
log_info 'Starting service'
|
||
initctl stop pulse-docker-agent 2>/dev/null || true
|
||
initctl start pulse-docker-agent
|
||
|
||
log_header 'Installation complete'
|
||
log_info 'Agent service enabled and started via Upstart'
|
||
log_info 'Check status : initctl status pulse-docker-agent'
|
||
log_info 'Follow logs : tail -f /var/log/upstart/pulse-docker-agent.log'
|
||
log_info 'Host visible in Pulse : ~30 seconds'
|
||
exit 0
|
||
fi
|
||
|
||
# Check if this is macOS (Darwin)
|
||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||
log_info 'Detected macOS environment'
|
||
|
||
LAUNCHD_PLIST="/Library/LaunchDaemons/com.pulse.docker-agent.plist"
|
||
|
||
# Construct environment variables dict
|
||
ENV_DICT=""
|
||
if [[ -n "$PRIMARY_URL" ]]; then
|
||
ENV_DICT="$ENV_DICT
|
||
<key>PULSE_URL</key>
|
||
<string>$PRIMARY_URL</string>"
|
||
fi
|
||
if [[ -n "$PRIMARY_TOKEN" ]]; then
|
||
ENV_DICT="$ENV_DICT
|
||
<key>PULSE_TOKEN</key>
|
||
<string>$PRIMARY_TOKEN</string>"
|
||
fi
|
||
if [[ -n "$JOINED_TARGETS" ]]; then
|
||
ENV_DICT="$ENV_DICT
|
||
<key>PULSE_TARGETS</key>
|
||
<string>$JOINED_TARGETS</string>"
|
||
fi
|
||
if [[ -n "$PRIMARY_INSECURE" ]]; then
|
||
ENV_DICT="$ENV_DICT
|
||
<key>PULSE_INSECURE_SKIP_VERIFY</key>
|
||
<string>$PRIMARY_INSECURE</string>"
|
||
fi
|
||
|
||
cat > "$LAUNCHD_PLIST" <<EOF
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||
<plist version="1.0">
|
||
<dict>
|
||
<key>Label</key>
|
||
<string>com.pulse.docker-agent</string>
|
||
<key>ProgramArguments</key>
|
||
<array>
|
||
<string>$AGENT_PATH</string>
|
||
<string>--url</string>
|
||
<string>$PRIMARY_URL</string>
|
||
<string>--interval</string>
|
||
<string>$INTERVAL</string>
|
||
<string>--no-auto-update</string>
|
||
</array>
|
||
<key>EnvironmentVariables</key>
|
||
<dict>$ENV_DICT
|
||
</dict>
|
||
<key>RunAtLoad</key>
|
||
<true/>
|
||
<key>KeepAlive</key>
|
||
<true/>
|
||
<key>StandardOutPath</key>
|
||
<string>/var/log/pulse-docker-agent.log</string>
|
||
<key>StandardErrorPath</key>
|
||
<string>/var/log/pulse-docker-agent.log</string>
|
||
</dict>
|
||
</plist>
|
||
EOF
|
||
|
||
chmod 644 "$LAUNCHD_PLIST"
|
||
log_success "Created launchd plist: $LAUNCHD_PLIST"
|
||
|
||
log_info 'Starting service'
|
||
launchctl unload "$LAUNCHD_PLIST" 2>/dev/null || true
|
||
launchctl load -w "$LAUNCHD_PLIST"
|
||
|
||
log_header 'Installation complete'
|
||
log_info 'Agent service enabled and started via launchd'
|
||
log_info 'Check status : sudo launchctl list com.pulse.docker-agent'
|
||
log_info 'Follow logs : tail -f /var/log/pulse-docker-agent.log'
|
||
log_info 'Host visible in Pulse : ~30 seconds'
|
||
exit 0
|
||
fi
|
||
|
||
log_info 'Manual startup environment detected'
|
||
log_info "Binary location : $AGENT_PATH"
|
||
log_info 'Start manually with :'
|
||
printf ' PULSE_URL=%s PULSE_TOKEN=<api-token> \\n' "$PRIMARY_URL"
|
||
printf ' PULSE_TARGETS="%s" \\n' "https://pulse.example.com|<token>[;https://pulse-alt.example.com|<token2>]"
|
||
printf ' %s --interval %s &
|
||
' "$AGENT_PATH" "$INTERVAL"
|
||
log_info 'Add the same command to your init system to start automatically.'
|
||
exit 0
|
||
|
||
fi
|
||
|
||
|
||
# Check if server is in development mode
|
||
NO_AUTO_UPDATE_FLAG=""
|
||
if http::detect_download_tool >/dev/null 2>&1; then
|
||
SERVER_INFO_URL="$PRIMARY_URL/api/server/info"
|
||
IS_DEV="false"
|
||
|
||
SERVER_INFO=""
|
||
declare -a __server_info_args=(--url "$SERVER_INFO_URL")
|
||
if [[ "$PRIMARY_INSECURE" == "true" ]]; then
|
||
__server_info_args+=(--insecure)
|
||
fi
|
||
SERVER_INFO="$(http::api_call "${__server_info_args[@]}" 2>/dev/null || true)"
|
||
unset __server_info_args
|
||
|
||
if [[ -n "$SERVER_INFO" ]] && echo "$SERVER_INFO" | grep -q '"isDevelopment"[[:space:]]*:[[:space:]]*true'; then
|
||
IS_DEV="true"
|
||
NO_AUTO_UPDATE_FLAG=" --no-auto-update"
|
||
log_info 'Development server detected – auto-update disabled'
|
||
fi
|
||
|
||
if [[ -n "$NO_AUTO_UPDATE_FLAG" ]]; then
|
||
if ! "$AGENT_PATH" --help 2>&1 | grep -q -- '--no-auto-update'; then
|
||
log_warn 'Agent binary lacks --no-auto-update flag; keeping auto-update enabled'
|
||
NO_AUTO_UPDATE_FLAG=""
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# Create systemd service
|
||
log_header 'Configuring systemd service'
|
||
SYSTEMD_ENV_TARGETS_LINE=""
|
||
if [[ -n "$JOINED_TARGETS" ]]; then
|
||
SYSTEMD_ENV_TARGETS_LINE="Environment=\"PULSE_TARGETS=$JOINED_TARGETS\""
|
||
fi
|
||
SYSTEMD_ENV_URL_LINE="Environment=\"PULSE_URL=$PRIMARY_URL\""
|
||
SYSTEMD_ENV_TOKEN_LINE="Environment=\"PULSE_TOKEN=$PRIMARY_TOKEN\""
|
||
SYSTEMD_ENV_INSECURE_LINE="Environment=\"PULSE_INSECURE_SKIP_VERIFY=$PRIMARY_INSECURE\""
|
||
systemd::create_service "$SERVICE_PATH" <<EOF
|
||
[Unit]
|
||
Description=Pulse Docker Agent
|
||
After=network-online.target docker.socket docker.service
|
||
Wants=network-online.target docker.socket
|
||
|
||
[Service]
|
||
Type=simple
|
||
$SYSTEMD_ENV_URL_LINE
|
||
$SYSTEMD_ENV_TOKEN_LINE
|
||
$SYSTEMD_ENV_TARGETS_LINE
|
||
$SYSTEMD_ENV_INSECURE_LINE
|
||
ExecStart=$AGENT_PATH --url "$PRIMARY_URL" --interval "$INTERVAL"$NO_AUTO_UPDATE_FLAG
|
||
Restart=on-failure
|
||
RestartSec=5s
|
||
User=root
|
||
ProtectSystem=full
|
||
ProtectHome=read-only
|
||
ProtectControlGroups=yes
|
||
ProtectKernelModules=yes
|
||
ProtectKernelTunables=yes
|
||
ProtectKernelLogs=yes
|
||
UMask=0077
|
||
NoNewPrivileges=yes
|
||
RestrictSUIDSGID=yes
|
||
RestrictRealtime=yes
|
||
PrivateTmp=yes
|
||
MemoryDenyWriteExecute=yes
|
||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||
ReadWritePaths=/var/run/docker.sock
|
||
ProtectHostname=yes
|
||
ProtectClock=yes
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
EOF
|
||
|
||
log_success "Wrote unit file: $SERVICE_PATH"
|
||
|
||
# Reload systemd and start service
|
||
log_info 'Starting service'
|
||
systemd::enable_and_start "pulse-docker-agent"
|
||
|
||
log_header 'Installation complete'
|
||
log_info 'Agent service enabled and started'
|
||
log_info 'Check status : systemctl status pulse-docker-agent'
|
||
log_info 'Follow logs : journalctl -u pulse-docker-agent -f'
|
||
log_info 'Host visible in Pulse : ~30 seconds'
|
||
# === End: scripts/install-docker-agent-v2.sh ===
|
||
|