Skip to content

Commit ae4d325

Browse files
committed
feat(install): harden install.sh and support multiple units [no ci]
1 parent e027a86 commit ae4d325

1 file changed

Lines changed: 97 additions & 64 deletions

File tree

install.sh

Lines changed: 97 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,32 @@
11
#!/bin/bash
22

3-
set -e
3+
set -euo pipefail
44

55
INSTALL_DIR="/opt/relaysms/relaysms-publisher"
66
SERVICE_NAME="relaysms-publisher"
77
REPO_URL="https://github.com/smswithoutborders/RelaySMS-Publisher.git"
88
BRANCH="${BRANCH:-main}"
99
CARGO_BIN="$HOME/.cargo/bin"
10+
DEPS_MARKER="/var/lib/relaysms-publisher-deps-installed"
1011

11-
# Determine service user: prefer the invoking user (SUDO_USER) so no extra
12-
# system account is needed. Fall back to a dedicated 'relaysms' system user
13-
# when running directly as root.
12+
# Single source of truth for the unit set. Add/rename a service here and it
13+
# propagates to install, enable/start, and restart — no need to touch it
14+
# in more than one place.
15+
TARGET_UNIT="relaysms-publisher.target"
16+
SERVICE_UNITS=(
17+
relaysms-publisher-rest.service
18+
relaysms-publisher-grpc.service
19+
relaysms-publisher-worker.service
20+
relaysms-publisher-beat.service
21+
)
22+
ALL_UNITS=("$TARGET_UNIT" "${SERVICE_UNITS[@]}")
23+
24+
# Prefer the invoking user (SUDO_USER) as the service owner so no extra
25+
# system account is needed; fall back to a dedicated 'relaysms' user when
26+
# run directly as root. Note this only affects ownership of the app's
27+
# runtime files/services — the build itself (Rust, venv, `make build-setup`)
28+
# always runs as root/root's $HOME, since the script isn't re-exec'd with
29+
# `sudo -E`.
1430
if [ -n "${SUDO_USER:-}" ] && id "$SUDO_USER" &>/dev/null; then
1531
SERVICE_USER="$SUDO_USER"
1632
else
@@ -22,26 +38,59 @@ error() {
2238
echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2
2339
exit 1
2440
}
41+
# Safety net for failures not already wrapped in `|| error "..."` (e.g. a
42+
# bare apt-get/git call). Explicit exit calls from error() don't re-trigger
43+
# this trap, so messages are never duplicated.
44+
on_err() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: aborted at line $1 (last command: $2)" >&2; }
45+
trap 'on_err "$LINENO" "$BASH_COMMAND"' ERR
2546

2647
check_root() { [ "$EUID" -eq 0 ] || error "Run with sudo"; }
2748

