Files
linux-scripts/promtail-to-alloy.sh
T
chiefgeek a1a17e81a1 Sync all scripts from website downloads — 352 scripts total
Includes updated JS challenge scripts with Claude-User whitelist,
same-site referer bypass, Blackbox-Exporter allowed bot, and all
new exporters, cheat sheets, and automation scripts.
2026-05-25 03:31:08 +02:00

653 lines
22 KiB
Bash
Executable File

#!/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 <<EOF
Usage: $SCRIPT_NAME [OPTIONS] [PROMTAIL_CONFIG]
Promtail to Alloy Config Converter v${VERSION}
Reads a Promtail YAML configuration file and outputs equivalent Grafana Alloy
River syntax to stdout or a file.
Options:
-i, --input FILE Promtail YAML config file (or pass as positional arg)
-o, --output FILE Output file (default: stdout)
--hostname NAME Override hostname label (default: extracted from config
or \$(hostname -s))
--dry-run Parse and show what would be converted without generating
-h, --help Show this help
--version Show version
Examples:
$SCRIPT_NAME promtail.yml
$SCRIPT_NAME promtail.yml -o config.alloy
$SCRIPT_NAME --input /etc/promtail/config.yml --output /etc/alloy/config.alloy
$SCRIPT_NAME --dry-run promtail.yml
EOF
exit 0
}
check_deps() {
if ! command -v yq &>/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 "$@"