From 990b99f0c0dea2bdab1165049c2670550f35ca7b Mon Sep 17 00:00:00 2001 From: Yann Autissier Date: Thu, 3 Sep 2026 18:29:36 +0200 Subject: [PATCH] add the config and compose modules config: dotenv files are parsed, never sourced, so a value can hold a # or a $(...) without breaking or executing. The make engine included .env with make syntax, which neither allowed. compose: one call per project instead of one per sub-stack. --- lib/compose.sh | 60 ++++++++++++++++++++++++ lib/config.sh | 79 +++++++++++++++++++++++++++++++ spec/unit/config_load_helper.sh | 5 ++ spec/unit/config_spec.sh | 82 +++++++++++++++++++++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 lib/compose.sh create mode 100644 lib/config.sh create mode 100644 spec/unit/config_load_helper.sh create mode 100644 spec/unit/config_spec.sh diff --git a/lib/compose.sh b/lib/compose.sh new file mode 100644 index 0000000..187c4bf --- /dev/null +++ b/lib/compose.sh @@ -0,0 +1,60 @@ +#shellcheck shell=sh +# compose: find a usable docker compose, and call it once per project. +# +# The legacy engine ran one `docker compose up` per sub-stack, each time with +# the full file list, so N sub-stacks meant N identical calls. The CLI calls +# compose once per project (documented in spec/golden/DELTAS.md). + +MYOS_COMPOSE_MIN_VERSION=${COMPOSE_VERSION:-2.24.4} + +# myos_compose_bin print the compose command to use, fail with MYOS_E_NOREQ +myos_compose_bin() { + [ -n "${MYOS_COMPOSE_BIN:-}" ] && { printf '%s' "$MYOS_COMPOSE_BIN"; return 0; } + if myos_have docker; then + _v=$(docker compose version --short 2>/dev/null) + if myos_verle "$MYOS_COMPOSE_MIN_VERSION" "$_v"; then printf 'docker compose'; return 0; fi + fi + if myos_have docker-compose; then + _v=$(docker-compose version --short 2>/dev/null) + if myos_verle "$MYOS_COMPOSE_MIN_VERSION" "$_v"; then printf 'docker-compose'; return 0; fi + fi + myos_error "docker compose >= $MYOS_COMPOSE_MIN_VERSION not found (install the docker compose plugin or docker-compose)" + return "$MYOS_E_NOREQ" +} + +# myos_compose PROJECT FILES -- ARGS... +# FILES is a newline separated list; the project directory is that of the first +# file, so relative build contexts and env_file entries keep working. +myos_compose() { + _project=$1; _files=$2; shift 2 + [ "${1:-}" = "--" ] && shift + [ -n "$_files" ] || { myos_error "no compose file for project $_project"; return "$MYOS_E_NOSTACK"; } + _bin=$(myos_compose_bin) || return $? + + _fargs="" + _first="" + for _f in $_files; do + [ -n "$_first" ] || _first=$_f + _fargs="$_fargs -f $_f" + done + _dir=$(dirname "$_first") + + # variables the compose files reference, plus the network names they default + # on (networks.yml is appended after the scan, so its variables are added here) + # shellcheck disable=SC2086 # both are deliberate word lists + _vars=$(myos_env_vars $_files) + # shellcheck disable=SC2086 + _envargs=$(myos_env_export $_vars DOCKER_NETWORK_DEFAULT DOCKER_NETWORK_PRIVATE DOCKER_NETWORK_PUBLIC COMPOSE_SERVICE_NAME) + + if [ "${DRYRUN:-false}" = true ]; then + printf '%s%s -p %s --project-directory %s %s\n' "$_bin" "$_fargs" "$_project" "$_dir" "$*" + else + _IFS=$IFS; IFS=' +' + # shellcheck disable=SC2046,SC2086 # deliberate word splitting on IFS=newline + env $_envargs $_bin --ansi=auto $_fargs -p "$_project" --project-directory "$_dir" "$@" + _rc=$? + IFS=$_IFS + return $_rc + fi +} diff --git a/lib/config.sh b/lib/config.sh new file mode 100644 index 0000000..5c1c908 --- /dev/null +++ b/lib/config.sh @@ -0,0 +1,79 @@ +#shellcheck shell=sh +# config: where settings come from, and in which order. +# +# Layers, last one wins: +# defaults < /etc/conf.d/myos, /etc/default/myos < ~/.config/myos/config +# < /.env < /.env. < environment < CLI VAR=val +# MYOS_CONF_PRIORITY=system restores the old make behaviour where the system +# file won over the project .env. +# +# Files are dotenv: KEY=value, one per line, # comments, optional quotes. +# They are parsed, never sourced: a value never runs as code. + +# myos_dotenv_parse FILE print normalized KEY=value lines +myos_dotenv_parse() { + [ -f "$1" ] || return 0 + sed -e 's/\r$//' -e '/^[[:space:]]*#/d' -e '/^[[:space:]]*$/d' "$1" | + while IFS= read -r _line; do + case $_line in *=*) ;; *) continue ;; esac + _k=${_line%%=*} + _v=${_line#*=} + _k=$(printf '%s' "$_k" | tr -d '[:space:]') + # whitespace around the = is not part of the value (make: s/[[:space:]]*=[[:space:]]*/=/) + _v=${_v#"${_v%%[![:space:]]*}"} + case $_k in ''|*[!A-Za-z0-9_]*) continue ;; esac + # strip one layer of matching quotes + case $_v in + \"*\") _v=${_v#\"}; _v=${_v%\"} ;; + \'*\') _v=${_v#\'}; _v=${_v%\'} ;; + esac + printf '%s=%s\n' "$_k" "$_v" + done +} + +# myos_dotenv_load FILE set the variables of FILE that are not already set +# (an already exported variable wins, as `?=` does in make) +myos_dotenv_load() { + [ -f "$1" ] || return 0 + while IFS= read -r _kv; do + _k=${_kv%%=*} + [ -n "$(myos_var "$_k")" ] && continue + eval "$_k=\${_kv#*=}" + done </dev/null | + grep -oE '\$\{?[A-Z0-9_]+' | + tr -d '{}$' | + sort -u | + tr '\n' ' ' | + sed 's/ $//' +} + +# myos_env_export VAR... print VAR='value' for each variable that has a value, +# ready to be passed to env(1) +myos_env_export() { + for _v in "$@"; do + _val=$(myos_var "$_v") + [ -n "$_val" ] && printf "%s=%s\n" "$_v" "$_val" + done + return 0 +} diff --git a/spec/unit/config_load_helper.sh b/spec/unit/config_load_helper.sh new file mode 100644 index 0000000..8b47160 --- /dev/null +++ b/spec/unit/config_load_helper.sh @@ -0,0 +1,5 @@ +#shellcheck shell=sh +# Helper: myos_dotenv_load sets variables in the caller's scope. +[ -n "${2:-}" ] && LOADED=$2 +myos_dotenv_load "$1" +printf '%s\n' "$LOADED" diff --git a/spec/unit/config_spec.sh b/spec/unit/config_spec.sh new file mode 100644 index 0000000..87b46a4 --- /dev/null +++ b/spec/unit/config_spec.sh @@ -0,0 +1,82 @@ +#shellcheck shell=sh +Include lib/str.sh +Include lib/core.sh +Include lib/tags.sh +Include lib/config.sh + +Describe 'lib/config.sh' + setup() { MYOS_TMP=$(mktemp -d "${TMPDIR:-/tmp}/myos-cfg.XXXXXX"); } + cleanup() { rm -rf "$MYOS_TMP"; } + BeforeEach setup + AfterEach cleanup + + Describe 'myos_dotenv_parse' + It 'keeps plain assignments, drops comments and blanks, trims around the =' + printf '# a comment\n\nFOO=bar\n BAZ = qux \nnot an assignment\n' > "$MYOS_TMP/.env" + When call myos_dotenv_parse "$MYOS_TMP/.env" + The line 1 should equal "FOO=bar" + The line 2 should equal "BAZ=qux " + The lines of output should equal 2 + End + It 'strips one layer of quotes' + printf 'A="quoted"\nB='"'"'single'"'"'\nC=bare\n' > "$MYOS_TMP/.env" + When call myos_dotenv_parse "$MYOS_TMP/.env" + The line 1 should equal "A=quoted" + The line 2 should equal "B=single" + The line 3 should equal "C=bare" + End + It 'keeps a value containing a hash, which the make include could not' + printf 'PASS=aa#bb\n' > "$MYOS_TMP/.env" + When call myos_dotenv_parse "$MYOS_TMP/.env" + The output should equal "PASS=aa#bb" + End + It 'never executes the file' + printf 'X=$(touch %s/pwned)\n' "$MYOS_TMP" > "$MYOS_TMP/.env" + When call myos_dotenv_parse "$MYOS_TMP/.env" + The output should equal 'X=$(touch '"$MYOS_TMP"'/pwned)' + The path "$MYOS_TMP/pwned" should not be exist + End + It 'is empty for a missing file' + When call myos_dotenv_parse "$MYOS_TMP/nope" + The output should equal "" + End + End + + Describe 'myos_dotenv_load' + It 'sets the variables of the file' + printf 'LOADED=yes\n' > "$MYOS_TMP/.env" + When run source spec/unit/config_load_helper.sh "$MYOS_TMP/.env" + The output should equal "yes" + End + It 'never overrides a variable that already has a value' + printf 'LOADED=fromfile\n' > "$MYOS_TMP/.env" + When run source spec/unit/config_load_helper.sh "$MYOS_TMP/.env" preset + The output should equal "preset" + End + End + + Describe 'myos_env_vars' + It 'collects the variables referenced by a compose file' + printf 'services:\n a:\n image: ${IMAGE}\n environment:\n X: ${FOO:-d}\n' > "$MYOS_TMP/c.yml" + When call myos_env_vars "$MYOS_TMP/c.yml" + The output should equal "FOO IMAGE" + End + It 'ignores the $$ compose escape' + printf 'services:\n a:\n command: echo $$HOME ${REAL}\n' > "$MYOS_TMP/c.yml" + When call myos_env_vars "$MYOS_TMP/c.yml" + The output should equal "REAL" + End + It 'is empty without files' + When call myos_env_vars + The output should equal "" + End + End + + Describe 'myos_env_export' + It 'prints only the variables that have a value' + SET_ONE=x; SET_TWO= + When call myos_env_export SET_ONE SET_TWO SET_MISSING + The output should equal "SET_ONE=x" + End + End +End