diff --git a/bin/myos b/bin/myos new file mode 100755 index 0000000..6a155c5 --- /dev/null +++ b/bin/myos @@ -0,0 +1,188 @@ +#!/bin/sh +# myos - Make Your Own Stack +# +# shellcheck disable=SC2034 # most globals here are read by lib/ and lib/cmd/ +# shellcheck disable=SC1091 # lib files are sourced by path at runtime +# +# Runs docker compose stacks: on a host, in a project directory, for a user. +# See README.md, or `myos help`. +set -u + +MYOS_VERSION=2.0.0-dev + +# --- locate the installation --------------------------------------------- +_self=$0 +while [ -L "$_self" ]; do + _link=$(readlink "$_self") + case $_link in /*) _self=$_link ;; *) _self=$(dirname "$_self")/$_link ;; esac +done +MYOS_ROOT=$(cd "$(dirname "$_self")/.." && pwd -P) +export MYOS_ROOT + +for _m in core str tags naming stack config compose; do + # shellcheck source=/dev/null + . "$MYOS_ROOT/lib/$_m.sh" +done + +# --- command line --------------------------------------------------------- +# These are read by the lib/cmd/* files sourced further down. +# shellcheck disable=SC2034 +{ + MYOS_CMD= + MYOS_REFS= + MYOS_REFS_RAW= + MYOS_VARS= + MYOS_ARGS= + MYOS_HOSTS= + VERBOSE=${VERBOSE:-} + DEBUG=${DEBUG:-} +} + +usage() { + cat <<'USAGE' +Usage: myos [options] [stack...] [VAR=value...] [-- args...] + +Options: + -C DIR work in DIR instead of the current directory + -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 + -v, --verbose show what myos does + -d, --debug show every command + -h, --help this help + +Commands: + up down start stop restart recreate manage the containers of a stack + ps logs config exec run inspect and enter them + ls [--groups] list the stacks myos can see + env [VAR...] show resolved variables + doctor check the installation + version print the myos version + +Stacks: + myos up the stack of the current directory + myos up host a group, expanded from host=... in a .env or .mk + myos up host/fabio a single stack + myos up postgres:9.6 a versioned stack +USAGE +} + +while [ $# -gt 0 ]; do + case $1 in + -C) WORKDIR=$2; shift 2 ;; + -e) ENV=$2; shift 2 ;; + -H) MYOS_HOSTS=$2; shift 2 ;; + -n|--dry-run) DRYRUN=true; shift ;; + -v|--verbose) VERBOSE=true; shift ;; + -d|--debug) DEBUG=true; shift ;; + -h|--help) usage; exit 0 ;; + --) shift; MYOS_ARGS="$*"; break ;; + -*) + if [ -n "$MYOS_CMD" ]; then + MYOS_ARGS="${MYOS_ARGS:+$MYOS_ARGS }$1"; shift + else + myos_error "unknown option: $1"; usage >&2; exit "$MYOS_E_USAGE" + fi ;; + *=*) eval "${1%%=*}=\${1#*=}"; export "${1%%=*}"; shift ;; + *) + if [ -z "$MYOS_CMD" ]; then MYOS_CMD=$1; else MYOS_REFS="${MYOS_REFS:+$MYOS_REFS }$1"; fi + shift ;; + esac +done + +[ -n "$MYOS_CMD" ] || { usage; exit "$MYOS_E_USAGE"; } +MYOS_REFS_RAW=$MYOS_REFS +case $MYOS_CMD in + env|ls|doctor) MYOS_VARS=$MYOS_REFS; MYOS_REFS= ;; +esac + +# Targets of the make engine keep working: print-VAR, stack--, +# @ and a bare group name. +case $MYOS_CMD in + print-*) + MYOS_VARS=${MYOS_CMD#print-} + # shellcheck disable=SC2209 # the literal string "env", not the command + MYOS_CMD=env ;; + *@*) ENV=${MYOS_CMD#*@}; MYOS_CMD=${MYOS_CMD%@*} ;; +esac +case $MYOS_CMD in + stack-*-*) + _rest=${MYOS_CMD#stack-} + MYOS_CMD=${_rest##*-} + MYOS_REFS="${_rest%-*} $MYOS_REFS" + MYOS_REFS=${MYOS_REFS% } ;; +esac + +# --- configuration -------------------------------------------------------- +WORKDIR=${WORKDIR:-$PWD} +WORKDIR=$(cd "$WORKDIR" 2>/dev/null && pwd -P) || myos_die "$MYOS_E_USAGE" "no such directory: $WORKDIR" + +for _f in $(myos_conf_files); do myos_dotenv_load "$_f"; done +myos_dotenv_load "$HOME/.config/myos/config" +ENV=${ENV:-local} +myos_dotenv_load "$WORKDIR/.env.$ENV" +myos_dotenv_load "$WORKDIR/.env" + +USER=${USER:-$(id -nu 2>/dev/null)} +HOSTNAME=${HOSTNAME:-$(hostname 2>/dev/null | sed 's/\..*//')} +HOSTNAME=$(myos_lower "$HOSTNAME") +DOMAIN=${DOMAIN:-localhost} +DRYRUN=${DRYRUN:-false} +myos_colors + +# --- stack references ----------------------------------------------------- +# No reference given: the current directory when it holds a compose file, +# else the STACK of the configuration. +if [ -z "$MYOS_REFS" ]; then + if [ -f "$WORKDIR/docker-compose.yml" ] || [ -f "$WORKDIR/compose.yml" ] || + [ -f "$WORKDIR/docker/docker-compose.yml" ]; then + MYOS_REFS=./ + else + MYOS_REFS=${STACK:-} + fi +fi +if [ -z "$MYOS_REFS" ]; then + case $MYOS_CMD in + env|ls|doctor|version|help) ;; + *) myos_die "$MYOS_E_USAGE" "no stack given, and no compose file in $WORKDIR" ;; + esac +fi + +# shellcheck disable=SC2086 # a list of references +MYOS_STACKS=$(myos_group_expand $MYOS_REFS) + +# myos_stack_compose_files REF the ordered compose files of one reference +myos_stack_compose_files() { + _dir=$(myos_stack_resolve "$1") || return $? + _name=$(myos_stack_name "$1") + _suffixes="$(myos_compose_suffixes) $(myos_stack_version "$1")" + case $1 in + .|./*|/*|../*) + myos_compose_files "$_dir" "docker-compose compose" "$_suffixes" "$ENV" + myos_compose_files "$_dir/docker" "docker-compose compose" "$_suffixes" "$ENV" ;; + *) + myos_compose_files "$_dir" "docker-compose $_name" "$_suffixes" "$ENV" ;; + esac +} + +# --- dispatch ------------------------------------------------------------- +case $MYOS_CMD in + version) printf 'myos %s\n' "$MYOS_VERSION"; exit 0 ;; + help) usage; exit 0 ;; +esac + +# shellcheck source=/dev/null +if [ -f "$MYOS_ROOT/lib/cmd/$MYOS_CMD.sh" ]; then + . "$MYOS_ROOT/lib/cmd/$MYOS_CMD.sh" + "myos_cmd_$MYOS_CMD" + exit $? +fi + +# compose passthrough commands +case $MYOS_CMD in + up|down|start|stop|restart|ps|logs|config|build|pull|create|kill|top|images) ;; + *) myos_error "unknown command: $MYOS_CMD"; usage >&2; exit "$MYOS_E_USAGE" ;; +esac + +. "$MYOS_ROOT/lib/cmd/_compose.sh" +myos_cmd_compose "$MYOS_CMD" diff --git a/lib/cmd/_compose.sh b/lib/cmd/_compose.sh new file mode 100644 index 0000000..a57368a --- /dev/null +++ b/lib/cmd/_compose.sh @@ -0,0 +1,74 @@ +#shellcheck shell=sh +# The commands that map straight onto docker compose. +# +# Stacks are grouped by compose project: every host stack shares the project of +# the machine, so `myos up host` is a single compose call with every file, the +# way the stack was meant to be described. + +# myos_cmd_compose COMMAND +myos_cmd_compose() { + _cmd=$1 + _projects= + _rc=0 + + for _ref in $MYOS_STACKS; do + _files=$(myos_stack_compose_files "$_ref") || { _rc=$MYOS_E_NOSTACK; continue; } + [ -n "$_files" ] || { myos_warning "no compose file for stack $_ref"; continue; } + _scope=$(myos_scope "$_ref") + _app=$(myos_stack_name "$_ref") + case $_ref in .|./*|/*|../*) _app=$(basename "$(myos_stack_resolve "$_ref")") ;; esac + _project=$(myos_project_name "$_scope" "$USER" "$ENV" "$_app") + # accumulate the files of every stack sharing a project, keeping the order + _projects=$(printf '%s\n%s\t%s' "$_projects" "$_project" "$(printf '%s' "$_files" | tr '\n' ' ')") + done + + [ "$_rc" = 0 ] || return "$_rc" + + for _project in $(printf '%s' "$_projects" | sed '/^$/d' | cut -f1 | awk '!seen[$0]++'); do + _files=$(printf '%s' "$_projects" | sed '/^$/d' | awk -F'\t' -v p="$_project" '$1==p {print $2}' | tr ' ' '\n' | sed '/^$/d' | awk '!seen[$0]++') + # the framework networks always come last, as the make engine did + [ -f "$MYOS_ROOT/share/compose/networks.yml" ] && + _files="$_files +$MYOS_ROOT/share/compose/networks.yml" + + COMPOSE_PROJECT_NAME=$_project + COMPOSE_SERVICE_NAME=$(myos_service_name "$_project") + DOCKER_NETWORK_DEFAULT=${DOCKER_NETWORK_DEFAULT:-$(myos_network_default "$_project")} + DOCKER_NETWORK_PRIVATE=$(myos_network_private "$USER" "$ENV") + # shellcheck disable=SC3028 # HOSTNAME is set by bin/myos, not by the shell + DOCKER_NETWORK_PUBLIC=$(myos_network_public "$HOSTNAME") + export COMPOSE_PROJECT_NAME COMPOSE_SERVICE_NAME + export DOCKER_NETWORK_DEFAULT DOCKER_NETWORK_PRIVATE DOCKER_NETWORK_PUBLIC + + case $_cmd in + up) myos_network_ensure "$DOCKER_NETWORK_PRIVATE" "$DOCKER_NETWORK_PUBLIC" ;; + esac + + # shellcheck disable=SC2086,SC2046 # options and MYOS_ARGS are word lists + myos_compose "$_project" "$_files" -- "$_cmd" $(myos_compose_options "$_cmd") ${MYOS_ARGS:-} || _rc=$? + done + return "$_rc" +} + +# myos_compose_options COMMAND the default options of each compose command +myos_compose_options() { + case $1 in + up) printf '%s' "${DOCKER_COMPOSE_UP_OPTIONS:--d}" ;; + logs) printf '%s' "${DOCKER_COMPOSE_LOGS_OPTIONS:---follow --tail=100}" ;; + down) printf '%s' "${DOCKER_COMPOSE_DOWN_OPTIONS:-}" ;; + *) printf '' ;; + esac +} + +# myos_network_ensure NAME... create the external networks if they are missing +myos_network_ensure() { + for _n in "$@"; do + [ -n "$_n" ] || continue + if [ "${DRYRUN:-false}" = true ]; then + printf 'docker network create %s\n' "$_n" + else + docker network inspect "$_n" >/dev/null 2>&1 || myos_run docker network create "$_n" >/dev/null + fi + done + return 0 +} diff --git a/lib/cmd/doctor.sh b/lib/cmd/doctor.sh new file mode 100644 index 0000000..37c3770 --- /dev/null +++ b/lib/cmd/doctor.sh @@ -0,0 +1,39 @@ +#shellcheck shell=sh +# myos doctor check that this installation can actually run a stack +myos_cmd_doctor() { + _rc=0 + _ok() { printf ' %-28s %s\n' "$1" "$2"; } + _bad() { printf ' %-28s %s%s%s\n' "$1" "$MYOS_C_ERROR" "$2" "$MYOS_C_RESET"; _rc=$MYOS_E_NOREQ; } + + printf 'myos %s at %s\n' "$MYOS_VERSION" "$MYOS_ROOT" + printf 'requirements:\n' + if myos_have docker; then _ok docker "$(docker version --format '{{.Client.Version}}' 2>/dev/null || echo present)" + else _bad docker "not found"; fi + if _c=$(myos_compose_bin 2>/dev/null); then _ok "compose" "$_c"; else _bad compose "docker compose >= $MYOS_COMPOSE_MIN_VERSION not found"; fi + if [ "${DRYRUN:-false}" != true ]; then + if docker info >/dev/null 2>&1; then _ok "docker daemon" "reachable${DOCKER_HOST:+ via $DOCKER_HOST}" + else _bad "docker daemon" "unreachable${DOCKER_HOST:+ ($DOCKER_HOST)}"; fi + fi + + printf 'configuration:\n' + for _f in $(myos_conf_files); do _ok "$_f" "read"; done + [ -f "$HOME/.config/myos/config" ] && _ok "$HOME/.config/myos/config" "read" + [ -f "$WORKDIR/.env" ] && _ok "$WORKDIR/.env" "read" + _ok ENV "$ENV" + _ok USER "$USER" + # shellcheck disable=SC3028 # HOSTNAME is set by bin/myos + _ok HOSTNAME "$HOSTNAME" + _ok DOMAIN "$DOMAIN" + _ok "project format" "${MYOS_PROJECT_FORMAT:-user-env-app}" + + printf 'stacks:\n' + _p=$(myos_path) + if [ -n "$_p" ]; then printf '%s\n' "$_p" | tr ':' '\n' | sed 's/^/ /' + else _bad "stack path" "empty"; fi + + # a .env written for the make engine can hold values the shell reads differently + if [ -f "$WORKDIR/.env" ] && grep -qE '\$\(|\$\{[a-z]' "$WORKDIR/.env"; then + printf ' %-28s %s%s%s\n' "$WORKDIR/.env" "$MYOS_C_WARN" "contains make expansions" "$MYOS_C_RESET" + fi + return "$_rc" +} diff --git a/lib/cmd/env.sh b/lib/cmd/env.sh new file mode 100644 index 0000000..9e1a2aa --- /dev/null +++ b/lib/cmd/env.sh @@ -0,0 +1,38 @@ +#shellcheck shell=sh +# myos env [VAR...] show resolved variables (replaces the make print-VAR target) +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" + 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/ $//')" ;; + *) printf '%s %s\n' "$_v" "$(myos_var "$_v")" ;; + esac + done +} + +# myos_all_compose_files every compose file of every requested stack, in order +myos_all_compose_files() { + for _ref in $MYOS_STACKS; do + myos_stack_compose_files "$_ref" 2>/dev/null + done + [ -f "$MYOS_ROOT/share/compose/networks.yml" ] && printf '%s\n' "$MYOS_ROOT/share/compose/networks.yml" + return 0 +} + +# myos_first_project the compose project of the first requested stack +myos_first_project() { + for _ref in $MYOS_STACKS; do + _app=$(myos_stack_name "$_ref") + case $_ref in .|./*|/*|../*) _app=$(basename "$(myos_stack_resolve "$_ref" 2>/dev/null)") ;; esac + myos_project_name "$(myos_scope "$_ref")" "$USER" "$ENV" "$_app" + return 0 + done +} diff --git a/lib/cmd/ls.sh b/lib/cmd/ls.sh new file mode 100644 index 0000000..158d8d7 --- /dev/null +++ b/lib/cmd/ls.sh @@ -0,0 +1,36 @@ +#shellcheck shell=sh +# myos ls [--groups] list the stacks myos can see, and where they come from +myos_cmd_ls() { + case ${MYOS_REFS_RAW:-}${MYOS_ARGS:-} in + *--groups*) myos_ls_groups; return 0 ;; + esac + _IFS=$IFS; IFS=: + for _d in $(myos_path); do + IFS=$_IFS + printf '%s%s%s\n' "$MYOS_C_INFO" "$_d" "$MYOS_C_RESET" + for _s in "$_d"/*; do + [ -d "$_s" ] || continue + _n=$(basename "$_s") + _f=$(find "$_s" -maxdepth 1 \( -name '*.yml' -o -name '*.yaml' \) | wc -l | tr -d ' ') + [ "$_f" = 0 ] && continue + printf ' %-24s %s compose file(s)\n' "$_n" "$_f" + done + IFS=: + done + IFS=$_IFS +} + +myos_ls_groups() { + _IFS=$IFS; IFS=: + for _d in $(myos_path); do + IFS=$_IFS + for _f in "$_d"/*.mk "$_d"/*.env "$_d"/*/*.mk; do + [ -f "$_f" ] || continue + _n=$(basename "$_f"); _n=${_n%.mk}; _n=${_n%.env} + _v=$(myos_group_value "$_n") + [ -n "$_v" ] && printf '%-16s %s\n' "$_n" "$_v" + done + IFS=: + done + IFS=$_IFS +} diff --git a/lib/stack.sh b/lib/stack.sh index 79c8485..bd9edcc 100644 --- a/lib/stack.sh +++ b/lib/stack.sh @@ -26,17 +26,25 @@ myos_path() { 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_name REF the stack name: "host/fabio:1.6" -> "fabio" +# myos_stack_version REF the version, "latest" when the reference has none +# Both are pure, so a caller can use them inside a command substitution. +myos_stack_name() { + _r=${1%/} + case $_r in *:*) _r=${_r%:*} ;; esac + basename "$_r" .yml +} +myos_stack_version() { + _r=${1%/} + case $_r in *:*) printf '%s' "${_r##*:}" ;; *) printf 'latest' ;; esac +} + +# myos_stack_resolve REF print the directory holding the stack, +# or fail with MYOS_E_NOSTACK 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) - } + case $_ref in *:*) _ref=${_ref%:*} ;; esac + _name=$(myos_stack_name "$1") # a path reference resolves to itself case $_ref in @@ -52,7 +60,7 @@ myos_stack_resolve() { 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 + if [ -d "$_d/$_name" ]; then printf '%s' "$_d/$_name"; return 0; fi IFS=: done IFS=$_IFS @@ -114,9 +122,14 @@ myos_group_expand() { done } -# myos_group_value REF the list a group expands to, empty when not a group +# myos_group_value REF the list a group expands to, empty when not a group. +# Groups are lowercase by convention (host, testing, coroot, default): without +# that rule any environment variable sharing a stack name would be expanded, +# which is how the make engine behaved. The character class is spelled out +# because a-z also matches uppercase under a dictionary collation (fr_FR). myos_group_value() { case $1 in .|/*|*/*|*:*) return 0 ;; esac + case $1 in *[![:lower:][:digit:]_-]*) return 0 ;; esac _v=$(myos_var "$1") [ -n "$_v" ] && { printf '%s' "$_v"; return 0; } _IFS=$IFS; IFS=: diff --git a/spec/unit/stack_spec.sh b/spec/unit/stack_spec.sh index 818abd5..c0e76e5 100644 --- a/spec/unit/stack_spec.sh +++ b/spec/unit/stack_spec.sh @@ -38,9 +38,9 @@ Describe 'lib/stack.sh' 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" + It 'resolves a versioned reference to its stack directory' + When call myos_stack_resolve "postgres:9.6" + The output should equal "$HOME/.local/share/myos/stack/postgres" End It 'resolves a directory reference' When call myos_stack_resolve "$WORKDIR/stack/host" @@ -53,6 +53,24 @@ Describe 'lib/stack.sh' End End + Describe 'myos_stack_name / myos_stack_version' + Parameters + "postgres" postgres latest + "postgres:9.6" postgres 9.6 + "host/fabio" fabio latest + "host/fabio:1.6" fabio 1.6 + "drone/drone.yml" drone latest + End + It "reads the name out of $1" + When call myos_stack_name "$1" + The output should equal "$2" + End + It "reads the version out of $1" + When call myos_stack_version "$1" + The output should equal "$3" + 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 @@ -134,3 +152,19 @@ Describe 'lib/stack.sh' End End End + +Describe 'lib/stack.sh group safety' + BeforeEach 'MYOS_PATH=/nonexistent' + It 'does not expand an uppercase variable that happens to share a name' + # shellcheck disable=SC2034 + DOMAIN="example.test" + When call myos_group_expand DOMAIN + The output should equal "DOMAIN" + End + It 'still expands a lowercase group' + # shellcheck disable=SC2034 + host="host/consul host/fabio" + When call myos_group_expand host + The lines of output should equal 2 + End +End diff --git a/spec/unit/stack_version_helper.sh b/spec/unit/stack_version_helper.sh deleted file mode 100644 index 08520a7..0000000 --- a/spec/unit/stack_version_helper.sh +++ /dev/null @@ -1,5 +0,0 @@ -#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"