Files
multica/scripts/selfhost-config.test.sh
Eve 70c215f63c fix(selfhost): installers read the published port; drop the second parser
Addresses all four blockers from the consolidated review on #6168.

1. Both installers probed and printed the wrong port. scripts/install.sh:402 and
   scripts/install.ps1 start Compose inheriting the current environment, and
   Compose lets that environment outrank .env — but the installers then derived
   the probe port and the summary URL from .env alone. Measured on this branch
   before the fix, every port variable diverged:

     ambient PORT=9100        -> compose 9100, installer 8080
     ambient BACKEND_PORT=9200 -> compose 9200, installer 8080
     ambient API_PORT=9300     -> compose 9300, installer 8080
     ambient SERVER_PORT=9400  -> compose 9400, installer 8080
     ambient FRONTEND_PORT=3100 -> compose 3100, installer 3000

   Both now read the ports back once with `docker compose port` after `up -d`
   and reuse that single result for the health check and the summary. If the
   query fails they fail loudly instead of claiming success. The .env-derived
   helpers are deleted rather than left beside the new path.

2. The Make preflight is removed entirely, as the review recommended. It
   modelled `origin=environment` and `origin=file` but not `command line`, so
   `make selfhost BACKEND_PORT=9000` over a .env with API_PORT=7000 announced
   7000 while Compose published 9000. That was its fourth wrong report in four
   rounds, because it was a second parser of precedence rules — the very thing
   this PR removes elsewhere. `docker compose port` is the runtime truth, so the
   script, the Makefile captures and the macro are gone.

3. Tests now cover the installer paths that were unguarded. scripts/install.test.sh
   gains a `--with-server` matrix (defaults, .env PORT, all four backend aliases,
   FRONTEND_PORT, ambient overriding .env for all five, and explicit-empty
   fallback) plus a case proving an unresolvable port fails loudly. A new
   scripts/install.ps1.test.ps1 drives the same matrix through Start-LocalInstall.
   Every case asserts Compose's published port == the probed URL == the printed
   URL. Ambient variables are explicitly cleared per case so a runner's own PORT
   cannot leak. The Makefile matrix gains the command-line origin cases. CI runs
   the PowerShell suite on windows-latest in the always-on installer job, and the
   frontend filter now includes both installers.

4. Docs state the real contract: the alias order is shared, but which *source*
   wins is per entry point — Compose prefers the environment, make prefers the
   included env file, and a make command-line assignment outranks both. The
   removed preflight's promise is gone from SELF_HOSTING_AI.md, and the compose
   header no longer claims the installer derives its own health-check port.

Verified failing without the fixes: the Bash matrix reports
`[ambient PORT beats .env] compose published 9500 / probed 9100 / printed 9100`
against the old installer, and the PowerShell matrix reports
`printed backend=8080` against an injected summary divergence.

Co-authored-by: multica-agent <github@multica.ai>
2026-07-30 17:49:30 +08:00

