Compare commits

...
4 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
24 changed files with 918 additions and 71 deletions
+6 -1
View File
@@ -24,7 +24,12 @@
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
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`.
+12 -1
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 expose; 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
@@ -70,6 +70,7 @@ Commands:
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
@@ -148,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)
+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'
}
+45 -31
View File
@@ -1,52 +1,56 @@
#shellcheck shell=sh
# myos expose [--strict] what the stacks publish, and to whom
#
# Reads the resolved compose configuration, so it reports what `myos up` would
# open rather than what happens to be running. A port bound to 0.0.0.0 answers
# the internet: on linux docker writes its own firewall rules and the host
# firewall does not see it. --strict exits 1 when a port is world-bound
# without the stack declaring that scope.
# 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; }
[ -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 SCOPE "$MYOS_C_RESET"
"$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%%|*}; _rest=${_row#*|}
_sv=${_rest%%|*}; _rest=${_rest#*|}
_on=${_rest%%|*}; _rest=${_rest#*|}
_pt=${_rest%%|*}; _sc=${_rest#*|}
case ${_on%:*} in
0.0.0.0|''|'::'|'*')
[ "$_sc" = public ] || _bad=$((_bad + 1))
printf '%-20s %-14s %s%-22s%s %-6s %s\n' \
"$_st" "$_sv" "$MYOS_C_WARN" "$_on" "$MYOS_C_RESET" "$_pt" "$_sc" ;;
*)
printf '%-20s %-14s %-22s %-6s %s\n' "$_st" "$_sv" "$_on" "$_pt" "$_sc" ;;
esac
_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) reachable from anywhere without declaring the public scope"
myos_warning "bind them: ports: [\"\${MYOS_BIND_PRIVATE}::<port>\"]"
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|SCOPE for every
# published port of the requested stacks
# 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
@@ -56,15 +60,26 @@ myos_expose_rows() {
$_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_parse "$_ref" "$(myos_stack_prefix "$_ref")"
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_parse STACK NAME (compose config on stdin)
# 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_parse() {
awk -v stack="$1" '
myos_expose_resolved() {
awk '
/^services:/ { insvc = 1; next }
insvc && /^ [a-zA-Z0-9_.-]+:/ { svc = $1; sub(/:$/, "", svc); inports = 0 }
insvc && /^ ports:/ { inports = 1; next }
@@ -73,10 +88,9 @@ myos_expose_parse() {
inports && /published:/ { pub = $2; gsub(/"/, "", pub) }
inports && /target:/ { tgt = $2 }
inports && /protocol:/ {
printf "%s|%s|%s:%s|%s\n", stack, svc, (ip == "" ? "0.0.0.0" : ip), pub, tgt
# 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 = ""
}
' | while IFS='|' read -r _s _v _o _t; do
printf '%s|%s|%s|%s|%s\n' "$_s" "$_v" "$_o" "$_t" "$(myos_expose_scope "$2" "$_v" "$_t")"
done
'
}
+51 -12
View File
@@ -15,7 +15,11 @@
# mesh the private network between the hosts of the fleet
# private this host only: everything the load balancer reaches for you
#
# MYOS_BIND_<SCOPE> overrides any of them.
# 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() {
@@ -66,15 +70,50 @@ myos_stack_prefix() {
esac
}
# myos_expose_scope PREFIX SERVICE PORT the scope a stack declares for a port:
# <PREFIX>_SERVICE_<port>_EXPOSE, then <PREFIX>_SERVICE_EXPOSE, then the same
# two on the service name, else private
myos_expose_scope() {
_u=$(myos_upper "$1")
for _n in "${_u}_SERVICE_${3}_EXPOSE" "${_u}_SERVICE_EXPOSE" \
"$(myos_upper "$2")_SERVICE_${3}_EXPOSE" "$(myos_upper "$2")_SERVICE_EXPOSE"; do
_s=$(myos_var "$_n")
[ -n "$_s" ] && { printf '%s' "$_s"; return 0; }
done
printf 'private'
# 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 }
' "$@"
}
+2 -1
View File
@@ -85,7 +85,8 @@ See `references/conventions.md`.
- 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. Bind it instead: `ports: ["${MYOS_BIND_PRIVATE}::<port>"]`.
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.
+1
View File
@@ -28,6 +28,7 @@ myos [options] <command> [stack...] [VAR=value...] [-- args...]
| `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 |
+47 -8
View File
@@ -198,22 +198,61 @@ services:
addresses; `MYOS_MESH_IFACE` names the interface when it is not one of
easytier, tun0, tailscale0, mycelium or wg0.
A stack also declares what it means, so an audit can tell a deliberate choice
from an oversight:
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
<PREFIX>_SERVICE_EXPOSE=public # the whole stack
<PREFIX>_SERVICE_443_EXPOSE=public # one port
myos expose # what each stack publishes, on which address
myos expose --strict # exits 1 when a port is published without a binding
```
`<PREFIX>` is `HOST_<name>` for a host stack, `USER_<name>` for a user stack,
`<name>` otherwise.
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 expose # what each stack publishes, and its declared scope
myos expose --strict # exits 1 when a port faces the world undeclared
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
+1
View File
@@ -91,3 +91,4 @@ 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
+2
View File
@@ -0,0 +1,2 @@
no routed hostname: nothing to certify
[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]
+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
+38 -17
View File
@@ -54,26 +54,47 @@ Describe 'lib/expose.sh'
End
End
Describe 'myos_expose_scope'
It 'is private unless the stack says otherwise'
When call myos_expose_scope HOST_FTPS ftps 21
The output should equal "private"
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 'reads the scope of one port'
HOST_FTPS_SERVICE_21_EXPOSE=public
When call myos_expose_scope HOST_FTPS ftps 21
The output should equal "public"
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 'reads the scope of a whole stack'
HOST_FTPS_SERVICE_EXPOSE=mesh
When call myos_expose_scope HOST_FTPS ftps 21
The output should equal "mesh"
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 'prefers the port over the stack'
HOST_FTPS_SERVICE_EXPOSE=mesh
HOST_FTPS_SERVICE_21_EXPOSE=public
When call myos_expose_scope HOST_FTPS ftps 21
The output should equal "public"
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