Harden strict substitution workflow

This commit is contained in:
Brian Pooe 2026-06-17 22:15:54 +02:00
parent 7bbee7bf60
commit b67c32c2e9
8 changed files with 109 additions and 74 deletions

View file

@ -1,6 +1,6 @@
# Homelab Blueprint Makefile # Homelab Blueprint Makefile
.PHONY: help check-env status logs-caddy deploy-caddy deploy-ha deploy-arr deploy-immich deploy-dns deploy-vault deploy-postgres deploy-gramps deploy-beszel deploy-proxy .PHONY: help check-env status deploy-caddy deploy-ha deploy-arr deploy-immich deploy-dns deploy-vault deploy-postgres deploy-gramps deploy-beszel deploy-proxy
# Default shell # Default shell
SHELL := /bin/bash SHELL := /bin/bash
@ -39,7 +39,9 @@ check-env:
if [ -d "$$dir" ]; then \ if [ -d "$$dir" ]; then \
stack=$$(basename $$dir); \ stack=$$(basename $$dir); \
local_env="$$dir/.env"; \ local_env="$$dir/.env"; \
placeholders=$$(grep -roE '\{\{[A-Za-z_][A-Za-z0-9_]*\}\}' "$$dir" | cut -d: -f2 | sort -u | sed 's/{{//;s/}}//' | grep -v ':-' || true); \ placeholders=$$(find "$$dir" -type f \( -name '*template*' -o -name '*.yaml' -o -name '*.yml' \) -print0 | \
xargs -0 awk '!/^[[:space:]]*#/ { while (match($$0, /\{\{[A-Za-z_][A-Za-z0-9_]*\}\}/)) { print substr($$0, RSTART + 2, RLENGTH - 4); $$0 = substr($$0, RSTART + RLENGTH) } }' | \
sort -u || true); \
if [ -n "$$placeholders" ]; then \ if [ -n "$$placeholders" ]; then \
echo "Validating environment for: $$stack"; \ echo "Validating environment for: $$stack"; \
ERR=0; \ ERR=0; \
@ -65,6 +67,7 @@ status:
done done
ip-check: ip-check:
@test -x ./ipfconfig || { echo "Error: ipfconfig script not found or not executable." >&2; exit 1; }
@./ipfconfig @./ipfconfig
clean: clean:
@ -72,7 +75,17 @@ clean:
@rm -f docker-compose.*.yml @rm -f docker-compose.*.yml
# Individual Stack Deployments # Individual Stack Deployments
#
# deploy_stack(stack-dir, compose-suffix) - shared recipe for the common case:
# check-env -> substitute template -> docker compose up -d
define deploy_stack
@$(MAKE) check-env STACK=$(1)
@echo "Deploying $(1)..."
@$(SUBSTITUTE_SCRIPT) $(STACKS_DIR)/$(1)/template.yaml docker-compose.$(2).yml $(ENV_FILE) $(STACKS_DIR)/$(1)/.env
@docker compose -f docker-compose.$(2).yml up -d
endef
# Caddy is special: also renders the Caddyfile and forces an image rebuild.
deploy-caddy: deploy-caddy:
@$(MAKE) check-env STACK=caddy @$(MAKE) check-env STACK=caddy
@echo "Deploying Caddy..." @echo "Deploying Caddy..."
@ -81,61 +94,46 @@ deploy-caddy:
@chmod 644 $(STACKS_DIR)/caddy/Caddyfile @chmod 644 $(STACKS_DIR)/caddy/Caddyfile
@docker compose -f docker-compose.caddy.yml up -d --build @docker compose -f docker-compose.caddy.yml up -d --build
# HA uses an external setup script instead of substitute_env.sh.
deploy-ha: deploy-ha:
@test -x ./setup-homeassistant.sh || { echo "Error: setup-homeassistant.sh not found or not executable." >&2; exit 1; }
@$(MAKE) check-env STACK=homeassistant @$(MAKE) check-env STACK=homeassistant
@echo "Deploying Home Assistant..." @echo "Deploying Home Assistant..."
@./setup-homeassistant.sh @./setup-homeassistant.sh
@docker compose -f docker-compose.homeassistant.yml up -d @docker compose -f docker-compose.homeassistant.yml up -d
deploy-arr: deploy-arr:
@$(MAKE) check-env STACK=arr-stack $(call deploy_stack,arr-stack,arr-stack)
@echo "Deploying Arr Stack..."
@$(SUBSTITUTE_SCRIPT) $(STACKS_DIR)/arr-stack/template.yaml docker-compose.arr-stack.yml $(ENV_FILE) $(STACKS_DIR)/arr-stack/.env
@docker compose -f docker-compose.arr-stack.yml up -d
deploy-immich: deploy-immich:
@$(MAKE) check-env STACK=immich $(call deploy_stack,immich,immich)
@echo "Deploying Immich..."
@$(SUBSTITUTE_SCRIPT) $(STACKS_DIR)/immich/template.yaml docker-compose.immich.yml $(ENV_FILE) $(STACKS_DIR)/immich/.env
@docker compose -f docker-compose.immich.yml up -d
deploy-dns: deploy-dns:
@$(MAKE) check-env STACK=technitium $(call deploy_stack,technitium,technitium)
@echo "Deploying Technitium..."
@$(SUBSTITUTE_SCRIPT) $(STACKS_DIR)/technitium/template.yaml docker-compose.technitium.yml $(ENV_FILE) $(STACKS_DIR)/technitium/.env
@docker compose -f docker-compose.technitium.yml up -d
deploy-vault: deploy-vault:
@$(MAKE) check-env STACK=vault $(call deploy_stack,vault,vault)
@echo "Deploying Vault..."
@$(SUBSTITUTE_SCRIPT) $(STACKS_DIR)/vault/template.yaml docker-compose.vault.yml $(ENV_FILE) $(STACKS_DIR)/vault/.env
@docker compose -f docker-compose.vault.yml up -d
deploy-postgres: deploy-postgres:
@$(MAKE) check-env STACK=postgres $(call deploy_stack,postgres,postgres)
@echo "Deploying PostgreSQL..."
@$(SUBSTITUTE_SCRIPT) $(STACKS_DIR)/postgres/template.yaml docker-compose.postgres.yml $(ENV_FILE) $(STACKS_DIR)/postgres/.env
@docker compose -f docker-compose.postgres.yml up -d
deploy-gramps: deploy-gramps:
@$(MAKE) check-env STACK=gramps-web @$(MAKE) check-env STACK=gramps-web
@echo "Deploying Gramps Web..." @echo "Deploying gramps-web..."
@mkdir -p gramps_config
@$(SUBSTITUTE_SCRIPT) $(STACKS_DIR)/gramps-web/config/config_template.cfg gramps_config/config.cfg $(ENV_FILE) $(STACKS_DIR)/gramps-web/.env
@$(SUBSTITUTE_SCRIPT) $(STACKS_DIR)/gramps-web/template.yaml docker-compose.gramps.yml $(ENV_FILE) $(STACKS_DIR)/gramps-web/.env @$(SUBSTITUTE_SCRIPT) $(STACKS_DIR)/gramps-web/template.yaml docker-compose.gramps.yml $(ENV_FILE) $(STACKS_DIR)/gramps-web/.env
@docker compose -f docker-compose.gramps.yml up -d @docker compose -f docker-compose.gramps.yml up -d
deploy-beszel: deploy-beszel:
@$(MAKE) check-env STACK=beszel-agent $(call deploy_stack,beszel-agent,beszel)
@echo "Deploying Beszel Agent..."
@$(SUBSTITUTE_SCRIPT) $(STACKS_DIR)/beszel-agent/template.yaml docker-compose.beszel.yml $(ENV_FILE) $(STACKS_DIR)/beszel-agent/.env
@docker compose -f docker-compose.beszel.yml up -d
deploy-proxy: deploy-proxy:
@$(MAKE) check-env STACK=socket-proxy $(call deploy_stack,socket-proxy,proxy)
@echo "Deploying Socket Proxy..."
@$(SUBSTITUTE_SCRIPT) $(STACKS_DIR)/socket-proxy/template.yaml docker-compose.proxy.yml $(ENV_FILE) $(STACKS_DIR)/socket-proxy/.env
@docker compose -f docker-compose.proxy.yml up -d
reload-caddy: reload-caddy:
@docker compose -f docker-compose.caddy.yml ps --status running --services 2>/dev/null | grep -q '^caddy$$' \
|| { echo "Error: Caddy container is not running. Run 'make deploy-caddy' first." >&2; exit 1; }
@$(MAKE) check-env STACK=caddy @$(MAKE) check-env STACK=caddy
@echo "Performing zero-downtime Caddy reload..." @echo "Performing zero-downtime Caddy reload..."
@$(SUBSTITUTE_SCRIPT) $(STACKS_DIR)/caddy/Caddyfile_template $(STACKS_DIR)/caddy/Caddyfile $(ENV_FILE) $(STACKS_DIR)/caddy/.env @$(SUBSTITUTE_SCRIPT) $(STACKS_DIR)/caddy/Caddyfile_template $(STACKS_DIR)/caddy/Caddyfile $(ENV_FILE) $(STACKS_DIR)/caddy/.env

