Skip to content

Commit ed761d5

Browse files
geekypunkclaude
andcommitted
fix(self-host): make a fresh-server install work, and stop it reporting false success
Found by running the documented install end to end on a vanilla Amazon Linux 2023 EC2 instance. Six defects, all of which a first-time self-hoster hits. bootstrap-server.sh was Ubuntu-only: it called apt-get and hardcoded a `ubuntu` deploy user, so Amazon Linux and RHEL had no supported path at all. It now detects apt vs dnf/yum, resolves the deploy user from SUDO_USER or the image convention, and installs Docker the way each platform supports -- get.docker.com refuses to run on Amazon Linux, so that path uses the distro package. That exposed three more gaps on a genuinely vanilla box, where the baseline is docker, git, compose and buildx ALL missing: * No Compose v2 plugin. `docker compose` simply does not exist after `dnf install docker`. * No buildx new enough. The package bundles 0.12.1 and Compose refuses to build with anything below 0.17.0 -- so a presence check passes and the failure lands much later, at `docker compose up --build`, naming neither. Both plugins are now version-compared, not merely detected, and the script verifies versions again before claiming success. * No git, which is what the README's own step 2 asks you to run. install.sh wrote prompted values into .env unquoted while every self-host script does `set -a; source .env`. Answering "Company / organization name" with `Acme Corp` -- the obvious answer -- produced `line 216: Corp: command not found` from install.sh, status.sh and smoke-test.sh, naming neither the variable nor the prompt. Values are now single-quoted with embedded quotes escaped, and the `eval "export NAME=$value"` that re-parsed passwords (so one containing $(...) would execute) is a plain assignment. Finally, the installer could report success while leaving no way in. The admin bootstrap ran with `|| true` and only warned on failure, and install.sh flips SECURITY_ADMIN_BOOTSTRAP_ENABLED off and restarts the backend afterwards -- health returns UP before logins are served. Both runs that reached this point saw install.sh exit 0 and print the login, then smoke-test.sh fail with a bare `curl: (22) 401`; the same credentials worked minutes later. A failed bootstrap is now fatal, install.sh polls until the credentials it just created actually authenticate, and smoke-test.sh retries login rather than aborting on the first attempt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9d1bca2 commit ed761d5

3 files changed

Lines changed: 274 additions & 33 deletions

File tree

scripts/self-host/bootstrap-server.sh

Lines changed: 185 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,36 +6,196 @@
66
# curl -fsSL https://.../bootstrap-server.sh | sudo bash
77
# Or copy and run locally:
88
# sudo ./scripts/self-host/bootstrap-server.sh
9+
#
10+
# Handles Debian/Ubuntu (apt) and Amazon Linux 2023 / RHEL family (dnf). The two
11+
# differ in ways that are easy to miss and fatal to a first install:
12+
#
13+
# * get.docker.com refuses to run on Amazon Linux ("unsupported distribution
14+
# amzn"), so Docker there comes from the distro package instead.
15+
# * The AL2023 `docker` package ships NEITHER the Compose v2 plugin NOR buildx.
16+
# Compose delegates every build to buildx and refuses to start without
17+
# >= 0.17.0, so `docker compose up --build` — the entire distribution model
18+
# for this project — fails on a box that looks correctly set up.
19+
# * The unprivileged account is `ubuntu` on Ubuntu images and `ec2-user` on
20+
# Amazon Linux ones.
21+
#
22+
# Overrides: DEEPSQL_DEPLOY_DIR, DEEPSQL_DEPLOY_USER.
923

1024
set -euo pipefail
1125

1226
DEPLOY_DIR="${DEEPSQL_DEPLOY_DIR:-/opt/deepsql}"
13-
DEPLOY_USER="${DEEPSQL_DEPLOY_USER:-ubuntu}"
27+
CLI_PLUGIN_DIR="/usr/local/lib/docker/cli-plugins"
1428

1529
echo "=== DeepSQL server bootstrap ==="
1630

