168 lines
5.5 KiB
Bash
Executable file
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
|