View file

@ -39,7 +39,7 @@ The repository includes a `Makefile` to unify management of all stacks. It suppo
| Command | Description | | Command | Description |
|---|---| |---|---|
| `make check-env` | Validates all `.env` files against placeholders in templates | | `make check-env` | Validates root and stack `.env` files against required placeholders in renderable templates |
| `make status` | Shows `docker compose ps` for all generated compose files | | `make status` | Shows `docker compose ps` for all generated compose files |
| `make reload-caddy` | Zero-downtime reload of the Caddy configuration | | `make reload-caddy` | Zero-downtime reload of the Caddy configuration |
| `make ip-check` | Verifies VPN connectivity for Arr Stack containers | | `make ip-check` | Verifies VPN connectivity for Arr Stack containers |
@ -65,6 +65,9 @@ docker compose -f docker-compose.<stack>.yml up -d
Home Assistant uses its setup script instead. Arr Stack, Caddy, and Gramps Web Home Assistant uses its setup script instead. Arr Stack, Caddy, and Gramps Web
render additional configuration templates. render additional configuration templates.
`substitute_env.sh` only reads env files passed on the command line. It does
not fall back to OS environment variables or an implicit `.env`.
## Requirements ## Requirements
- Linux host with Docker Engine and Docker Compose - Linux host with Docker Engine and Docker Compose
@ -83,4 +86,3 @@ Git.
- [Recyclarr](docs/RECYCLARR.md) - [Recyclarr](docs/RECYCLARR.md)
- [Troubleshooting](docs/TROUBLESHOOTING.md) - [Troubleshooting](docs/TROUBLESHOOTING.md)
- [UDM Pro SE zones and VLAN migration](docs/network/udm-pro-zones-migration.md) - [UDM Pro SE zones and VLAN migration](docs/network/udm-pro-zones-migration.md)
zones and VLAN migration](docs/network/udm-pro-zones-migration.md)