17-
# ── Install Docker if missing ─────────────────────────────────────────────────
18-
if ! command -v docker >/dev/null 2>&1; then
19-
echo "Installing Docker..."
20-
curl -fsSL https://get.docker.com | sh
21-
usermod -aG docker "$DEPLOY_USER"
22-
echo "Docker installed. Note: log out and back in as $DEPLOY_USER for group to take effect."
31+
if [[ "${EUID}" -ne 0 ]]; then
32+
echo "Error: run this as root or with sudo." >&2
33+
exit 1
34+
fi
35+
36+
# ── Detect the platform ───────────────────────────────────────────────────────
37+
if command -v apt-get >/dev/null 2>&1; then
38+
PKG=apt
39+
elif command -v dnf >/dev/null 2>&1; then
40+
PKG=dnf
41+
elif command -v yum >/dev/null 2>&1; then
42+
PKG=yum
2343
else
44+
echo "Error: no supported package manager found (apt-get, dnf, yum)." >&2
45+
exit 1
46+
fi
47+
48+
# Prefer an explicit override, then the account that invoked sudo, then the
49+
# conventional image default. Guessing wrong here silently creates a deploy
50+
# directory nobody can write to.
51+
if [[ -n "${DEEPSQL_DEPLOY_USER:-}" ]]; then
52+
DEPLOY_USER="$DEEPSQL_DEPLOY_USER"
53+
elif [[ -n "${SUDO_USER:-}" && "$SUDO_USER" != "root" ]]; then
54+
DEPLOY_USER="$SUDO_USER"
55+
else
56+
DEPLOY_USER=""
57+
for candidate in ubuntu ec2-user debian admin rocky fedora cloud-user; do
58+
if id -u "$candidate" >/dev/null 2>&1; then DEPLOY_USER="$candidate"; break; fi
59+
done
60+
fi
61+
if [[ -z "$DEPLOY_USER" ]]; then
62+
echo "Error: could not determine the deploy user. Set DEEPSQL_DEPLOY_USER." >&2
63+
exit 1
64+
fi
65+
66+
. /etc/os-release 2>/dev/null || true
67+
echo "Platform: ${PRETTY_NAME:-unknown} (package manager: $PKG, deploy user: $DEPLOY_USER)"
68+
69+
pkg_install() {
70+
case "$PKG" in
71+
apt) DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@" >/dev/null ;;
72+
dnf) dnf install -y -q "$@" >/dev/null ;;
73+
yum) yum install -y -q "$@" >/dev/null ;;
74+
esac
75+
}
76+
77+
# ── Base tools ────────────────────────────────────────────────────────────────
78+
# install.sh needs curl for the health probes and the bootstrap call, and openssl to
79+
# generate the JWT secret and the vault encryption key. git is here because the very
80+
# first documented step is `git clone` — and a vanilla Amazon Linux 2023 image does not
81+
# ship it, so the README's step 2 fails before DeepSQL is involved at all.
82+
[[ "$PKG" == apt ]] && apt-get update -qq >/dev/null
83+
for tool in curl openssl tar git; do
84+
if ! command -v "$tool" >/dev/null 2>&1; then
85+
echo "Installing $tool..."
86+
pkg_install "$tool"
87+
fi
88+
done
89+
90+
# ── Docker engine ─────────────────────────────────────────────────────────────
91+
if command -v docker >/dev/null 2>&1; then
2492
echo "Docker already installed: $(docker --version)"
93+
else
94+
echo "Installing Docker..."
95+
case "$PKG" in
96+
apt)
97+
# The convenience script pulls docker-ce, which bundles the compose and
98+
# buildx plugins, so those checks below become no-ops on Debian/Ubuntu.
99+
curl -fsSL https://get.docker.com | sh
100+
;;
101+
dnf|yum)
102+
pkg_install docker
103+
;;
104+
esac
25105
fi
26106

