fix(stoat): write every bind-mounted file before anything that can fail

The install read the generated LiveKit credentials with a plain grep, but
secrets.env is chmod 600 and owned by the docker install user while the hooks
run as the manager — so the read returned nothing, the hook errored out, and
Caddyfile and livekit.yml were never written. Compose then refused to start,
because a bind mount whose source does not exist is not a soft failure.

Read secrets through runFileOp, and reorder so the Caddyfile and the three
URL-bearing files are written first: any step that can fail now comes after
every mount source already exists. The missing LiveKit keys are downgraded from
fatal to a warning for the same reason — losing voice is worth reporting, but it
is no reason to take the other fifteen services down with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-18 20:54:49 +01:00
parent ec98a83b48
commit 80b94fb21f
2 changed files with 56 additions and 19 deletions

View File

@ -192,31 +192,44 @@ stoat_install_post_compose()
"$app_dir/data/caddy-data" "$app_dir/data/caddy-config")
checkSuccess "Creating $app_name data folders"
_stoatWriteSecrets "$app_dir/secrets.env"
# Read the LiveKit credentials back out — either the ones just generated or
# the ones preserved from a previous install — because livekit.yml has to
# carry the same pair the API is configured with.
local livekit_key livekit_secret
livekit_key=$(grep -oP "REVOLT__API__LIVEKIT__NODES__WORLDWIDE__KEY='\K[^']*" "$app_dir/secrets.env" 2>/dev/null)
livekit_secret=$(grep -oP "REVOLT__API__LIVEKIT__NODES__WORLDWIDE__SECRET='\K[^']*" "$app_dir/secrets.env" 2>/dev/null)
if [[ -z "$livekit_key" || -z "$livekit_secret" ]]; then
isError "Could not read the LiveKit credentials from secrets.env — voice will not work."
return 1
fi
# Ordering rule for everything below: every file bind-mounted into a
# container must be written before the first step that could fail. A missing
# mount source is not a soft failure — docker either creates a directory in
# its place or refuses to start the container, and both outcomes outlive the
# install and break every later run.
result=$(copyResource "$app_name" "Caddyfile" "" | runInstallWrite -a "$logs_dir/$docker_log_file" 2>&1)
checkSuccess "Copying Caddyfile to $app_dir"
local video_enabled=""
[[ "$CFG_STOAT_ENABLE_VIDEO" != "false" ]] && video_enabled="true"
# The port is not allocated yet, so this is a guess whenever there is no
# domain. The files still have to exist now: they are bind-mounted, and
# docker would silently create directories in their place otherwise.
# stoat_install_post_start rewrites them once the real port is known.
# domain; stoat_install_post_start rewrites these once it is known.
local base
base=$(_stoatBaseUrl "$app_name")
_stoatWriteUrlFiles "$app_dir" "$base" "$video_enabled"
checkSuccess "Writing .env.web, stoat.json and Revolt.toml for $base"
_stoatWriteSecrets "$app_dir/secrets.env"
# Read the LiveKit credentials back out — either the ones just generated or
# the ones preserved from a previous install — because livekit.yml has to
# carry the same pair the API is configured with.
#
# Read via runFileOp: secrets.env is chmod 600 and owned by the docker
# install user, while these hooks run as the manager, so a plain grep gets
# EACCES and silently yields nothing.
local livekit_key livekit_secret
livekit_key=$(runFileOp grep -oP "REVOLT__API__LIVEKIT__NODES__WORLDWIDE__KEY='\K[^']*" "$app_dir/secrets.env" 2>/dev/null)
livekit_secret=$(runFileOp grep -oP "REVOLT__API__LIVEKIT__NODES__WORLDWIDE__SECRET='\K[^']*" "$app_dir/secrets.env" 2>/dev/null)
if [[ -z "$livekit_key" || -z "$livekit_secret" ]]; then
# Deliberately not fatal. livekit.yml still gets written below so the
# bind mount is a file; voice is broken until the keys are fixed, but
# the other fifteen services come up and text chat works.
isError "Could not read the LiveKit credentials from secrets.env — voice will not work."
isNotice "Fix the keys in $app_dir/secrets.env and livekit.yml, then restart $app_name."
fi
# use_external_ip lets LiveKit discover the address to advertise for WebRTC.
# The port range matches the literal UDP mapping in the compose file; change
# one and you must change the other.
@ -243,9 +256,6 @@ webhook:
EOF
checkSuccess "Writing livekit.yml"
result=$(copyResource "$app_name" "Caddyfile" "" | runInstallWrite -a "$logs_dir/$docker_log_file" 2>&1)
checkSuccess "Copying Caddyfile to $app_dir"
runFileOp chown -R "$docker_install_user":"$docker_install_user" "$app_dir"
checkSuccess "Setting ownership on the $app_name install directory"
}

View File

@ -36,12 +36,39 @@ authAdapterCall() {
}
# Persist a value to CFG_<APP>_<KEY> in the per-app config file.
#
# Adapters call with the bare name (ADMIN_PASSWORD), but a key whose value is
# generated carries a slot number (CFG_<APP>_ADMIN_PASSWORD_1). Resolve to the
# slot when the bare key isn't there, so an adapter never has to know how a
# credential is numbered — and so adding a slot to a config can't quietly
# disconnect the adapter that writes it.
#
# updateConfigOption only rewrites a key that already exists; handed a name that
# is absent it just prints a notice. That is a silent failure from the caller's
# side: the app's password really did change, but the config and the WebUI carry
# on showing the old one. Hence the explicit return 1 and warning below.
authPersistCfg() {
local app="$1" key="$2" value="$3"
local cfg="${containers_dir}${app}/${app}.config"
[[ ! -f "$cfg" ]] && cfg="${install_containers_dir}/${app}/${app}.config"
[[ ! -f "$cfg" ]] && return 1
updateConfigOption "CFG_${app^^}_${key}" "$value" "$cfg"
local app_upper="${app^^}"
app_upper="${app_upper//-/_}"
local name="CFG_${app_upper}_${key}"
if ! grep -q "^${name}=" "$cfg" 2>/dev/null; then
local slotted
slotted=$(grep -oE "^CFG_${app_upper}_${key}_[0-9]+=" "$cfg" 2>/dev/null | head -1)
if [[ -n "$slotted" ]]; then
name="${slotted%=}"
else
isNotice "$app has no ${name} (or numbered slot) in $(basename "$cfg") — the new value was applied to the app but not recorded in its config."
return 1
fi
fi
updateConfigOption "$name" "$value" "$cfg"
}
# Read a tool-modal arg (pipe-encoded) and unescape pipes.