View file

@ -24,27 +24,36 @@ stack.
2. Set the Gramps, OIDC, and email values in `.env`. At minimum configure 2. Set the Gramps, OIDC, and email values in `.env`. At minimum configure
`TREE`, `BASE_URL`, `SECRET_KEY`, and `USER_DB_URI`. `TREE`, `BASE_URL`, `SECRET_KEY`, and `USER_DB_URI`.
3. Create the config directory and render both templates: 3. Deploy with `make`:
```bash
make deploy-gramps
```
This creates `gramps_config/config.cfg`, renders
`docker-compose.gramps.yml`, and starts the stack.
Manual equivalent:
```bash ```bash
mkdir -p gramps_config mkdir -p gramps_config
./substitute_env.sh docker-compose-files/gramps-web/config/config_template.cfg gramps_config/config.cfg .env ./substitute_env.sh docker-compose-files/gramps-web/config/config_template.cfg gramps_config/config.cfg .env
chmod 644 gramps_config/config.cfg chmod 644 gramps_config/config.cfg
./substitute_env.sh docker-compose-files/gramps-web/template.yaml docker-compose.gramps-web.yml .env ./substitute_env.sh docker-compose-files/gramps-web/template.yaml docker-compose.gramps.yml .env
``` ```
4. Validate and start Gramps Web: 4. Validate and start Gramps Web:
```bash ```bash
docker compose -f docker-compose.gramps-web.yml config --quiet docker compose -f docker-compose.gramps.yml config --quiet
docker compose -f docker-compose.gramps-web.yml up -d docker compose -f docker-compose.gramps.yml up -d
``` ```
5. Verify the containers and open `http://<HOST_IP>`: 5. Verify the containers and open `http://<HOST_IP>`:
```bash ```bash
docker compose -f docker-compose.gramps-web.yml ps docker compose -f docker-compose.gramps.yml ps
docker compose -f docker-compose.gramps-web.yml logs --tail=100 docker compose -f docker-compose.gramps.yml logs --tail=100
``` ```
## Apply Configuration Changes ## Apply Configuration Changes
@ -52,8 +61,8 @@ stack.
```bash ```bash
./substitute_env.sh docker-compose-files/gramps-web/config/config_template.cfg gramps_config/config.cfg .env ./substitute_env.sh docker-compose-files/gramps-web/config/config_template.cfg gramps_config/config.cfg .env
chmod 644 gramps_config/config.cfg chmod 644 gramps_config/config.cfg
./substitute_env.sh docker-compose-files/gramps-web/template.yaml docker-compose.gramps-web.yml .env ./substitute_env.sh docker-compose-files/gramps-web/template.yaml docker-compose.gramps.yml .env
docker compose -f docker-compose.gramps-web.yml up -d docker compose -f docker-compose.gramps.yml up -d
``` ```
## Initial Setup ## Initial Setup
@ -74,7 +83,7 @@ writable by the container before starting the stack.
## Useful Commands ## Useful Commands
```bash ```bash
docker compose -f docker-compose.gramps-web.yml restart grampsweb docker compose -f docker-compose.gramps.yml restart grampsweb
``` ```
## Backup ## Backup