27-
# Ensure docker compose v2 plugin is available
28-
if ! docker compose version >/dev/null 2>&1; then
29-
echo "Installing docker compose plugin..."
30-
apt-get install -y docker-compose-plugin
107+
systemctl enable --now docker >/dev/null 2>&1 || true
108+
if ! docker info >/dev/null 2>&1; then
109+
echo "Error: the Docker daemon is not running after install." >&2
110+
exit 1
31111
fi
32112

33-
# ── Install curl if missing ───────────────────────────────────────────────────
34-
if ! command -v curl >/dev/null 2>&1; then
35-
apt-get update && apt-get install -y curl
113+
usermod -aG docker "$DEPLOY_USER"
114+
115+
# ── Compose v2 and buildx ─────────────────────────────────────────────────────
116+
install_cli_plugin() {
117+
local name="$1" url="$2"
118+
# /usr/local/lib takes precedence over the distro's /usr/libexec, so this also
119+
# shadows a too-old plugin shipped by the package manager.
120+
mkdir -p "$CLI_PLUGIN_DIR"
121+
curl -fsSL "$url" -o "$CLI_PLUGIN_DIR/docker-$name"
122+
chmod +x "$CLI_PLUGIN_DIR/docker-$name"
123+
}
124+
125+
# Presence is not the question — the version is. The Amazon Linux 2023 `docker`
126+
# package bundles buildx 0.12.1, and Compose refuses to build with anything below
127+
# 0.17.0. A plain `command -v` style check passes there and then fails at
128+
# `docker compose up --build`, which is the least useful place to find out.
129+
version_ge() {
130+
[[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" == "$2" ]]
131+
}
132+
# `|| true` is load-bearing. This script runs under `set -euo pipefail`, and on a host
133+
# with no compose plugin `docker compose version` exits non-zero — as does the grep when
134+
# there is nothing to match. Without the guard that failing pipeline propagates out of
135+
# the command substitution and kills the script at the very check whose job is to notice
136+
# the plugin is missing, which is the one case it has to survive.
137+
plugin_version() {
138+
docker "$1" version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true
139+
}
140+
141+
COMPOSE_MIN="2.0.0"
142+
BUILDX_MIN="0.17.0"
143+
144+
compose_have="$(plugin_version compose)"
145+
if [[ -n "$compose_have" ]] && version_ge "$compose_have" "$COMPOSE_MIN"; then
146+
echo "Compose already available: $compose_have"
147+
else
148+
echo "Installing Docker Compose v2 plugin (have: ${compose_have:-none}, need >= $COMPOSE_MIN)..."
149+
if [[ "$PKG" == apt ]]; then
150+
pkg_install docker-compose-plugin
151+
else
152+
install_cli_plugin compose \
153+
"https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$(uname -m)"
154+
fi
36155
fi
37156

38-
# ── Create deploy directory ───────────────────────────────────────────────────
157+
buildx_have="$(plugin_version buildx)"
158+
if [[ -n "$buildx_have" ]] && version_ge "$buildx_have" "$BUILDX_MIN"; then
159+
echo "buildx already available: $buildx_have"
160+
else
161+
echo "Installing Docker buildx plugin (have: ${buildx_have:-none}, need >= $BUILDX_MIN)..."
162+
if [[ "$PKG" == apt ]]; then
163+
pkg_install docker-buildx-plugin
164+
else
165+
# The buildx release assets embed the version in the filename, so there is no
166+
# /latest/download shortcut as there is for compose — resolve the real URL.
167+
arch="$(uname -m)"
168+
[[ "$arch" == "x86_64" ]] && arch="amd64"
169+
[[ "$arch" == "aarch64" ]] && arch="arm64"
170+
bx_url="$(curl -fsSL https://api.github.com/repos/docker/buildx/releases/latest \
171+
| grep -o "\"browser_download_url\": *\"[^\"]*linux-${arch}\"" | head -1 | cut -d'"' -f4)"
172+
if [[ -z "$bx_url" ]]; then
173+
echo "Error: could not resolve a buildx release for linux-${arch}." >&2
174+
exit 1
175+
fi
176+
install_cli_plugin buildx "$bx_url"
177+
fi
178+
fi
179+
180+
# ── Verify before declaring success ───────────────────────────────────────────
181+
# Checked explicitly because each of these failing produces an error at
182+
# `docker compose up --build` time that names neither the missing plugin nor
183+
# this script.
184+
fail=0
185+
compose_have="$(plugin_version compose)"
186+
buildx_have="$(plugin_version buildx)"
187+
if [[ -z "$compose_have" ]] || ! version_ge "$compose_have" "$COMPOSE_MIN"; then
188+
echo "FAIL: docker compose is ${compose_have:-unavailable}, need >= $COMPOSE_MIN" >&2; fail=1
189+
fi
190+
if [[ -z "$buildx_have" ]] || ! version_ge "$buildx_have" "$BUILDX_MIN"; then
191+
echo "FAIL: docker buildx is ${buildx_have:-unavailable}, need >= $BUILDX_MIN" >&2; fail=1
192+
fi
193+
for t in openssl curl git; do
194+
command -v "$t" >/dev/null 2>&1 || { echo "FAIL: $t missing" >&2; fail=1; }
195+
done
196+
[[ "$fail" -eq 0 ]] || exit 1
197+
198+
# ── Deploy directory ──────────────────────────────────────────────────────────
39199
mkdir -p "$DEPLOY_DIR"
40200
chown "$DEPLOY_USER:$DEPLOY_USER" "$DEPLOY_DIR"
41201
echo "Deploy directory: $DEPLOY_DIR"
@@ -56,9 +216,15 @@ fi
56216

57217
echo
58218
echo "=== Bootstrap complete ==="
219+
echo " docker : $(docker --version)"
220+
echo " compose : $(docker compose version --short 2>/dev/null)"
221+
echo " buildx : $(docker buildx version 2>/dev/null | awk '{print $2}')"
222+
echo
223+
echo "Log out and back in as $DEPLOY_USER before continuing, so the docker group applies."
224+
echo
59225
echo "Next steps:"
60-
echo " 1. Put a checkout of the DeepSQL source in $DEPLOY_DIR (git clone), if it is not there already."
61-
echo " 2. cd $DEPLOY_DIR && cp .env.example .env (skip if .env was created above)"
62-
echo " 3. Edit .env — at minimum DEEPSQL_CHAT_PROVIDER, DEEPSQL_CHAT_API_KEY, DEEPSQL_CHAT_ENDPOINT, DEEPSQL_CHAT_MODEL."
226+
echo " 1. Put a checkout of the DeepSQL source in $DEPLOY_DIR (git clone), if not already there."
227+
echo " 2. cd $DEPLOY_DIR && cp .env.example .env (skip if .env was created above)"
228+
echo " 3. Edit .env — at minimum DEEPSQL_CHAT_PROVIDER, DEEPSQL_CHAT_API_KEY,"
229+
echo " DEEPSQL_CHAT_ENDPOINT, DEEPSQL_CHAT_MODEL."
63230
echo " 4. Run: ./scripts/self-host/install.sh"
64-
echo " (or: docker compose up -d --build — the first build takes several minutes)"

scripts/self-host/install.sh

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,49 @@ require_env_value() {
2828
fi
2929
}
3030

31+
# Write NAME='value' into $ENV_FILE, single-quoted with embedded quotes escaped.
32+
#
33+
# Every self-host script does `set -a; source .env`, so an unquoted value containing a
34+
# space is executed as a command: answering the company-name prompt with "Acme Corp"
35+
# produced `DEEPSQL_COMPANY_NAME=Acme Corp`, and then `line 216: Corp: command not found`
36+
# from install.sh, status.sh and smoke-test.sh alike — naming neither the variable nor
37+
# the prompt that set it. Docker Compose strips the surrounding quotes when it reads the
38+
# same file, so this is safe for both readers.
39+
write_env_value() {
40+
local name="$1" value="$2" quoted
41+
# Close the quote, emit an escaped quote, reopen: ' -> '\''. Built from variables
42+
# because writing the replacement inline is easy to get subtly wrong -- the first
43+
# attempt produced O\'\\'\'Brien, which made `source .env` die on an unterminated
44+
# string, exactly the class of breakage this function exists to prevent.
45+
local sq="'" esc="'\\''"
46+
quoted="${sq}${value//${sq}/${esc}}${sq}"
47+
if grep -q "^${name}=" "$ENV_FILE" 2>/dev/null; then
48+
# Literal replacement rather than a sed expression: the value may contain |, & or \,
49+
# each of which sed would otherwise interpret.
50+
NAME="$name" QUOTED="$quoted" python3 - "$ENV_FILE" <<'PY'
51+
import os, re, sys
52+
path = sys.argv[1]
53+
name, quoted = os.environ["NAME"], os.environ["QUOTED"]
54+
text = open(path).read()
55+
text = re.sub(rf"(?m)^{re.escape(name)}=.*$", lambda _: f"{name}={quoted}", text)
56+
open(path, "w").write(text)
57+
PY
58+
else
59+
printf '%s=%s\n' "$name" "$quoted" >> "$ENV_FILE"
60+
fi
61+
# No eval: `eval export NAME=$value` re-parses the value, so a password containing
62+
# $(...) or a backtick would execute rather than be stored.
63+
export "${name}=${value}"
64+
}
65+
3166
generate_secret() {
3267
local name="$1"
3368
local cmd="$2"
3469
local value="${!name:-}"
3570
if is_placeholder "$value"; then
3671
local generated
3772
generated="$(eval "$cmd")"
38-
sed_inplace "s|^${name}=.*|${name}=${generated}|" "$ENV_FILE"
39-
eval "export ${name}=${generated}"
73+
write_env_value "$name" "$generated"
4074
echo "Auto-generated $name."
4175
fi
4276
}
@@ -52,8 +86,7 @@ prompt_env_value() {
5286
echo "Error: '$name' is required." >&2
5387
exit 1
5488
fi
55-
sed_inplace "s|^${name}=.*|${name}=${value}|" "$ENV_FILE"
56-
eval "export ${name}=${value}"
89+
write_env_value "$name" "$value"
5790
fi
5891
}
5992

@@ -69,8 +102,7 @@ prompt_secret_env_value() {
69102
echo "Error: '$name' is required." >&2
70103
exit 1
71104
fi
72-
sed_inplace "s|^${name}=.*|${name}=${value}|" "$ENV_FILE"
73-
eval "export ${name}=${value}"
105+
write_env_value "$name" "$value"
74106
fi
75107
}
76108

@@ -86,12 +118,9 @@ prompt_optional_env_value() {
86118
printf '%s: ' "$label"
87119
read -r value
88120
if [[ -n "$value" ]]; then
89-
if grep -q "^${name}=" "$ENV_FILE" 2>/dev/null; then
90-
sed_inplace "s|^${name}=.*|${name}=${value}|" "$ENV_FILE"
91-
else
92-
printf '%s=%s\n' "$name" "$value" >> "$ENV_FILE"
93-
fi
94-
eval "export ${name}=\"\${value}\""
121+
# This is the prompt that first exposed the quoting bug: "Company / organization
122+
# name" invites an answer with a space, and almost every real one has one.
123+
write_env_value "$name" "$value"
95124
fi
96125
fi
97126
}
@@ -235,11 +264,39 @@ bootstrap_admin() {
235264
if [[ "$response" == *"Admin reset successfully"* || "$response" == *"Admin created successfully"* ]]; then
236265
echo "Admin bootstrap complete. Login username: admin"
237266
else
238-
echo "Warning: admin bootstrap did not return a success message." >&2
267+
# Previously a warning that the install continued past, so install.sh exited 0 while
268+
# leaving no account to log in with. An installer that cannot create the only user
269+
# has not succeeded, and saying so here beats an opaque 401 from the next command.
270+
echo "Error: admin bootstrap did not return a success message." >&2
239271
echo "$response" >&2
272+
return 1
240273
fi
241274
}
242275

276+
# Poll until the credentials just created actually authenticate.
277+
#
278+
# Health being UP is not the same as being able to log in: install.sh flips
279+
# SECURITY_ADMIN_BOOTSTRAP_ENABLED back to false and restarts the backend afterwards, and
280+
# a login issued in the seconds after that restart returns 401. That is what made
281+
# smoke-test.sh -- the very next command install.sh recommends -- fail on a good install.
282+
wait_for_login() {
283+
local url="http://localhost:${DEEPSQL_BACKEND_PORT:-8080}/api/auth/login"
284+
local payload deadline=$((SECONDS + 120))
285+
payload="$(printf '{"email":"%s","password":"%s"}' \
286+
"${DEEPSQL_INITIAL_ADMIN_EMAIL}" "${DEEPSQL_INITIAL_ADMIN_PASSWORD}")"
287+
while (( SECONDS < deadline )); do
288+
if curl -fsS -o /dev/null -H 'Content-Type: application/json' \
289+
-X POST "$url" --data "$payload" 2>/dev/null; then
290+
echo "Login verified for ${DEEPSQL_INITIAL_ADMIN_EMAIL}."
291+
return 0
292+
fi
293+
sleep 5
294+
done
295+
echo "Error: the admin account was created but could not log in within 120s." >&2
296+
echo "Check 'docker compose logs backend' before running smoke-test.sh." >&2
297+
return 1
298+
}
299+
243300
build_application_images() {
244301
echo "Building the DeepSQL backend and frontend from source..."
245302
echo "The first build compiles the Java backend and bundles the frontend; expect"
@@ -373,6 +430,11 @@ export SECURITY_ADMIN_BOOTSTRAP_ENABLED=false
373430
compose up -d backend >/dev/null
374431
wait_for_http "http://localhost:${DEEPSQL_BACKEND_PORT}/api/actuator/health" "Backend"
375432

433+
# The restart above is why this exists: health returns UP before logins are served, so
434+
# without it the installer declares success on a stack that rejects the credentials it
435+
# just printed.
436+
wait_for_login
437+
376438
echo
377439

378440
echo "DeepSQL self-hosted stack is ready."

scripts/self-host/smoke-test.sh

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,20 @@ fi
7979
base="http://localhost:${DEEPSQL_BACKEND_PORT}/api"
8080
cookie_jar="$(mktemp)"
8181
trap 'rm -f "$cookie_jar"' EXIT
82-
login_json="$(curl -fsS -c "$cookie_jar" -H 'Content-Type: application/json' -X POST "$base/auth/login" -d "{\"email\":\"${DEEPSQL_SMOKE_EMAIL}\",\"password\":\"${DEEPSQL_SMOKE_PASSWORD}\"}")"
82+
# Retried rather than attempted once. The backend answers /actuator/health UP before it
83+
# serves logins, so this script -- the command install.sh recommends running next -- used
84+
# to abort on a perfectly good install with a bare `curl: (22) 401`. Because curl runs
85+
# under `set -e` with -f, that exit happened before the error message below could print,
86+
# so the failure named neither the endpoint nor the reason.
87+
login_json=""
88+
login_deadline=$((SECONDS + 120))
89+
while (( SECONDS < login_deadline )); do
90+
if login_json="$(curl -fsS -c "$cookie_jar" -H 'Content-Type: application/json' -X POST "$base/auth/login" -d "{\"email\":\"${DEEPSQL_SMOKE_EMAIL}\",\"password\":\"${DEEPSQL_SMOKE_PASSWORD}\"}" 2>/dev/null)"; then
91+
break
92+
fi
93+
echo "Waiting for the backend to accept logins..."
94+
sleep 5
95+
done
8396

8497
if [[ "$login_json" != *"\"email\""* ]]; then
8598
echo "Error: login failed during smoke test." >&2

0 commit comments

Comments
 (0)