add the myos CLI: one model for both modes

A stack is a directory of compose files; the current directory is a stack when
it holds one. The same command works for a project, a catalogue stack, a group
and a host singleton, and stacks sharing a project are now a single compose
call.

Two traps of the make engine are closed on the way: an unknown command is an
error instead of a silent success, and a group is only expanded when its name
is lowercase, so an environment variable can no longer be mistaken for one.
That guard spells out its character class because a-z matches uppercase too
under a dictionary collation.
This commit is contained in:
Yann Autissier
2026-09-03 18:33:47 +02:00
parent 990b99f0c0
commit a346d4f4e1
8 changed files with 436 additions and 19 deletions
Executable
+188
View File
@@ -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] <command> [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-<stack>-<command>,
# <command>@<env> 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"
+74
View File
@@ -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
}
+39
View File
@@ -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"
}
+38
View File
@@ -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
}
+36
View File
@@ -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
}
+24 -11
View File
@@ -26,17 +26,25 @@ myos_path() {
printf '%s' "$_out" printf '%s' "$_out"
} }
# myos_stack_resolve REF print the directory holding the stack, or fail with # myos_stack_name REF the stack name: "host/fabio:1.6" -> "fabio"
# MYOS_E_NOSTACK. Sets MYOS_STACK_NAME and MYOS_STACK_VERSION as a side effect. # 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() { myos_stack_resolve() {
_ref=${1%/} _ref=${1%/}
# MYOS_STACK_NAME and MYOS_STACK_VERSION are read back by the callers case $_ref in *:*) _ref=${_ref%:*} ;; esac
# shellcheck disable=SC2034 _name=$(myos_stack_name "$1")
{
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 # a path reference resolves to itself
case $_ref in case $_ref in
@@ -52,7 +60,7 @@ myos_stack_resolve() {
if [ -f "$_d/$_ref.yml" ] || [ -f "$_d/$_ref.yaml" ]; then if [ -f "$_d/$_ref.yml" ] || [ -f "$_d/$_ref.yaml" ]; then
printf '%s' "$(dirname "$_d/$_ref")"; return 0 printf '%s' "$(dirname "$_d/$_ref")"; return 0
fi 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=: IFS=:
done done
IFS=$_IFS IFS=$_IFS
@@ -114,9 +122,14 @@ myos_group_expand() {
done 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() { myos_group_value() {
case $1 in .|/*|*/*|*:*) return 0 ;; esac case $1 in .|/*|*/*|*:*) return 0 ;; esac
case $1 in *[![:lower:][:digit:]_-]*) return 0 ;; esac
_v=$(myos_var "$1") _v=$(myos_var "$1")
[ -n "$_v" ] && { printf '%s' "$_v"; return 0; } [ -n "$_v" ] && { printf '%s' "$_v"; return 0; }
_IFS=$IFS; IFS=: _IFS=$IFS; IFS=:
+37 -3
View File
@@ -38,9 +38,9 @@ Describe 'lib/stack.sh'
When call myos_stack_resolve postgres When call myos_stack_resolve postgres
The output should equal "$HOME/.local/share/myos/stack/postgres" The output should equal "$HOME/.local/share/myos/stack/postgres"
End End
It 'resolves a versioned reference' It 'resolves a versioned reference to its stack directory'
When run source spec/unit/stack_version_helper.sh When call myos_stack_resolve "postgres:9.6"
The output should equal "postgres 9.6" The output should equal "$HOME/.local/share/myos/stack/postgres"
End End
It 'resolves a directory reference' It 'resolves a directory reference'
When call myos_stack_resolve "$WORKDIR/stack/host" When call myos_stack_resolve "$WORKDIR/stack/host"
@@ -53,6 +53,24 @@ Describe 'lib/stack.sh'
End End
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' Describe 'myos_compose_files'
It 'loads the stack file and its env overlay' It 'loads the stack file and its env overlay'
When call myos_compose_files "$HOME/.local/share/myos/stack/postgres" "docker-compose postgres" "" local 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 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
-5
View File
@@ -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"