View file

@ -22,6 +22,19 @@ Self-hosted photo and video management solution with high performance and visual
IMMICH_REDIS_HOSTNAME=immich-redis IMMICH_REDIS_HOSTNAME=immich-redis
``` ```
Optional OIDC settings can also be supplied. Set `OIDC_ENABLED=true`
explicitly when enabling OAuth; leaving it unset keeps Immich local-login
only.
```dotenv
OIDC_ENABLED=true
OIDC_ISSUER=https://auth.example.com/application/o/immich/
OIDC_CLIENT_ID=<immich-client-id>
OIDC_CLIENT_SECRET=<immich-client-secret>
OIDC_NAME=Single Sign-On
OIDC_AUTO_REDIRECT=false
OIDC_MOBILE_OVERRIDE_ENABLED=false
```
2. **Create Directories**: 2. **Create Directories**:
Create the local appdata directories and the shared media directory: Create the local appdata directories and the shared media directory:
```bash ```bash

View file

@ -8,8 +8,6 @@ services:
volumes: volumes:
- {{IMMICH_UPLOAD_LOCATION}}:/usr/src/app/upload - {{IMMICH_UPLOAD_LOCATION}}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
env_file:
- .env
environment: environment:
- DB_PASSWORD={{IMMICH_DB_PASSWORD}} - DB_PASSWORD={{IMMICH_DB_PASSWORD}}
- DB_USERNAME={{IMMICH_DB_USERNAME}} - DB_USERNAME={{IMMICH_DB_USERNAME}}
@ -17,6 +15,20 @@ services:
- DB_HOSTNAME={{IMMICH_DB_HOSTNAME}} - DB_HOSTNAME={{IMMICH_DB_HOSTNAME}}
- REDIS_HOSTNAME={{IMMICH_REDIS_HOSTNAME}} - REDIS_HOSTNAME={{IMMICH_REDIS_HOSTNAME}}
- TZ={{TZ}} - TZ={{TZ}}
# OIDC (optional - leave disabled unless explicitly configured)
- OAUTH_ENABLED={{OIDC_ENABLED:-false}}
- OAUTH_ISSUER_URL={{OIDC_ISSUER:-}}
- OAUTH_CLIENT_ID={{OIDC_CLIENT_ID:-}}
- OAUTH_CLIENT_SECRET={{OIDC_CLIENT_SECRET:-}}
- OAUTH_BUTTON_TEXT={{OIDC_NAME:-}}
- OAUTH_AUTO_LAUNCH={{OIDC_AUTO_REDIRECT:-}}
- OAUTH_MOBILE_OVERRIDE_ENABLED={{OIDC_MOBILE_OVERRIDE_ENABLED:-false}}
# Email (optional - leave blank to disable)
- SMTP_HOST={{EMAIL_HOST:-}}
- SMTP_PORT={{EMAIL_PORT:-}}
- SMTP_USERNAME={{EMAIL_HOST_USER:-}}
- SMTP_PASSWORD={{EMAIL_HOST_PASSWORD:-}}
- SMTP_FROM={{DEFAULT_FROM_EMAIL:-}}
ports: ports:
- "2283:2283" - "2283:2283"
depends_on: depends_on:
@ -41,8 +53,6 @@ services:
runtime: nvidia runtime: nvidia
volumes: volumes:
- {{DOCKERCONFDIR}}/immich/model-cache:/cache - {{DOCKERCONFDIR}}/immich/model-cache:/cache
env_file:
- .env
environment: environment:
- NVIDIA_VISIBLE_DEVICES=all - NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=compute,utility - NVIDIA_DRIVER_CAPABILITIES=compute,utility

