diff --git a/lib/core.sh b/lib/core.sh new file mode 100644 index 0000000..60bb2a5 --- /dev/null +++ b/lib/core.sh @@ -0,0 +1,45 @@ +#shellcheck shell=sh +# core: logging, error handling and command execution. +# +# Every myos command goes through myos_run, which honours DRYRUN by printing +# the command instead of running it. Messages go to stderr so that stdout stays +# usable for data (myos env, myos config, myos ls). + +MYOS_E_OK=0 # success +MYOS_E_FAIL=1 # command failed +MYOS_E_USAGE=2 # bad invocation +MYOS_E_NOSTACK=3 # stack not found +MYOS_E_NOREQ=4 # missing requirement + +myos_colors() { + if [ -t 2 ] && [ "${TERM:-dumb}" != dumb ] && [ -z "${NO_COLOR:-}" ]; 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') + else + MYOS_C_ERROR=; MYOS_C_WARN=; MYOS_C_INFO=; MYOS_C_DEBUG=; MYOS_C_VALUE=; MYOS_C_RESET= + fi +} + +myos_error() { printf '%sERROR:%s %s\n' "$MYOS_C_ERROR" "$MYOS_C_RESET" "$*" >&2; } +myos_warning() { printf '%sWARNING:%s %s\n' "$MYOS_C_WARN" "$MYOS_C_RESET" "$*" >&2; } +myos_info() { [ -n "${VERBOSE:-}" ] && printf '%s%s%s\n' "$MYOS_C_INFO" "$*" "$MYOS_C_RESET" >&2; return 0; } +myos_debug() { [ -n "${DEBUG:-}" ] && printf '%s%s%s\n' "$MYOS_C_DEBUG" "$*" "$MYOS_C_RESET" >&2; return 0; } + +# myos_die CODE MESSAGE... +myos_die() { _code=$1; shift; myos_error "$*"; exit "$_code"; } + +# myos_run CMD... run a command, or print it when DRYRUN is true +myos_run() { + if [ "${DRYRUN:-false}" = true ]; then + printf '%s\n' "$*" + return 0 + fi + myos_debug "+ $*" + "$@" +} + +# myos_have CMD is a command available? +myos_have() { command -v "$1" >/dev/null 2>&1; } + +myos_colors diff --git a/lib/naming.sh b/lib/naming.sh new file mode 100644 index 0000000..a78644b --- /dev/null +++ b/lib/naming.sh @@ -0,0 +1,78 @@ +#shellcheck shell=sh +# naming: compose project name, service name, networks, user identity. +# +# Ported from make/apps/def.docker.mk (COMPOSE_PROJECT_NAME, COMPOSE_SERVICE_NAME), +# make/def.docker.mk (HOST_*/USER_*, DOCKER_NETWORK_*) and make/def.mk (RESU). + +# myos_scope REF -> host | user | cluster | app +# The first segment of a stack reference decides how the stack is named: +# host stacks are singletons of the machine (they bind privileged ports), +# user stacks are singletons of the user, cluster stacks are swarm namespaces. +myos_scope() { + [ -n "${MYOS_SCOPE:-}" ] && { printf '%s' "$MYOS_SCOPE"; return 0; } + case ${1%%/*} in + host) printf 'host' ;; + User|user) printf 'user' ;; + cluster) printf 'cluster' ;; + *) printf 'app' ;; + esac +} + +# myos_resu MAIL -> user.domain identity of a mail address (make: RESU) +# Also sets MYOS_RESU_NIAMOD (reversed domain + reversed user) and +# MYOS_RESU_PATH (that identity as a path), used by the User stacks. +myos_resu() { + _mail=$(myos_lower "${1:-}" | tr '+_' '..') + case $_mail in + *@*) ;; + *) MYOS_RESU_NIAMOD=; MYOS_RESU_PATH=; printf '%s' "${USER:-}"; return 0 ;; + esac + _user=${_mail%@*} + _domain=${_mail##*@} + [ -n "$_domain" ] || { MYOS_RESU_NIAMOD=; MYOS_RESU_PATH=; printf '%s' "${USER:-}"; return 0; } + _niamod=$(myos_reverse "$(printf '%s' "$_domain" | tr '.' ' ')" | tr ' ' '.') + _resu=$(myos_reverse "$(printf '%s' "$_user" | tr '.' ' ')" | tr ' ' '.') + MYOS_RESU_NIAMOD="$_niamod.$_resu" + MYOS_RESU_PATH=$(printf '%s' "$MYOS_RESU_NIAMOD" | tr '.' '/') + printf '%s.%s' "$_user" "$_domain" +} + +# myos_project_name SCOPE USER ENV APP [PATH] +# host -> HOST_COMPOSE_PROJECT_NAME, defaults to the hostname +# user -> USER_COMPOSE_PROJECT_NAME, defaults to the RESU identity +# cluster -> the stack name: one namespace per swarm, not per user +# app -> MYOS_PROJECT_FORMAT: user-env-app (default) or user-app-env (legacy) +myos_project_name() { + _scope=$1; _user=$2; _env=$3; _app=$4; _path=${5:-} + [ -n "${DOCKER_COMPOSE_PROJECT_NAME:-}" ] && { printf '%s' "$DOCKER_COMPOSE_PROJECT_NAME"; return 0; } + case $_scope in + host) + printf '%s' "${HOST_COMPOSE_PROJECT_NAME:-${HOSTNAME:-localhost}}"; return 0 ;; + user) + if [ -n "${USER_COMPOSE_PROJECT_NAME:-}" ]; then printf '%s' "$USER_COMPOSE_PROJECT_NAME" + else printf '%s' "$(myos_resu "${MAIL:-}" | tr '.' '-')"; fi + return 0 ;; + cluster) + printf '%s' "${MYOS_CLUSTER_PROJECT:-$(myos_name "$_app")}"; return 0 ;; + esac + _n=$(myos_name "$_app") + case ${MYOS_PROJECT_FORMAT:-user-env-app} in + user-app-env) _out="$_user-$_n-$_env" ;; + user-env-app) _out="$_user-$_env-$_n" ;; + *) myos_die "$MYOS_E_USAGE" "unknown MYOS_PROJECT_FORMAT: ${MYOS_PROJECT_FORMAT}" ;; + esac + # the path fragment loses its slashes too (make: $(subst /,,$(subst -,,$(APP_PATH)))) + [ -n "$_path" ] && _out="$_out-$(myos_name "$_path" | tr -d /)" + myos_lower "$_out" | tr -d '.' +} + +# myos_service_name PROJECT prefix of the SERVICE__NAME labels +myos_service_name() { printf '%s' "$1" | tr '_' '-'; } + +# myos_network_default PROJECT +# The leading underscore keeps this network first in alphabetical order, so it +# is the first interface attached and service names never resolve across stacks. +# https://github.com/moby/libnetwork/issues/2093 +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}}"; } diff --git a/lib/str.sh b/lib/str.sh new file mode 100644 index 0000000..f56da3e --- /dev/null +++ b/lib/str.sh @@ -0,0 +1,33 @@ +#shellcheck shell=sh +# str: string helpers ported from make/utils.mk and make/def.mk. + +# myos_lower STRING / myos_upper STRING +myos_lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; } +myos_upper() { printf '%s' "$1" | tr '[:lower:]-.' '[:upper:]__'; } + +# myos_name STRING compose-project-safe name: lowercase, no . - _ +# (make: $(subst _,,$(subst -,,$(subst .,,$(call LOWERCASE,$(1)))))) +myos_name() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -d '._-'; } + +# myos_slugify STRING keep [a-z0-9_], everything else becomes _ +myos_slugify() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_]/_/g'; } + +# myos_reverse WORDS... reverse the order of space separated words +myos_reverse() { + _out= + for _w in $1; do _out="$_w${_out:+ }$_out"; done + printf '%s' "$_out" +} + +# myos_verle A B true when version A <= B (make: verle) +myos_verle() { + [ -n "$1" ] || return 1 + [ -n "$2" ] || return 1 + [ "$1" = "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)" ] +} + +# myos_verlt A B true when version A < B +myos_verlt() { + [ "$1" = "$2" ] && return 1 + myos_verle "$1" "$2" +} diff --git a/lib/tags.sh b/lib/tags.sh new file mode 100644 index 0000000..9123fd3 --- /dev/null +++ b/lib/tags.sh @@ -0,0 +1,70 @@ +#shellcheck shell=sh +# tags: fabio route tags derived from stack variables. +# +# Ported from make/apps/def.mk (uri, url, urlprefix, urlprefixs, tagprefix, +# envprefix, servicenvs). Registrator publishes the SERVICE__TAGS label +# to consul, fabio routes on the urlprefix- tags it finds there. + +# myos_var NAME value of the variable named NAME, empty when unset +myos_var() { eval "printf '%s' \"\${$1:-}\""; } + +# myos_uri SERVICE PORT [BASE_URI] +# ., unless _SERVICE[_]_NAME overrides the prefix +myos_uri() { + _svc=$1; _port=${2:-}; _base=${3:-${APP_URI:-}} + _u=$(myos_upper "$_svc") + _name=$(myos_var "${_u}_SERVICE_${_port}_NAME") + [ -n "$_name" ] || _name=$(myos_var "${_u}_SERVICE_NAME") + [ -n "$_name" ] || _name=$_svc + _out= + for _b in $_base; do _out="${_out:+$_out }${_name}.${_b}"; done + printf '%s' "$_out" +} + +# myos_url SERVICE PORT [BASE_URI] +myos_url() { + _out= + for _u in $(myos_uri "$@"); do _out="${_out:+$_out }${APP_SCHEME:-http}://$_u"; done + printf '%s' "$_out" +} + +# myos_urlprefix [PATH] [OPTS] [URIS] +# one comma separated "urlprefix-* [opts]" per uri +myos_urlprefix() { + _path=${1:-}; _opts=${2:-}; _uris=${3:-${APP_URI:-}} + _out= + for _u in $_uris; do + _tag="urlprefix-${_u}${_path}${MYOS_URL_SUFFIX:-*}${_opts:+ $_opts}" + _out="${_out:+$_out,}$_tag" + done + printf '%s' "$_out" +} + +# myos_envprefix STACK PORT KEYS... +# "key=value" for each _SERVICE__ that is set +myos_envprefix() { + _stack=$1; _port=$2; shift 2 + _out= + for _k in "$@"; do + _v=$(myos_var "$(myos_upper "${_stack}_SERVICE_${_port}_${_k}")") + [ -n "$_v" ] && _out="${_out:+$_out }${_k}=${_v}" + done + printf '%s' "$_out" +} + +# myos_tagprefix STACK PORT [URI_KEYS...] +# the fabio tag of a service, assembled from its PATH, OPTS and URIS variables +myos_tagprefix() { + _stack=$1; _port=$2; shift 2 + _u=$(myos_upper "$_stack") + _path=$(myos_var "${_u}_SERVICE_${_port}_PATH"); [ -n "$_path" ] || _path=$(myos_var "${_u}_SERVICE_PATH") + _opts=$(myos_var "${_u}_SERVICE_${_port}_OPTS"); [ -n "$_opts" ] || _opts=$(myos_var "${_u}_SERVICE_OPTS") + [ -n "$_opts" ] || _opts=$(myos_envprefix "$_stack" "$_port" allow auth deny prepend proto register strip) + _uris= + for _k in "$@"; do + _v=$(myos_var "${_u}_SERVICE_${_port}_${_k}"); [ -n "$_v" ] && _uris="${_uris:+$_uris }$_v" + done + [ -n "$_uris" ] || _uris=$(myos_var "${_u}_SERVICE_${_port}_URIS") + [ -n "$_uris" ] || _uris=$(myos_uri "$_stack" "$_port") + myos_urlprefix "$_path" "$_opts" "$_uris" +} diff --git a/spec/golden/DELTAS.md b/spec/golden/DELTAS.md index cb5ee72..b8407e2 100644 --- a/spec/golden/DELTAS.md +++ b/spec/golden/DELTAS.md @@ -6,3 +6,5 @@ the case gets an override in `expected.cli/.txt` and a line here. | case | delta | why | |---|---|---| +| `myos_urlprefix` with several uris | the bash port joins tags with `,`, the make macro emits `tag ,tag` | the make template ends with ` $(2)` (options), so an empty option list leaves a space before the comma. Harmless but sloppy; every tag written by hand on the fleet uses the clean form. | +| `myos_urlprefix` with options passed inside the path argument | the bash port keeps `*` right after the path (`urlprefix-host:443/* proto=https`), the make macro appends it after the options (`urlprefix-host:443/ proto=https*`) | only reachable by stuffing options into argument 1, which the stale example in `make/apps/def.mk` did. Through `tagprefix`, the real code path, both engines agree. | diff --git a/spec/unit/naming_resu_helper.sh b/spec/unit/naming_resu_helper.sh new file mode 100644 index 0000000..96d8630 --- /dev/null +++ b/spec/unit/naming_resu_helper.sh @@ -0,0 +1,5 @@ +#shellcheck shell=sh +# Helper: myos_resu sets MYOS_RESU_NIAMOD/MYOS_RESU_PATH as a side effect; +# a subshell would lose them, so the assertion runs them here. +myos_resu "aya@github.com" >/dev/null +printf '%s %s\n' "$MYOS_RESU_NIAMOD" "$MYOS_RESU_PATH" diff --git a/spec/unit/naming_spec.sh b/spec/unit/naming_spec.sh new file mode 100644 index 0000000..4e1e55f --- /dev/null +++ b/spec/unit/naming_spec.sh @@ -0,0 +1,125 @@ +#shellcheck shell=sh +Include lib/str.sh +Include lib/core.sh +Include lib/naming.sh + +Describe 'lib/naming.sh' + Describe 'myos_scope' + Parameters + "host/fabio" host + "host" host + "User/User" user + "user/ipfs" user + "cluster/postgres-ha" cluster + "postgres" app + "drone/drone" app + "./" app + End + It "scopes $1 as $2" + When call myos_scope "$1" + The output should equal "$2" + End + It 'honours an explicit MYOS_SCOPE' + MYOS_SCOPE=host + When call myos_scope postgres + The output should equal host + End + End + + Describe 'myos_resu' + It 'turns a mail address into a user.domain identity' + When call myos_resu "aya@github.com" + The output should equal "aya.github.com" + End + It 'folds + and _ into dots, like the make macro' + When call myos_resu "aya+git@github.com" + The output should equal "aya.git.github.com" + End + It 'falls back to USER without a mail address' + USER=fallback + When call myos_resu "" + The output should equal "fallback" + End + It 'exposes the reversed identity and its path' + When run source spec/unit/naming_resu_helper.sh + The output should equal "com.github.aya com/github/aya" + End + End + + Describe 'myos_project_name' + Describe 'app scope' + It 'defaults to user-env-app' + When call myos_project_name app aya master duniter + The output should equal "aya-master-duniter" + End + It 'keeps user-app-env when asked to' + MYOS_PROJECT_FORMAT=user-app-env + When call myos_project_name app aya master duniter + The output should equal "aya-duniter-master" + End + It 'normalizes the app name' + When call myos_project_name app aya local "My-App.v2" + The output should equal "aya-local-myappv2" + End + It 'appends the app path when there is one' + When call myos_project_name app aya local app sub/dir + The output should equal "aya-local-app-subdir" + End + It 'rejects an unknown format' + MYOS_PROJECT_FORMAT=nope + When run myos_project_name app aya local app + The status should equal 2 + The stderr should include "unknown MYOS_PROJECT_FORMAT" + End + End + + Describe 'other scopes' + It 'names a host stack after the machine' + HOSTNAME=sonic + When call myos_project_name host aya master fabio + The output should equal "sonic" + End + It 'prefers an explicit HOST_COMPOSE_PROJECT_NAME' + HOST_COMPOSE_PROJECT_NAME=axiomstudio + When call myos_project_name host aya master fabio + The output should equal "axiomstudio" + End + It 'names a user stack after the user identity' + MAIL=aya@github.com + When call myos_project_name user aya master User + The output should equal "aya-github-com" + End + It 'names a cluster stack after the stack itself' + When call myos_project_name cluster aya master postgres-ha + The output should equal "postgresha" + End + It 'always honours DOCKER_COMPOSE_PROJECT_NAME' + DOCKER_COMPOSE_PROJECT_NAME=explicit + When call myos_project_name app aya master duniter + The output should equal "explicit" + End + End + End + + Describe 'networks' + It 'prefixes the default network so it is attached first' + When call myos_network_default "aya-master-duniter" + The output should equal "_aya-master-duniter" + End + It 'derives the private network from user and env' + When call myos_network_private aya master + The output should equal "aya-master" + End + It 'derives the public network from the hostname' + When call myos_network_public sonic + The output should equal "sonic" + End + End + + Describe 'myos_service_name' + It 'replaces underscores with dashes' + When call myos_service_name "aya_master_app" + The output should equal "aya-master-app" + End + End +End diff --git a/spec/unit/str_spec.sh b/spec/unit/str_spec.sh new file mode 100644 index 0000000..3ab0b85 --- /dev/null +++ b/spec/unit/str_spec.sh @@ -0,0 +1,70 @@ +#shellcheck shell=sh +Include lib/str.sh + +Describe 'lib/str.sh' + Describe 'myos_lower / myos_upper' + Parameters + "Foo-Bar" "foo-bar" "FOO_BAR" + "MYOS" "myos" "MYOS" + "a.b-c" "a.b-c" "A_B_C" + End + It "converts $1" + When call myos_lower "$1" + The output should equal "$2" + End + It "upcases $1 (- and . become _, as the make macro does)" + When call myos_upper "$1" + The output should equal "$3" + End + End + + Describe 'myos_name' + Parameters + "Duniter" "duniter" + "my-app" "myapp" + "my_app.v2" "myappv2" + "host" "host" + End + It "normalizes $1 to a compose project fragment" + When call myos_name "$1" + The output should equal "$2" + End + End + + Describe 'myos_reverse' + It 'reverses word order' + When call myos_reverse "com github aya" + The output should equal "aya github com" + End + It 'handles a single word' + When call myos_reverse "aya" + The output should equal "aya" + End + End + + Describe 'myos_verle' + Parameters + "2.24.4" "2.29.0" success + "2.24.4" "2.24.4" success + "2.29.0" "2.24.4" failure + "2.5" "2.10" success + "" "2.24.4" failure + "2.24.4" "" failure + End + It "compares $1 <= $2" + When call myos_verle "$1" "$2" + The status should be "$3" + End + End + + Describe 'myos_verlt' + It 'is false for equal versions' + When call myos_verlt "1.2.3" "1.2.3" + The status should be failure + End + It 'is true for a lower version' + When call myos_verlt "1.2.3" "1.10.0" + The status should be success + End + End +End diff --git a/spec/unit/tags_spec.sh b/spec/unit/tags_spec.sh new file mode 100644 index 0000000..af2d552 --- /dev/null +++ b/spec/unit/tags_spec.sh @@ -0,0 +1,109 @@ +#shellcheck shell=sh +Include lib/str.sh +Include lib/core.sh +Include lib/tags.sh + +# The expectations below are the very examples left as comments in +# make/apps/def.mk, which were never executed by anything. +Describe 'lib/tags.sh' + # APP_URI is "/" in the framework, so it always ends with a slash + # (make: APP_URI ?= $(patsubst %,%/$(APP_PATH),$(APP_HOST))). + BeforeEach 'APP_URI=app.domain/; APP_SCHEME=http' + + Describe 'myos_urlprefix' + It 'routes the whole app by default' + When call myos_urlprefix + The output should equal "urlprefix-app.domain/*" + End + It 'routes a sub path' + When call myos_urlprefix "admin/" + The output should equal "urlprefix-app.domain/admin/*" + End + It 'carries fabio options' + When call myos_urlprefix ":443/" "proto=https" "app.domain" + The output should equal "urlprefix-app.domain:443/* proto=https" + End + It 'emits one comma separated tag per uri' + When call myos_urlprefix "" "" "a.domain/ b.domain/" + The output should equal "urlprefix-a.domain/*,urlprefix-b.domain/*" + End + End + + Describe 'myos_uri' + It 'prefixes the base uri with the service name' + When call myos_uri kong 8000 + The output should equal "kong.app.domain/" + End + It 'prefers an explicit service name' + SUPABASE_SERVICE_8000_NAME=studio + When call myos_uri supabase 8000 + The output should equal "studio.app.domain/" + End + It 'expands every base uri' + When call myos_uri api 80 "a.tld b.tld" + The output should equal "api.a.tld api.b.tld" + End + End + + Describe 'myos_url' + It 'prepends the scheme' + When call myos_url api 80 + The output should equal "http://api.app.domain/" + End + End + + Describe 'myos_envprefix' + It 'collects the fabio options that are set' + HOST_NGINX_SERVICE_443_PROTO="https tlsskipverify=true" + HOST_NGINX_SERVICE_443_STRIP="/api" + When call myos_envprefix HOST_NGINX 443 allow proto strip + The output should equal "proto=https tlsskipverify=true strip=/api" + End + It 'is empty when nothing is set' + When call myos_envprefix HOST_NGINX 443 allow proto strip + The output should equal "" + End + End + + Describe 'myos_tagprefix' + It 'builds the tag of a service from its own uri' + When call myos_tagprefix supabase 8000 + The output should equal "urlprefix-supabase.app.domain/*" + End + It 'uses the explicit URIS when there is one' + SUPABASE_SERVICE_8000_URIS="supabase.example.org/" + When call myos_tagprefix supabase 8000 + The output should equal "urlprefix-supabase.example.org/*" + End + It 'appends the options collected from the SERVICE__* variables' + DUNITER_V2S_SERVICE_9944_STRIP="/ws" + DUNITER_V2S_SERVICE_9944_URIS="g1.example.org/" + When call myos_tagprefix duniter_v2s 9944 + The output should equal "urlprefix-g1.example.org/* strip=/ws" + End + It 'honours an explicit PATH and OPTS' + HOST_FABIO_SERVICE_9998_PATH="admin/" + HOST_FABIO_SERVICE_9998_OPTS="proto=https" + HOST_FABIO_SERVICE_9998_URIS="fabio.example.org/" + When call myos_tagprefix host_fabio 9998 + The output should equal "urlprefix-fabio.example.org/admin/* proto=https" + End + End +End + +# Two deviations from the make macros, both documented in spec/golden/DELTAS.md: +# the make template leaves a stray space before the comma when it joins several +# uris, and it appends the url suffix after the options instead of after the +# path. Neither shape is used by anything on the fleet. +Describe 'lib/tags.sh deviations from the make macros' + BeforeEach 'APP_URI=app.domain/' + It 'joins several uris without a stray space' + When call myos_urlprefix "" "" "a.domain/ b.domain/" + The output should equal "urlprefix-a.domain/*,urlprefix-b.domain/*" + The output should not include " ," + End + It 'keeps the url suffix right after the path when options are given' + When call myos_urlprefix ":443/" "proto=https" "app.domain" + The output should equal "urlprefix-app.domain:443/* proto=https" + End +End