11#! /bin/bash
22
3- set -e
3+ set -euo pipefail
44
55INSTALL_DIR=" /opt/relaysms/relaysms-publisher"
66SERVICE_NAME=" relaysms-publisher"
77REPO_URL=" https://github.com/smswithoutborders/RelaySMS-Publisher.git"
88BRANCH=" ${BRANCH:- main} "
99CARGO_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`.
1430if [ -n " ${SUDO_USER:- } " ] && id " $SUDO_USER " & > /dev/null; then
1531 SERVICE_USER=" $SUDO_USER "
1632else
@@ -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
2647check_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`.
2953read_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
3579install_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
4796install_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
75121clone_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
107154build_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
114161setup_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 .
143190resolve_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
191219create_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
216244install_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
241274main () {
0 commit comments