View file

@ -8,7 +8,7 @@ This repository now uses Recyclarr **v8** with guide-backed quality profiles (no
### 1. Generate `recyclarr.yml` ### 1. Generate `recyclarr.yml`
```bash ```bash
./substitute_env.sh docker-compose-files/arr-stack/config/recyclarr_template.yml /opt/arr-stack/appdata/recyclarr/recyclarr.yml ./substitute_env.sh docker-compose-files/arr-stack/config/recyclarr_template.yml /opt/arr-stack/appdata/recyclarr/recyclarr.yml .env
nano /opt/arr-stack/appdata/recyclarr/recyclarr.yml nano /opt/arr-stack/appdata/recyclarr/recyclarr.yml
``` ```
@ -150,10 +150,10 @@ Fix:
```bash ```bash
# Regenerate v8 config from current template # Regenerate v8 config from current template
./substitute_env.sh docker-compose-files/arr-stack/config/recyclarr_template.yml /opt/arr-stack/appdata/recyclarr/recyclarr.yml ./substitute_env.sh docker-compose-files/arr-stack/config/recyclarr_template.yml /opt/arr-stack/appdata/recyclarr/recyclarr.yml .env
# Recreate recyclarr with v8 image from compose template # Recreate recyclarr with v8 image from compose template
./substitute_env.sh docker-compose-files/arr-stack/template.yaml docker-compose.arr-stack.yml ./substitute_env.sh docker-compose-files/arr-stack/template.yaml docker-compose.arr-stack.yml .env
docker-compose -f docker-compose.arr-stack.yml pull recyclarr docker-compose -f docker-compose.arr-stack.yml pull recyclarr
docker-compose -f docker-compose.arr-stack.yml up -d recyclarr docker-compose -f docker-compose.arr-stack.yml up -d recyclarr
``` ```

View file

@ -23,7 +23,7 @@ Symptoms:
Fix: Fix:
```bash ```bash
./substitute_env.sh docker-compose-files/arr-stack/template.yaml docker-compose.arr-stack.yml ./substitute_env.sh docker-compose-files/arr-stack/template.yaml docker-compose.arr-stack.yml .env
``` ```
The script prints any unresolved placeholders. Add missing keys to `.env` (or provide defaults in templates). The script prints any unresolved placeholders. Add missing keys to `.env` (or provide defaults in templates).
@ -45,7 +45,7 @@ radarr:
Generate your file from this repo template: Generate your file from this repo template:
```bash ```bash
./substitute_env.sh docker-compose-files/arr-stack/config/recyclarr_template.yml /opt/arr-stack/appdata/recyclarr/recyclarr.yml ./substitute_env.sh docker-compose-files/arr-stack/config/recyclarr_template.yml /opt/arr-stack/appdata/recyclarr/recyclarr.yml .env
``` ```
### 2b. Recyclarr errors (`Unable to find include template ...`) ### 2b. Recyclarr errors (`Unable to find include template ...`)
@ -64,10 +64,10 @@ Fix (v8 path used by this repository):
```bash ```bash
# 1) Regenerate recyclarr config from the v8 template # 1) Regenerate recyclarr config from the v8 template
./substitute_env.sh docker-compose-files/arr-stack/config/recyclarr_template.yml /opt/arr-stack/appdata/recyclarr/recyclarr.yml ./substitute_env.sh docker-compose-files/arr-stack/config/recyclarr_template.yml /opt/arr-stack/appdata/recyclarr/recyclarr.yml .env
# 2) Regenerate compose and recreate recyclarr (v8.6.0 pin) # 2) Regenerate compose and recreate recyclarr (v8.6.0 pin)
./substitute_env.sh docker-compose-files/arr-stack/template.yaml docker-compose.arr-stack.yml ./substitute_env.sh docker-compose-files/arr-stack/template.yaml docker-compose.arr-stack.yml .env
docker-compose -f docker-compose.arr-stack.yml pull recyclarr docker-compose -f docker-compose.arr-stack.yml pull recyclarr
docker-compose -f docker-compose.arr-stack.yml up -d recyclarr docker-compose -f docker-compose.arr-stack.yml up -d recyclarr
``` ```

View file

@ -8,7 +8,7 @@ set -euo pipefail
# Ensure we have Bash 4+ for associative arrays # Ensure we have Bash 4+ for associative arrays
if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then
echo "Error: This script requires Bash 4.0 or higher." echo "Error: This script requires Bash 4.0 or higher." >&2
exit 1 exit 1
fi fi
@ -23,8 +23,8 @@ trim() {
# 1. Validation: Check arguments # 1. Validation: Check arguments
if [[ $# -lt 2 ]]; then if [[ $# -lt 2 ]]; then
echo "Usage: $0 <source_template_file> <output_destination_file> [env_file1] [env_file2] ..." 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" echo "Example: $0 config_template.cfg config.cfg .env global.env" >&2
exit 1 exit 1
fi fi
@ -32,19 +32,19 @@ SOURCE_FILE="$1"
OUTPUT_FILE="$2" OUTPUT_FILE="$2"
if [[ ! -f "$SOURCE_FILE" ]]; then if [[ ! -f "$SOURCE_FILE" ]]; then
echo "Error: Source file '$SOURCE_FILE' not found." echo "Error: Source file '$SOURCE_FILE' not found." >&2
exit 1 exit 1
fi fi
if [[ "$SOURCE_FILE" == "$OUTPUT_FILE" ]]; then if [[ "$SOURCE_FILE" == "$OUTPUT_FILE" ]]; then
echo "Error: Source and output files must be different." echo "Error: Source and output files must be different." >&2
exit 1 exit 1
fi fi
load_env() { load_env() {
local env_file="$1" local env_file="$1"
if [[ -f "$env_file" ]]; then if [[ -f "$env_file" ]]; then
echo "Info: Loading environment from $env_file" echo "Info: Loading environment from $env_file" >&2
while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do
line="${raw_line%$'\r'}" line="${raw_line%$'\r'}"
line="$(trim "$line")" line="$(trim "$line")"
@ -53,7 +53,7 @@ load_env() {
[[ -z "$line" || "$line" == \#* ]] && continue [[ -z "$line" || "$line" == \#* ]] && continue
if [[ "$line" != *"="* ]]; then if [[ "$line" != *"="* ]]; then
echo "Warning: Skipping invalid line in $env_file: $line" echo "Warning: Skipping invalid line in $env_file: $line" >&2
continue continue
fi fi
@ -63,7 +63,7 @@ load_env() {
key="$(trim "$key")" key="$(trim "$key")"
if [[ ! "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then if [[ ! "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
echo "Warning: Skipping invalid variable name '$key' in $env_file" echo "Warning: Skipping invalid variable name '$key' in $env_file" >&2
continue continue
fi fi
@ -85,7 +85,7 @@ load_env() {
LOADED_VARS["$key"]="$value" LOADED_VARS["$key"]="$value"
done <"$env_file" done <"$env_file"
else else
echo "Notice: $env_file not found; skipping." echo "Notice: $env_file not found; skipping." >&2
fi fi
} }
@ -94,8 +94,6 @@ if [[ $# -ge 3 ]]; then
for (( i=3; i<=$#; i++ )); do for (( i=3; i<=$#; i++ )); do
load_env "${!i}" load_env "${!i}"
done done
elif [[ -f ".env" ]]; then
load_env ".env"
fi fi
# 3. Process the template (handles {{VAR}} and {{VAR:-default}}) # 3. Process the template (handles {{VAR}} and {{VAR:-default}})
@ -128,12 +126,17 @@ render_template() {
elif [[ -n "$has_default" ]]; then elif [[ -n "$has_default" ]]; then
replacement="$default" replacement="$default"
else else
# Leave placeholder intact so post-render validation can report it.
replacement="$full" replacement="$full"
fi fi
# %% finds the first occurrence of $full by removing the longest
# suffix that starts with it, then #* advances past it.
before="${rest%%"$full"*}" before="${rest%%"$full"*}"
rendered+="$before$replacement" rendered+="$before$replacement"
rest="${rest#*"$full"}" rest="${rest#*"$full"}"
# Replacements are appended to `rendered`, which is never re-scanned.
# Values containing {{...}} are passed through verbatim (no nested expansion).
done done
line="$rendered$rest" line="$rendered$rest"
printf '%s\n' "$line" >>"$target" printf '%s\n' "$line" >>"$target"
@ -143,23 +146,23 @@ render_template() {
TMP_OUTPUT="$(mktemp)" TMP_OUTPUT="$(mktemp)"
trap 'rm -f "$TMP_OUTPUT"' EXIT trap 'rm -f "$TMP_OUTPUT"' EXIT
render_template "$SOURCE_FILE" "$TMP_OUTPUT" render_template "$SOURCE_FILE" "$TMP_OUTPUT"
mv "$TMP_OUTPUT" "$OUTPUT_FILE"
trap - EXIT
# 4. Final validation: check for unreplaced placeholders # 4. Final validation: check for unreplaced placeholders
if [[ "$SOURCE_FILE" =~ \.ya?ml$ ]]; then if [[ "$SOURCE_FILE" =~ \.ya?ml$ ]]; then
MISSING_VARS="$(awk '!/^[[:space:]]*#/' "$OUTPUT_FILE" | grep -oE '\{\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\}\}' | sort -u || true)" MISSING_VARS="$(awk '!/^[[:space:]]*#/' "$TMP_OUTPUT" | grep -oE '\{\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\}\}' | sort -u || true)"
else else
MISSING_VARS="$(grep -oE '\{\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\}\}' "$OUTPUT_FILE" | sort -u || true)" MISSING_VARS="$(grep -oE '\{\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\}\}' "$TMP_OUTPUT" | sort -u || true)"
fi fi
if [[ -n "$MISSING_VARS" ]]; then if [[ -n "$MISSING_VARS" ]]; then
echo "--------------------------------------------------------" echo "--------------------------------------------------------" >&2
echo "ERROR: The following placeholders were not replaced:" echo "ERROR: The following placeholders were not replaced:" >&2
echo "$MISSING_VARS" echo "$MISSING_VARS" >&2
echo "These MUST be defined in your .env files (OS environment ignored)." echo "These MUST be defined in your .env files (OS environment ignored)." >&2
echo "--------------------------------------------------------" echo "--------------------------------------------------------" >&2
exit 1 exit 1
else else
echo "Success: All variables substituted in $OUTPUT_FILE" mv "$TMP_OUTPUT" "$OUTPUT_FILE"
trap - EXIT
echo "Success: All variables substituted in $OUTPUT_FILE" >&2
fi fi