#!/usr/bin/env bash # # Promtail to Alloy Config Converter # # Parses an existing Promtail YAML configuration and outputs equivalent # Grafana Alloy River syntax. Requires yq for YAML parsing. # # Usage: # ./promtail-to-alloy.sh promtail.yml # ./promtail-to-alloy.sh promtail.yml -o config.alloy # ./promtail-to-alloy.sh --input /etc/promtail/config.yml --output /etc/alloy/config.alloy # # Author: Phil Connor # Contact: contact@mylinux.work # Website: https://mylinux.work # License: MIT # Version: 1.0 set -euo pipefail # Constants VERSION="1.0" SCRIPT_NAME="$(basename "$0")" # Defaults INPUT="" OUTPUT="" CUSTOM_HOSTNAME="" DRY_RUN=false # Counters for summary COUNT_SCRAPE_CONFIGS=0 COUNT_PIPELINE_STAGES=0 COUNT_RELABEL_RULES=0 # --- Functions --- usage() { cat </dev/null; then echo "ERROR: yq is required but not installed." >&2 echo "" >&2 echo "Install yq (mikefarah version):" >&2 echo " # Binary install:" >&2 echo " sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64" >&2 echo " sudo chmod +x /usr/local/bin/yq" >&2 echo "" >&2 echo " # Or via snap:" >&2 echo " sudo snap install yq" >&2 echo "" >&2 echo " See: https://github.com/mikefarah/yq" >&2 exit 1 fi } sanitize_label() { local raw="$1" echo "$raw" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_]/_/g' | sed 's/^_//;s/_$//' | sed 's/__*/_/g' } escape_river_string() { local raw="$1" # Double backslashes for River string literals echo "$raw" | sed 's/\\/\\\\/g' } generate_header() { local input_file="$1" local input_basename input_basename="$(basename "$input_file")" echo "// Grafana Alloy Configuration" echo "// Converted from Promtail config: ${input_basename}" echo "// Date: $(date +%Y-%m-%d)" echo "// Converter: ${SCRIPT_NAME} v${VERSION}" echo "" } extract_clients() { local file="$1" local client_count client_count=$(yq '.clients | length' "$file" 2>/dev/null || echo "0") if [[ "$client_count" -eq 0 ]]; then echo >&2 "WARN: No clients found in config, using placeholder URL" echo "// Loki endpoint" echo 'loki.write "default" {' echo ' endpoint {' echo ' url = "http://loki:3100/loki/api/v1/push"' echo ' }' echo '}' return fi # Use the first client URL for the default write endpoint local url url=$(yq '.clients[0].url' "$file") echo "// Loki endpoint" echo 'loki.write "default" {' echo ' endpoint {' echo " url = \"${url}\"" echo ' }' echo '}' } detect_hostname() { local file="$1" if [[ -n "$CUSTOM_HOSTNAME" ]]; then echo "$CUSTOM_HOSTNAME" return fi # Try to extract from first scrape_config's static_configs labels local host host=$(yq '.scrape_configs[0].static_configs[0].labels.host // ""' "$file" 2>/dev/null || echo "") if [[ -n "$host" && "$host" != "null" ]]; then echo "$host" return fi hostname -s 2>/dev/null || echo "server" } convert_relabel_configs() { local file="$1" local sc_index="$2" local label="$3" local forward_to="$4" local relabel_count relabel_count=$(yq ".scrape_configs[${sc_index}].relabel_configs | length" "$file" 2>/dev/null || echo "0") if [[ "$relabel_count" -eq 0 || "$relabel_count" == "null" ]]; then return 1 fi COUNT_RELABEL_RULES=$((COUNT_RELABEL_RULES + relabel_count)) echo "" echo "// Relabel rules: ${label}" echo "loki.relabel \"${label}\" {" echo " forward_to = [${forward_to}]" for ((r = 0; r < relabel_count; r++)); do echo "" echo " rule {" # source_labels local sl_count sl_count=$(yq ".scrape_configs[${sc_index}].relabel_configs[${r}].source_labels | length" "$file" 2>/dev/null || echo "0") if [[ "$sl_count" -gt 0 && "$sl_count" != "null" ]]; then local sl_arr="" for ((s = 0; s < sl_count; s++)); do local sl_val sl_val=$(yq ".scrape_configs[${sc_index}].relabel_configs[${r}].source_labels[${s}]" "$file") if [[ -n "$sl_arr" ]]; then sl_arr+=", " fi sl_arr+="\"${sl_val}\"" done echo " source_labels = [${sl_arr}]" fi # target_label local tl tl=$(yq ".scrape_configs[${sc_index}].relabel_configs[${r}].target_label // \"\"" "$file") if [[ -n "$tl" && "$tl" != "null" ]]; then echo " target_label = \"${tl}\"" fi # regex local rx rx=$(yq ".scrape_configs[${sc_index}].relabel_configs[${r}].regex // \"\"" "$file") if [[ -n "$rx" && "$rx" != "null" ]]; then rx=$(escape_river_string "$rx") echo " regex = \"${rx}\"" fi # action local act act=$(yq ".scrape_configs[${sc_index}].relabel_configs[${r}].action // \"\"" "$file") if [[ -n "$act" && "$act" != "null" ]]; then echo " action = \"${act}\"" fi # replacement local repl repl=$(yq ".scrape_configs[${sc_index}].relabel_configs[${r}].replacement // \"\"" "$file") if [[ -n "$repl" && "$repl" != "null" ]]; then repl=$(escape_river_string "$repl") echo " replacement = \"${repl}\"" fi echo " }" done echo "}" return 0 } convert_pipeline_stages() { local file="$1" local sc_index="$2" local label="$3" local stage_count stage_count=$(yq ".scrape_configs[${sc_index}].pipeline_stages | length" "$file" 2>/dev/null || echo "0") if [[ "$stage_count" -eq 0 || "$stage_count" == "null" ]]; then return 1 fi COUNT_PIPELINE_STAGES=$((COUNT_PIPELINE_STAGES + stage_count)) echo "" echo "// Pipeline: ${label}" echo "loki.process \"${label}\" {" echo " forward_to = [loki.write.default.receiver]" for ((p = 0; p < stage_count; p++)); do # Determine stage type — yq returns the first key local stage_type stage_type=$(yq ".scrape_configs[${sc_index}].pipeline_stages[${p}] | keys | .[0]" "$file") echo "" case "$stage_type" in regex) local expr expr=$(yq ".scrape_configs[${sc_index}].pipeline_stages[${p}].regex.expression" "$file") expr=$(escape_river_string "$expr") echo " stage.regex {" echo " expression = \"${expr}\"" echo " }" ;; json) echo " stage.json {" echo " expressions = {" local json_keys json_keys=$(yq ".scrape_configs[${sc_index}].pipeline_stages[${p}].json.expressions | keys | .[]" "$file" 2>/dev/null || true) if [[ -n "$json_keys" ]]; then while IFS= read -r key; do local val val=$(yq ".scrape_configs[${sc_index}].pipeline_stages[${p}].json.expressions.${key}" "$file") if [[ "$val" == "null" || -z "$val" ]]; then echo " ${key} = \"\"," else echo " ${key} = \"${val}\"," fi done <<< "$json_keys" fi echo " }" echo " }" ;; logfmt) echo " stage.logfmt {}" ;; labels) echo " stage.labels {" echo " values = {" local lbl_keys lbl_keys=$(yq ".scrape_configs[${sc_index}].pipeline_stages[${p}].labels | keys | .[]" "$file" 2>/dev/null || true) if [[ -n "$lbl_keys" ]]; then while IFS= read -r key; do local val val=$(yq ".scrape_configs[${sc_index}].pipeline_stages[${p}].labels.${key} // \"\"" "$file") if [[ "$val" == "null" ]]; then val="" fi echo " ${key} = \"${val}\"," done <<< "$lbl_keys" fi echo " }" echo " }" ;; timestamp) local ts_source ts_format ts_source=$(yq ".scrape_configs[${sc_index}].pipeline_stages[${p}].timestamp.source" "$file") ts_format=$(yq ".scrape_configs[${sc_index}].pipeline_stages[${p}].timestamp.format" "$file") echo " stage.timestamp {" echo " source = \"${ts_source}\"" echo " format = \"${ts_format}\"" echo " }" ;; output) local out_source out_source=$(yq ".scrape_configs[${sc_index}].pipeline_stages[${p}].output.source" "$file") echo " stage.output {" echo " source = \"${out_source}\"" echo " }" ;; drop) local drop_expr drop_reason drop_expr=$(yq ".scrape_configs[${sc_index}].pipeline_stages[${p}].drop.expression // \"\"" "$file") drop_reason=$(yq ".scrape_configs[${sc_index}].pipeline_stages[${p}].drop.drop_counter_reason // \"converted\"" "$file") drop_expr=$(escape_river_string "$drop_expr") echo " stage.drop {" if [[ -n "$drop_expr" && "$drop_expr" != "null" ]]; then echo " expression = \"${drop_expr}\"" fi echo " drop_counter_reason = \"${drop_reason}\"" echo " }" ;; match) local match_sel match_sel=$(yq ".scrape_configs[${sc_index}].pipeline_stages[${p}].match.selector" "$file") echo " stage.match {" echo " selector = \"${match_sel}\"" # Nested stages in match are complex; output a placeholder comment local nested_count nested_count=$(yq ".scrape_configs[${sc_index}].pipeline_stages[${p}].match.stages | length" "$file" 2>/dev/null || echo "0") if [[ "$nested_count" -gt 0 && "$nested_count" != "null" ]]; then echo " // NOTE: ${nested_count} nested stage(s) — review and convert manually" fi echo " }" ;; multiline) local ml_firstline ml_max_lines ml_firstline=$(yq ".scrape_configs[${sc_index}].pipeline_stages[${p}].multiline.firstline" "$file") ml_max_lines=$(yq ".scrape_configs[${sc_index}].pipeline_stages[${p}].multiline.max_lines // 128" "$file") ml_firstline=$(escape_river_string "$ml_firstline") echo " stage.multiline {" echo " firstline = \"${ml_firstline}\"" echo " max_lines = ${ml_max_lines}" echo " }" ;; static_labels) echo " stage.static_labels {" echo " values = {" local sl_keys sl_keys=$(yq ".scrape_configs[${sc_index}].pipeline_stages[${p}].static_labels | keys | .[]" "$file" 2>/dev/null || true) if [[ -n "$sl_keys" ]]; then while IFS= read -r key; do local val val=$(yq ".scrape_configs[${sc_index}].pipeline_stages[${p}].static_labels.${key}" "$file") echo " ${key} = \"${val}\"," done <<< "$sl_keys" fi echo " }" echo " }" ;; *) echo " // Unsupported stage type: ${stage_type} — convert manually" ;; esac done echo "}" return 0 } convert_journal_source() { local file="$1" local sc_index="$2" local label="$3" local host="$4" local forward_target="loki.write.default.receiver" # Check for relabel_configs — if present, forward through relabel first local has_relabel=false local relabel_count relabel_count=$(yq ".scrape_configs[${sc_index}].relabel_configs | length" "$file" 2>/dev/null || echo "0") if [[ "$relabel_count" -gt 0 && "$relabel_count" != "null" ]]; then has_relabel=true forward_target="loki.relabel.${label}.receiver" fi # Extract journal config local max_age max_age=$(yq ".scrape_configs[${sc_index}].journal.max_age // \"12h\"" "$file") echo "" echo "// Journal source: ${label}" echo "loki.source.journal \"${label}\" {" echo " max_age = \"${max_age}\"" # Journal labels local jl_keys jl_keys=$(yq ".scrape_configs[${sc_index}].journal.labels | keys | .[]" "$file" 2>/dev/null || true) if [[ -n "$jl_keys" ]]; then echo " labels = {" while IFS= read -r key; do local val val=$(yq ".scrape_configs[${sc_index}].journal.labels.${key}" "$file") echo " ${key} = \"${val}\"," done <<< "$jl_keys" echo " }" fi if [[ "$has_relabel" == true ]]; then echo " relabel_rules = loki.relabel.${label}.rules" fi echo " forward_to = [${forward_target}]" echo "}" # Generate relabel block if needed if [[ "$has_relabel" == true ]]; then convert_relabel_configs "$file" "$sc_index" "$label" "loki.write.default.receiver" fi } convert_file_source() { local file="$1" local sc_index="$2" local label="$3" local host="$4" local forward_target="loki.write.default.receiver" # Check for pipeline_stages — if present, forward through process local has_pipeline=false local stage_count stage_count=$(yq ".scrape_configs[${sc_index}].pipeline_stages | length" "$file" 2>/dev/null || echo "0") if [[ "$stage_count" -gt 0 && "$stage_count" != "null" ]]; then has_pipeline=true forward_target="loki.process.${label}.receiver" fi # Iterate static_configs local sc_count sc_count=$(yq ".scrape_configs[${sc_index}].static_configs | length" "$file" 2>/dev/null || echo "0") if [[ "$sc_count" -eq 0 || "$sc_count" == "null" ]]; then echo >&2 "WARN: scrape_config[${sc_index}] '${label}' has no static_configs, skipping" return fi echo "" echo "// File source: ${label}" echo "loki.source.file \"${label}\" {" echo " targets = [" for ((t = 0; t < sc_count; t++)); do # Get __path__ local path_val path_val=$(yq ".scrape_configs[${sc_index}].static_configs[${t}].labels.__path__ // \"\"" "$file") if [[ -z "$path_val" || "$path_val" == "null" ]]; then # Try targets array format path_val=$(yq ".scrape_configs[${sc_index}].static_configs[${t}].targets[0].__path__ // \"\"" "$file" 2>/dev/null || echo "") fi echo " {" if [[ -n "$path_val" && "$path_val" != "null" ]]; then echo " \"__path__\" = \"${path_val}\"," fi # Get other labels (excluding __path__) local label_keys label_keys=$(yq ".scrape_configs[${sc_index}].static_configs[${t}].labels | keys | .[]" "$file" 2>/dev/null || true) if [[ -n "$label_keys" ]]; then while IFS= read -r lk; do if [[ "$lk" == "__path__" ]]; then continue fi local lv lv=$(yq ".scrape_configs[${sc_index}].static_configs[${t}].labels.${lk}" "$file") # Pad alignment printf ' "%-8s = "%s",\n' "${lk}\"" "${lv}" done <<< "$label_keys" fi # Add host label if not already present if ! echo "$label_keys" | grep -q '^host$'; then printf ' "%-8s = "%s",\n' 'host"' "${host}" fi echo " }," done echo " ]" echo " forward_to = [${forward_target}]" echo "}" # Generate pipeline_stages block if present if [[ "$has_pipeline" == true ]]; then convert_pipeline_stages "$file" "$sc_index" "$label" fi } extract_scrape_configs() { local file="$1" local host="$2" local sc_total sc_total=$(yq '.scrape_configs | length' "$file" 2>/dev/null || echo "0") if [[ "$sc_total" -eq 0 || "$sc_total" == "null" ]]; then echo >&2 "WARN: No scrape_configs found in config" return fi COUNT_SCRAPE_CONFIGS=$sc_total for ((i = 0; i < sc_total; i++)); do local job_name job_name=$(yq ".scrape_configs[${i}].job_name" "$file") local label label=$(sanitize_label "$job_name") # Detect journal vs file source local has_journal has_journal=$(yq ".scrape_configs[${i}].journal // \"\"" "$file" 2>/dev/null || echo "") if [[ -n "$has_journal" && "$has_journal" != "null" && "$has_journal" != "" ]]; then convert_journal_source "$file" "$i" "$label" "$host" else convert_file_source "$file" "$i" "$label" "$host" fi done } generate_config() { local file="$1" local host="$2" generate_header "$file" extract_clients "$file" extract_scrape_configs "$file" "$host" echo "" } # --- Main --- main() { # Parse arguments while [[ $# -gt 0 ]]; do case "$1" in -i|--input) INPUT="$2"; shift 2 ;; -o|--output) OUTPUT="$2"; shift 2 ;; --hostname) CUSTOM_HOSTNAME="$2"; shift 2 ;; --dry-run) DRY_RUN=true; shift ;; -h|--help) usage ;; --version) echo "$SCRIPT_NAME v${VERSION}"; exit 0 ;; -*) echo "ERROR: Unknown option: $1" >&2; echo "" >&2; usage ;; *) # Positional argument — treat as input file if [[ -z "$INPUT" ]]; then INPUT="$1" else echo "ERROR: Unexpected argument: $1" >&2 exit 1 fi shift ;; esac done # Validate input if [[ -z "$INPUT" ]]; then echo "ERROR: No input file specified" >&2 echo "" >&2 usage fi if [[ ! -f "$INPUT" ]]; then echo "ERROR: Input file not found: ${INPUT}" >&2 exit 1 fi # Check dependencies check_deps # Validate YAML if ! yq '.' "$INPUT" &>/dev/null; then echo "ERROR: Failed to parse YAML: ${INPUT}" >&2 echo "Ensure the file is valid YAML and yq can read it." >&2 exit 1 fi # Detect hostname local host host=$(detect_hostname "$INPUT") # Dry-run mode if [[ "$DRY_RUN" == true ]]; then echo "Dry run — parsing ${INPUT}" >&2 echo "" >&2 local sc_total sc_total=$(yq '.scrape_configs | length' "$INPUT" 2>/dev/null || echo "0") local client_count client_count=$(yq '.clients | length' "$INPUT" 2>/dev/null || echo "0") echo " Clients: ${client_count}" >&2 echo " Scrape configs: ${sc_total}" >&2 echo " Hostname: ${host}" >&2 echo "" >&2 for ((i = 0; i < sc_total; i++)); do local jn jn=$(yq ".scrape_configs[${i}].job_name" "$INPUT") local has_j has_j=$(yq ".scrape_configs[${i}].journal // \"\"" "$INPUT" 2>/dev/null || echo "") local src_type="file" if [[ -n "$has_j" && "$has_j" != "null" && "$has_j" != "" ]]; then src_type="journal" fi local ps_count ps_count=$(yq ".scrape_configs[${i}].pipeline_stages | length" "$INPUT" 2>/dev/null || echo "0") local rl_count rl_count=$(yq ".scrape_configs[${i}].relabel_configs | length" "$INPUT" 2>/dev/null || echo "0") echo " [${i}] ${jn} (${src_type}) — ${ps_count} pipeline stages, ${rl_count} relabel rules" >&2 done echo "" >&2 echo "Run without --dry-run to generate the Alloy config." >&2 exit 0 fi # Generate config if [[ -n "$OUTPUT" ]]; then generate_config "$INPUT" "$host" > "$OUTPUT" echo >&2 "Config written to: ${OUTPUT}" else generate_config "$INPUT" "$host" fi # Summary to stderr echo >&2 "Converted ${COUNT_SCRAPE_CONFIGS} scrape configs, ${COUNT_PIPELINE_STAGES} pipeline stages, ${COUNT_RELABEL_RULES} relabel rules" } main "$@"