Skip to content

Commit be6c212

Browse files
haksungjangclaude
andauthored
Make the deploy crash-recovery restart respect COMPOSE_FILE (#441) (#452)
deploy/hetzner/remote-deploy.sh's crash-recovery trap hard-coded docker-compose -f docker-compose.yml, dropping whatever overlay the deployment actually runs with. The demo host runs docker-compose.yml + docker-compose.demo.yml, and the overlay is what enables the public read-only lock and the worker's CPU cap - a crash-recovery restart could silently bring the stack back up without either. scripts/lib/compose_args.sh extracts the COMPOSE_FILE-resolution logic scripts/upgrade.sh already had into a shared, sourceable function, so the trap and the demo-reseed exec (which had its own separate, cruder version) both use the real overlay. Resolved once before the crash-recovery trap is installed (from .env, which is untouched by the tag checkout) and again after checkout, so the trap has a correct value even if the fetch/checkout step itself is what failed, or the deploy is a rollback to a tag predating this file. Writing a test that actually executes the shared function (rather than re-describing the bash in Python) caught a real bug in the first draft: its last statement was a bare `[ cond ] && action`, which is a safe no-op under set -e as a top-level script statement but becomes the function's own failing return status once wrapped in a function - aborting the entire deploy under set -e any time COMPOSE_FILE resolved to at least one file, which is every real deployment including the demo server's own config. Claude-Session: https://claude.ai/code/session_01Q44qCG1TEkZxDo9cb9voYT Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 42f4738 commit be6c212

4 files changed

Lines changed: 205 additions & 23 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright 2026 TRUSCA contributors
3+
"""
4+
scripts/lib/compose_args.sh's compose_args_from_env() (#441).
5+
6+
Neither this helper nor the inline logic it replaced in scripts/upgrade.sh
7+
had ever been executed by anything CI runs - both were exercised only by an
8+
operator actually deploying. deploy/hetzner/remote-deploy.sh's own
9+
crash-recovery restart hard-coded `-f docker-compose.yml` with no COMPOSE_FILE
10+
handling at all before this change (#441), which this repo's shellcheck gate
11+
cannot catch (it checks syntax, not which env var wins). This drives the real
12+
function through subprocess with a real temp directory and .env file rather
13+
than re-describing the bash in Python, which would test the description
14+
instead of the shell.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import subprocess
20+
from pathlib import Path
21+
22+
REPO_ROOT = Path(__file__).resolve().parents[4]
23+
COMPOSE_ARGS_SH = REPO_ROOT / "scripts" / "lib" / "compose_args.sh"
24+
25+
26+
def _compose_args(tmp_path: Path, *, env: dict[str, str] | None = None) -> list[str]:
27+
"""Source the helper and call compose_args_from_env() in tmp_path, cwd'd
28+
there the way every real caller (upgrade.sh, remote-deploy.sh) already
29+
does, and return the resulting COMPOSE_ARGS elements."""
30+
script = (
31+
f'cd "{tmp_path}" && source "{COMPOSE_ARGS_SH}" && compose_args_from_env '
32+
'&& printf "%s\\n" "${COMPOSE_ARGS[@]}"'
33+
)
34+
result = subprocess.run(
35+
["bash", "-c", script],
36+
capture_output=True,
37+
text=True,
38+
env=env,
39+
timeout=10,
40+
check=True,
41+
)
42+
return result.stdout.splitlines()
43+
44+
45+
def test_no_env_file_and_no_compose_file_var_defaults_to_the_base_file(
46+
tmp_path: Path,
47+
) -> None:
48+
assert _compose_args(tmp_path, env={"PATH": "/usr/bin:/bin"}) == ["-f", "docker-compose.yml"]
49+
50+
51+
def test_compose_file_already_in_the_environment_wins_over_env_file(
52+
tmp_path: Path,
53+
) -> None:
54+
(tmp_path / ".env").write_text("COMPOSE_FILE=ignored.yml\n")
55+
args = _compose_args(
56+
tmp_path,
57+
env={"PATH": "/usr/bin:/bin", "COMPOSE_FILE": "docker-compose.yml:docker-compose.demo.yml"},
58+
)
59+
assert args == ["-f", "docker-compose.yml", "-f", "docker-compose.demo.yml"]
60+
61+
62+
def test_compose_file_declared_only_in_dot_env_is_picked_up(tmp_path: Path) -> None:
63+
"""The actual bug this PR fixes: a deployment declares its overlay in
64+
.env (the documented way), not as a shell-exported variable."""
65+
(tmp_path / ".env").write_text("COMPOSE_FILE=docker-compose.yml:docker-compose.demo.yml\n")
66+
args = _compose_args(tmp_path, env={"PATH": "/usr/bin:/bin"})
67+
assert args == ["-f", "docker-compose.yml", "-f", "docker-compose.demo.yml"]
68+
69+
70+
def test_dot_env_with_no_compose_file_line_defaults_to_the_base_file(
71+
tmp_path: Path,
72+
) -> None:
73+
(tmp_path / ".env").write_text("SOME_OTHER_KEY=value\n")
74+
assert _compose_args(tmp_path, env={"PATH": "/usr/bin:/bin"}) == ["-f", "docker-compose.yml"]
75+
76+
77+
def test_a_trailing_colon_does_not_produce_an_empty_dash_f(tmp_path: Path) -> None:
78+
(tmp_path / ".env").write_text("COMPOSE_FILE=docker-compose.yml:\n")
79+
args = _compose_args(tmp_path, env={"PATH": "/usr/bin:/bin"})
80+
assert args == ["-f", "docker-compose.yml"]
81+
82+
83+
def test_a_compose_file_that_resolves_to_nothing_falls_back_to_the_base_file(
84+
tmp_path: Path,
85+
) -> None:
86+
"""COMPOSE_FILE set but entirely colons (":::") is the pathological
87+
input that must not leave COMPOSE_ARGS empty - an empty array means every
88+
caller's `docker-compose "${COMPOSE_ARGS[@]}" up -d` runs with no -f at
89+
all, silently falling through to compose's own default file discovery."""
90+
args = _compose_args(tmp_path, env={"PATH": "/usr/bin:/bin", "COMPOSE_FILE": ":::"})
91+
assert args == ["-f", "docker-compose.yml"]

deploy/hetzner/remote-deploy.sh

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,29 @@ IMG="${TAG#v}" # image tags are published without the leading 'v' (see release
3737

3838
cd "$REMOTE_PATH"
3939

40+
# Compose file selection - follow the deploy's own overlay (#441). Shared
41+
# with scripts/upgrade.sh (scripts/lib/compose_args.sh's own docstring has
42+
# the full "why": a docker-compose call that drops the demo overlay silently
43+
# turns off the public read-only lock and the worker's CPU cap).
44+
#
45+
# Resolved TWICE, deliberately. .env (gitignored, untouched by the checkout
46+
# below) already holds the real COMPOSE_FILE before anything else in this
47+
# script runs, so waiting until after the checkout to resolve it at all would
48+
# leave restore_stack_on_failure's trap using the wrong overlay for the
49+
# entire fetch/checkout window - not just a first-deploy edge case, but every
50+
# ROLLBACK to a tag predating this file, where checkout would otherwise be
51+
# the only thing standing between the trap and a correct COMPOSE_ARGS
52+
# (security review on #441). Falling back to a bare default here only when
53+
# the shared helper genuinely does not exist yet on disk keeps that window
54+
# closed without hand-duplicating compose_args_from_env's own logic.
55+
if [ -f "$REMOTE_PATH/scripts/lib/compose_args.sh" ]; then
56+
# shellcheck source=../../scripts/lib/compose_args.sh
57+
source "$REMOTE_PATH/scripts/lib/compose_args.sh"
58+
compose_args_from_env
59+
else
60+
COMPOSE_ARGS=(-f docker-compose.yml)
61+
fi
62+
4063
# Bring the stack back if any step below fails. upgrade.sh recreates services
4164
# one at a time, so a failure partway through leaves the earlier ones stopped —
4265
# a failed deploy became an outage that the next deploy could not clear on its
@@ -46,7 +69,7 @@ restore_stack_on_failure() {
4669
rc=$?
4770
[ "$rc" -eq 0 ] && exit 0
4871
echo "==> deploy failed (exit $rc) — attempting to restart the stack" >&2
49-
if docker-compose -f docker-compose.yml up -d >&2; then
72+
if docker-compose "${COMPOSE_ARGS[@]}" up -d >&2; then
5073
echo "==> stack is back up; the deploy itself still FAILED (exit $rc)" >&2
5174
else
5275
echo "==> could not restart the stack — it needs manual attention" >&2
@@ -67,6 +90,20 @@ git checkout -f "tags/$TAG"
6790
# logs (the fetch uses --force, so a moved upstream tag is accepted silently).
6891
echo " $TAG resolves to commit $(git rev-parse --short HEAD)"
6992

93+
# Re-resolve now that TAG's own copy of scripts/lib/compose_args.sh (or its
94+
# absence, on a rollback older than this file) is what is actually on disk.
95+
# If TAG predates this file, this deliberately lets `source` fail the script
96+
# via `set -e` rather than silently keeping a possibly-stale COMPOSE_ARGS:
97+
# restore_stack_on_failure still has the correct value from the resolution
98+
# above when that happens, since COMPOSE_ARGS is only overwritten once this
99+
# source succeeds.
100+
if [ -f "$REMOTE_PATH/scripts/lib/compose_args.sh" ]; then
101+
# shellcheck source=../../scripts/lib/compose_args.sh
102+
source "$REMOTE_PATH/scripts/lib/compose_args.sh"
103+
compose_args_from_env
104+
fi
105+
echo "==> compose files: ${COMPOSE_ARGS[*]}"
106+
70107
echo "==> pinning IMAGE_TAG=$IMG in .env"
71108
if [ ! -f .env ]; then
72109
echo "remote-deploy: .env not found in $REMOTE_PATH — run scripts/install.sh first" >&2
@@ -117,10 +154,6 @@ NO_PROMPT=1 UPGRADE_SKIP_DRAIN=1 bash scripts/upgrade.sh
117154
# transaction. Accounts outside it are untouched.
118155
if [ "${RESEED:-0}" = "1" ]; then
119156
echo "==> reseeding the demo dataset (RESEED=1)"
120-
compose_args="-f docker-compose.yml"
121-
if [ -f .env ] && grep -qE '^COMPOSE_FILE=' .env; then
122-
compose_args="" # docker-compose reads COMPOSE_FILE from .env itself
123-
fi
124157
# < /dev/null: this whole script runs as `bash -s < remote-deploy.sh` over SSH,
125158
# so its own stdin is the SSH channel still streaming the rest of THIS file.
126159
# `docker-compose exec` keeps stdin open by default (-T only disables the
@@ -130,8 +163,7 @@ if [ "${RESEED:-0}" = "1" ]; then
130163
# error and no visible sign the tail never ran. Confirmed 2026-09-08: a
131164
# RESEED=1 deploy reported success with zero reset_demo output in the
132165
# backend logs and stale (pre-reseed) row IDs still live.
133-
# shellcheck disable=SC2086 # compose_args is our own literal, word-splitting intended.
134-
docker-compose $compose_args exec -T -e APP_ENV=demo backend python -m scripts.reset_demo < /dev/null
166+
docker-compose "${COMPOSE_ARGS[@]}" exec -T -e APP_ENV=demo backend python -m scripts.reset_demo < /dev/null
135167
echo "==> reseed complete"
136168
else
137169
echo "==> skipping demo reseed (set RESEED=1 to rebuild the demo dataset)"

scripts/lib/compose_args.sh

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: Apache-2.0
3+
# Copyright 2026 TRUSCA contributors
4+
# TrustedOSS Portal - shared docker-compose file-selection helper.
5+
#
6+
# Exposes ``compose_args_from_env`` which sets the caller's ``COMPOSE_ARGS``
7+
# array to the ``-f`` flags a deployment's overlay actually needs.
8+
#
9+
# Usage:
10+
# source "$ROOT_DIR/scripts/lib/compose_args.sh"
11+
# compose_args_from_env
12+
# docker-compose "${COMPOSE_ARGS[@]}" up -d
13+
#
14+
# Why this exists:
15+
# scripts/upgrade.sh used to hard-code `-f docker-compose.yml` on every
16+
# docker-compose call, which silently DROPS whatever overlay a deployment
17+
# actually runs with. An explicit `-f` also overrides the standard
18+
# COMPOSE_FILE variable, so declaring the overlay the documented way (in
19+
# .env) had no effect there either. That was fixed in upgrade.sh directly;
20+
# this extracts the same logic so deploy/hetzner/remote-deploy.sh's own
21+
# docker-compose calls (the crash-recovery restart, and the demo reseed)
22+
# can share it instead of re-deriving it a third time by hand (#441 - the
23+
# reseed branch had its own ad-hoc, plain-string version, and the
24+
# crash-recovery restart had no COMPOSE_FILE handling at all).
25+
#
26+
# Concretely: the demo host runs `docker-compose.yml` +
27+
# `docker-compose.demo.yml`, and the overlay is what passes
28+
# DEMO_READ_ONLY into the backend and caps the worker at the box's 2 CPUs.
29+
# A docker-compose call that drops the overlay rebuilds the stack WITHOUT
30+
# the public read-only lock and with the 4.0 CPU default - a deploy or a
31+
# crash-recovery restart quietly turning off a safety boundary.
32+
#
33+
# COMPOSE_FILE is read from .env when the environment does not already carry
34+
# it, because that is where an operator declares it and where docker-compose
35+
# itself looks. Unset (the single-file default) keeps the previous
36+
# behaviour. Must be called from the directory holding docker-compose.yml
37+
# and .env (every caller already `cd`s there first).
38+
39+
compose_args_from_env() {
40+
if [ -z "${COMPOSE_FILE:-}" ] && [ -f .env ]; then
41+
COMPOSE_FILE="$(grep -E '^COMPOSE_FILE=' .env | tail -1 | cut -d= -f2- || true)"
42+
if [ -n "$COMPOSE_FILE" ]; then
43+
export COMPOSE_FILE
44+
fi
45+
fi
46+
COMPOSE_ARGS=(-f docker-compose.yml)
47+
if [ -n "${COMPOSE_FILE:-}" ]; then
48+
COMPOSE_ARGS=()
49+
local _f _compose_files
50+
IFS=':' read -ra _compose_files <<< "$COMPOSE_FILE"
51+
for _f in "${_compose_files[@]}"; do
52+
[ -n "$_f" ] && COMPOSE_ARGS+=(-f "$_f")
53+
done
54+
if [ ${#COMPOSE_ARGS[@]} -eq 0 ]; then
55+
COMPOSE_ARGS=(-f docker-compose.yml)
56+
fi
57+
fi
58+
# Explicit, unconditional success: the two guards above are `if` blocks
59+
# whose CONDITION can be false with no `else`, and under `set -e` (every
60+
# caller sets it) a function's return status is its LAST executed
61+
# command's - a false `if` condition with no `else` still exits 0 for the
62+
# `if` construct itself, but this makes it impossible for a future edit to
63+
# accidentally leave a bare `[ cond ] && action` as the final statement
64+
# here, which WOULD make this function's return status (and therefore
65+
# `compose_args_from_env && next_thing` at every call site) fail whenever
66+
# cond happens to be false. That exact bug shipped once in this file's
67+
# first version and was only caught by the test suite, not by shellcheck.
68+
return 0
69+
}

scripts/upgrade.sh

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -53,22 +53,12 @@ command -v docker-compose >/dev/null 2>&1 || fail "docker-compose (V1) is requir
5353
# the base file alone rebuilt the stack WITHOUT the public read-only lock and
5454
# with the 4.0 CPU default — a deploy quietly turning off a safety boundary.
5555
#
56-
# COMPOSE_FILE is read from .env when the environment does not already carry
57-
# it, because that is where an operator declares it and where docker-compose
58-
# itself looks. Unset (the single-file default) keeps the previous behaviour.
59-
if [ -z "${COMPOSE_FILE:-}" ] && [ -f .env ]; then
60-
COMPOSE_FILE="$(grep -E '^COMPOSE_FILE=' .env | tail -1 | cut -d= -f2- || true)"
61-
[ -n "$COMPOSE_FILE" ] && export COMPOSE_FILE
62-
fi
63-
COMPOSE_ARGS=(-f docker-compose.yml)
64-
if [ -n "${COMPOSE_FILE:-}" ]; then
65-
COMPOSE_ARGS=()
66-
IFS=':' read -ra _compose_files <<< "$COMPOSE_FILE"
67-
for _f in "${_compose_files[@]}"; do
68-
[ -n "$_f" ] && COMPOSE_ARGS+=(-f "$_f")
69-
done
70-
[ ${#COMPOSE_ARGS[@]} -eq 0 ] && COMPOSE_ARGS=(-f docker-compose.yml)
71-
fi
56+
# The selection logic itself lives in scripts/lib/compose_args.sh (#441) so
57+
# deploy/hetzner/remote-deploy.sh's own docker-compose calls share it instead
58+
# of re-deriving it by hand.
59+
# shellcheck source=scripts/lib/compose_args.sh
60+
source "$ROOT_DIR/scripts/lib/compose_args.sh"
61+
compose_args_from_env
7262
note "compose files: ${COMPOSE_ARGS[*]}"
7363

7464
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)