diff --git a/CHANGELOG.md b/CHANGELOG.md index b00711b..2b2f6c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ - `myos ls`, `myos env`, `myos doctor` to inspect an installation - `install.sh`, and the catalogue is looked up beside the installation - agent skill in `skills/myos/`, contributor notes in `AGENTS.md` +- stacks carry their settings in `.env` and `.sh` hooks, so the + catalogue no longer needs make to be installed +- `--color always|never|auto`, and no colour when the output is piped +- verified under the /bin/sh of Alpine (busybox) and Debian (dash) - the make engine still works and is still covered by the golden tests ## v1.1 - 2026-09-03 diff --git a/Makefile b/Makefile index 7f095a3..b00e326 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -DEV_TARGETS := test test-unit test-golden test-integration lint golden-record +DEV_TARGETS := test test-unit test-golden test-integration test-portability lint golden-record ifneq ($(filter $(DEV_TARGETS),$(MAKECMDGOALS)),) SHELLSPEC ?= shellspec @@ -16,6 +16,17 @@ test-integration: ## Run tests needing a real docker daemon MYOS_INTEGRATION=1 $(SHELLSPEC) spec/integration golden-record: ## Re-record golden expectations from the legacy engine spec/golden/record.sh $(CASES) +test-portability: ## Run the CLI under the /bin/sh of Alpine and Debian + @for img in alpine:3.20 debian:13-slim; do \ + printf '%s: ' "$$img"; \ + tar cf - --exclude .git . | docker run -i --rm "$$img" /bin/sh -c \ + 'mkdir -p /myos && tar xf - -C /myos && cd /tmp && \ + export PATH=/myos/spec/support/bin:$$PATH && \ + /myos/bin/myos version >/dev/null && \ + /myos/bin/myos -C /myos/spec/fixtures/host-project -n up host >/dev/null && \ + echo ok'; \ + done + lint: ## shellcheck all shell sources $(SHELLCHECK) -s bash myos spec/golden/record.sh spec/support/run.sh spec/support/bin/* $(wildcard bin/* lib/*.sh lib/cmd/*.sh install.sh) diff --git a/bin/myos b/bin/myos index 431285d..c2b8c35 100755 --- a/bin/myos +++ b/bin/myos @@ -19,7 +19,7 @@ done MYOS_ROOT=$(cd "$(dirname "$_self")/.." && pwd -P) export MYOS_ROOT -for _m in core str tags naming stack config compose; do +for _m in core str tags naming stack config compose hooks; do # shellcheck source=/dev/null . "$MYOS_ROOT/lib/$_m.sh" done @@ -34,6 +34,7 @@ done MYOS_VARS= MYOS_ARGS= MYOS_HOSTS= + MYOS_COLOR=${MYOS_COLOR:-auto} VERBOSE=${VERBOSE:-} DEBUG=${DEBUG:-} } @@ -47,6 +48,7 @@ Options: -e ENV environment (default: local, or ENV from the config) -H HOSTS run on remote hosts instead (comma separated, or "all") -n, --dry-run print the commands instead of running them + --color WHEN always, never or auto (default: colour when on a terminal) -v, --verbose show what myos does -d, --debug show every command -h, --help this help @@ -73,6 +75,8 @@ while [ $# -gt 0 ]; do -e) ENV=$2; shift 2 ;; -H) MYOS_HOSTS=$2; shift 2 ;; -n|--dry-run) DRYRUN=true; shift ;; + --color) MYOS_COLOR=$2; shift 2 ;; + --color=*) MYOS_COLOR=${1#--color=}; shift ;; -v|--verbose) VERBOSE=true; shift ;; -d|--debug) DEBUG=true; shift ;; -h|--help) usage; exit 0 ;; @@ -163,6 +167,21 @@ fi # shellcheck disable=SC2086 # a list of references MYOS_STACKS=$(myos_group_expand $MYOS_REFS) +# The uri a stack is served on: the tag helpers build on it, so it has to be +# known before the hooks run. +MYOS_SCOPE_FIRST=$(for _r in $MYOS_STACKS; do myos_scope "$_r"; break; done) +MYOS_APP_FIRST=$(for _r in $MYOS_STACKS; do myos_stack_name "$_r"; break; done) +APP_HOST=${APP_HOST:-$(myos_app_host "$MYOS_SCOPE_FIRST" "$USER" "$ENV" "$MYOS_APP_FIRST" "$DOMAINNAME" "$HOSTNAME")} +APP_URI=${APP_URI:-$(myos_app_uri "$APP_HOST" "${APP_PATH:-}")} +APP_SCHEME=${APP_SCHEME:-http} + +# Per-stack hooks must run in this shell: everything downstream reads the +# variables they set, and a command substitution would throw them away. +for _ref in $MYOS_STACKS; do + _hdir=$(myos_stack_resolve "$_ref" 2>/dev/null) || continue + myos_stack_hooks "$_hdir" "$(myos_stack_name "$_ref")" +done + # myos_framework_compose_files the networks and volumes overlays myos itself # provides; they always come last so a stack can rely on them being there. myos_framework_compose_files() { diff --git a/lib/cmd/env.sh b/lib/cmd/env.sh index 9a2a4db..c9315e1 100644 --- a/lib/cmd/env.sh +++ b/lib/cmd/env.sh @@ -1,34 +1,47 @@ #shellcheck shell=sh # shellcheck disable=SC3028 # HOSTNAME is a myos variable, set by bin/myos # myos env [VAR...] show resolved variables (replaces the make print-VAR target) + +# myos_env_print NAME VALUE +# Same shape as the make print- target: the name padded to 37 columns in +# the highlight colour, then the value in the value colour. +myos_env_print() { + printf '%s%-37s%s%s%s%s\n' \ + "$MYOS_C_HIGHLIGHT" "$1" "$MYOS_C_RESET" "$MYOS_C_VALUE" "$2" "$MYOS_C_RESET" +} + myos_cmd_env() { _vars=${MYOS_VARS:-} [ -n "$_vars" ] || _vars=$MYOS_ARGS [ -n "$_vars" ] || _vars=$MYOS_REFS_RAW if [ -z "$_vars" ]; then - _vars="ENV USER HOSTNAME DOMAIN WORKDIR MYOS_PATH STACK COMPOSE_PROJECT_NAME COMPOSE_FILE" + _vars="ENV USER HOSTNAME DOMAIN WORKDIR MYOS_PATH SCOPE STACK COMPOSE_PROJECT_NAME COMPOSE_FILE" fi for _v in $_vars; do case $_v in - MYOS_PATH) printf '%s %s\n' "$_v" "$(myos_path)" ;; - COMPOSE_FILE) printf '%s %s\n' "$_v" "$(myos_all_compose_files | tr '\n' ' ' | sed 's/ $//')" ;; - COMPOSE_PROJECT_NAME) printf '%s %s\n' "$_v" "$(myos_first_project)" ;; - STACK) printf '%s %s\n' "$_v" "$(printf '%s' "$MYOS_STACKS" | tr '\n' ' ' | sed 's/ $//')" ;; - DOCKER_NETWORK_DEFAULT) printf '%s %s\n' "$_v" "$(myos_network_default "$(myos_first_project)")" ;; - DOCKER_NETWORK_PRIVATE) printf '%s %s\n' "$_v" "$(myos_network_private "$USER" "$ENV")" ;; - DOCKER_NETWORK_PUBLIC) printf '%s %s\n' "$_v" "$(myos_network_public "${HOSTNAME:-}")" ;; - COMPOSE_SERVICE_NAME) printf '%s %s\n' "$_v" "$(myos_service_name "$(myos_first_project)")" ;; - COMPOSE_FILE_SUFFIX) printf '%s %s\n' "$_v" "$(myos_compose_suffixes)" ;; - APP|APP_NAME) printf '%s %s\n' "$_v" "$(myos_first_app)" ;; - SCOPE) printf '%s %s\n' "$_v" "$(myos_first_scope)" ;; - # HOST_STACK/USER_STACK are the names the make engine used for the scope - HOST_STACK) [ "$(myos_first_scope)" = host ] && printf '%s host\n' "$_v" || printf '%s\n' "$_v" ;; - USER_STACK) [ "$(myos_first_scope)" = user ] && printf '%s User\n' "$_v" || printf '%s\n' "$_v" ;; - DOCKER_REPOSITORY) printf '%s %s\n' "$_v" "$(printf '%s' "$(myos_first_project)" | tr '_-' '//')" ;; + MYOS_PATH) myos_env_print "$_v" "$(myos_path)" ;; + COMPOSE_FILE) myos_env_print "$_v" "$(myos_all_compose_files | tr '\n' ' ' | sed 's/ $//')" ;; + COMPOSE_PROJECT_NAME) myos_env_print "$_v" "$(myos_first_project)" ;; + COMPOSE_SERVICE_NAME) myos_env_print "$_v" "$(myos_service_name "$(myos_first_project)")" ;; + COMPOSE_FILE_SUFFIX) myos_env_print "$_v" "$(myos_compose_suffixes)" ;; + STACK) myos_env_print "$_v" "$(printf '%s' "$MYOS_STACKS" | tr '\n' ' ' | sed 's/ $//')" ;; + SCOPE) myos_env_print "$_v" "$(myos_first_scope)" ;; + APP|APP_NAME) myos_env_print "$_v" "$(myos_first_app)" ;; + DOCKER_REPOSITORY) myos_env_print "$_v" "$(printf '%s' "$(myos_first_project)" | tr '_-' '//')" ;; + DOCKER_NETWORK_DEFAULT) myos_env_print "$_v" "$(myos_network_default "$(myos_first_project)")" ;; + DOCKER_NETWORK_PRIVATE) myos_env_print "$_v" "$(myos_network_private "$USER" "$ENV")" ;; + DOCKER_NETWORK_PUBLIC) myos_env_print "$_v" "$(myos_network_public "${HOSTNAME:-}")" ;; DOCKER_NETWORK) - if [ "$(myos_first_scope)" = user ]; then printf '%s %s\n' "$_v" "$USER" - else printf '%s %s\n' "$_v" "$(myos_network_private "$USER" "$ENV")"; fi ;; - *) printf '%s %s\n' "$_v" "$(myos_var "$_v")" ;; + if [ "$(myos_first_scope)" = user ]; then myos_env_print "$_v" "$USER" + else myos_env_print "$_v" "$(myos_network_private "$USER" "$ENV")"; fi ;; + # HOST_STACK and USER_STACK are what the make engine called the scope + HOST_STACK) + if [ "$(myos_first_scope)" = host ]; then myos_env_print "$_v" host + else myos_env_print "$_v" ""; fi ;; + USER_STACK) + if [ "$(myos_first_scope)" = user ]; then myos_env_print "$_v" User + else myos_env_print "$_v" ""; fi ;; + *) myos_env_print "$_v" "$(myos_var "$_v")" ;; esac done } diff --git a/lib/core.sh b/lib/core.sh index 1c29223..4e38a34 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -13,13 +13,25 @@ MYOS_E_USAGE=2 # bad invocation MYOS_E_NOSTACK=3 # stack not found MYOS_E_NOREQ=4 # missing requirement +# myos_colors decide whether to emit colour. +# MYOS_COLOR=always|never|auto (default auto: only when stdout is a terminal). +# The make engine always emitted the escape codes, even into a pipe. myos_colors() { - if [ -t 2 ] && [ "${TERM:-dumb}" != dumb ] && [ -z "${NO_COLOR:-}" ]; then + _want=${MYOS_COLOR:-auto} + [ -n "${NO_COLOR:-}" ] && _want=never + case $_want in + never) _want=no ;; + always) _want=yes ;; + *) if [ -t 1 ] && [ "${TERM:-dumb}" != dumb ]; then _want=yes; else _want=no; fi ;; + esac + if [ "$_want" = yes ]; then MYOS_C_ERROR=$(printf '\033[31m'); MYOS_C_WARN=$(printf '\033[01;33m') MYOS_C_INFO=$(printf '\033[33m'); MYOS_C_DEBUG=$(printf '\033[01;34m') MYOS_C_VALUE=$(printf '\033[36m'); MYOS_C_RESET=$(printf '\033[0m') + MYOS_C_HIGHLIGHT=$(printf '\033[32m') else MYOS_C_ERROR=; MYOS_C_WARN=; MYOS_C_INFO=; MYOS_C_DEBUG=; MYOS_C_VALUE=; MYOS_C_RESET= + MYOS_C_HIGHLIGHT= fi } diff --git a/lib/hooks.sh b/lib/hooks.sh new file mode 100644 index 0000000..16d0603 --- /dev/null +++ b/lib/hooks.sh @@ -0,0 +1,28 @@ +#shellcheck shell=sh +# hooks: the per-stack settings that used to live in a .mk file. +# +# A stack may ship, next to its compose files: +# .env dotenv, for plain values +# .env. the same, for one environment +# .sh shell, for values that have to be computed (fabio tags, JWTs) +# .mk the legacy make snippet, still read for its groups +# +# A .sh hook runs with the myos helpers available (myos_tagprefix, myos_uri, +# myos_var) and sets variables directly. This is what lets a stack of the +# catalogue work on a machine that has no make. + +# myos_stack_hooks DIR NAME load the hooks of one stack, most specific last +myos_stack_hooks() { + _hdir=$1; _hname=$2 + for _h in "$_hdir/$_hname.env" "$_hdir/$_hname.env.$ENV"; do + [ -f "$_h" ] && myos_dotenv_load "$_h" + done + for _h in "$_hdir/$_hname.sh" "$_hdir/$_hname.$ENV.sh"; do + if [ -f "$_h" ]; then + myos_debug "hook $_h" + # shellcheck source=/dev/null + . "$_h" + fi + done + return 0 +} diff --git a/lib/naming.sh b/lib/naming.sh index 0155520..e8bb73d 100644 --- a/lib/naming.sh +++ b/lib/naming.sh @@ -79,3 +79,44 @@ myos_service_name() { printf '%s' "$1" | tr '_' '-'; } myos_network_default() { printf '_%s' "$1"; } myos_network_private() { printf '%s' "${DOCKER_NETWORK_PRIVATE:-${1}-${2}}"; } myos_network_public() { printf '%s' "${DOCKER_NETWORK_PUBLIC:-${1}}"; } + +# myos_app_domain SCOPE USER DOMAIN +# The domain a stack is served on. A host stack is never prefixed by the user: +# it belongs to the machine. +myos_app_domain() { + _scope=$1; _u=$2; _dom=$3 + if [ "$_scope" != host ] && [ "${APP_HOST_MULTI_USER:-false}" = true ]; then + printf '%s.%s' "$_u" "$_dom" + else + printf '%s' "$_dom" + fi +} + +# myos_app_host SCOPE USER ENV APP DOMAIN HOSTNAME +# A host stack is served on .; anything else is prefixed by +# the environment unless the environment is the main one. +myos_app_host() { + _scope=$1; _u=$2; _env=$3; _app=$4; _dom=$5; _host=$6 + _multi_env=${APP_HOST_MULTI_ENV:-} + if [ -z "$_multi_env" ]; then + case $_env in local|master|main) _multi_env=false ;; *) _multi_env=true ;; esac + fi + _prefix= + if [ "$_scope" = host ]; then _prefix="$_host." + elif [ "$_multi_env" = true ]; then _prefix="$_env." + fi + _name= + [ "${APP_HOST_MULTI_APP:-false}" = true ] && _name="$(myos_name "$_app")." + _out="$_prefix$_name$(myos_app_domain "$_scope" "$_u" "$_dom")" + # a host stack behind the load balancer also answers on the bare domain + [ "$_scope" = host ] && [ -n "${HOST_LB:-}" ] && _out="$_out $_dom" + printf '%s' "$_out" +} + +# myos_app_uri HOST [PATH] the base uri the fabio tags are built on; +# it always ends with a slash +myos_app_uri() { + _out= + for _h in $1; do _out="${_out:+$_out }$_h/${2:-}"; done + printf '%s' "$_out" +} diff --git a/lib/tags.sh b/lib/tags.sh index ae7310c..714d146 100644 --- a/lib/tags.sh +++ b/lib/tags.sh @@ -71,3 +71,17 @@ myos_tagprefix() { [ -n "$_uris" ] || _uris=$(myos_uri "$_stack" "$_port") myos_urlprefix "$_path" "$_opts" "$_uris" } + +# myos_servicenvs STACK GROUP KEY +# Collect _SERVICE__ for every env listed in +# _SERVICE__ENVS. Used to build a list of listeners out of one +# variable per protocol. +myos_servicenvs() { + _s=$(myos_upper "$1"); _g=$(myos_upper "$2"); _k=$(myos_upper "$3") + _out= + for _e in $(myos_var "${_s}_SERVICE_${_g}_ENVS"); do + _v=$(myos_var "${_s}_SERVICE_$(myos_upper "$_e")_${_k}") + [ -n "$_v" ] && _out="${_out:+$_out }$_v" + done + printf '%s' "$_out" +} diff --git a/skills/myos/references/authoring.md b/skills/myos/references/authoring.md index ffe04a2..b9bdfc8 100644 --- a/skills/myos/references/authoring.md +++ b/skills/myos/references/authoring.md @@ -6,6 +6,8 @@ stack//.yml the services stack//.local.yml what only makes sense on a workstation (published ports…) stack//.labels.yml the registrator labels, so routing stays optional +stack//.env plain settings: versions, defaults +stack//.sh computed settings (fabio tags), no make needed stack//.env.dist the variables it expects, with defaults stack//README.md what it is and what it needs ``` diff --git a/skills/myos/references/conventions.md b/skills/myos/references/conventions.md index 8a89e57..bcca7e4 100644 --- a/skills/myos/references/conventions.md +++ b/skills/myos/references/conventions.md @@ -99,6 +99,33 @@ parsed, never sourced, so a value may contain a `#` or a `$(...)` without breaking anything or being executed. The make engine included `.env` as a makefile, where both broke. +## Per-stack settings + +A stack keeps its own settings next to its compose files: + +| file | for | +|---|---| +| `.env` | plain values: versions, defaults | +| `.env.` | the same, for one environment | +| `.sh` | values that have to be computed | +| `.mk` | the legacy make snippet; still read for its groups | + +A `.sh` hook is sourced with the myos helpers available, and sets variables +directly. This is what lets a stack work on a machine that has no make: + +```sh +# stack/host/fabio.sh +HOST_FABIO_VERSION=${HOST_FABIO_VERSION:-1.6.3} +HOST_FABIO_SERVICE_9998_NAME=${HOST_FABIO_SERVICE_9998_NAME:-fabio} +HOST_FABIO_SERVICE_9998_AUTH=${HOST_FABIO_SERVICE_9998_AUTH:-default} +HOST_FABIO_SERVICE_9998_TAGS=${HOST_FABIO_SERVICE_9998_TAGS:-$(myos_tagprefix HOST_FABIO 9998)} +``` + +Always write `${VAR:-default}` so the environment and the `.env` still win. +Helpers available in a hook: `myos_tagprefix`, `myos_urlprefix`, `myos_uri`, +`myos_url`, `myos_envprefix`, `myos_servicenvs`, `myos_var`, `myos_lower`, +`myos_upper`. + ## Groups A group is a lowercase name whose value lists stacks. It can live in a `.env`, diff --git a/spec/golden/expected.cli/cat-unknown-target.txt b/spec/golden/expected.cli/cat-unknown-target.txt index 97c37a3..08636f8 100644 --- a/spec/golden/expected.cli/cat-unknown-target.txt +++ b/spec/golden/expected.cli/cat-unknown-target.txt @@ -6,6 +6,7 @@ Options: -e ENV environment (default: local, or ENV from the config) -H HOSTS run on remote hosts instead (comma separated, or "all") -n, --dry-run print the commands instead of running them + --color WHEN always, never or auto (default: colour when on a terminal) -v, --verbose show what myos does -d, --debug show every command -h, --help this help diff --git a/spec/unit/hooks_helper.sh b/spec/unit/hooks_helper.sh new file mode 100644 index 0000000..712281f --- /dev/null +++ b/spec/unit/hooks_helper.sh @@ -0,0 +1,5 @@ +#shellcheck shell=sh +# Helper: hooks set variables in the caller's scope, which a subshell loses. +[ -n "${4:-}" ] && eval "$3=\$4" +myos_stack_hooks "$1" "$2" +eval "printf '%s\n' \"\${$3:-}\"" diff --git a/spec/unit/hooks_spec.sh b/spec/unit/hooks_spec.sh new file mode 100644 index 0000000..078ff65 --- /dev/null +++ b/spec/unit/hooks_spec.sh @@ -0,0 +1,82 @@ +#shellcheck shell=sh +Include lib/str.sh +Include lib/core.sh +Include lib/tags.sh +Include lib/naming.sh +Include lib/config.sh +Include lib/hooks.sh + +# A stack ships its computed settings as a shell hook, so the catalogue works +# on a machine without make. These check the hook and the uri it builds on. +Describe 'lib/hooks.sh' + setup() { + MYOS_TMP=$(mktemp -d "${TMPDIR:-/tmp}/myos-hook.XXXXXX") + MYOS_TMP=$(cd "$MYOS_TMP" && pwd -P) + ENV=local; DOMAIN=example.org; USER=tester; HOSTNAME=testhost + APP_HOST=demo.example.org; APP_URI=demo.example.org/ + } + cleanup() { rm -rf "$MYOS_TMP"; } + BeforeEach setup + AfterEach cleanup + + It 'loads plain values from a .env hook' + printf 'DEMO_VERSION=1.2.3\n' > "$MYOS_TMP/demo.env" + When run source spec/unit/hooks_helper.sh "$MYOS_TMP" demo DEMO_VERSION + The output should equal "1.2.3" + End + + It 'loads a computed value from a .sh hook' + printf 'DEMO_TAGS=$(myos_tagprefix demo 8000)\n' > "$MYOS_TMP/demo.sh" + When run source spec/unit/hooks_helper.sh "$MYOS_TMP" demo DEMO_TAGS + The output should equal "urlprefix-demo.demo.example.org/*" + End + + It 'lets a hook name the service it publishes' + printf 'DEMO_SERVICE_8000_NAME=www\nDEMO_TAGS=$(myos_tagprefix demo 8000)\n' > "$MYOS_TMP/demo.sh" + When run source spec/unit/hooks_helper.sh "$MYOS_TMP" demo DEMO_TAGS + The output should equal "urlprefix-www.demo.example.org/*" + End + + It 'lets the environment win over a hook default' + printf 'DEMO_VERSION=${DEMO_VERSION:-1.2.3}\n' > "$MYOS_TMP/demo.sh" + When run source spec/unit/hooks_helper.sh "$MYOS_TMP" demo DEMO_VERSION 9.9.9 + The output should equal "9.9.9" + End + + It 'is quiet when a stack has no hook' + When call myos_stack_hooks "$MYOS_TMP" nothing + The status should be success + The output should equal "" + End +End + +Describe 'lib/naming.sh service uris' + BeforeEach 'ENV=local; unset APP_HOST_MULTI_APP APP_HOST_MULTI_USER APP_HOST_MULTI_ENV HOST_LB 2>/dev/null || true' + + It 'serves a host stack on .' + When call myos_app_host host aya local fabio example.org sonic + The output should equal "sonic.example.org" + End + It 'adds the bare domain when the host is the load balancer' + HOST_LB=true + When call myos_app_host host aya local fabio example.org sonic + The output should equal "sonic.example.org example.org" + End + It 'does not prefix a main environment' + When call myos_app_host app aya master duniter example.org sonic + The output should equal "example.org" + End + It 'prefixes any other environment' + When call myos_app_host app aya staging duniter example.org sonic + The output should equal "staging.example.org" + End + It 'prefixes the user when asked to' + APP_HOST_MULTI_USER=true + When call myos_app_host app aya master duniter example.org sonic + The output should equal "aya.example.org" + End + It 'builds a uri that ends with a slash' + When call myos_app_uri "a.example.org b.example.org" + The output should equal "a.example.org/ b.example.org/" + End +End