Compare commits

...
8 Commits
Author SHA1 Message Date
Yann Autissier 803dcd1e10 add the restart protocol
The rewrite starts over from the make engine: fix it, record its behaviour
target by target as golden tests, then rewrite in red/green. The first
attempt in lib/ is tagged attempt-1-lib and kept for its ideas only.
2026-09-05 15:45:53 +02:00
Yann Autissier ed3c5a0c7c add the engine benchmark: make, sh, just and a Go prototype
Same work on each, median of five. The numbers separate three costs that the
earlier measurements mixed up: the engine itself (go 23 ms flat, sh 177 ms
plus 63 per stack, just 204 plus 32, make 792 plus 700), the shell hooks
(about 40 ms per computed setting whatever the engine, since Go runs the same
sh), and bin/myos loading a directory's hooks once per stack reference rather
than once per directory, which doubles the hook cost for a group.

Memoising the lazy defaults changes nothing: the cost is the command
substitutions inside each tag helper, not repeated lookups.
2026-09-05 15:07:53 +02:00
Yann Autissier f429b8c38d derive the certificates a server needs from what it routes
The hostnames are already declared, once, in the fabio route tags. A
domains.txt would be a second source of truth free to disagree with what is
actually served, so myos cert reads the tags instead and decides on its own
which name needs a wildcard: one is asked for where a tag uses one, and it
absorbs the concrete names it covers. A wildcard covers a single label, so
a.b.example.org keeps its own certificate.

dehydrated issues them: a shell script, no python, which fits a tool that has
to install on any server. It answers http-01 itself on a port bound to the
loopback and routed by fabio, and delegates dns-01 to a provider hook. The
deploy hook writes the two file names fabio watches for, through a temporary
name so fabio never reads half a certificate.

Fixed on the way: the port parser wanted six spaces of indentation and the
catalogue writes four, so every stack that binds its ports was reported
unbound.
2026-09-05 13:41:16 +02:00
Yann Autissier be777fc9e6 read the exposure scope from the compose file, drop the _EXPOSE variable
The variable declared an intent and changed nothing: it fed the report and
never the binding, so it could say public while the file published on the
loopback, or the reverse. A declaration that cannot be wrong is better than one
that has to be kept in step.

The scope is now which binding the file asks for, read from the compose files
as written: ${MYOS_BIND_PUBLIC} is public, a hand-written address is pinned,
and a bare '- 80' or '9000:9000' is unbound, meaning docker opens it on every
address and nobody chose that. --strict fails on those.

Reading the resolved configuration instead would lose the distinction: every
form ends up as a plain address, and an unbound port looks exactly like a
deliberate public one.

What is left to configure per host is the address of each scope, which is the
part that belongs to the host rather than to the stack.
2026-09-05 13:22:04 +02:00
Yann Autissier 90bb97cca8 replace the linux-only firewall patching with a portable exposure model
The catalogue publishes 51 compose files with the bare form 'ports: [80]',
which binds a random host port on 0.0.0.0: every service answers the internet.
ufw-docker existed to take that back afterwards, as root, on linux only,
because docker writes its own firewall rules and ufw never sees those ports.

Publishing where you mean to solves it at the source. Verified against the
daemon: '- 80' gives 0.0.0.0:32768, '127.0.0.1::80' gives 127.0.0.1:32769.
Same on macOS and on linux, no privilege, and visible in docker ps.

A stack binds with ${MYOS_BIND_PRIVATE|PUBLIC|MESH} and declares what it
means with <PREFIX>_SERVICE[_<port>]_EXPOSE. myos expose reads the resolved
compose configuration and reports what would be opened; --strict fails when a
port faces the world without declaring it, which is what an agent runs against
a server it did not set up.
2026-09-05 13:07:13 +02:00
Yann Autissier 60668fc80a implement recreate, reload and status, and stop advertising -H
The usage listed recreate but the dispatcher did not know it: myos recreate
exited 2 saying the command was unknown. status was missing too, and -H was
parsed into a variable nothing ever reads, so it was accepted and ignored. It
now refuses rather than pretending.
2026-09-05 12:15:05 +02:00
Yann Autissier f2bcbc6857 give make a single call for the settings the CLI resolves
myos export prints every setting the requested stacks declare, in one process:
68 variables in half a second, where asking for them one at a time costs a
process each and about ten seconds.

The shim reads that once and includes it, so a .mk target sees
HOST_FABIO_VERSION and the computed fabio tag without make knowing anything
about how they are produced. --make quotes the values so make neither expands
a $ nor starts a comment at a #.
2026-09-05 12:10:32 +02:00
Yann Autissier ca7338bcb6 fix the bugs the rewrite uncovered, in the make engine too
- the wrapper followed one absolute symlink only, and fed the config file to
  env(1) as-is, so a comment or a blank line in /etc/conf.d/myos made every
  command fail with 'env: #comment: No such file or directory'
- verlt called verlte, which does not exist, and used return outside a
  function: it never compared anything
- stat asked for the access time on macOS and the modification time elsewhere,
  so newer/older did not mean the same thing depending on the machine
- setup-docker-group called ansible-user-add-groups, removed with ansible, then
  announced that the user had been added to the docker group. It now runs
  usermod or addgroup, and says so when neither works
- the ssh targets looped over AWS_INSTANCE_IP, which nothing defines since
  make/apps/aws was dropped: they exited 0 having done nothing. They take
  SSH_HOSTS and fail when it is empty
- patsublist left a space before the comma joining two fabio routes
- the JWT macro splits a payload on its commas; documented at the macro, since
  fixing it would change every key it has already produced
