#!/bin/bash ############################################################################## #### Promtail to Grafana Alloy Migration Script #### #### #### #### Detects OS, reads existing Promtail config for Loki URL/hostname, #### #### generates equivalent Alloy River config, installs Alloy, and #### #### handles the cutover from Promtail to Alloy. #### #### #### #### Supports: Ubuntu, Debian, RHEL, CentOS, Rocky, Alma, Amazon Linux #### #### #### #### Author: Phil Connor #### #### License: MIT #### #### Contact: contact@mylinux.work #### #### Version: 1.0.0-030326 #### ############################################################################## set -euo pipefail readonly SCRIPT_NAME=$(basename "$0") readonly SCRIPT_VERSION="1.0.0-030326" # Defaults ALLOY_CONFIG_DIR="/etc/alloy" ALLOY_CONFIG_FILE="/etc/alloy/config.alloy" PROMTAIL_CONFIG="/etc/promtail/config.yml" LOKI_URL="" CUSTOM_HOSTNAME="" DRY_RUN=false GENERATE_ONLY=false SKIP_INSTALL=false SKIP_CUTOVER=false KEEP_PROMTAIL=true VERBOSE=false INCLUDE_JOURNAL=true INCLUDE_NGINX=false INCLUDE_APACHE=false # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' log() { echo -e "${GREEN}[INFO]${NC} $1"; } warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } error() { echo -e "${RED}[ERROR]${NC} $1" >&2; } debug() { [[ "$VERBOSE" == true ]] && echo -e "${BLUE}[DEBUG]${NC} $1"; } show_help() { cat << EOF Usage: $SCRIPT_NAME [OPTIONS] Migrate from Promtail to Grafana Alloy. Generates an Alloy config that maintains Promtail-compatible labels so existing dashboards keep working. OPTIONS: --loki-url URL Loki push URL (default: extracted from Promtail config) --hostname NAME Override hostname (default: auto-detect or from Promtail) --promtail-config F Path to existing Promtail config (default: /etc/promtail/config.yml) --output FILE Alloy config output path (default: /etc/alloy/config.alloy) --generate-only Only generate the Alloy config, don't install or cutover --skip-install Skip Alloy installation (already installed) --skip-cutover Generate config and install, but don't stop Promtail --no-journal Skip systemd journal collection --include-nginx Include nginx log collection --include-apache Include Apache log collection --remove-promtail Remove Promtail package after cutover (default: keep) --dry-run Show what would be done without making changes --verbose Enable verbose output --version Show version --help, -h Show this help message EXAMPLES: # Auto-detect everything from existing Promtail config sudo $SCRIPT_NAME # Specify Loki URL and hostname sudo $SCRIPT_NAME --loki-url http://loki.example.com:3100 --hostname web-01 # Generate config only (don't install or cutover) $SCRIPT_NAME --generate-only --loki-url http://loki:3100 --output /tmp/config.alloy # Full migration with nginx logs sudo $SCRIPT_NAME --include-nginx --remove-promtail # Dry run to see what would happen sudo $SCRIPT_NAME --dry-run EOF } parse_arguments() { while [[ $# -gt 0 ]]; do case $1 in --loki-url) LOKI_URL="$2"; shift 2 ;; --hostname) CUSTOM_HOSTNAME="$2"; shift 2 ;; --promtail-config) PROMTAIL_CONFIG="$2"; shift 2 ;; --output) ALLOY_CONFIG_FILE="$2"; shift 2 ;; --generate-only) GENERATE_ONLY=true; shift ;; --skip-install) SKIP_INSTALL=true; shift ;; --skip-cutover) SKIP_CUTOVER=true; shift ;; --no-journal) INCLUDE_JOURNAL=false; shift ;; --include-nginx) INCLUDE_NGINX=true; shift ;; --include-apache) INCLUDE_APACHE=true; shift ;; --remove-promtail) KEEP_PROMTAIL=false; shift ;; --dry-run) DRY_RUN=true; shift ;; --verbose) VERBOSE=true; shift ;; --version) echo "$SCRIPT_NAME version $SCRIPT_VERSION"; exit 0 ;; --help|-h) show_help; exit 0 ;; *) error "Unknown option: $1"; show_help; exit 1 ;; esac done } detect_os() { if [[ -f /etc/os-release ]]; then . /etc/os-release OS=$ID OS_PRETTY="$PRETTY_NAME" else error "Cannot detect OS" exit 1 fi debug "Detected OS: $OS_PRETTY" } detect_hostname() { if [[ -n "$CUSTOM_HOSTNAME" ]]; then DETECTED_HOSTNAME="$CUSTOM_HOSTNAME" debug "Using custom hostname: $DETECTED_HOSTNAME" return fi # Try to extract from Promtail config if [[ -f "$PROMTAIL_CONFIG" ]]; then local pt_host pt_host=$(grep -m1 'host:' "$PROMTAIL_CONFIG" 2>/dev/null | awk '{print $2}' | tr -d '"' || true) if [[ -n "$pt_host" ]]; then DETECTED_HOSTNAME="$pt_host" debug "Extracted hostname from Promtail config: $DETECTED_HOSTNAME" return fi fi DETECTED_HOSTNAME=$(hostname -f 2>/dev/null || hostname) debug "Using system hostname: $DETECTED_HOSTNAME" } detect_loki_url() { if [[ -n "$LOKI_URL" ]]; then debug "Using provided Loki URL: $LOKI_URL" return fi # Extract from Promtail config if [[ -f "$PROMTAIL_CONFIG" ]]; then LOKI_URL=$(grep -m1 'url:' "$PROMTAIL_CONFIG" 2>/dev/null | awk '{print $2}' | tr -d '"' | sed 's|/loki/api/v1/push||' || true) if [[ -n "$LOKI_URL" ]]; then debug "Extracted Loki URL from Promtail config: $LOKI_URL" return fi fi error "Could not determine Loki URL" error "Provide with --loki-url or ensure Promtail config exists at $PROMTAIL_CONFIG" exit 1 } check_promtail_status() { if systemctl is-active --quiet promtail 2>/dev/null; then PROMTAIL_RUNNING=true log "Promtail is currently running" else PROMTAIL_RUNNING=false debug "Promtail is not running" fi } # Generate an Alloy loki.source.file block if the log file exists generate_file_source() { local label="$1" local path="$2" local job="$3" local extra_labels="$4" if [[ "$DRY_RUN" == true ]] || [[ -f "$path" ]] || [[ "$path" == *"*"* ]]; then cat << EOF loki.source.file "$label" { targets = [ { "__path__" = "$path", "job" = "$job", "host" = "$DETECTED_HOSTNAME",${extra_labels} }, ] forward_to = [loki.write.default.receiver] } EOF else debug "Skipping $path (file does not exist)" fi } generate_alloy_config() { log "Generating Alloy config for $OS ($DETECTED_HOSTNAME)..." local os_label case "$OS" in ubuntu|debian) os_label="ubuntu" ;; rhel|centos|rocky|almalinux|amzn) os_label="rhel-family" ;; *) os_label="$OS" ;; esac local config="" # Header config+="// Grafana Alloy Configuration for $DETECTED_HOSTNAME // Migrated from Promtail on $(date +%Y-%m-%d) // OS: $OS_PRETTY // Labels maintained for Promtail dashboard compatibility logging { level = \"info\" } " # Journal source if [[ "$INCLUDE_JOURNAL" == true ]]; then config+=" // System logs via systemd journal loki.source.journal \"systemd_journal\" { max_age = \"12h\" labels = { job = \"systemd-journal\", host = \"$DETECTED_HOSTNAME\", os = \"$os_label\", } forward_to = [loki.relabel.journal_relabel.receiver] } loki.relabel \"journal_relabel\" { forward_to = [loki.write.default.receiver] rule { source_labels = [\"__journal__systemd_unit\"] target_label = \"unit\" } rule { source_labels = [\"__journal_priority\"] target_label = \"priority\" } rule { source_labels = [\"__journal__hostname\"] target_label = \"hostname\" } } " fi # OS-specific file sources case "$OS" in ubuntu|debian) config+=" // Ubuntu/Debian system logs" config+=$(generate_file_source "syslog" "/var/log/syslog" "messages" " \"os\" = \"ubuntu\",") config+=$(generate_file_source "auth" "/var/log/auth.log" "auth" " \"log_type\" = \"authentication\",") config+=$(generate_file_source "kern" "/var/log/kern.log" "kernel" "") config+=$(generate_file_source "cron" "/var/log/cron.log" "cron" "") config+=$(generate_file_source "mail" "/var/log/mail.log" "mail" "") config+=$(generate_file_source "apt" "/var/log/apt/history.log" "packages" " \"package_manager\" = \"apt\",") config+=$(generate_file_source "boot" "/var/log/boot.log" "boot" "") ;; rhel|centos|rocky|almalinux|amzn) config+=" // RHEL/CentOS/Rocky/Alma/Amazon Linux system logs" config+=$(generate_file_source "messages" "/var/log/messages" "messages" " \"os\" = \"rhel-family\",") config+=$(generate_file_source "secure" "/var/log/secure" "auth" " \"log_type\" = \"authentication\",") config+=$(generate_file_source "cron" "/var/log/cron" "cron" "") config+=$(generate_file_source "maillog" "/var/log/maillog" "mail" "") config+=$(generate_file_source "yum" "/var/log/yum.log" "packages" " \"package_manager\" = \"yum\",") config+=$(generate_file_source "boot" "/var/log/boot.log" "boot" "") ;; *) config+=" // Generic system logs" config+=$(generate_file_source "syslog" "/var/log/syslog" "messages" "") config+=$(generate_file_source "auth" "/var/log/auth.log" "auth" " \"log_type\" = \"authentication\",") ;; esac # Application wildcard config+=$(generate_file_source "application_logs" "/var/log/*.log" "application" "") # Nginx if [[ "$INCLUDE_NGINX" == true ]]; then config+=" // Nginx logs" config+=$(generate_file_source "nginx_access" "/var/log/nginx/access.log" "nginx" " \"log_type\" = \"access\",") config+=$(generate_file_source "nginx_error" "/var/log/nginx/error.log" "nginx" " \"log_type\" = \"error\",") fi # Apache if [[ "$INCLUDE_APACHE" == true ]]; then config+=" // Apache logs" config+=$(generate_file_source "apache_access" "/var/log/apache2/access.log" "apache" " \"log_type\" = \"access\",") config+=$(generate_file_source "apache_error" "/var/log/apache2/error.log" "apache" " \"log_type\" = \"error\",") config+=$(generate_file_source "httpd_access" "/var/log/httpd/access_log" "apache" " \"log_type\" = \"access\",") config+=$(generate_file_source "httpd_error" "/var/log/httpd/error_log" "apache" " \"log_type\" = \"error\",") fi # Loki write endpoint config+=" // Write to Loki loki.write \"default\" { endpoint { url = \"${LOKI_URL}/loki/api/v1/push\" } } " GENERATED_CONFIG="$config" } write_config() { local output_file="$1" if [[ "$DRY_RUN" == true ]]; then log "DRY RUN: Would write config to $output_file" echo "--- Generated config.alloy ---" echo "$GENERATED_CONFIG" echo "--- End config ---" return fi local output_dir output_dir=$(dirname "$output_file") mkdir -p "$output_dir" # Backup existing config if [[ -f "$output_file" ]]; then local backup="${output_file}.bak.$(date +%Y%m%d%H%M%S)" cp "$output_file" "$backup" log "Backed up existing config to $backup" fi echo "$GENERATED_CONFIG" > "$output_file" log "Alloy config written to $output_file" } install_alloy() { if [[ "$DRY_RUN" == true ]]; then log "DRY RUN: Would install Grafana Alloy" return fi # Check if already installed if command -v alloy >/dev/null 2>&1; then log "Alloy is already installed: $(alloy --version 2>&1 | head -1)" return fi log "Installing Grafana Alloy..." case "$OS" in ubuntu|debian) apt-get install -y apt-transport-https software-properties-common mkdir -p /etc/apt/keyrings/ wget -q -O - https://apt.grafana.com/gpg.key | gpg --dearmor | tee /etc/apt/keyrings/grafana.gpg > /dev/null echo "deb [signed-by=/etc/apt/keyrings/grafana.gpg] https://apt.grafana.com stable main" | tee /etc/apt/sources.list.d/grafana.list apt-get update -qq apt-get install -y alloy ;; rhel|centos|rocky|almalinux|amzn) cat > /etc/yum.repos.d/grafana.repo << 'REPO' [grafana] name=grafana baseurl=https://rpm.grafana.com repo_gpgcheck=1 enabled=1 gpgcheck=1 gpgkey=https://rpm.grafana.com/gpg.key sslverify=1 sslcacert=/etc/pki/tls/certs/ca-bundle.crt REPO if command -v dnf >/dev/null 2>&1; then dnf install -y alloy else yum install -y alloy fi ;; *) error "Unsupported OS for automatic installation: $OS" error "Install Alloy manually: https://grafana.com/docs/alloy/latest/set-up/install/" exit 1 ;; esac log "Alloy installed: $(alloy --version 2>&1 | head -1)" } validate_config() { if [[ "$DRY_RUN" == true ]]; then log "DRY RUN: Would validate config with 'alloy fmt'" return fi if ! command -v alloy >/dev/null 2>&1; then warn "Alloy not installed, skipping validation" return fi log "Validating Alloy config..." if alloy fmt "$ALLOY_CONFIG_FILE" >/dev/null 2>&1; then log "Config validation passed" else error "Config validation failed. Check $ALLOY_CONFIG_FILE for syntax errors" error "Run: alloy fmt $ALLOY_CONFIG_FILE" exit 1 fi } perform_cutover() { if [[ "$DRY_RUN" == true ]]; then log "DRY RUN: Would stop Promtail and start Alloy" return fi # Stop Promtail if systemctl is-active --quiet promtail 2>/dev/null; then log "Stopping Promtail..." systemctl stop promtail systemctl disable promtail log "Promtail stopped and disabled" fi # Add alloy user to required groups if getent group adm >/dev/null 2>&1; then usermod -a -G adm alloy 2>/dev/null || true fi if getent group systemd-journal >/dev/null 2>&1; then usermod -a -G systemd-journal alloy 2>/dev/null || true fi # Start Alloy log "Starting Alloy..." systemctl enable --now alloy sleep 2 if systemctl is-active --quiet alloy; then log "Alloy is running" else error "Alloy failed to start. Check: journalctl -u alloy --no-pager -n 30" error "Rolling back — restarting Promtail" systemctl enable --now promtail 2>/dev/null || true exit 1 fi # Remove Promtail if requested if [[ "$KEEP_PROMTAIL" == false ]]; then log "Removing Promtail package..." case "$OS" in ubuntu|debian) apt-get remove -y promtail 2>/dev/null || true ;; *) yum remove -y promtail 2>/dev/null || dnf remove -y promtail 2>/dev/null || true ;; esac log "Promtail removed" else log "Promtail package kept (use 'systemctl start promtail' to rollback)" fi } print_summary() { echo "" echo "==========================================" echo " Migration Summary" echo "==========================================" echo " OS: $OS_PRETTY" echo " Hostname: $DETECTED_HOSTNAME" echo " Loki URL: $LOKI_URL" echo " Alloy config: $ALLOY_CONFIG_FILE" if [[ "$DRY_RUN" != true ]] && [[ "$GENERATE_ONLY" != true ]]; then echo "" echo " Alloy status: $(systemctl is-active alloy 2>/dev/null || echo 'not checked')" echo "" echo " Verify:" echo " systemctl status alloy" echo " journalctl -u alloy -f" echo " curl http://localhost:12345 (Alloy UI)" echo "" echo " Rollback:" echo " sudo systemctl stop alloy" echo " sudo systemctl start promtail" fi if [[ "$GENERATE_ONLY" == true ]]; then echo "" echo " Config generated. Review and deploy manually." fi echo "==========================================" echo "" } main() { parse_arguments "$@" log "Promtail → Alloy Migration Script v${SCRIPT_VERSION}" echo "" # Check root (unless generate-only) if [[ "$GENERATE_ONLY" != true ]] && [[ "$DRY_RUN" != true ]] && [[ "$EUID" -ne 0 ]]; then error "This script must be run as root (or use --generate-only)" exit 1 fi detect_os detect_hostname detect_loki_url check_promtail_status # Generate config generate_alloy_config write_config "$ALLOY_CONFIG_FILE" if [[ "$GENERATE_ONLY" == true ]]; then print_summary exit 0 fi # Install Alloy if [[ "$SKIP_INSTALL" != true ]]; then install_alloy fi # Validate validate_config # Cutover if [[ "$SKIP_CUTOVER" != true ]]; then perform_cutover fi print_summary } main "$@"