homelab-blueprint/substitute_env.sh
2026-06-17 22:15:54 +02:00

168 lines
5.5 KiB
Bash
Executable file

#!/bin/bash
# Strict substitution script: only uses variables from provided .env files.
# Does NOT fall back to OS environment variables.
# Fails with exit code 1 if any non-defaulted placeholders remain.
set -euo pipefail
# Ensure we have Bash 4+ for associative arrays
if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then
echo "Error: This script requires Bash 4.0 or higher." >&2
exit 1
fi
declare -A LOADED_VARS
trim() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
# 1. Validation: Check arguments
if [[ $# -lt 2 ]]; then
echo "Usage: $0 <source_template_file> <output_destination_file> [env_file1] [env_file2] ..." >&2
echo "Example: $0 config_template.cfg config.cfg .env global.env" >&2
exit 1
fi
SOURCE_FILE="$1"
OUTPUT_FILE="$2"
if [[ ! -f "$SOURCE_FILE" ]]; then
echo "Error: Source file '$SOURCE_FILE' not found." >&2
exit 1
fi
if [[ "$SOURCE_FILE" == "$OUTPUT_FILE" ]]; then
echo "Error: Source and output files must be different." >&2
exit 1
fi
load_env() {
local env_file="$1"
if [[ -f "$env_file" ]]; then
echo "Info: Loading environment from $env_file" >&2
while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do
line="${raw_line%$'\r'}"
line="$(trim "$line")"
# Skip comments and empty lines
[[ -z "$line" || "$line" == \#* ]] && continue
if [[ "$line" != *"="* ]]; then
echo "Warning: Skipping invalid line in $env_file: $line" >&2
continue
fi
key="$(trim "${line%%=*}")"
value="$(trim "${line#*=}")"
key="${key#export }"
key="$(trim "$key")"
if [[ ! "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
echo "Warning: Skipping invalid variable name '$key' in $env_file" >&2
continue
fi
# Handle quoted values and optional inline comments.
if [[ "$value" =~ ^\"(.*)\"[[:space:]]*(#.*)?$ ]]; then
value="${BASH_REMATCH[1]}"
value="${value//\\\"/\"}"
value="${value//\\\\/\\}"
elif [[ "$value" =~ ^\'(.*)\'[[:space:]]*(#.*)?$ ]]; then
value="${BASH_REMATCH[1]}"
else
if [[ "$value" =~ ^(.*[^[:space:]])[[:space:]]+#.*$ ]]; then
value="${BASH_REMATCH[1]}"
fi
value="$(trim "$value")"
fi
# Store in associative array (last match wins)
LOADED_VARS["$key"]="$value"
done <"$env_file"
else
echo "Notice: $env_file not found; skipping." >&2
fi
}
# 2. Load all provided .env files
if [[ $# -ge 3 ]]; then
for (( i=3; i<=$#; i++ )); do
load_env "${!i}"
done
fi
# 3. Process the template (handles {{VAR}} and {{VAR:-default}})
render_template() {
local source="$1"
local target="$2"
local line full key default replacement has_default rest rendered before
: >"$target"
while IFS= read -r line || [[ -n "$line" ]]; do
# In YAML templates, ignore full-line comments so commented placeholders
# do not require env values.
if [[ "$source" =~ \.ya?ml$ && "$line" =~ ^[[:space:]]*# ]]; then
printf '%s\n' "$line" >>"$target"
continue
fi
rest="$line"
rendered=""
while [[ "$rest" =~ \{\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}\} ]]; do
full="${BASH_REMATCH[0]}"
key="${BASH_REMATCH[1]}"
has_default="${BASH_REMATCH[2]-}"
default="${BASH_REMATCH[3]-}"
# STRICT CHECK: Only look in LOADED_VARS, not the system environment
if [[ ${LOADED_VARS[$key]+x} ]]; then
replacement="${LOADED_VARS[$key]}"
elif [[ -n "$has_default" ]]; then
replacement="$default"
else
# Leave placeholder intact so post-render validation can report it.
replacement="$full"
fi
# %% finds the first occurrence of $full by removing the longest
# suffix that starts with it, then #* advances past it.
before="${rest%%"$full"*}"
rendered+="$before$replacement"
rest="${rest#*"$full"}"
# Replacements are appended to `rendered`, which is never re-scanned.
# Values containing {{...}} are passed through verbatim (no nested expansion).
done
line="$rendered$rest"
printf '%s\n' "$line" >>"$target"
done <"$source"
}
TMP_OUTPUT="$(mktemp)"
trap 'rm -f "$TMP_OUTPUT"' EXIT
render_template "$SOURCE_FILE" "$TMP_OUTPUT"
# 4. Final validation: check for unreplaced placeholders
if [[ "$SOURCE_FILE" =~ \.ya?ml$ ]]; then
MISSING_VARS="$(awk '!/^[[:space:]]*#/' "$TMP_OUTPUT" | grep -oE '\{\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\}\}' | sort -u || true)"
else
MISSING_VARS="$(grep -oE '\{\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\}\}' "$TMP_OUTPUT" | sort -u || true)"
fi
if [[ -n "$MISSING_VARS" ]]; then
echo "--------------------------------------------------------" >&2
echo "ERROR: The following placeholders were not replaced:" >&2
echo "$MISSING_VARS" >&2
echo "These MUST be defined in your .env files (OS environment ignored)." >&2
echo "--------------------------------------------------------" >&2
exit 1
else
mv "$TMP_OUTPUT" "$OUTPUT_FILE"
trap - EXIT
echo "Success: All variables substituted in $OUTPUT_FILE" >&2
fi