28-
# Read a single key from an env file without evaluating the file.
49+
# Reads a single KEY=value from an env file without sourcing/evaluating it
50+
# (avoids executing arbitrary shell in a file we don't fully control).
51+
# Tolerates an `export ` prefix and quoted values. The `|| true` on the
52+
# grep keeps a no-match result from tripping `pipefail`.
2953
read_env_var() {
30-
local key="$1" file="$2"
31-
grep -E "^${key}[[:space:]]*=" "$file" 2>/dev/null | tail -1 |
32-
sed 's/^[^=]*=//;s/^[[:space:]]*//;s/[[:space:]]*$//'
54+
local key="$1" file="$2" val
55+
val=$( (grep -E "^(export[[:space:]]+)?${key}[[:space:]]*=" "$file" 2>/dev/null || true) |
56+
tail -1 | sed -E 's/^(export[[:space:]]+)?[^=]*=//; s/^[[:space:]]*//; s/[[:space:]]*$//')
57+
val="${val%\"}"
58+
val="${val#\"}"
59+
val="${val%\'}"
60+
val="${val#\'}"
61+
echo "$val"
62+
}
63+
64+
# Appends a directory to the global RW_DIRS array (resolving it relative to
65+
# INSTALL_DIR if not absolute), de-duplicating as it goes. Defined once at
66+
# top level — resolve_app_directories is called twice per run (directory
67+
# creation, then systemd ReadWritePaths), and both must see the same set.
68+
_resolve_dir() {
69+
local dir="$1"
70+
[ -z "$dir" ] && return
71+
[[ "$dir" = /* ]] || dir="$INSTALL_DIR/$dir"
72+
local existing
73+
for existing in "${RW_DIRS[@]}"; do
74+
[ "$existing" = "$dir" ] && return
75+
done
76+
RW_DIRS+=("$dir")
3377
}
3478

3579
install_system_deps() {
80+
if [ -f "$DEPS_MARKER" ] && [ "${FORCE_DEPS:-0}" != "1" ]; then
81+
log "System dependencies already installed, skipping (FORCE_DEPS=1 to force)"
82+
return
83+
fi
3684
log "Installing system dependencies"
3785
apt-get update -qq
3886
apt-get install -y --no-install-recommends \
3987
python3 python3-pip python3-venv python3-dev \
4088
build-essential pkg-config \
4189
libsqlcipher-dev \
4290
libmagic1 \
43-
openssl git make curl ||
44-
error "Failed to install system dependencies"
91+
openssl git make curl
92+
mkdir -p "$(dirname "$DEPS_MARKER")"
93+
touch "$DEPS_MARKER"
4594
}
4695

4796
install_rust() {
@@ -51,9 +100,7 @@ install_rust() {
51100
return
52101
fi
53102
log "Installing Rust"
54-
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs |
55-
sh -s -- -y --no-modify-path ||
56-
error "Rust installation failed"
103+
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path
57104
export PATH="$CARGO_BIN:$PATH"
58105
cargo --version || error "cargo not found after install"
59106
}
@@ -67,28 +114,28 @@ setup_service_user() {
67114
log "Service user: relaysms (already exists)"
68115
else
69116
log "Creating service user: relaysms"
70-
useradd --system --no-create-home --shell /usr/sbin/nologin relaysms ||
71-
error "Failed to create service user"
117+
useradd --system --no-create-home --shell /usr/sbin/nologin relaysms
72118
fi
73119
}
74120

75121
clone_repository() {
76122
log "Cloning repository"
123+
# `-c url....insteadOf` is scoped to this git invocation only, so we
124+
# don't permanently rewrite the invoking user's global git config just
125+
# to fetch this one repo over HTTPS instead of SSH.
77126
if [ -d "$INSTALL_DIR/.git" ]; then
78127
log "Repository exists, updating"
79128
cd "$INSTALL_DIR"
80-
git fetch origin
129+
git -c url."https://github.com/".insteadOf="git@github.com:" fetch origin
81130
git checkout "$BRANCH"
82-
git pull origin "$BRANCH" || error "Failed to update repository"
131+
git -c url."https://github.com/".insteadOf="git@github.com:" pull origin "$BRANCH"
83132
log "Updating submodules"
84-
git submodule update --init --recursive || error "Failed to update submodules"
133+
git submodule update --init --recursive
85134
else
86135
mkdir -p "$(dirname "$INSTALL_DIR")"
87-
# Rewrite SSH submodule URLs to HTTPS so build works without SSH credentials
88-
git config --global url."https://github.com/".insteadOf "git@github.com:"
89136
log "Cloning with submodules"
90-
git clone --recurse-submodules -b "$BRANCH" "$REPO_URL" "$INSTALL_DIR" ||
91-
error "Failed to clone repository"
137+
git -c url."https://github.com/".insteadOf="git@github.com:" \
138+
clone --recurse-submodules -b "$BRANCH" "$REPO_URL" "$INSTALL_DIR"
92139
fi
93140
}
94141

@@ -99,16 +146,16 @@ setup_virtualenv() {
99146
log "Removing existing venv for a clean rebuild"
100147
rm -rf venv
101148
fi
102-
python3 -m venv venv || error "Failed to create venv"
103-
venv/bin/pip install --quiet --upgrade pip || error "Failed to upgrade pip"
104-
venv/bin/pip install --quiet -r requirements.txt || error "Failed to install Python dependencies"
149+
python3 -m venv venv
150+
venv/bin/pip install --quiet --upgrade pip
151+
venv/bin/pip install --quiet -r requirements.txt
105152
}
106153

107154
build_application() {
108155
log "Building application"
109156
cd "$INSTALL_DIR"
110157
export PATH="$CARGO_BIN:$INSTALL_DIR/venv/bin:$PATH"
111-
make build-setup || error "Failed to build application"
158+
make build-setup
112159
}
113160

114161
setup_env() {
@@ -121,10 +168,10 @@ setup_env() {
121168
[ -f "template.env" ] || error "template.env not found"
122169
cp template.env .env
123170

124-
local db_key
125-
db_key=$(openssl rand -hex 32) || error "Failed to generate database encryption key"
126-
field_key=$(openssl rand -hex 32) || error "Failed to generate database field encryption key"
127-
data_key=$(openssl rand -hex 32) || error "Failed to generate data encryption key"
171+
local db_key field_key data_key
172+
db_key=$(openssl rand -hex 32)
173+
field_key=$(openssl rand -hex 32)
174+
data_key=$(openssl rand -hex 32)
128175
sed -i "s|^DATABASE_ENCRYPTION_ENABLED=.*|DATABASE_ENCRYPTION_ENABLED=true|" .env
129176
sed -i "s|^DATABASE_FIELD_ENCRYPTION_ENABLED=.*|DATABASE_FIELD_ENCRYPTION_ENABLED=true|" .env
130177
sed -i "s|^DATABASE_ENCRYPTION_KEY=.*|DATABASE_ENCRYPTION_KEY=$db_key|" .env
@@ -137,9 +184,9 @@ setup_env() {
137184
log "Edit $INSTALL_DIR/.env before starting services"
138185
}
139186

140-
# Resolves app data directories from .env (absolute or relative to
141-
# INSTALL_DIR) into the global RW_DIRS array. Shared by create_app_directories
142-
# and install_services so systemd ReadWritePaths always matches reality.
187+
# Shared by create_app_directories and install_services so the directories
188+
# that get created and the paths granted to systemd's ReadWritePaths can
189+
# never drift apart from what .env actually configures.
143190
resolve_app_directories() {
144191
local envfile="$INSTALL_DIR/.env"
145192
[ -f "$envfile" ] || error ".env not found"
@@ -156,36 +203,17 @@ resolve_app_directories() {
156203
registry_file=$(read_env_var "PLATFORMS_REGISTRY_FILE" "$envfile")
157204

158205
RW_DIRS=()
159-
_resolve_dir() {
160-
local dir="$1"
161-
[ -z "$dir" ] && return
162-
[[ "$dir" = /* ]] || dir="$INSTALL_DIR/$dir"
163-
# Skip duplicates (e.g. celery paths sharing the same directory as sqlite).
164-
local existing
165-
for existing in "${RW_DIRS[@]}"; do
166-
[ "$existing" = "$dir" ] && return
167-
done
168-
RW_DIRS+=("$dir")
169-
}
170206

171207
if [ -n "$sqlite_path" ] && [ "$sqlite_path" != ":memory:" ]; then
172208
_resolve_dir "$(dirname "$sqlite_path")"
173209
fi
174-
if [ -n "$celery_broker_path" ]; then
175-
_resolve_dir "$(dirname "$celery_broker_path")"
176-
fi
177-
if [ -n "$celery_result_path" ]; then
178-
_resolve_dir "$(dirname "$celery_result_path")"
179-
fi
180-
if [ -n "$celery_beat_path" ]; then
181-
_resolve_dir "$(dirname "$celery_beat_path")"
182-
fi
210+
[ -n "$celery_broker_path" ] && _resolve_dir "$(dirname "$celery_broker_path")"
211+
[ -n "$celery_result_path" ] && _resolve_dir "$(dirname "$celery_result_path")"
212+
[ -n "$celery_beat_path" ] && _resolve_dir "$(dirname "$celery_beat_path")"
183213
_resolve_dir "$adapters_dir"
184214
_resolve_dir "$adapters_venv"
185215
_resolve_dir "$adapters_assets"
186-
if [ -n "$registry_file" ]; then
187-
_resolve_dir "$(dirname "$registry_file")"
188-
fi
216+
[ -n "$registry_file" ] && _resolve_dir "$(dirname "$registry_file")"
189217
}
190218

191219
create_app_directories() {
@@ -210,7 +238,7 @@ run_migrations() {
210238
set +a
211239
cd '$INSTALL_DIR'
212240
PATH='$INSTALL_DIR/venv/bin:$PATH' make migrate-up
213-
" || error "Database migrations failed"
241+
"
214242
}
215243

216244
install_services() {
@@ -225,17 +253,22 @@ install_services() {
225253
)
226254
[ -n "$rw_paths" ] || error "No application directories resolved for ReadWritePaths"
227255

228-
for svc in relaysms-publisher.target relaysms-publisher-rest.service relaysms-publisher-grpc.service relaysms-publisher-worker.service relaysms-publisher-beat.service; do
256+
local svc
257+
for svc in "${ALL_UNITS[@]}"; do
229258
[ -f "$svc" ] || error "Service file not found: $svc"
259+
# '#' delimiter (not '|') since a resolved path could contain a pipe.
230260
sed \
231261
-e "s/User=relaysms/User=$SERVICE_USER/" \
232-
-e "s|__RW_PATHS__|$rw_paths|" \
233-
"$svc" >"/etc/systemd/system/$svc" ||
234-
error "Failed to install $svc"
262+
-e "s#__RW_PATHS__#$rw_paths#" \
263+
"$svc" >"/etc/systemd/system/$svc"
264+
done
265+
266+
systemctl daemon-reload
267+
systemctl enable "$TARGET_UNIT"
268+
for svc in "${SERVICE_UNITS[@]}"; do
269+
systemctl restart "$svc"
235270
done
236-
systemctl daemon-reload || error "Failed to reload systemd"
237-
systemctl enable "$SERVICE_NAME.target" || error "Failed to enable service"
238-
systemctl start "$SERVICE_NAME.target" || error "Failed to start service"
271+
systemctl start "$TARGET_UNIT"
239272
}
240273

241274
main() {

0 commit comments

Comments
 (0)