2026-09-05 12:03:53 +02:00
49 changed files with 1326 additions and 29 deletions
+10
View File
@@ -20,6 +20,16 @@
- the project `.env` now wins over `/etc/conf.d/myos`, as documented;
`MYOS_CONF_PRIORITY=system` restores the previous order
- `share/make/shim.mk`: make as an optional front end over the same shell code
- `myos expose` reports what each stack publishes and to whom, and `--strict`
fails when a port faces the world without saying so. `MYOS_BIND_PUBLIC`,
`_PRIVATE` and `_MESH` let a stack bind its published ports, which replaces
the linux-only ufw-docker patching with something that behaves the same on
macOS and needs no privilege. The scope is read from the compose file rather
than declared beside it, so it cannot disagree with what is published
- `myos cert` derives the certificates a server needs from the route tags its
stacks publish, and asks dehydrated for them: a wildcard where a tag uses one,
a certificate per name otherwise. The `host/dehydrated` stack answers http-01
itself and delegates dns-01 to a provider hook
- commands chain: `myos build up logs host/fabio`, as make targets did
- the stack catalogue no longer needs make at all: its settings are hooks, and
only six stacks keep a .mk, for targets
+40
View File
@@ -0,0 +1,40 @@
# Restart protocol (2026-09-05)
The rewrite starts over from the make engine, not from `lib/`.
1. **Reference = `make/*` as it is**, bugs fixed one by one, each with a test
that goes red then green. Known bugs and their locations are listed in the
planning notes and in `git log --grep=fix` on this branch.
2. **Tests first, against make**: every target in scope gets a functional case
in `spec/golden/cases.txt`, recorded from the make engine
(`spec/golden/record.sh`, `spec/support/run.sh` engine `legacy`). That
recording is the historical behaviour, defects included.
3. **Rewrite target by target in red/green**: the new implementation (`just`
as the interface with line recipes, POSIX sh as the logic) must turn each
case green under `MYOS_ENGINE=just`. A deliberate departure from the
historical behaviour is written down in `spec/golden/DELTAS.md`.
4. The first attempt (`bin/myos`, `lib/`, `lib/cmd/`, `share/make/shim.mk`,
`spec/golden/expected.cli/`, `spec/unit/`) is tagged `attempt-1-lib` and is
not a base for the rewrite. What is worth keeping from it is ideas: port
exposure by bind address (`expose --strict`), certificates derived from the
route tags (dehydrated), `env-update` with forward references, stack
directories merged along the search path, lazy defaults, command chaining,
typed exit codes, the agent skill, the installer.
5. The catalogue readable by make is `myos-stacks@7289b83` (or `github/develop`
here): the later hooks (`_stack.sh`) are not read by make.
6. Scope from real fleet usage (~15 targets): up down build config logs ps
restart status, the `host` group, print-VAR, docker-build-<image>,
setup-ufw, install bootstrap clean, apps-install. Never used: release,
subrepo, git-*, deploy, ssh-*.
7. Keep and reuse: `spec/support/run.sh`, the docker mocks, the fixtures, the
golden cases, `spec/bench/` (make 312 ms fixed + ~700 ms per stack; just
line recipe 19 ms, shebang recipe 160 ms; shell hooks with command
substitutions ~40 ms per computed setting on any engine).
Traps already paid for in POSIX sh, do not pay them again: `IFS=$'\n'` stops
argument splitting; `for w in $list` globs a `*` (use `set -f`); `[a-z]`
matches uppercase under fr_FR (use `[:lower:]`); a function called inside
`$( )` cannot return through a global; an environment variable must never be
taken for a stack group (lowercase names only); an unprefixed lazy default
named `host` runs `/usr/bin/host`; zsh does not split unquoted variables, so
test scripts run under `sh`.
+20 -7
View File
@@ -19,7 +19,7 @@ done
MYOS_ROOT=$(cd "$(dirname "$_self")/.." && pwd -P)
export MYOS_ROOT
for _m in core str var tags naming stack config compose hooks context; do
for _m in core str var tags naming stack config compose hooks context expose cert; do
# shellcheck source=/dev/null
. "$MYOS_ROOT/lib/$_m.sh"
done
@@ -28,7 +28,7 @@ done
myos_is_command() {
case $1 in
up|down|start|stop|restart|ps|logs|config|build|pull|create|kill|top|images) return 0 ;;
version|help) return 0 ;;
version|help|export) return 0 ;;
print-*|stack-*-*) return 0 ;;
*@*) myos_is_command "${1%@*}"; return $? ;;
esac
@@ -44,7 +44,6 @@ myos_is_command() {
MYOS_REFS_RAW=
MYOS_VARS=
MYOS_ARGS=
MYOS_HOSTS=
MYOS_COLOR=${MYOS_COLOR:-auto}
VERBOSE=${VERBOSE:-}
DEBUG=${DEBUG:-}
@@ -57,7 +56,6 @@ 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
--color WHEN always, never or auto (default: colour when on a terminal)
-v, --verbose show what myos does
@@ -66,10 +64,13 @@ Options:
Commands:
up down start stop restart recreate manage the containers of a stack
ps logs config exec run inspect and enter them
ps status logs config exec run inspect and enter them
ls [--groups] list the stacks myos can see
env [VAR...] show resolved variables
export every setting of the stacks, as KEY=value
env-update fill .env from the .env.dist templates
expose [--strict] what the stacks publish, and to whom
cert list|issue|renew|show certificates, derived from the routes
doctor check the installation
version print the myos version
@@ -85,7 +86,9 @@ while [ $# -gt 0 ]; do
case $1 in
-C) WORKDIR=$2; shift 2 ;;
-e) ENV=$2; shift 2 ;;
-H) MYOS_HOSTS=$2; shift 2 ;;
# running on remote hosts is not implemented yet; the flag is refused
# rather than silently ignored
-H) myos_die "$MYOS_E_USAGE" "-H is not implemented yet: run myos on the host itself" ;;
-n|--dry-run) DRYRUN=true; shift ;;
--color) MYOS_COLOR=$2; shift 2 ;;
--color=*) MYOS_COLOR=${1#--color=}; shift ;;
@@ -146,6 +149,16 @@ for _c in $MYOS_CMDS; do
done
MYOS_CMDS=$_cmds
# cert takes a subcommand where the others take only stacks
case $MYOS_CMDS in
*cert*)
case ${MYOS_REFS%% *} in
list|domains|issue|renew|show)
MYOS_VARS=${MYOS_REFS%% *}
case $MYOS_REFS in *' '*) MYOS_REFS=${MYOS_REFS#* } ;; *) MYOS_REFS= ;; esac ;;
esac ;;
esac
# env, ls and doctor take variable names where the others take stacks
case $MYOS_CMDS in
env|ls|doctor)
@@ -216,7 +229,7 @@ if [ -z "$MYOS_REFS" ]; then
# these commands describe the installation rather than act on a stack
for _c in $MYOS_CMDS; do
case $_c in
env|env-update|ls|doctor|version|help) ;;
env|env-update|export|expose|ls|doctor|version|help) ;;
*) myos_die "$MYOS_E_USAGE" "no stack given, and no compose file in $WORKDIR" ;;
esac
done
+98
View File
@@ -0,0 +1,98 @@
#shellcheck shell=sh
# cert: which certificates a server needs, derived from what its stacks route.
#
# The hostnames are already declared, once, in the fabio route tags a stack
# publishes: urlprefix-<host>/<path>. Asking for them a second time in a
# domains.txt would be a second source of truth, free to disagree with what is
# actually served. They are read from the resolved compose configuration
# instead.
#
# A name written *.example.org needs a wildcard, which ACME only issues over
# dns-01; a concrete name can be had over http-01. That is the whole of
# "per-site or wildcard according to need": the tags say which.
# myos_cert_names the hostnames the requested stacks route, one per line
myos_cert_names() {
for _ref in $MYOS_STACKS; do
_files=$(myos_stack_compose_files "$_ref" 2>/dev/null) || continue
[ -n "$_files" ] || continue
_fw=$(myos_framework_compose_files)
[ -n "$_fw" ] && _files="$_files
$_fw"
_app=$(myos_stack_name "$_ref")
_project=$(myos_project_name "$(myos_scope "$_ref")" "$USER" "$ENV" "$_app")
DRYRUN=false myos_compose "$_project" "$_files" -- config 2>/dev/null
done | myos_cert_parse
}
# myos_cert_parse (compose config on stdin) -> hostnames
# A tag is urlprefix-<host>[:<port>]/<path> with options after a space; the
# bare "*" is fabio's catch-all and names nothing.
myos_cert_parse() {
grep -oE 'urlprefix-[^",[:space:]]*' 2>/dev/null |
sed -e 's/^urlprefix-//' -e 's|/.*||' -e 's/:[0-9]*$//' |
grep -vE '^\*?$' |
sort -u
}
# myos_cert_covers WILDCARD_PARENT NAME does *.parent cover this name?
# A wildcard matches one label, so *.example.org covers a.example.org but
# neither example.org nor a.b.example.org.
myos_cert_covers() {
case $2 in
*".$1")
_head=${2%".$1"}
case $_head in *.*|'') return 1 ;; *) return 0 ;; esac ;;
*) return 1 ;;
esac
}
# myos_cert_groups the certificates to ask for, one per line, in the shape
# dehydrated reads: the common name first, then its subject alternative names.
#
# MYOS_CERT_MODE:
# auto a wildcard where the tags use one, a certificate per name otherwise
# wildcard one wildcard per domain, whether or not a tag asked for it
# per-site never a wildcard: one certificate per name, dns-01 not required
myos_cert_groups() {
_names=$(myos_cert_names)
[ -n "$_names" ] || return 0
_mode=${MYOS_CERT_MODE:-auto}
# the parents a wildcard is wanted for
_wild=
for _n in $_names; do
case $_n in
\*.*) [ "$_mode" = per-site ] || _wild="$_wild ${_n#\*.}" ;;
esac
done
if [ "$_mode" = wildcard ]; then
for _n in $_names; do
case $_n in
\*.*) ;;
*.*.*) _wild="$_wild ${_n#*.}" ;;
esac
done
fi
_wild=$(printf '%s' "$_wild" | tr ' ' '\n' | sed '/^$/d' | sort -u)
# one line per wildcard, the parent first so it is the common name
for _p in $_wild; do
printf '%s *.%s\n' "$_p" "$_p"
done
# the concrete names a wildcard does not already cover
for _n in $_names; do
case $_n in \*.*) continue ;; esac
_covered=no
for _p in $_wild; do
[ "$_n" = "$_p" ] && { _covered=yes; break; }
myos_cert_covers "$_p" "$_n" && { _covered=yes; break; }
done
[ "$_covered" = no ] && printf '%s\n' "$_n"
done
return 0
}
# myos_cert_needs_dns true when any certificate asked for is a wildcard
myos_cert_needs_dns() { myos_cert_groups | grep -q '\*\.'; }
+91
View File
@@ -0,0 +1,91 @@
#shellcheck shell=sh
# shellcheck disable=SC1091 # lib/cmd files are sourced by path at run time
# shellcheck disable=SC3028 # HOSTNAME is a myos variable, set by bin/myos
# myos cert <list|domains|issue|renew|show> the certificates a server needs
#
# The hostnames come from the route tags of the stacks, so a site gets a
# certificate by being routed, not by being written down a second time.
myos_cmd_cert() {
_sub=$(myos_firstword "${MYOS_VARS:-}${MYOS_ARGS:+ $MYOS_ARGS}")
[ -n "$_sub" ] || _sub=list
case $_sub in
list) myos_cert_list ;;
domains) myos_cert_write_domains ;;
issue) myos_cert_run "" ;;
renew) myos_cert_run "--cron" ;;
show) myos_cert_show ;;
*) myos_die "$MYOS_E_USAGE" "myos cert <list|domains|issue|renew|show>" ;;
esac
}
# myos_cert_list the certificates that would be asked for, and how
myos_cert_list() {
_groups=$(myos_cert_groups)
[ -n "$_groups" ] || {
printf 'no routed hostname: nothing to certify\n'
return 0
}
printf '%s%-46s %-9s %s%s\n' "$MYOS_C_HIGHLIGHT" CERTIFICATE CHALLENGE NAMES "$MYOS_C_RESET"
printf '%s\n' "$_groups" | while IFS= read -r _line; do
_cn=$(myos_firstword "$_line")
case $_line in
*'*.'*) _ch=dns-01 ;;
*) _ch=http-01 ;;
esac
printf '%-46s %-9s %s\n' "$_cn" "$_ch" "$_line"
done
myos_cert_needs_dns &&
myos_info "a wildcard is asked for: dns-01 needs MYOS_CERT_HOOK to talk to your dns provider"
return 0
}
# myos_cert_write_domains the domains.txt dehydrated reads
myos_cert_write_domains() {
_dir=${MYOS_CERT_DIR:-$WORKDIR/.myos/dehydrated}
_file=$_dir/domains.txt
_groups=$(myos_cert_groups)
[ -n "$_groups" ] || { myos_warning "no routed hostname: not writing $_file"; return 0; }
myos_run mkdir -p "$_dir"
if [ "${DRYRUN:-false}" = true ]; then
printf 'would write %s:\n%s\n' "$_file" "$_groups"
else
printf '%s\n' "$_groups" > "$_file"
printf '%s\n' "$_file"
fi
}
# myos_cert_run ARGS run dehydrated in the host stack, on the domains derived
myos_cert_run() {
myos_cert_write_domains >/dev/null || return $?
_args=$1
[ -n "${MYOS_CERT_STAGING:-}" ] && _args="$_args --staging"
case ${MYOS_ARGS:-} in
*--staging*) _args="$_args --staging" ;;
esac
case ${MYOS_ARGS:-} in
*--force*) _args="$_args --force" ;;
esac
MYOS_ARGS="$_args" SERVICE=${SERVICE:-dehydrated} \
MYOS_STACKS="host/dehydrated" myos_cert_exec
}
myos_cert_exec() {
# shellcheck source=lib/cmd/exec.sh
. "$MYOS_ROOT/lib/cmd/exec.sh"
myos_cmd_exec
}
# myos_cert_show the certificates that exist, and when they expire
myos_cert_show() {
_vol=${HOST_DOCKER_VOLUME:-${HOSTNAME:-localhost}}
# shellcheck disable=SC2016 # the script runs in the container, not here
myos_run docker run --rm -v "$_vol:/host" alpine:3.20 sh -c '
apk add -q openssl 2>/dev/null
for c in /host/certs/*-cert.pem; do
[ -f "$c" ] || continue
n=$(basename "$c" -cert.pem)
e=$(openssl x509 -in "$c" -noout -enddate 2>/dev/null | sed "s/notAfter=//")
i=$(openssl x509 -in "$c" -noout -issuer 2>/dev/null | sed "s/.*CN *= *//;s/,.*//")
printf "%-46s %-28s %s\n" "$n" "$e" "$i"
done'
}
+36
View File
@@ -0,0 +1,36 @@
#shellcheck shell=sh
# myos export print every setting the requested stacks declare, as KEY=value.
#
# One call, so a Makefile can read the whole set at once:
# $(eval $(shell myos export STACK=host))
# Asking for each variable separately costs a process per variable.
myos_cmd_export() {
# shellcheck disable=SC2209 # these are literal format names, not commands
_fmt=sh
# shellcheck disable=SC2209
case ${MYOS_ARGS:-}${MYOS_VARS:-} in *--make*) _fmt=make ;; esac
for _v in $(myos_declared_defaults); do
_val=$(myos_var "$_v")
case $_fmt in
make)
# make would expand a $ and start a comment at a #, and := stops it
# from expanding the value again later
printf '%s := %s\n' "$_v" "$(printf '%s' "$_val" | sed -e 's/\$/$$/g' -e 's/#/\\#/g')" ;;
*) printf '%s=%s\n' "$_v" "$_val" ;;
esac
done
}
# myos_declared_defaults the variables the loaded hooks declare
myos_declared_defaults() {
for _ref in $MYOS_STACKS; do
for _d in $(myos_stack_dirs "$_ref"); do
for _f in "$_d"/_stack.sh "$_d/$(myos_stack_name "$_ref").sh"; do
[ -f "$_f" ] && sed -n 's/^myos_default_\([A-Za-z_][A-Za-z0-9_]*\)().*/\1/p' "$_f"
done
for _f in "$_d"/_stack.env "$_d/$(myos_stack_name "$_ref").env"; do
[ -f "$_f" ] && myos_dotenv_parse "$_f" | sed 's/=.*//'
done
done
done | sort -u
}
+96
View File
@@ -0,0 +1,96 @@
#shellcheck shell=sh
# myos expose [--strict] what the stacks publish, and to whom
#
# Two readings are joined: the compose files as written, which say which
# binding each port asks for, and the resolved configuration, which says the
# address it ends up on. The first is the intent, the second is the fact, and
# reporting both is the point: a port nobody bound answers the internet, and on
# linux the host firewall does not see it, because docker writes its own rules.
#
# --strict exits 1 when a port is published without a binding.
myos_cmd_expose() {
_strict=false
case ${MYOS_ARGS:-}${MYOS_VARS:-} in *--strict*) _strict=true ;; esac
_rows=$(myos_expose_rows)
[ -n "$_rows" ] || {
printf 'no published port: nothing is reachable from outside the docker network\n'
return 0
}
printf '%s%-20s %-14s %-22s %-6s %s%s\n' \
"$MYOS_C_HIGHLIGHT" STACK SERVICE "PUBLISHED ON" PORT BINDING "$MYOS_C_RESET"
_bad=0
_oIFS=$IFS; IFS='
'
for _row in $_rows; do
IFS=$_oIFS
_st=${_row%%|*}; _r=${_row#*|}
_sv=${_r%%|*}; _r=${_r#*|}
_on=${_r%%|*}; _r=${_r#*|}
_pt=${_r%%|*}; _sc=${_r#*|}
if [ "$_sc" = unbound ]; then
_bad=$((_bad + 1))
printf '%-20s %-14s %s%-22s%s %-6s %s%s%s\n' "$_st" "$_sv" \
"$MYOS_C_WARN" "$_on" "$MYOS_C_RESET" "$_pt" "$MYOS_C_WARN" "$_sc" "$MYOS_C_RESET"
else
printf '%-20s %-14s %-22s %-6s %s\n' "$_st" "$_sv" "$_on" "$_pt" "$_sc"
fi
IFS='
'
done
IFS=$_oIFS
if [ "$_bad" -gt 0 ]; then
myos_warning "$_bad port(s) published without a binding: docker opens them on every address"
# shellcheck disable=SC2016 # the variable name is the message, not a value
myos_warning 'bind them: ports: ["${MYOS_BIND_PRIVATE}::<port>"] for a service behind the load balancer'
[ "$_strict" = true ] && return "$MYOS_E_FAIL"
fi
return 0
}
# myos_expose_rows STACK|SERVICE|ADDR:PORT|CONTAINER_PORT|BINDING
myos_expose_rows() {
for _ref in $MYOS_STACKS; do
_files=$(myos_stack_compose_files "$_ref" 2>/dev/null) || continue
[ -n "$_files" ] || continue
_fw=$(myos_framework_compose_files)
[ -n "$_fw" ] && _files="$_files
$_fw"
_app=$(myos_stack_name "$_ref")
_project=$(myos_project_name "$(myos_scope "$_ref")" "$USER" "$ENV" "$_app")
# what the files ask for, later overlays overriding earlier ones
_decl=$(mktemp "${TMPDIR:-/tmp}/myos-expose.XXXXXX")
# shellcheck disable=SC2086 # a newline separated list of paths
myos_expose_declared $_files > "$_decl" 2>/dev/null
DRYRUN=false myos_compose "$_project" "$_files" -- config 2>/dev/null |
myos_expose_resolved |
while IFS='|' read -r _v _t _o; do
_b=$(awk -F'|' -v s="$_v" -v p="$_t" '$1==s && $2==p {last=$3} END {print last}' "$_decl")
printf '%s|%s|%s|%s|%s\n' "$_ref" "$_v" "$_o" "$_t" "${_b:-unbound}"
done
rm -f "$_decl"
done
}
# myos_expose_resolved (compose config on stdin) -> SERVICE|CONTAINER_PORT|ADDR:PORT
# compose normalises every port to the long form, so one shape is enough
myos_expose_resolved() {
awk '
/^services:/ { insvc = 1; next }
insvc && /^ [a-zA-Z0-9_.-]+:/ { svc = $1; sub(/:$/, "", svc); inports = 0 }
insvc && /^ ports:/ { inports = 1; next }
inports && /^ [a-z]/ { inports = 0 }
inports && /host_ip:/ { ip = $2 }
inports && /published:/ { pub = $2; gsub(/"/, "", pub) }
inports && /target:/ { tgt = $2 }
inports && /protocol:/ {
# compose leaves published empty when docker picks the port at run time
printf "%s|%s|%s:%s\n", svc, tgt, (ip == "" ? "0.0.0.0" : ip), (pub == "" ? "auto" : pub)
ip = ""; pub = ""; tgt = ""
}
'
}
+10
View File
@@ -0,0 +1,10 @@
#shellcheck shell=sh
# myos recreate remove the containers and create them again
# myos reload the same, under the name the make engine used
myos_cmd_recreate() {
# shellcheck source=lib/cmd/_compose.sh
. "$MYOS_ROOT/lib/cmd/_compose.sh"
MYOS_ARGS="--force-recreate ${MYOS_ARGS:-}"
myos_cmd_compose up
}
myos_cmd_reload() { myos_cmd_recreate; }
+4
View File
@@ -0,0 +1,4 @@
#shellcheck shell=sh
# myos reload: see lib/cmd/recreate.sh
# shellcheck source=lib/cmd/recreate.sh
. "$MYOS_ROOT/lib/cmd/recreate.sh"
+7
View File
@@ -0,0 +1,7 @@
#shellcheck shell=sh
# myos status what is running, under the name the make engine used for ps
myos_cmd_status() {
# shellcheck source=lib/cmd/_compose.sh
. "$MYOS_ROOT/lib/cmd/_compose.sh"
myos_cmd_compose ps
}
+4
View File
@@ -58,6 +58,10 @@ myos_context_defaults() {
myos_default_HOST() { myos_addprefix "${HOSTNAME:-}." "$(myos_var DOMAIN)"; }
myos_default_HOSTNAME() { printf '%s' "${HOSTNAME:-}"; }
myos_default_DOMAINNAME() { myos_firstword "$(myos_var DOMAIN)"; }
# the addresses a stack binds its published ports to
myos_default_MYOS_BIND_PUBLIC() { myos_bind public; }
myos_default_MYOS_BIND_PRIVATE() { myos_bind private; }
myos_default_MYOS_BIND_MESH() { myos_bind mesh; }
myos_default_MACHINE() { uname -m 2>/dev/null; }
myos_default_SYSTEM() { uname -s 2>/dev/null; }
myos_default_HOST_COMPOSE_PROJECT_NAME() { printf '%s' "${HOSTNAME:-}"; }
+119
View File
@@ -0,0 +1,119 @@
#shellcheck shell=sh
# shellcheck disable=SC3028 # HOSTNAME is a myos variable, set by bin/myos
# expose: which addresses a published port binds to.
#
# Docker writes its own firewall rules, so on linux a port published with
# `-p 8080:80` answers the internet whatever the host firewall says. ufw-docker
# patches that afterwards, on linux only, as root.
#
# The portable answer is to publish where you mean to in the first place:
# `-p 127.0.0.1:8080:80` only ever listens on the loopback, identically on
# linux and on macOS, with no firewall and no privilege. A stack says which
# scope a port belongs to, and myos resolves the address.
#
# public the internet: a load balancer, a public DNS or mail service
# mesh the private network between the hosts of the fleet
# private this host only: everything the load balancer reaches for you
#
# A stack does not declare its scope on the side: it is which of these it binds
# to, read from the compose file. One source of truth, which cannot drift from
# what is actually published. MYOS_BIND_<SCOPE> sets the address of a scope on
# a given host, which is the part that belongs to the host rather than to the
# stack.
# myos_bind SCOPE the address a port of that scope binds to
myos_bind() {
case $1 in
public) printf '%s' "${MYOS_BIND_PUBLIC:-0.0.0.0}" ;;
mesh) printf '%s' "${MYOS_BIND_MESH:-$(myos_bind_mesh)}" ;;
private|*) printf '%s' "${MYOS_BIND_PRIVATE:-127.0.0.1}" ;;
esac
}
# myos_bind_mesh the address of the mesh interface, empty when there is none.
# Falls back to the private address so that a stack scoped to the mesh on a
# host that has none stays local rather than becoming public.
myos_bind_mesh() {
_if=${MYOS_MESH_IFACE:-}
if [ -z "$_if" ]; then
for _c in easytier tun0 tailscale0 mycelium wg0; do
if myos_iface_addr "$_c" >/dev/null 2>&1 && [ -n "$(myos_iface_addr "$_c")" ]; then
_if=$_c; break
fi
done
fi
[ -n "$_if" ] || { printf '%s' "${MYOS_BIND_PRIVATE:-127.0.0.1}"; return 0; }
_a=$(myos_iface_addr "$_if")
[ -n "$_a" ] || _a=${MYOS_BIND_PRIVATE:-127.0.0.1}
printf '%s' "$_a"
}
# myos_iface_addr NAME the first address of an interface, on linux or macOS
myos_iface_addr() {
if myos_have ip; then
ip -o addr show "$1" 2>/dev/null | awk '$3 ~ /^inet6?$/ {sub(/\/.*/,"",$4); print $4; exit}'
elif myos_have ifconfig; then
ifconfig "$1" 2>/dev/null | awk '$1 == "inet" || $1 == "inet6" {print $2; exit}'
fi
}
# myos_stack_prefix REF the prefix the settings of a stack use.
# A host stack is prefixed by HOST_, which is how the catalogue names them:
# HOST_FABIO_SERVICE_9998_TAGS, HOST_FTPS_UFW_DOCKER. Everything else uses the
# stack name alone: SUPABASE_KONG_SERVICE_8000_TAGS.
myos_stack_prefix() {
_n=$(myos_upper "$(myos_stack_name "$1")")
case $(myos_scope "$1") in
host) printf 'HOST_%s' "$_n" ;;
user) printf 'USER_%s' "$_n" ;;
*) printf '%s' "$_n" ;;
esac
}
# myos_expose_declared FILE... SERVICE|CONTAINER_PORT|SCOPE for every port a
# compose file publishes, read from the file as written rather than from the
# resolved configuration.
#
# The scope is not declared twice: it is which binding the file asks for.
# ${MYOS_BIND_PUBLIC}:443:443 public
# ${MYOS_BIND_PRIVATE}::8080 private
# ${MYOS_BIND_MESH}::7946 mesh
# 127.0.0.1:5432:5432 pinned to an address, deliberate but fixed
# 80 or 8080:80 unbound: docker binds every address, and
# nobody chose that
#
# Resolving first would lose the difference: ${MYOS_BIND_PRIVATE} and a
# hand-written 127.0.0.1 both become 127.0.0.1, and an unbound port becomes
# 0.0.0.0 exactly like a deliberate public one.
myos_expose_declared() {
awk '
function indent(line, n) { match(line, /^ */); return RLENGTH }
function emit(entry, e, scope, target, n, parts) {
e = entry
sub(/^ *- */, "", e)
gsub(/^["'"'"']|["'"'"']$/, "", e)
if (e ~ /\$\{MYOS_BIND_PUBLIC[^}]*\}/) scope = "public"
else if (e ~ /\$\{MYOS_BIND_MESH[^}]*\}/) scope = "mesh"
else if (e ~ /\$\{MYOS_BIND_PRIVATE[^}]*\}/) scope = "private"
else if (e ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:/) scope = "pinned"
else if (e ~ /^\[/) scope = "pinned"
else scope = "unbound"
target = e
sub(/\/[a-z]+$/, "", target)
n = split(target, parts, ":")
target = parts[n]
if (target ~ /^[0-9]+(-[0-9]+)?$/) printf "%s|%s|%s\n", svc, target, scope
}
/^services:[ \t]*$/ { insvc = 1; svcind = -1; next }
!insvc { next }
# a service is the first level of keys under services:
/^ *[a-zA-Z0-9_.-]+:[ \t]*$/ && (svcind == -1 || indent($0) == svcind) {
if (svcind == -1) svcind = indent($0)
svc = $1; sub(/:$/, "", svc); inports = 0; next
}
/^ *ports:/ { inports = 1; portind = indent($0); next }
# the list items of a ports: block, whatever indent they use
inports && /^ *- / && indent($0) >= portind { emit($0); next }
inports && /^ *[a-zA-Z0-9_.-]+:/ { inports = 0 }
' "$@"
}
+3 -1
View File
@@ -30,7 +30,9 @@ NFS_HOST ?= host.docker.internal
SERVICES ?= $(DOCKER_SERVICES)
envprefix = $(foreach env,$(3),$(if $($(call UPPERCASE,$(1)_SERVICE_$(2)_$(env))),$(env)=$($(call UPPERCASE,$(1)_SERVICE_$(2)_$(env)))))
patsublist = $(patsubst $(1),$(2),$(firstword $(3)))$(foreach pattern,$(wordlist 2,255,$(3)),$(comma)$(patsubst $(1),$(2),$(pattern)))
## the replacement ends with the options, so an empty option list left a space
## before the comma joining two routes: "urlprefix-a/* ,urlprefix-b/*"
patsublist = $(subst $(space)$(comma),$(comma),$(patsubst $(1),$(2),$(firstword $(3)))$(foreach pattern,$(wordlist 2,255,$(3)),$(comma)$(patsubst $(1),$(2),$(pattern))))
servicenvs = $(foreach env,$(call UPPERCASE,$($(1)_SERVICE_$(2)_ENVS)),$(if $(3),$($(1)_SERVICE_$(env)_$(3)),$($(1)_SERVICE_$(2)_$(env))))
tagprefix = $(call urlprefix,$(or $($(call UPPERCASE,$(1)_SERVICE_$(2)_PATH)),$($(call UPPERCASE,$(1)_SERVICE_PATH))),$(or $($(call UPPERCASE,$(1)_SERVICE_$(2)_OPTS)),$($(call UPPERCASE,$(1)_SERVICE_OPTS)),$(call envprefix,$(1),$(2),allow auth deny preprend proto register strip)),$(or $(foreach env,$(3),$($(call UPPERCASE,$(1)_SERVICE_$(2)_$(env)))),$($(call UPPERCASE,$(1)_SERVICE_$(2)_URIS)),$(call uri,$(1),$(2))))
uri = $(foreach svc,$(1),$(patsubst %,$(addsuffix .,$(or $($(call UPPERCASE,$(svc)_SERVICE_$(2)_NAME)),$($(call UPPERCASE,$(svc)_SERVICE_NAME)),$(svc)))%,$(or $(3),$(APP_URI))))
+3 -1
View File
@@ -13,7 +13,9 @@ endif
setup-docker-group:
ifneq ($(DOCKER),)
ifeq ($(or $(filter $(USER),$(subst $(comma), ,$(shell awk -F':' '$$1 == "docker" {print $$4}' /etc/group))),$(filter 0,$(UID))),)
$(call ansible-user-add-groups,$(USER),docker)
$(RUN) $(SUDO) usermod -aG docker $(USER) 2>/dev/null \
|| $(RUN) $(SUDO) addgroup $(USER) docker 2>/dev/null \
|| $(call ERROR,unable to add user,$(USER),to group,docker)
$(call WARNING,user,$(USER),added in group,docker)
endif
ifeq ($(filter 0 $(DOCKER_GID),$(GIDS)),)
+23 -12
View File
@@ -1,10 +1,25 @@
##
# SSH
#
# The remote hosts used to come from AWS, through
# ssh-get-PrivateIpAddress-% -> aws-ec2-get-instances-PrivateIpAddress-%.
# make/apps/aws was removed and AWS_INSTANCE_IP is defined nowhere, so these
# targets looped over an empty list and exited 0 without doing anything.
# They now take SSH_HOSTS, and say so when it is empty rather than pretending
# to have connected.
# variable SSH_HOSTS: hosts the ssh targets act on, space separated
SSH_HOSTS ?= $(AWS_INSTANCE_IP)
# target ssh-hosts-check: Fail when no remote host is known
.PHONY: ssh-hosts-check
ssh-hosts-check:
$(if $(SSH_HOSTS),,$(call ERROR,no remote host: set SSH_HOSTS=host1 host2))
# target ssh: Call ssh-connect ARGS or SHELL
.PHONY: ssh
ssh: # ssh-get-PrivateIpAddress-$(SERVER_NAME) ## Connect to first remote host
$(call ssh-connect,$(AWS_INSTANCE_IP),$(if $(ARGS),$(ARGS),$(SHELL)))
ssh: ssh-hosts-check ## Connect to first remote host
$(call ssh-connect,$(SSH_HOSTS),$(if $(ARGS),$(ARGS),$(SHELL)))
# target ssh-add: Fire ssh-key and ssh-add file SSH_PRIVATE_KEYS in folder SSH_DIR
.PHONY: ssh-add
@@ -15,8 +30,8 @@ ssh-add: ssh-key
# target ssh-connect: Call ssh-connect make connect SERVICE
.PHONY: ssh-connect
ssh-connect: # ssh-get-PrivateIpAddress-$(SERVER_NAME)
$(call ssh-connect,$(AWS_INSTANCE_IP),make connect COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME) ENV=$(ENV) $(if $(SERVICE),SERVICE=$(SERVICE)))
ssh-connect: ssh-hosts-check
$(call ssh-connect,$(SSH_HOSTS),make connect COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME) ENV=$(ENV) $(if $(SERVICE),SERVICE=$(SERVICE)))
# target ssh-del: ssh-add -d file SSH_PRIVATE_KEYS in folder SSH_DIR
.PHONY: ssh-del
@@ -26,12 +41,8 @@ ssh-del:
# target ssh-exec: Call ssh-exec make exec SERVICE ARGS
.PHONY: ssh-exec
ssh-exec: # ssh-get-PrivateIpAddress-$(SERVER_NAME)
$(call ssh-exec,$(AWS_INSTANCE_IP),make exec COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME) ENV=$(ENV) $(if $(SERVICE),SERVICE=$(SERVICE)) $(if $(ARGS),ARGS='\''"$(ARGS)"'\''))
# target ssh-get-PrivateIpAddress-%: Fire aws-ec2-get-instances-PrivateIpAddress-%
.PHONY: ssh-get-PrivateIpAddress-%
ssh-get-PrivateIpAddress-%: aws-ec2-get-instances-PrivateIpAddress-%;
ssh-exec: ssh-hosts-check
$(call ssh-exec,$(SSH_HOSTS),make exec COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME) ENV=$(ENV) $(if $(SERVICE),SERVICE=$(SERVICE)) $(if $(ARGS),ARGS='\''"$(ARGS)"'\''))
# target ssh-key: Add ssh private key SSH_KEY to SSH_DIR
.PHONY: ssh-key
@@ -43,5 +54,5 @@ endif
# target ssh-run: Call ssh-run make run SERVICE ARGS
.PHONY: ssh-run
ssh-run: # ssh-get-PrivateIpAddress-$(SERVER_NAME)
$(call ssh-exec,$(AWS_INSTANCE_IP),make run $(if $(SERVICE),SERVICE=$(SERVICE)) $(if $(ARGS),ARGS='\''"$(ARGS)"'\''))
ssh-run: ssh-hosts-check
$(call ssh-exec,$(SSH_HOSTS),make run $(if $(SERVICE),SERVICE=$(SERVICE)) $(if $(ARGS),ARGS='\''"$(ARGS)"'\''))
+8 -2
View File
@@ -141,7 +141,9 @@ MACHINE ?= $(shell uname -m 2>/dev/null)
ifeq ($(SYSTEM),Darwin)
SED_SUFFIX := ''
STAT_FORMAT_ARG := -f
STAT_FORMAT_FILE := '%a %N'
# %m is the modification time; %a is the access time, which is what this used
# to ask for, so newer/older did not mean the same thing as on linux
STAT_FORMAT_FILE := '%m %N'
else
STAT_FORMAT_ARG := -c
STAT_FORMAT_FILE := '%Y %n'
@@ -199,6 +201,9 @@ rs256 = $(shell echo -n '$(1)' |openssl dgst -sha256 -binary -sign '$(2)')
JWT_HEADER = {"alg":"HS256","typ":"JWT"}
# macro JWT: Print Json Web Token for header $1 payload $2 and key $3
## a payload is JSON and holds commas, which make read as argument separators,
## so the token came out with an empty payload. Pass the payload in a variable
## and name it here rather than inlining it.
JWT := $(strip \
$(eval header := $(or $(1),$(JWT_HEADER))) \
$(eval payload := $(or $(2),$(JWT_PAYLOAD))) \
@@ -284,7 +289,8 @@ sed = $(RUN) sed -i $(SED_SUFFIX) '$(1)' $(2)
verle = [ -n "$(1)" ] && [ "$(1)" = "$(shell echo -e "$(1)\n$(2)" |sort -V |head -n1)" ]
# macro verlt: Return true when version 1 < 2
verlt = [ "$(1)" = "$(2)" ] && return 1 || $(call verlte,$(1),$(2))
## it was calling verlte, which does not exist, and returning from no function
verlt = [ "$(1)" != "$(2)" ] && $(call verle,$(1),$(2))
# function conf: Extract variable=value line from configuration files
## it prints the line with variable 3 definition from block 2 in file 1
+13 -2
View File
@@ -3,7 +3,14 @@
set -eu
# define MYOS path
MYOS="$(dirname "$(readlink "$0" || echo "$0")")"
## readlink without -f only followed one level and only an absolute link, so a
## relative or chained symlink pointed the framework at the wrong directory
MYOS="$0"
while [ -L "$MYOS" ]; do
_link="$(readlink "$MYOS")"
case "$_link" in /*) MYOS="$_link" ;; *) MYOS="$(dirname "$MYOS")/$_link" ;; esac
done
MYOS="$(cd "$(dirname "$MYOS")" && pwd -P)"
# load system config: /etc/conf.d/myos (openrc convention) first, then the
# debian-style /etc/default/myos as fallback
@@ -17,4 +24,8 @@ MYOS_CONF=/etc/conf.d/myos
# call myos Makefile: a WORKDIR from the config or environment wins over PWD,
# so a machine can pin its deployment dir and run myos from anywhere
IFS=$'\n'; exec env $(cat "$MYOS_CONF" 2>/dev/null) MYOS=. WORKDIR="${WORKDIR:-${PWD}}" make -esC "${MYOS:-.}" "$@"
## a comment or a blank line in the config used to become the program env(1)
## was asked to run, so the whole command failed
IFS=$'\n'
exec env $(sed -e '/^[[:space:]]*#/d' -e '/^[[:space:]]*$/d' "$MYOS_CONF" 2>/dev/null) \
MYOS=. WORKDIR="${WORKDIR:-${PWD}}" make -esC "${MYOS:-.}" "$@"
+11 -4
View File
@@ -36,10 +36,17 @@ define make
$(MYOS_BIN) $(MYOS_ARGS) $(1)
endef
# function myos-var: the value myos resolves for a variable.
# A stack keeps its settings in hooks that only myos reads, so a .mk target
# asks for them rather than defining them itself:
# $(call myos-var,HOST_DOCKER_VOLUME)
# The settings of the stacks, read once and evaluated here.
# A stack keeps its settings in hooks that only myos reads; asking for them one
# at a time costs a process per variable, so they come in a single call.
## Written while this file is read, not as a target: a target would collide
## with the catch-all rule at the bottom, which hands anything else to myos.
MYOS_SETTINGS ?= .myos.settings.mk
MYOS_SETTINGS_FILE := $(shell $(MYOS_BIN) --color=never $(MYOS_ARGS) export --make > $(MYOS_SETTINGS) 2>/dev/null && echo $(MYOS_SETTINGS))
-include $(MYOS_SETTINGS_FILE)
# function myos-var: the value myos resolves for one variable, when a single
# lookup is cheaper than the whole set
myos-var = $(shell $(MYOS_BIN) --color=never $(MYOS_ARGS) env $(1) | awk '{print $$2}')
# target help: List the myos commands
+5
View File
@@ -82,6 +82,11 @@ See `references/conventions.md`.
## Rules
- Check what a stack opens before starting it on a server that faces the
internet: `myos expose <stack>`. A port shown on `0.0.0.0` answers the world,
and on linux the host firewall does not see it, because docker writes its own
rules. A port reported as `unbound` was published without anyone choosing an
address: bind it with `ports: ["${MYOS_BIND_PRIVATE}::<port>"]`.
- Never run `myos clean` on a host stack: it removes images **and volumes**,
including the certificates.
- Secrets belong in a file outside the repository, never in a compose file.
+3
View File
@@ -27,6 +27,9 @@ myos [options] <command> [stack...] [VAR=value...] [-- args...]
| `ls [--groups]` | the stacks and groups myos can see |
| `env [VAR...]` | resolved variables |
| `env-update` | fill the `.env` from the `.env.dist` templates |
| `expose [--strict]` | what the stacks publish, and to whom |
| `cert list\|issue\|renew\|show` | certificates, derived from the route tags |
| `export [--make]` | every setting of the stacks, as `KEY=value` |
| `doctor` | check the installation |
| `version` | the myos version |
+85
View File
@@ -168,6 +168,91 @@ A line may refer to a variable defined further down. A variable that already
has a value keeps it: the `.env` records choices, it never overwrites them, and
running the command twice changes nothing.
## What a stack publishes, and to whom
On linux docker writes its own firewall rules, so a port published with
`ports: ["8080:80"]` answers the internet whatever the host firewall says. The
portable answer is to publish where you mean to, which behaves the same on
linux and on macOS and needs no privilege:
```yaml
services:
app:
ports:
- "${MYOS_BIND_PRIVATE}::8080" # this host only, reached through fabio
gateway:
ports:
- "${MYOS_BIND_PUBLIC}:443:443" # the internet, on purpose
peer:
ports:
- "${MYOS_BIND_MESH}::7946" # the private network between the hosts
```
| scope | address | for |
|---|---|---|
| `private` | `127.0.0.1` | everything the load balancer reaches for you. The default. |
| `public` | `0.0.0.0` | a load balancer, a public DNS or mail service |
| `mesh` | the mesh interface | services shared between the hosts of a fleet |
`MYOS_BIND_PUBLIC`, `MYOS_BIND_PRIVATE` and `MYOS_BIND_MESH` override the
addresses; `MYOS_MESH_IFACE` names the interface when it is not one of
easytier, tun0, tailscale0, mycelium or wg0.
There is nothing else to declare: the scope **is** the binding the file asks
for. A port written `- 80` or `- "9000:9000"` is *unbound*, which means docker
opens it on every address and nobody chose that.
```sh
myos expose # what each stack publishes, on which address
myos expose --strict # exits 1 when a port is published without a binding
```
The command reads the compose files as written **and** the resolved
configuration, and shows both: the binding the stack asked for, and the address
it ends up on. Resolving first would lose the difference, since
`${MYOS_BIND_PRIVATE}` and a hand-written `127.0.0.1` both become `127.0.0.1`,
and an unbound port becomes `0.0.0.0` exactly like a deliberate public one.
The split of responsibility: the **scope** belongs to the stack, in its compose
file; the **address** of a scope belongs to the host, in its configuration.
## Certificates
A site gets a certificate by being routed, not by being written down a second
time. `myos cert` reads the same `urlprefix-` tags fabio routes on, and decides
what to ask for:
```sh
myos cert list # what would be asked for, and over which challenge
myos cert issue # ask for it
myos cert renew # what is close to expiry, for a cron
myos cert show # what exists, and when it expires
```
| a tag routes | myos asks for | challenge |
|---|---|---|
| `app.example.org` | a certificate for that name | http-01 |
| `*.ipns.example.org` | `ipns.example.org` **and** `*.ipns.example.org` | dns-01 |
That is the whole of "per site or wildcard as needed": a wildcard is asked for
where a tag uses one, and it absorbs the concrete names it covers. A wildcard
covers one label, so `*.example.org` absorbs `a.example.org` but not
`a.b.example.org`, which keeps its own certificate.
`MYOS_CERT_MODE=per-site` never asks for a wildcard, which keeps everything on
http-01 and needs no DNS credentials. `wildcard` asks for one per domain.
The issuer is [dehydrated](https://github.com/dehydrated-io/dehydrated), a
shell script, in the `host/dehydrated` stack. It answers http-01 itself on a
port bound to the loopback, which fabio routes
`/.well-known/acme-challenge/` to. A wildcard needs dns-01, so point
`HOST_DEHYDRATED_DNS_HOOK` at your provider's script; it receives dehydrated's
own hook arguments.
Certificates land where fabio looks for them, `<name>-cert.pem` and
`<name>-key.pem` under `/host/certs`, written to a temporary name and moved, so
fabio never reads half a file.
## Groups
A group is a lowercase name whose value lists stacks. It can live in a `.env`,
+40
View File
@@ -0,0 +1,40 @@
# Benchmark of the engines
Same work, four engines, five runs, median. Docker is the mock of
`spec/support/bin`, the catalogue is `myos-stacks` reached through
`$HOME/.local/share/myos/stack`, the environment is `env.sh`.
sh spec/bench/run.sh
`justfile` is a prototype of just as the engine: shebang recipes that source
`lib/*.sh` once. `go/main.go` is a prototype of the core in Go: stack path,
groups, compose files, project name, dry-run command; `export` runs one `sh`
per stack directory to evaluate the shell hooks.
## Results, 2026-09-05, Mac Studio M2 Ultra
| work | make | sh (bin/myos) | just | go |
|---|---:|---:|---:|---:|
| fixed cost, empty target | 312 ms | 50 ms | 170 ms | 23 ms |
| `up` 1 / 3 stacks, no hooks in the stack | 792 / 2201 | 177 / 332 | 204 / 268 | 23 / 23 |
| `up` 1 / 3 stacks, real catalogue with hooks | — | 353 / 855 | (prototype does not load hooks) | (idem) |
| `export`, 80 settings of the `host` group | — | 1477 | 1314 | 720 (1 sh) |
| same, `MYOS_VAR_MEMO=1` | — | 1503 | 1318 | 774 |
| one computed setting (`HOST_FABIO_SERVICE_9998_TAGS`) | — | ~43 ms net (69 − 26) | | |
Reference points: `sh -c :` 24 ms, `just --version` 27 ms, sourcing `lib/*.sh` +2 ms.
## What it says
- The engine's own cost: go flat at 23 ms; sh 177 ms + ~63 ms per stack; just
204 ms + ~32 ms per stack; make 792 ms + ~700 ms per stack (it re-reads
itself for every stack).
- The shell hooks cost ~40 ms per computed setting, on every engine: 80
settings ≈ 0.7 s even from Go, which runs the very same `sh`. Memoisation
changes nothing, because the cost is not repeated lookups: each `tagprefix`
spawns 15-20 command substitutions for distinct, mostly empty, variables.
- `bin/myos` doubles that to 1.5 s by loading the hooks of a directory once
per stack reference instead of once per directory: `host/consul`,
`host/fabio` and `host/registrator` share `stack/host/_stack.sh`.
- just's fixed cost (170 ms for a shebang recipe, against 27 ms for `just
--version`) is its own overhead of writing and running the recipe script.
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# bench.sh LABEL N -- CMD... run CMD N times, print the median wall time in ms
# Hermetic: docker is the mock of spec/support/bin, config comes from the
# environment only, HOME points at the fixture catalogue.
set -u
label=$1; n=$2; shift 2; [ "$1" = "--" ] && shift
i=0; times=""
while [ "$i" -lt "$n" ]; do
s=$(python3 -c 'import time;print(int(time.time()*1e6))')
"$@" >/dev/null 2>&1
e=$(python3 -c 'import time;print(int(time.time()*1e6))')
times="$times $(( (e - s) / 1000 ))"
i=$((i + 1))
done
median=$(printf '%s\n' $times | sort -n | awk '{a[NR]=$1} END {print a[int((NR+1)/2)]}')
printf '%-44s %6s ms (runs:%s)\n' "$label" "$median" "$times"
+5
View File
@@ -0,0 +1,5 @@
# the hermetic environment every engine runs in
export PATH=/Users/aya/dev/myos/spec/support/bin:/Users/aya/.local/bin:/usr/bin:/bin
export HOME=/tmp/myos-bench/home WORKDIR=/tmp/myos-bench/wd MYOS_ROOT=/Users/aya/dev/myos
export USER=tester HOSTNAME=testhost DOMAIN=example.test ENV=local DRYRUN=true
export MYOS_CONF=/dev/null MYOS_PROJECT_FORMAT=user-app-env DOCKER_MACHINE=x86_64 DOCKER_SYSTEM=Linux
+238
View File
@@ -0,0 +1,238 @@
// A prototype of the myos core in Go, just large enough to be benchmarked
// fairly against the other engines: stack path, group expansion, compose file
// resolution across every directory of the path, project name, and the
// dry-run compose command. Same rules as lib/stack.sh and lib/naming.sh.
//
// export delegates the shell hooks to ONE sh per stack directory, which is
// what a Go engine would do to keep the developer contract in shell.
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
func env(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
// stackPath: the directories stacks are looked up in, project first
func stackPath(workdir string) []string {
home := env("HOME", "/nonexistent")
root := env("MYOS_ROOT", ".")
prefix := filepath.Dir(filepath.Dir(root))
var out []string
seen := map[string]bool{}
for _, d := range []string{workdir, filepath.Join(workdir, ".."), filepath.Join(home, ".local/share"), filepath.Join(prefix, "share"), "/usr/local/share", "/usr/share"} {
for _, c := range []string{filepath.Join(d, "stack"), filepath.Join(d, "myos/stack")} {
if st, err := os.Stat(c); err == nil && st.IsDir() {
if r, err := filepath.EvalSymlinks(c); err == nil {
c = r
}
if !seen[c] {
seen[c] = true
out = append(out, c)
}
}
}
}
return out
}
// groupValue: the list a lowercase group name expands to, from <g>.env,
// <g>/<g>.env or <g>/_stack.env along the path
func groupValue(path []string, name string) string {
if strings.ContainsAny(name, "/:.") || strings.ToLower(name) != name {
return ""
}
if v := os.Getenv(name); v != "" {
return v
}
for _, d := range path {
for _, f := range []string{filepath.Join(d, name+".env"), filepath.Join(d, name, name+".env"), filepath.Join(d, name, "_stack.env")} {
b, err := os.ReadFile(f)
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
if strings.HasPrefix(line, name+"=") {
return strings.Trim(strings.TrimPrefix(line, name+"="), "\"")
}
}
}
}
return ""
}
func expand(path []string, refs []string, depth int) []string {
var out []string
for _, r := range refs {
if v := groupValue(path, r); v != "" && depth < 16 {
out = append(out, expand(path, strings.Fields(v), depth+1)...)
} else {
out = append(out, r)
}
}
return out
}
func stackName(ref string) string {
r := strings.TrimSuffix(ref, "/")
if i := strings.LastIndex(r, ":"); i >= 0 {
r = r[:i]
}
return strings.TrimSuffix(filepath.Base(r), ".yml")
}
// stackDirs: every directory of the path holding the stack, least specific first
func stackDirs(path []string, ref string) []string {
r := strings.TrimSuffix(ref, "/")
if i := strings.LastIndex(r, ":"); i >= 0 {
r = r[:i]
}
name := stackName(ref)
var found []string
for _, d := range path {
var hit string
if st, err := os.Stat(filepath.Join(d, r)); err == nil && st.IsDir() {
hit = filepath.Join(d, r)
} else if _, err := os.Stat(filepath.Join(d, r+".yml")); err == nil {
hit = filepath.Dir(filepath.Join(d, r))
} else if st, err := os.Stat(filepath.Join(d, name)); err == nil && st.IsDir() {
hit = filepath.Join(d, name)
}
if hit != "" {
found = append([]string{hit}, found...)
}
}
return found
}
func exists(p string) bool { _, err := os.Stat(p); return err == nil }
// composeFiles: the files that exist, in the order the framework loads them
func composeFiles(dir string, names, suffixes []string, envName string) []string {
var out []string
for _, e := range []string{"yml", "yaml"} {
for _, n := range names {
for _, f := range []string{
filepath.Join(dir, n+"."+e), filepath.Join(dir, n+"."+envName+"."+e),
filepath.Join(dir, envName, n+"."+e), filepath.Join(dir, envName, n+"."+envName+"."+e)} {
if exists(f) {
out = append(out, f)
}
}
for _, s := range suffixes {
for _, f := range []string{filepath.Join(dir, n+"."+s+"."+e), filepath.Join(dir, n+"."+s+"."+envName+"."+e)} {
if exists(f) {
out = append(out, f)
}
}
}
}
}
return out
}
func scope(ref string) string {
switch strings.SplitN(ref, "/", 2)[0] {
case "host":
return "host"
case "User", "user":
return "user"
case "cluster":
return "cluster"
}
return "app"
}
func projectName(sc, user, envName, app string) string {
switch sc {
case "host":
return env("HOST_COMPOSE_PROJECT_NAME", env("HOSTNAME", "localhost"))
case "user":
return user
case "cluster":
return strings.ToLower(app)
}
n := strings.NewReplacer(".", "", "-", "", "_", "").Replace(strings.ToLower(app))
if env("MYOS_PROJECT_FORMAT", "user-env-app") == "user-app-env" {
return user + "-" + n + "-" + envName
}
return user + "-" + envName + "-" + n
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: myos-go <noop|up|export> [stack...]")
os.Exit(2)
}
workdir := env("WORKDIR", ".")
envName := env("ENV", "local")
user := env("USER", "tester")
path := stackPath(workdir)
suffixes := []string{"app", "labels", "networks", "ssh", "volumes", "latest"}
switch os.Args[1] {
case "noop":
return
case "up":
refs := expand(path, os.Args[2:], 0)
byProject := map[string][]string{}
var order []string
for _, ref := range refs {
app := stackName(ref)
var files []string
for _, d := range stackDirs(path, ref) {
files = append(files, composeFiles(d, []string{"docker-compose", app}, suffixes, envName)...)
}
p := projectName(scope(ref), user, envName, app)
if _, ok := byProject[p]; !ok {
order = append(order, p)
}
byProject[p] = append(byProject[p], files...)
}
for _, p := range order {
files := append(byProject[p], filepath.Join(env("MYOS_ROOT", "."), "share/compose/networks.yml"))
var b strings.Builder
b.WriteString("docker compose")
for _, f := range files {
b.WriteString(" -f " + f)
}
fmt.Printf("%s -p %s up -d\n", b.String(), p)
}
case "export":
// one sh per stack directory evaluates its hooks and prints every value
refs := expand(path, os.Args[2:], 0)
seen := map[string]bool{}
var dirs []string
for _, ref := range refs {
for _, d := range stackDirs(path, ref) {
if !seen[d] {
seen[d] = true
dirs = append(dirs, d)
}
}
}
sort.Strings(dirs)
root := env("MYOS_ROOT", ".")
for _, d := range dirs {
script := fmt.Sprintf(`for m in core str var tags naming stack config compose hooks; do . %s/lib/$m.sh; done
[ -f %s/_stack.sh ] || exit 0
myos_stack_hooks %s _
for v in $(sed -n 's/^myos_default_\([A-Za-z_][A-Za-z0-9_]*\)().*/\1/p' %s/_stack.sh | sort -u); do printf '%%s=%%s\n' "$v" "$(myos_var "$v")"; done`, root, d, d, d)
cmd := exec.Command("sh", "-c", script)
cmd.Env = os.Environ()
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
}
}
}
+52
View File
@@ -0,0 +1,52 @@
# just as the engine: the interface is a justfile, the logic stays in lib/*.sh.
# Recipes are shebang recipes, so a whole body runs in ONE sh that sources
# lib/ once; what is measured is just's own overhead on top of the shell.
set export
MYOS_ROOT := env_var_or_default("MYOS_ROOT", "/Users/aya/dev/myos")
WORKDIR := env_var_or_default("WORKDIR", justfile_directory())
ENV := env_var_or_default("ENV", "local")
USER := env_var_or_default("USER", "tester")
HOSTNAME := env_var_or_default("HOSTNAME", "testhost")
DOMAIN := env_var_or_default("DOMAIN", "example.test")
DRYRUN := env_var_or_default("DRYRUN", "true")
# a recipe that does nothing: the fixed cost of just + one sh + sourcing lib/
noop:
#!/bin/sh
for m in core str var tags naming stack config compose hooks; do . $MYOS_ROOT/lib/$m.sh; done
# up STACKS: resolve every stack of the groups, group by compose project,
# print one compose command per project (what bin/myos does)
up +stacks:
#!/bin/sh
for m in core str var tags naming stack config compose hooks; do . $MYOS_ROOT/lib/$m.sh; done
rows=""
for ref in $(myos_group_expand {{stacks}}); do
files=""
for d in $(myos_stack_dirs "$ref"); do
files="$files $(myos_compose_files "$d" "docker-compose $(myos_stack_name "$ref")" "$(myos_compose_suffixes)" "$ENV" | tr '\n' ' ')"
done
app=$(myos_stack_name "$ref")
project=$(myos_project_name "$(myos_scope "$ref")" "$USER" "$ENV" "$app")
rows="$rows
$project|$files"
done
for project in $(printf '%s\n' "$rows" | sed '/^$/d' | cut -d'|' -f1 | awk '!s[$0]++'); do
files=$(printf '%s\n' "$rows" | awk -F'|' -v p="$project" '$1==p {print $2}' | tr ' ' '\n' | sed '/^$/d' | awk '!s[$0]++')
fargs=""; for f in $files $MYOS_ROOT/share/compose/networks.yml; do fargs="$fargs -f $f"; done
echo "docker compose$fargs -p $project up -d"
done
# export STACKS: every setting the hooks of the stacks declare
export +stacks:
#!/bin/sh
for m in core str var tags naming stack config compose hooks; do . $MYOS_ROOT/lib/$m.sh; done
refs=$(myos_group_expand {{stacks}})
for ref in $refs; do
for d in $(myos_stack_dirs "$ref"); do myos_stack_hooks "$d" "$(myos_stack_name "$ref")"; done
done
names=$(for ref in $refs; do for d in $(myos_stack_dirs "$ref"); do
[ -f "$d/_stack.sh" ] && sed -n 's/^myos_default_\([A-Za-z_][A-Za-z0-9_]*\)().*/\1/p' "$d/_stack.sh"; done; done | sort -u)
for v in $names; do printf '%s=%s\n' "$v" "$(myos_var "$v")"; done
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
# the full matrix: 5 runs each, median, every engine on the same work
. /tmp/myos-bench/env.sh; cd "$WORKDIR"
B=/tmp/myos-bench/bench.sh; N=5
MK="make -esC $MYOS_ROOT MYOS=. WORKDIR=$WORKDIR"
SH="$MYOS_ROOT/bin/myos"
JU="just --justfile /tmp/myos-bench/justfile"
GO=/tmp/myos-bench/myos-go
S1="host/consul"; S2="host/consul host/fabio"; S3="host/consul host/fabio host/registrator"
echo "== cout fixe : demarrage + cible vide"
$B "make noop" $N -- $MK FORCE
$B "sh noop (myos version)" $N -- $SH version
$B "just noop (parse + 1 sh + source lib/)" $N -- $JU noop
$B "go noop" $N -- $GO noop
echo
echo "== up : 1 / 2 / 3 stacks, dry-run"
$B "make up 1" $N -- $MK up STACK="$S1"
$B "make up 2" $N -- $MK up STACK="$S2"
$B "make up 3" $N -- $MK up STACK="$S3"
$B "sh up 1" $N -- $SH up host/consul
$B "sh up 2" $N -- $SH up host/consul host/fabio
$B "sh up 3" $N -- $SH up host/consul host/fabio host/registrator
$B "just up 1" $N -- $JU up host/consul
$B "just up 2" $N -- $JU up host/consul host/fabio
$B "just up 3" $N -- $JU up host/consul host/fabio host/registrator
$B "go up 1" $N -- $GO up host/consul
$B "go up 2" $N -- $GO up host/consul host/fabio
$B "go up 3" $N -- $GO up host/consul host/fabio host/registrator
echo
echo "== export : les 80 reglages du groupe host (evaluation des hooks shell)"
$B "sh export, hooks tels quels" $N -- $SH export STACK=host
$B "just export, hooks tels quels" $N -- $JU export host
$B "go export, hooks tels quels (1 sh/repertoire)" $N -- $GO export host
echo
echo "== export : memes hooks, evalues en une passe (MYOS_VAR_MEMO=1)"
MYOS_VAR_MEMO=1 $B "sh export, memoise" $N -- $SH export STACK=host
MYOS_VAR_MEMO=1 $B "just export, memoise" $N -- $JU export host
MYOS_VAR_MEMO=1 $B "go export, memoise" $N -- $GO export host
+8
View File
@@ -84,3 +84,11 @@ shim-env | shim-project | @make env ARGS=COMPOSE_PROJECT
chain-build-up-logs | host-project | build up logs host/fabio
chain-up-group | host-project | up ps host
chain-print-two | host-project | print-COMPOSE_PROJECT_NAME print-APP STACK=host/consul
mk-ssh-no-hosts | app-nogit | ssh
mk-ssh-with-hosts | app-nogit | ssh SSH_HOSTS=example.test ARGS=id
bridge-export | host-project | export STACK=host/consul
cmd-recreate | host-project | recreate host/consul
cmd-status | host-project | status host/consul
expose-none | host-project | expose host/consul
expose-default | app-git | expose
cert-none | host-project | cert list host/consul
@@ -0,0 +1,2 @@
[exit 0]
+2
View File
@@ -0,0 +1,2 @@
no routed hostname: nothing to certify
[exit 0]
@@ -0,0 +1,4 @@
docker network create tester-local
docker network create testhost
docker compose -f @WD@/stack/host/consul.yml -f @MYOS@/share/compose/networks.yml -p testhost --project-directory @WD@/stack/host up -d --force-recreate
[exit 0]
+2
View File
@@ -0,0 +1,2 @@
docker compose -f @WD@/stack/host/consul.yml -f @MYOS@/share/compose/networks.yml -p testhost --project-directory @WD@/stack/host ps
[exit 0]
@@ -0,0 +1,2 @@
no published port: nothing is reachable from outside the docker network
[exit 0]
+2
View File
@@ -0,0 +1,2 @@
no published port: nothing is reachable from outside the docker network
[exit 0]
@@ -0,0 +1,3 @@
ERROR: unknown command: ssh
ERROR: to act on a stack of that name, say what to do: myos up ssh
[exit 2]
@@ -0,0 +1,3 @@
ERROR: unknown command: ssh
ERROR: to act on a stack of that name, say what to do: myos up ssh
[exit 2]
+2
View File
@@ -0,0 +1,2 @@
WARNING: myos[0] export-rule-exists: target export unavailable in app myos
[exit 0]
+4
View File
@@ -0,0 +1,4 @@
WARNING: myos[0] cert-rule-exists: target cert unavailable in app myos
WARNING: myos[0] list-rule-exists: target list unavailable in app myos
WARNING: myos[0] host/consul-rule-exists: target host/consul unavailable in app myos
[exit 0]
+7
View File
@@ -0,0 +1,7 @@
make -o docker-stack-recreate MAKE_OLDFILE=docker-stack-recreate ENV=local DOCKER_COMPOSE=docker --log-level=error compose docker-compose-recreate STACK=myos APP_NAME=myos
docker --log-level=error compose --ansi=auto -f @MYOS@/share/compose/networks.yml -p tester-myos-local rm -fs
sh -c docker network create tester-local >/dev/null 2>&1
sh -c docker network create testhost >/dev/null 2>&1
docker --log-level=error compose --ansi=auto -f @MYOS@/share/compose/networks.yml -p tester-myos-local up -d
WARNING: myos[0] host/consul-rule-exists: target host/consul unavailable in app myos
[exit 0]
+4
View File
@@ -0,0 +1,4 @@
make -o docker-stack-ps MAKE_OLDFILE=docker-stack-ps ENV=local DOCKER_COMPOSE=docker --log-level=error compose docker-compose-ps STACK=myos APP_NAME=myos
docker --log-level=error compose --ansi=auto -f @MYOS@/share/compose/networks.yml -p tester-myos-local ps
WARNING: myos[0] host/consul-rule-exists: target host/consul unavailable in app myos
[exit 0]
+2
View File
@@ -0,0 +1,2 @@
WARNING: myos[0] expose-rule-exists: target expose unavailable in app myos
[exit 0]
+3
View File
@@ -0,0 +1,3 @@
WARNING: myos[0] expose-rule-exists: target expose unavailable in app myos
WARNING: myos[0] host/consul-rule-exists: target host/consul unavailable in app myos
[exit 0]
+3
View File
@@ -0,0 +1,3 @@
ERROR: myos[0] ssh-hosts-check: no remote host: set SSH_HOSTS=host1 host2
make: *** [ssh-hosts-check] Error 2
[exit 2]
@@ -0,0 +1,4 @@
ERROR: myos[0] ssh: Unable to find docker tester-example-test
make: *** [ssh] Error 2
[exit 2]
+4
View File
@@ -0,0 +1,4 @@
#shellcheck shell=sh
MYOS_CERT_MODE=$1
myos_cert_names() { printf 'urlprefix-*.ipns.ex.org/*\nurlprefix-a.ex.org/*\n' | myos_cert_parse; }
myos_cert_needs_dns && echo yes || echo no
+10
View File
@@ -0,0 +1,10 @@
#shellcheck shell=sh
# myos_cert_names normally reads the compose configuration; here it is replaced
# by a fixed list so the grouping can be checked on its own.
MYOS_CERT_MODE=$1
_fixture=${2-'urlprefix-ipfs.ex.org/*
urlprefix-*.ipns.ex.org/*
urlprefix-a.ipns.ex.org/*
urlprefix-ex.org/*'}
myos_cert_names() { printf '%s' "$_fixture" | myos_cert_parse; }
myos_cert_groups
+2
View File
@@ -0,0 +1,2 @@
#shellcheck shell=sh
printf 'urlprefix-ipfs.ex.org/api/*\nurlprefix-*.ipns.ex.org/*\nurlprefix-ex.org:443/* proto=https\nurlprefix-*/*\n' | myos_cert_parse
+74
View File
@@ -0,0 +1,74 @@
#shellcheck shell=sh
Include lib/core.sh
Include lib/str.sh
Include lib/var.sh
Include lib/tags.sh
Include lib/naming.sh
Include lib/stack.sh
Include lib/cert.sh
# The hostnames a server must certify are already declared in the fabio route
# tags. Reading them there rather than in a domains.txt keeps one source of
# truth, and decides on its own which name needs a wildcard.
Describe 'lib/cert.sh'
Describe 'myos_cert_parse'
one_tag() { printf 'urlprefix-a.ex.org/x/*\n' | myos_cert_parse; }
It 'reads a hostname out of a route tag'
When call one_tag
The output should equal "a.ex.org"
End
It 'drops the path, the port and the options'
When run source spec/unit/cert_parse_helper.sh
The line 1 should equal "*.ipns.ex.org"
The line 2 should equal "ex.org"
The line 3 should equal "ipfs.ex.org"
The lines of output should equal 3
End
End
Describe 'myos_cert_covers'
Parameters
"ex.org" "a.ex.org" success
"ex.org" "ex.org" failure
"ex.org" "a.b.ex.org" failure
"ex.org" "other.org" failure
End
It "*.$1 against $2"
When call myos_cert_covers "$1" "$2"
The status should be "$3"
End
End
Describe 'myos_cert_groups'
It 'asks for a wildcard where a tag uses one, and absorbs what it covers'
When run source spec/unit/cert_groups_helper.sh auto
The line 1 should equal "ipns.ex.org *.ipns.ex.org"
The output should include "ipfs.ex.org"
The output should not include "a.ipns.ex.org *"
End
It 'never asks for a wildcard in per-site mode'
When run source spec/unit/cert_groups_helper.sh per-site
The output should not include "*"
The output should include "a.ipns.ex.org"
End
It 'asks for one per domain in wildcard mode'
When run source spec/unit/cert_groups_helper.sh wildcard
The output should include "ex.org *.ex.org"
End
It 'says nothing when nothing is routed'
When run source spec/unit/cert_groups_helper.sh auto ""
The output should equal ""
End
End
Describe 'myos_cert_needs_dns'
It 'is true when a wildcard is asked for'
When run source spec/unit/cert_dns_helper.sh auto
The output should equal "yes"
End
It 'is false without one'
When run source spec/unit/cert_dns_helper.sh per-site
The output should equal "no"
End
End
End
+100
View File
@@ -0,0 +1,100 @@
#shellcheck shell=sh
Include lib/core.sh
Include lib/str.sh
Include lib/var.sh
Include lib/tags.sh
Include lib/naming.sh
Include lib/stack.sh
Include lib/expose.sh
# Docker writes its own firewall rules, so a port published to 0.0.0.0 answers
# the internet whatever the host firewall says. Binding the publication is the
# portable answer: it behaves the same on linux and on macOS, without root.
Describe 'lib/expose.sh'
Describe 'myos_bind'
It 'binds the private scope to the loopback'
When call myos_bind private
The output should equal "127.0.0.1"
End
It 'binds the public scope to every address'
When call myos_bind public
The output should equal "0.0.0.0"
End
It 'takes an explicit address over the default'
MYOS_BIND_PRIVATE=10.0.0.1
When call myos_bind private
The output should equal "10.0.0.1"
End
It 'keeps an unknown scope private rather than public'
When call myos_bind nonsense
The output should equal "127.0.0.1"
End
It 'falls back to the private address when there is no mesh'
MYOS_MESH_IFACE=nosuchiface0
When call myos_bind mesh
The output should equal "127.0.0.1"
End
It 'uses the mesh address when one is given'
MYOS_BIND_MESH=10.144.0.2
When call myos_bind mesh
The output should equal "10.144.0.2"
End
End
Describe 'myos_stack_prefix'
Parameters
"host/fabio" "HOST_FABIO"
"User/ipfs" "USER_IPFS"
"supabase" "SUPABASE"
"drone/drone" "DRONE"
End
It "prefixes the settings of $1 with $2"
When call myos_stack_prefix "$1"
The output should equal "$2"
End
End
Describe 'myos_expose_declared'
setup() { MYOS_TMP=$(mktemp -d "${TMPDIR:-/tmp}/myos-exp.XXXXXX"); }
cleanup() { rm -rf "$MYOS_TMP"; }
BeforeEach setup
AfterEach cleanup
# The scope is not declared on the side: it is which binding the compose
# file asks for. Reading the resolved configuration instead would lose the
# difference, since every form ends up as a plain address.
It 'reads the scope out of the binding each port asks for'
printf 'services:\n a:\n ports:\n' > "$MYOS_TMP/c.yml"
printf ' - "${MYOS_BIND_PUBLIC}:443:443"\n' >> "$MYOS_TMP/c.yml"
printf ' - "${MYOS_BIND_PRIVATE}::8080"\n' >> "$MYOS_TMP/c.yml"
printf ' - "${MYOS_BIND_MESH}::7946"\n' >> "$MYOS_TMP/c.yml"
printf ' - "127.0.0.1:5432:5432"\n' >> "$MYOS_TMP/c.yml"
printf ' - 80\n' >> "$MYOS_TMP/c.yml"
When call myos_expose_declared "$MYOS_TMP/c.yml"
The line 1 should equal "a|443|public"
The line 2 should equal "a|8080|private"
The line 3 should equal "a|7946|mesh"
The line 4 should equal "a|5432|pinned"
The line 5 should equal "a|80|unbound"
End
It 'calls a plain host:container mapping unbound, because it is'
printf 'services:\n a:\n ports:\n - "9000:9000"\n - 25:25\n' > "$MYOS_TMP/c.yml"
When call myos_expose_declared "$MYOS_TMP/c.yml"
The line 1 should equal "a|9000|unbound"
The line 2 should equal "a|25|unbound"
End
It 'keeps the protocol out of the port'
printf 'services:\n a:\n ports:\n - 4001/udp\n' > "$MYOS_TMP/c.yml"
When call myos_expose_declared "$MYOS_TMP/c.yml"
The output should equal "a|4001|unbound"
End
It 'reports nothing for a service that publishes nothing'
printf 'services:\n a:\n image: alpine\n' > "$MYOS_TMP/c.yml"
When call myos_expose_declared "$MYOS_TMP/c.yml"
The output should equal ""
End
End
End