488 lines
18 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
require_config() {
local config=$1
local expected=$2
if ! grep -Fq "$expected" <<<"$config"; then
echo "Missing expected docker compose config value:"
echo " $expected"
exit 1
fi
}
require_env() {
local output=$1
local expected=$2
if ! grep -Fxq "$expected" <<<"$output"; then
echo "Missing expected derived env value:"
echo " $expected"
echo "Observed:"
echo "$output"
exit 1
fi
}
tmp_env="$(mktemp)"
tmp_dir="$(mktemp -d)"
trap 'rm -f "$tmp_env"; rm -rf "$tmp_dir"' EXIT
sed 's/^FRONTEND_PORT=.*/FRONTEND_PORT=3100/' .env.example >"$tmp_env"
printf '\nBACKEND_PORT=9100\n' >>"$tmp_env"
config="$(
docker compose \
--env-file "$tmp_env" \
-f docker-compose.selfhost.yml \
config
)"
require_config "$config" 'published: "3100"'
require_config "$config" 'published: "9100"'
require_config "$config" 'FRONTEND_ORIGIN: http://localhost:3100'
require_config "$config" 'GOOGLE_REDIRECT_URI: http://localhost:3100/auth/callback'
require_config "$config" 'MULTICA_APP_URL: http://localhost:3100'
for script in scripts/dev.sh scripts/check.sh; do
if ! grep -Fq '. scripts/local-env.sh' "$script"; then
echo "$script must source scripts/local-env.sh for shared local env derivation."
exit 1
fi
done
local_env="$(
env -i PATH="$PATH" bash -c '
set -euo pipefail
env_file=$1
set -a
# shellcheck disable=SC1090
. "$env_file"
set +a
# shellcheck disable=SC1091
. scripts/local-env.sh
printf "%s\n" \
"PORT=${PORT}" \
"FRONTEND_PORT=${FRONTEND_PORT}" \
"FRONTEND_ORIGIN=${FRONTEND_ORIGIN}" \
"MULTICA_APP_URL=${MULTICA_APP_URL}" \
"GOOGLE_REDIRECT_URI=${GOOGLE_REDIRECT_URI}" \
"MULTICA_SERVER_URL=${MULTICA_SERVER_URL}" \
"LOCAL_UPLOAD_BASE_URL=${LOCAL_UPLOAD_BASE_URL}" \
"PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL}"
' _ "$tmp_env"
)"
require_env "$local_env" 'PORT=9100'
require_env "$local_env" 'FRONTEND_PORT=3100'
require_env "$local_env" 'FRONTEND_ORIGIN=http://localhost:3100'
require_env "$local_env" 'MULTICA_APP_URL=http://localhost:3100'
require_env "$local_env" 'GOOGLE_REDIRECT_URI=http://localhost:3100/auth/callback'
require_env "$local_env" 'MULTICA_SERVER_URL=ws://localhost:9100/ws'
require_env "$local_env" 'LOCAL_UPLOAD_BASE_URL=http://localhost:9100'
require_env "$local_env" 'PLAYWRIGHT_BASE_URL=http://localhost:3100'
worktree_env="$tmp_dir/.env.worktree"
WORKTREE_NAME=selfhost-config-test bash scripts/init-worktree-env.sh "$worktree_env" >/dev/null
worktree_backend_port="$(sed -n 's/^PORT=//p' "$worktree_env")"
require_env "$(cat "$worktree_env")" "MULTICA_PUBLIC_URL=http://localhost:${worktree_backend_port}"
resolve_local_public_url() {
env -i PATH="$PATH" bash -c '
set -euo pipefail
env_file=$1
set -a
# shellcheck disable=SC1090
. "$env_file"
set +a
# shellcheck disable=SC1091
. scripts/local-env.sh
printf "%s\n" "$MULTICA_PUBLIC_URL"
' _ "$1"
}
make_env_probe="$tmp_dir/print-public-url.mk"
printf '%s\n' \
'.PHONY: print-public-url' \
'print-public-url:' \
' @printf "%s\n" "$$MULTICA_PUBLIC_URL"' \
>"$make_env_probe"
resolve_make_public_url() {
make \
--no-print-directory \
-s \
-f Makefile \
-f "$make_env_probe" \
ENV_FILE="$1" \
print-public-url
}
old_worktree_env="$tmp_dir/.env.worktree.old"
grep -v '^MULTICA_PUBLIC_URL=' "$worktree_env" >"$old_worktree_env"
require_env \
"$(resolve_local_public_url "$old_worktree_env")" \
"http://localhost:${worktree_backend_port}"
require_env \
"$(resolve_make_public_url "$old_worktree_env")" \
"http://localhost:${worktree_backend_port}"
explicit_worktree_env="$tmp_dir/.env.worktree.explicit"
cp "$old_worktree_env" "$explicit_worktree_env"
printf '\nMULTICA_PUBLIC_URL=https://api.explicit.example\n' >>"$explicit_worktree_env"
require_env \
"$(resolve_local_public_url "$explicit_worktree_env")" \
"https://api.explicit.example"
require_env \
"$(resolve_make_public_url "$explicit_worktree_env")" \
"https://api.explicit.example"
# ---------------------------------------------------------------------------
# Host port consistency (regression for #6145)
#
# The health check and the success message must use the port Compose actually
# published. The recipes used to probe ${PORT:-8080} while Compose published
# ${BACKEND_PORT:-8080} — two sources of truth, so any config where they
# disagreed hammered the wrong port for 60s and then reported "Services are
# still starting" on a healthy stack.
#
# These cases drive the real `make selfhost` recipe. The docker stub does NOT
# get told what to publish: on `up` it asks the real `docker compose config` to
# interpolate the compose file with the environment the recipe actually handed
# it, records that, and answers `port` from the recording. So the assertion
# crosses environment -> make -> compose -> report for real, and a stub that
# agreed with a broken recipe could not make it pass.
# ---------------------------------------------------------------------------
stub_dir="$tmp_dir/bin"
mkdir -p "$stub_dir"
cat >"$stub_dir/docker" <<'STUB'
#!/usr/bin/env bash
# `docker compose` stub. Derives the published host ports from the real compose
# interpolation of the environment this invocation received, so the recorded
# answer is whatever the recipe truly asked Compose for.
set -uo pipefail
args=("$@")
sub=""
subidx=0
for ((i = 0; i < ${#args[@]}; i++)); do
case "${args[i]}" in
up | pull | port | version | logs | down | build)
sub=${args[i]}
subidx=$i
break
;;
esac
done
files=()
for ((i = 0; i < ${#args[@]}; i++)); do
if [ "${args[i]}" = "-f" ]; then files+=(-f "${args[i + 1]}"); fi
done
case "$sub" in
version) echo "2.30.0" ;;
up)
"$REAL_DOCKER" compose "${files[@]}" config --format json 2>/dev/null | node -e '
let raw = "";
process.stdin.on("data", (chunk) => (raw += chunk));
process.stdin.on("end", () => {
const config = JSON.parse(raw);
for (const service of ["backend", "frontend"]) {
console.log(service + "=" + config.services[service].ports[0].published);
}
});
' >>"$STUB_PUBLISHED_RECORD"
;;
port)
service=${args[subidx + 1]}
published=$(grep "^${service}=" "$STUB_PUBLISHED_RECORD" 2>/dev/null | tail -n 1 | cut -d= -f2)
if [ -z "$published" ]; then exit 1; fi
printf '127.0.0.1:%s\n' "$published"
;;
*) : ;;
esac
exit 0
STUB
cat >"$stub_dir/curl" <<'STUB'
#!/usr/bin/env bash
# Records probed URLs and always reports a healthy backend.
set -uo pipefail
for arg in "$@"; do
case "$arg" in
http*) printf '%s\n' "$arg" >>"$STUB_CURL_LOG" ;;
esac
done
exit 0
STUB
chmod +x "$stub_dir/docker" "$stub_dir/curl"
# Throwaway checkout so the recipe never touches this repo's own .env.
recipe_dir="$tmp_dir/recipe"
mkdir -p "$recipe_dir/scripts"
cp Makefile .env.example docker-compose.selfhost.yml docker-compose.selfhost.build.yml "$recipe_dir/"
cp scripts/selfhost-wait.sh "$recipe_dir/scripts/"
record="$tmp_dir/published"
curl_log="$tmp_dir/probed"
real_docker="$(command -v docker)"
# Runs `make <target>` against the stubs after applying a sed script to .env.
# Remaining args are passed through to make (environment assignments must be
# given as VAR=value before the target via `env`, make variables after it).
run_recipe() {
local target=$1 env_mutation=$2 shell_env=$3 make_args=$4
cp "$recipe_dir/.env.example" "$recipe_dir/.env"
if [ -n "$env_mutation" ]; then
sed "$env_mutation" "$recipe_dir/.env" >"$recipe_dir/.env.tmp"
mv "$recipe_dir/.env.tmp" "$recipe_dir/.env"
fi
: >"$record"
: >"$curl_log"
(
cd "$recipe_dir" || exit 1
eval "env PATH=\"$stub_dir:\$PATH\" \
REAL_DOCKER=\"$real_docker\" \
STUB_PUBLISHED_RECORD=\"$record\" \
STUB_CURL_LOG=\"$curl_log\" \
$shell_env make $target $make_args"
)
}
published_port() {
grep "^$1=" "$record" | tail -n 1 | cut -d= -f2
}
probed_port() {
sed -n '1s#http://localhost:\([0-9]*\)/health#\1#p' "$curl_log"
}
require_consistent() {
local label=$1 expected=$2
local published probed
published=$(published_port backend)
probed=$(probed_port)
if [ "$published" != "$expected" ] || [ "$probed" != "$expected" ]; then
echo "[$label] host port disagreement"
echo " expected published and probed port: $expected"
echo " compose published: ${published:-<none>}"
echo " health check probed: ${probed:-<none>}"
exit 1
fi
}
# PORT is the value to edit, so editing it must move the published port and the
# probe together. Fails on the old recipe, which probed 9100 while Compose
# published 8080.
run_recipe selfhost 's/^PORT=8080/PORT=9100/' '' '' >/dev/null
require_consistent 'PORT edited in .env' 9100
# BACKEND_PORT remains an alias that overrides PORT.
run_recipe selfhost 's/^# BACKEND_PORT=8080/BACKEND_PORT=9200/' '' '' >/dev/null
require_consistent 'BACKEND_PORT alias in .env' 9200
# A make command-line override must not desync the probe from Compose. Fails on
# the old recipe, which probed 8080 while Compose published 9100.
run_recipe selfhost 's/^# BACKEND_PORT=8080/BACKEND_PORT=9100/' '' 'PORT=8080' >/dev/null
require_consistent 'make PORT=8080 over BACKEND_PORT=9100' 9100
# With no alias pinned in .env, an alias from the environment survives make's
# include and takes effect end to end.
run_recipe selfhost '' 'BACKEND_PORT=9300' '' >/dev/null
require_consistent 'BACKEND_PORT from the environment' 9300
# The env file stays authoritative for values it does set, so an environment
# PORT loses to it — and the probe must follow whatever Compose then published.
run_recipe selfhost '' 'PORT=9500' '' >/dev/null
require_consistent 'PORT from the environment is overridden by .env' 8080
# Defaults stay 8080/3000.
run_recipe selfhost '' '' '' >/dev/null
require_consistent 'defaults' 8080
if [ "$(published_port frontend)" != "3000" ]; then
echo "default frontend host port should be 3000, got $(published_port frontend)"
exit 1
fi
# selfhost-build resolves the port the same way.
run_recipe selfhost-build 's/^PORT=8080/PORT=9400/' '' '' >/dev/null
require_consistent 'selfhost-build with PORT edited' 9400
# Every alias at once: BACKEND_PORT wins, and the probe follows it.
run_recipe selfhost \
's/^PORT=8080/PORT=9000/;s/^# BACKEND_PORT=8080/BACKEND_PORT=8000/;s/^# API_PORT=8080/API_PORT=7000/;s/^# SERVER_PORT=8080/SERVER_PORT=6000/' \
'' '' >/dev/null
require_consistent 'every alias set at once' 8000
# A higher-priority alias in the env file beats a lower-priority one from the
# shell.
run_recipe selfhost \
's/^PORT=8080/PORT=8000/;s/^# BACKEND_PORT=8080/BACKEND_PORT=8000/' \
'API_PORT=7000' '' >/dev/null
require_consistent 'env-file BACKEND_PORT over shell API_PORT' 8000
# ---------------------------------------------------------------------------
# Make command-line variables
#
# A command-line assignment outranks both the env file and the environment, and
# it is a third origin that no re-derivation of the port got right. Since the
# recipe now asks Compose, the probe follows whatever actually got published.
# ---------------------------------------------------------------------------
# Command-line high-priority alias over a low-priority alias in the env file.
run_recipe selfhost 's/^# API_PORT=8080/API_PORT=7000/' '' 'BACKEND_PORT=9000' >/dev/null
require_consistent 'command-line BACKEND_PORT over env-file API_PORT' 9000
# Env-file high-priority alias over a low-priority alias on the command line.
run_recipe selfhost 's/^# BACKEND_PORT=8080/BACKEND_PORT=9100/' '' 'SERVER_PORT=6000' >/dev/null
require_consistent 'env-file BACKEND_PORT over command-line SERVER_PORT' 9100
# An explicit empty value on the command line drops out of the chain.
run_recipe selfhost '' '' 'BACKEND_PORT=' >/dev/null
require_consistent 'command-line BACKEND_PORT= falls through to PORT' 8080
# ---------------------------------------------------------------------------
# Explicit empty assignments in the env file
#
# `BACKEND_PORT=` is a distinct input from an absent one: make lets it override
# the same variable from the environment, and it drops out of the alias chain
# instead of setting a port. Compose treats the exported empty value the same
# way, so the two agree.
# ---------------------------------------------------------------------------
run_recipe selfhost 's/^# BACKEND_PORT=8080/BACKEND_PORT=/' 'BACKEND_PORT=9000' '' >/dev/null
require_consistent 'empty BACKEND_PORT in .env over shell BACKEND_PORT' 8080
run_recipe selfhost 's/^PORT=8080/PORT=/;s/^# BACKEND_PORT=8080/BACKEND_PORT=/' 'PORT=9000' '' >/dev/null
require_consistent 'every chain variable emptied in .env' 8080
run_recipe selfhost 's/^FRONTEND_PORT=3000/FRONTEND_PORT=/' 'FRONTEND_PORT=3100' '' >/dev/null
if [ "$(published_port frontend)" != "3000" ]; then
echo "an empty FRONTEND_PORT in .env must fall back to 3000, got $(published_port frontend)"
exit 1
fi
# The recipes must delegate instead of re-deriving the port.
for expected_call in 'bash scripts/selfhost-wait.sh official' 'bash scripts/selfhost-wait.sh build'; do
if ! grep -Fq "$expected_call" Makefile; then
echo "Makefile must call the shared wait script: $expected_call"
exit 1
fi
done
if grep -n 'localhost:$${PORT' Makefile; then
echo "The self-host recipes must not re-derive the backend host port from \$PORT."
echo "Use scripts/selfhost-wait.sh, which reads the published port from Compose."
exit 1
fi
# ---------------------------------------------------------------------------
# The backend port alias chain, and Compose's own source precedence
#
# BACKEND_PORT -> API_PORT -> SERVER_PORT -> PORT -> 8080
#
# Exercised through the direct Compose path, because the Makefile normalises all
# four into PORT before a recipe ever runs — so a recipe-only test cannot see a
# gap here.
#
# This is also where Compose's precedence is pinned against the real binary: the
# calling environment outranks the env file. Both installers relied on their own
# .env-only derivation and so probed a port Compose never published (#6145);
# they now ask `docker compose port` instead, which is asserted end to end in
# scripts/install.test.sh and scripts/install.ps1.test.ps1. Those suites run on
# agents without a Docker CLI, so the ground truth for precedence lives here.
# ---------------------------------------------------------------------------
# Neither installer may reconstruct the port from the env file again.
for installer in scripts/install.sh scripts/install.ps1; do
if grep -nE '(selfhost_backend_port|selfhost_frontend_port|Get-SelfHostBackendPort|Get-SelfHostFrontendPort)' "$installer"; then
echo "$installer must not re-derive host ports from .env."
echo "Read the published port from Compose, as scripts/selfhost-wait.sh does."
exit 1
fi
done
for installer_call in \
'compose_published_port backend 8080' \
'compose_published_port frontend 3000'; do
if ! grep -Fq "$installer_call" scripts/install.sh; then
echo "scripts/install.sh must read the published port from Compose: $installer_call"
exit 1
fi
done
for installer_call in \
'Get-ComposePublishedPort -Service "backend" -ContainerPort 8080' \
'Get-ComposePublishedPort -Service "frontend" -ContainerPort 3000'; do
if ! grep -Fq "$installer_call" scripts/install.ps1; then
echo "scripts/install.ps1 must read the published port from Compose: $installer_call"
exit 1
fi
done
compose_published_ports() {
local env_file=$1
shift
env "$@" docker compose --env-file "$env_file" -f docker-compose.selfhost.yml config --format json |
node -e '
let raw = "";
process.stdin.on("data", (chunk) => (raw += chunk));
process.stdin.on("end", () => {
const config = JSON.parse(raw);
console.log(config.services.backend.ports[0].published + " " + config.services.frontend.ports[0].published);
});
'
}
# Each case: label, sed applied to .env.example, ambient env, expected backend,
# expected frontend. .env.example ships PORT=8080 with every alias commented out.
while IFS='|' read -r case_label case_mutation case_ambient case_backend case_frontend; do
[ -n "$case_label" ] || continue
case_env="$tmp_dir/.env.alias"
if [ -n "$case_mutation" ]; then
sed "$case_mutation" .env.example >"$case_env"
else
cp .env.example "$case_env"
fi
# Unset every port variable first so the agent's own environment cannot leak.
read -r observed_backend observed_frontend < <(
compose_published_ports "$case_env" \
-u PORT -u BACKEND_PORT -u API_PORT -u SERVER_PORT -u FRONTEND_PORT \
${case_ambient:+"$case_ambient"}
)
if [ "$observed_backend" != "$case_backend" ] || [ "$observed_frontend" != "$case_frontend" ]; then
echo "[$case_label] Compose published an unexpected host port"
echo " expected: backend=$case_backend frontend=$case_frontend"
echo " observed: backend=$observed_backend frontend=$observed_frontend"
exit 1
fi
done <<'CASES'
defaults|||8080|3000
PORT only|s/^PORT=8080/PORT=9100/||9100|3000
SERVER_PORT overrides PORT|s/^# SERVER_PORT=8080/SERVER_PORT=9200/||9200|3000
API_PORT overrides SERVER_PORT|s/^# API_PORT=8080/API_PORT=9300/;s/^# SERVER_PORT=8080/SERVER_PORT=9200/||9300|3000
BACKEND_PORT overrides all|s/^# BACKEND_PORT=8080/BACKEND_PORT=9400/;s/^# API_PORT=8080/API_PORT=9300/;s/^# SERVER_PORT=8080/SERVER_PORT=9200/||9400|3000
ambient PORT beats the env file|s/^PORT=8080/PORT=9100/|PORT=9500|9500|3000
ambient BACKEND_PORT beats the env file|s/^PORT=8080/PORT=9100/|BACKEND_PORT=9600|9600|3000
ambient API_PORT beats the env file|s/^PORT=8080/PORT=9100/|API_PORT=9700|9700|3000
ambient SERVER_PORT beats the env file|s/^PORT=8080/PORT=9100/|SERVER_PORT=9800|9800|3000
ambient FRONTEND_PORT beats the env file|s/^FRONTEND_PORT=3000/FRONTEND_PORT=3100/|FRONTEND_PORT=3200|8080|3200
CASES
# An env-file alias beats the same alias from the environment, and the probe
# follows whatever Compose published either way.
for shadowed_alias in BACKEND_PORT API_PORT SERVER_PORT; do
run_recipe selfhost "s/^# ${shadowed_alias}=8080/${shadowed_alias}=9700/" \
"${shadowed_alias}=9600" '' >/dev/null
require_consistent "env-file ${shadowed_alias} over the same shell variable" 9700
done
echo "self-host env derivation ok"