From e7eab505e9849abc38e8444426f32afc6d4ca445 Mon Sep 17 00:00:00 2001 From: Yann Autissier Date: Thu, 3 Sep 2026 18:27:29 +0200 Subject: [PATCH] add stack resolution: search path, references, overlays, groups Replaces the 500-character stack_path expression of make/apps/def.docker.mk:159 and its leaked $(stackz) loop variable with three readable functions, covered by 20 assertions. --- lib/stack.sh | 138 ++++++++++++++++++++++++++++++ spec/unit/stack_spec.sh | 136 +++++++++++++++++++++++++++++ spec/unit/stack_version_helper.sh | 5 ++ 3 files changed, 279 insertions(+) create mode 100644 lib/stack.sh create mode 100644 spec/unit/stack_spec.sh create mode 100644 spec/unit/stack_version_helper.sh diff --git a/lib/stack.sh b/lib/stack.sh new file mode 100644 index 0000000..79c8485 --- /dev/null +++ b/lib/stack.sh @@ -0,0 +1,138 @@ +#shellcheck shell=sh +# stack: where stacks live, how a reference resolves to compose files. +# +# A stack is a directory holding compose files. A reference is +# [/][:] resolved along MYOS_PATH +# ./ or /abs/path or rel/path the directory itself +# Ported from make/def.docker.mk (STACK_DIR/SHARE_DIR) and +# make/apps/def.docker.mk (compose-file, docker-stack, docker-stack-update). + +# myos_path the stack search path, colon separated, existing directories only. +# Project first, then the shared catalogues: a project always wins over the +# catalogue installed system wide. +myos_path() { + [ -n "${MYOS_PATH:-}" ] && { printf '%s' "$MYOS_PATH"; return 0; } + _wd=${WORKDIR:-$PWD} + _name=${STACK_DIR_NAME:-stack} + _out= + for _d in "$_wd" "$_wd/.." "$HOME/.local/share" /usr/local/share /usr/share; do + for _c in "$_d/$_name" "$_d/myos/$_name"; do + [ -d "$_c" ] || continue + _c=$(cd "$_c" && pwd -P) + case ":$_out:" in *":$_c:"*) continue ;; esac + _out="${_out:+$_out:}$_c" + done + done + printf '%s' "$_out" +} + +# myos_stack_resolve REF print the directory holding the stack, or fail with +# MYOS_E_NOSTACK. Sets MYOS_STACK_NAME and MYOS_STACK_VERSION as a side effect. +myos_stack_resolve() { + _ref=${1%/} + # MYOS_STACK_NAME and MYOS_STACK_VERSION are read back by the callers + # shellcheck disable=SC2034 + { + MYOS_STACK_VERSION=latest + case $_ref in *:*) MYOS_STACK_VERSION=${_ref##*:}; _ref=${_ref%:*} ;; esac + MYOS_STACK_NAME=$(basename "$_ref" .yml) + } + + # a path reference resolves to itself + case $_ref in + .|./*|/*|../*) + if [ -d "$_ref" ]; then printf '%s' "$(cd "$_ref" && pwd -P)"; return 0; fi + myos_error "no such directory: $_ref"; return "$MYOS_E_NOSTACK" ;; + esac + + _IFS=$IFS; IFS=: + for _d in $(myos_path); do + IFS=$_IFS + if [ -d "$_d/$_ref" ]; then printf '%s' "$_d/$_ref"; return 0; fi + if [ -f "$_d/$_ref.yml" ] || [ -f "$_d/$_ref.yaml" ]; then + printf '%s' "$(dirname "$_d/$_ref")"; return 0 + fi + if [ -d "$_d/$MYOS_STACK_NAME" ]; then printf '%s' "$_d/$MYOS_STACK_NAME"; return 0; fi + IFS=: + done + IFS=$_IFS + myos_error "stack not found: $1 (searched $(myos_path))" + return "$MYOS_E_NOSTACK" +} + +# myos_compose_suffixes the overlay suffixes, from the COMPOSE_FILE_* variables +# that are not false (make: COMPOSE_FILE_SUFFIX). A value other than true also +# yields ".", which is how COMPOSE_FILE_WWW=nginx works. +myos_compose_suffixes() { + _out= + for _v in $(set | sed -n 's/^\(COMPOSE_FILE_[A-Z0-9_]*\)=.*/\1/p' | sort -u); do + case $_v in COMPOSE_FILE_SUFFIX) continue ;; esac + _val=$(myos_var "$_v") + case $_val in false|False|FALSE|'') continue ;; esac + _s=$(myos_lower "${_v#COMPOSE_FILE_}") + _out="${_out:+$_out }$_s" + case $_val in true|True|TRUE) ;; *) for _x in $_val; do _out="$_out $_s.$_x"; done ;; esac + done + printf '%s' "$_out" +} + +# myos_compose_files DIR NAMES SUFFIXES [ENV] +# Print the compose files that exist, in the order the framework loads them. +myos_compose_files() { + _dir=$1; _names=$2; _suffixes=${3:-}; _env=${4:-${ENV:-local}} + for _e in yml yaml; do + for _n in $_names; do + for _f in \ + "$_dir/$_n.$_e" "$_dir/$_n.$_env.$_e" \ + "$_dir/$_env/$_n.$_e" "$_dir/$_env/$_n.$_env.$_e"; do + [ -f "$_f" ] && printf '%s\n' "$_f" + done + for _s in $_suffixes; do + for _f in "$_dir/$_n.$_s.$_e" "$_dir/$_n.$_s.$_env.$_e"; do + [ -f "$_f" ] && printf '%s\n' "$_f" + done + done + done + done + return 0 +} + +# myos_group_expand REF... expand group references recursively. +# A group is a variable whose name is the reference: `host=host/consul host/fabio` +# in the environment, in a .env, in /.env or in a legacy .mk. +myos_group_expand() { + _depth=${MYOS_GROUP_DEPTH:-0} + [ "$_depth" -gt 16 ] && myos_die "$MYOS_E_USAGE" "stack group nested too deep: $*" + for _ref in "$@"; do + _val=$(myos_group_value "$_ref") + if [ -n "$_val" ]; then + # shellcheck disable=SC2086 # the group value is a list of references + MYOS_GROUP_DEPTH=$((_depth + 1)) myos_group_expand $_val + else + printf '%s\n' "$_ref" + fi + done +} + +# myos_group_value REF the list a group expands to, empty when not a group +myos_group_value() { + case $1 in .|/*|*/*|*:*) return 0 ;; esac + _v=$(myos_var "$1") + [ -n "$_v" ] && { printf '%s' "$_v"; return 0; } + _IFS=$IFS; IFS=: + for _d in $(myos_path); do + IFS=$_IFS + [ -f "$_d/$1.env" ] && { _v=$(sed -n "s/^$1=//p" "$_d/$1.env" | tail -1 | tr -d '"'); } + [ -z "$_v" ] && [ -f "$_d/$1.mk" ] && { _v=$(myos_mk_group "$_d/$1.mk" "$1"); } + [ -z "$_v" ] && [ -f "$_d/$1/$1.mk" ] && { _v=$(myos_mk_group "$_d/$1/$1.mk" "$1"); } + [ -n "$_v" ] && { printf '%s' "$_v"; return 0; } + IFS=: + done + IFS=$_IFS + return 0 +} + +# myos_mk_group FILE NAME read `name ?= a b c` out of a legacy .mk snippet +myos_mk_group() { + sed -n "s/^$2[[:space:]]*[?:]\{0,1\}=[[:space:]]*//p" "$1" 2>/dev/null | tail -1 +} diff --git a/spec/unit/stack_spec.sh b/spec/unit/stack_spec.sh new file mode 100644 index 0000000..818abd5 --- /dev/null +++ b/spec/unit/stack_spec.sh @@ -0,0 +1,136 @@ +#shellcheck shell=sh +Include lib/str.sh +Include lib/core.sh +Include lib/tags.sh +Include lib/stack.sh + +Describe 'lib/stack.sh' + setup() { + MYOS_SANDBOX=$(myos_sandbox host-project) + WORKDIR=$MYOS_SANDBOX/wd + HOME=$MYOS_SANDBOX/home + MYOS_PATH= + ENV=local + unset COMPOSE_FILE_WWW COMPOSE_FILE_DNS 2>/dev/null || true + } + cleanup() { rm -rf "$MYOS_SANDBOX"; } + BeforeEach setup + AfterEach cleanup + + Describe 'myos_path' + It 'puts the project stacks before the shared catalogue' + When call myos_path + The output should equal "$WORKDIR/stack:$HOME/.local/share/myos/stack" + End + It 'is overridable' + MYOS_PATH=/opt/stacks + When call myos_path + The output should equal "/opt/stacks" + End + End + + Describe 'myos_stack_resolve' + It 'resolves a stack of the project' + When call myos_stack_resolve host/consul + The output should equal "$WORKDIR/stack/host" + End + It 'resolves a stack of the shared catalogue' + When call myos_stack_resolve postgres + The output should equal "$HOME/.local/share/myos/stack/postgres" + End + It 'resolves a versioned reference' + When run source spec/unit/stack_version_helper.sh + The output should equal "postgres 9.6" + End + It 'resolves a directory reference' + When call myos_stack_resolve "$WORKDIR/stack/host" + The output should equal "$WORKDIR/stack/host" + End + It 'fails loudly on an unknown stack' + When run myos_stack_resolve nosuchstack + The status should equal 3 + The stderr should include "stack not found: nosuchstack" + End + End + + Describe 'myos_compose_files' + It 'loads the stack file and its env overlay' + When call myos_compose_files "$HOME/.local/share/myos/stack/postgres" "docker-compose postgres" "" local + The line 1 should equal "$HOME/.local/share/myos/stack/postgres/postgres.yml" + The line 2 should equal "$HOME/.local/share/myos/stack/postgres/postgres.local.yml" + The lines of output should equal 2 + End + It 'skips the env overlay of another env' + When call myos_compose_files "$HOME/.local/share/myos/stack/postgres" "docker-compose postgres" "" master + The output should equal "$HOME/.local/share/myos/stack/postgres/postgres.yml" + End + It 'loads the version overlay as a suffix' + When call myos_compose_files "$HOME/.local/share/myos/stack/postgres" "docker-compose postgres" "9.6" master + The line 2 should equal "$HOME/.local/share/myos/stack/postgres/postgres.9.6.yml" + End + It 'loads the suffix overlays in order' + When call myos_compose_files "$HOME/.local/share/myos/stack/host" "docker-compose nginx" "www dns" master + The line 1 should equal "$HOME/.local/share/myos/stack/host/nginx.yml" + The line 2 should equal "$HOME/.local/share/myos/stack/host/nginx.www.yml" + The line 3 should equal "$HOME/.local/share/myos/stack/host/nginx.dns.yml" + End + It 'is empty for a directory without compose files' + When call myos_compose_files "$WORKDIR" "docker-compose nothing" "" local + The output should equal "" + End + End + + Describe 'myos_compose_suffixes' + It 'keeps the enabled suffixes and drops the false ones' + COMPOSE_FILE_LABELS=true; COMPOSE_FILE_WWW=false; COMPOSE_FILE_DNS=true + When call myos_compose_suffixes + The output should include "labels" + The output should include "dns" + The output should not include "www" + End + It 'adds . when the value is not a boolean' + COMPOSE_FILE_WWW=nginx + When call myos_compose_suffixes + The output should include "www" + The output should include "www.nginx" + End + End + + Describe 'myos_group_expand' + It 'expands a group defined in the environment' + host="host/consul host/fabio" + When call myos_group_expand host + The line 1 should equal "host/consul" + The line 2 should equal "host/fabio" + End + It 'expands a group defined in a legacy .mk of the project' + When call myos_group_expand host + The line 1 should equal "host/consul" + The line 3 should equal "host/registrator" + The lines of output should equal 3 + End + It 'expands a group of the shared catalogue' + When call myos_group_expand testing + The line 1 should equal "drone/drone" + The line 2 should equal "redis" + End + It 'expands recursively' + # shellcheck disable=SC2034 + all="testing extra"; extra="redis" + When call myos_group_expand all + The lines of output should equal 3 + End + It 'leaves a plain stack reference alone' + When call myos_group_expand postgres host/fabio + The line 1 should equal "postgres" + The line 2 should equal "host/fabio" + End + It 'refuses an endless group loop' + # shellcheck disable=SC2034 + loop="loop" + When run myos_group_expand loop + The status should equal 2 + The stderr should include "nested too deep" + End + End +End diff --git a/spec/unit/stack_version_helper.sh b/spec/unit/stack_version_helper.sh new file mode 100644 index 0000000..08520a7 --- /dev/null +++ b/spec/unit/stack_version_helper.sh @@ -0,0 +1,5 @@ +#shellcheck shell=sh +# Helper: myos_stack_resolve exports the parsed name and version as a side +# effect, which a subshell would discard. +myos_stack_resolve "postgres:9.6" >/dev/null +printf '%s %s\n' "$MYOS_STACK_NAME" "$MYOS_STACK_VERSION"