-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinstall_app.sh
More file actions
executable file
·383 lines (353 loc) · 17.4 KB
/
Copy pathinstall_app.sh
File metadata and controls
executable file
·383 lines (353 loc) · 17.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
#!/usr/bin/env bash
# install_app.sh <request.json>
#
# Installs an app: its dependencies (from supported_deps.json) and the app
# itself (clone repo, then run setup.sh or install.sh). Run detached by the
# agent; it is the single owner of the install lifecycle.
#
# Everything is logged, line-by-line with timestamps, to $SETUP_LOG, which the
# agent serves as the "log" field of /progress. The AGENT owns the install
# lifecycle: it takes the lock (pending_install.json) before spawning this
# script, refuses concurrent installs, and decides the outcome —
# completed: the log's LAST line is "<name> installation completed" (written
# at the very end below, upon which pending_install.json is
# removed; the log stays until the next accepted install);
# failed: fail() archives the log and removes the lock, so /progress
# drops back to {} — the polling peer's failure signal. A run
# that dies silently is cleared by the agent's 30-minute stall rule.
REQ="${1:?usage: install_app.sh <request.json>}"
HERE="$(cd "$(dirname "$0")" && pwd)"
SUPPORT="$HERE/app_support" # per-app-type builders + per-db-type wiring scripts
SUPPORTED="$HERE/supported_deps.json"
DATA_DIR="${P5AGENT_DATA_DIR:-/var/lib/p5agent}"
TMP_DIR="${P5AGENT_TMP_DIR:-/tmp}"
APPS_DIR="${P5AGENT_APPS_DIR:-/opt}"
SETUP_LOG="$DATA_DIR/setup.log"
INSTALLED="$DATA_DIR/installed_apps.json"
PENDING="$DATA_DIR/pending_install.json" # the agent's install lock/status record
mkdir -p "$DATA_DIR" "$TMP_DIR"
ts() { date '+%Y-%m-%d %H:%M:%S'; }
logline() { printf '[%s] %s\n' "$(ts)" "$*" >> "$SETUP_LOG"; }
stamp() { while IFS= read -r line; do printf '[%s] %s\n' "$(ts)" "$line"; done >> "$SETUP_LOG"; }
runlog() { bash -c "$1" 2>&1 | stamp; return "${PIPESTATUS[0]}"; }
archive() { # archive() <suffix> — move the log out of the way
cp "$SETUP_LOG" "$TMP_DIR/p5agent_setup_$(date +%Y%m%d_%H%M%S)${1:-}.log" 2>/dev/null || true
rm -f "$SETUP_LOG"
}
fail() { logline "$*"; archive "-failed"; rm -f "$PENDING"; exit 1; }
# Create + start a systemd service that runs $APP_CMD in $APP_DIR. The only
# per-type difference is where the built artifact lives, so the builder passes
# that as the PATH prefix; everything else is uniform. Exported so the per-type
# install_<type>_app.sh scripts (run as child shells) can call it; it uses plain
# echo (captured into the install log by the caller) — no logline/runlog deps.
create_service() { # create_service <path-prefix> (uses APP_NAME/APP_DIR/APP_PORT/APP_CMD/APP_SERVICES/APP_USER)
[[ -n "${APP_CMD:-}" ]] || { echo "No run command (app-cmd) — skipping service"; return 0; }
echo "Creating systemd service ${APP_NAME}"
# Order (and pull in) after any service dependencies the app needs — the
# database etc. — so they are up before the app starts. APP_SERVICES is a
# space-separated list of unit names (e.g. "postgresql.service").
local after="network.target" wants=""
if [[ -n "${APP_SERVICES:-}" ]]; then
after="network.target ${APP_SERVICES}"
wants="Wants=${APP_SERVICES}"
fi
# Run as the dedicated non-root app user when one exists, granting it the
# capability to bind privileged ports (443) — no setcap on the binary needed.
# Hand it ownership of the app dir, env file and cert dir so it can read them.
local user_lines=""
if [[ -n "${APP_USER:-}" ]] && id "${APP_USER}" &>/dev/null; then
user_lines="User=${APP_USER}
Group=${APP_USER}
AmbientCapabilities=CAP_NET_BIND_SERVICE"
chown -R "${APP_USER}:${APP_USER}" "${APP_DIR}" 2>/dev/null || true
[[ -f "/etc/${APP_NAME}.env" ]] && chown "${APP_USER}:${APP_USER}" "/etc/${APP_NAME}.env" 2>/dev/null || true
[[ -d "/etc/${APP_NAME}" ]] && chown -R "${APP_USER}:${APP_USER}" "/etc/${APP_NAME}" 2>/dev/null || true
[[ -d "/var/lib/${APP_NAME}" ]] && chown -R "${APP_USER}:${APP_USER}" "/var/lib/${APP_NAME}" 2>/dev/null || true
fi
cat > "/etc/systemd/system/${APP_NAME}.service" <<EOF
[Unit]
Description=${APP_NAME} (p5agent)
After=${after}
${wants}
[Service]
Type=simple
${user_lines}
WorkingDirectory=${APP_DIR}
EnvironmentFile=-/etc/${APP_NAME}.env
Environment=PORT=${APP_PORT}
Environment=HOST=0.0.0.0
Environment=PATH=${1}:/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/bin/env ${APP_CMD}
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=${APP_NAME}
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "${APP_NAME}" 2>/dev/null || true
systemctl restart "${APP_NAME}" || echo "service ${APP_NAME} failed to start (journalctl -u ${APP_NAME})"
}
export -f create_service
# Shared DB helpers for the per-type wire_<dbtype>.sh scripts. Like create_service
# they run in those child shells, so they are exported. db_password re-uses the
# existing password on re-install; write_db_env writes /etc/<name>.env (loaded by
# the service via EnvironmentFile) with both DATABASE_* fields and a DATABASE_URL.
db_password() { # db_password <env-file> -> reused-or-new password
if [[ -f "$1" ]] && grep -q '^DATABASE_PASSWORD=' "$1"; then
sed -n 's/^DATABASE_PASSWORD=//p' "$1" | head -1
else
openssl rand -hex 16
fi
}
write_db_env() { # write_db_env <scheme> <port> <password> (uses $DB_NAME)
local f="/etc/${DB_NAME}.env"
( umask 077; cat > "$f" <<EOF
DATABASE_HOST=localhost
DATABASE_PORT=$2
DATABASE_NAME=$DB_NAME
DATABASE_USERNAME=$DB_NAME
DATABASE_PASSWORD=$3
DATABASE_URL=$1://$DB_NAME:$3@localhost:$2/$DB_NAME
EOF
)
echo "Wrote DB connection settings to $f"
}
export -f db_password write_db_env
# Generate a self-signed TLS cert for the droplet's IP (so generic apps can serve
# HTTPS) and record its paths in /etc/<name>.env. Re-uses an existing cert.
setup_tls() { # uses $name
local cert_dir="/etc/${name}/certs" cert="/etc/${name}/certs/cert.pem" key="/etc/${name}/certs/key.pem"
local env_file="/etc/${name}.env" ip
mkdir -p "$cert_dir"
if [[ ! -f "$cert" || ! -f "$key" ]]; then
ip=$(curl -s --max-time 10 http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address 2>/dev/null \
|| hostname -I | awk '{print $1}')
logline "Generating self-signed TLS certificate for ${ip:-the server}"
openssl req -x509 -newkey rsa:2048 -nodes -days 825 \
-keyout "$key" -out "$cert" \
-subj "/CN=${ip:-localhost}" -addext "subjectAltName=IP:${ip:-127.0.0.1}" 2>&1 | stamp
fi
touch "$env_file"; chmod 600 "$env_file"
sed -i '/^TLS_CERT_PATH=/d;/^TLS_KEY_PATH=/d' "$env_file"
{ echo "TLS_CERT_PATH=$cert"; echo "TLS_KEY_PATH=$key"; } >> "$env_file"
logline "Wrote TLS cert paths to $env_file"
}
# ── Read the request ─────────────────────────────────────────────────────────
# (No concurrency guard here: the agent refuses a second install and clears the
# previous run's log before spawning this script.)
jget() { python3 -c "import json,sys;print(json.load(open(sys.argv[1])).get(sys.argv[2],'') or '')" "$REQ" "$1"; }
repo=$(jget repo); key=$(jget key); branch=$(jget branch)
name=$(jget name); product=$(jget product-name); port=$(jget port)
app_type=$(jget app-type); app_cmd=$(jget app-cmd)
[[ -n "$name" ]] || { base="${repo##*/}"; name="${base%.git}"; }
target="$APPS_DIR/$name"
mapfile -t DEPS < <(python3 -c "
import json,re,sys
d=json.load(open(sys.argv[1])).get('dependencies') or []
if isinstance(d,str): d=[x for x in re.split(r'[,\n]',d) if x.strip()]
for x in d: print(str(x).strip())
" "$REQ")
# ── Start a fresh log ────────────────────────────────────────────────────────
: > "$SETUP_LOG"
logline "Setup started for ${product:-$name}"
# ── Install dependencies ─────────────────────────────────────────────────────
# Look up how to install a dependency in supported_deps.json (no per-package
# logic lives here — the registry carries the package-manager or install-cmd).
depinfo() { # depinfo <name> -> "display-name<TAB>mode<TAB>payload" (mode: cmd|apt|none)
python3 - "$SUPPORTED" "$1" <<'PY'
import json, sys
data = json.load(open(sys.argv[1]))
dn = sys.argv[2].lower()
e = next((x for x in data if x.get("name", "").lower() == dn), None)
if not e:
print("\t\t"); raise SystemExit
disp = e.get("display-name") or e.get("name")
if e.get("install-cmd"):
print("%s\tcmd\t%s" % (disp, e["install-cmd"]))
elif e.get("package-manager") == "apt":
print("%s\tapt\t%s" % (disp, e.get("package") or e.get("name")))
else:
print("%s\tnone\t" % disp)
PY
}
app_services="" # systemd units of service deps (postgres, redis, …) the app needs
if (( ${#DEPS[@]} == 0 )); then
logline "No dependencies requested"
else
# DigitalOcean password-auth droplets flag root's password "must change on
# first login". That makes PAM abort chfn/adduser inside package postinst
# scripts (notably postgresql) with "authentication token is no longer
# valid", failing the whole apt step. Reset root's last-change date so those
# service-user setups succeed.
runlog "chage -d \"\$(date +%F)\" root || true"
logline "Updating package lists"
runlog "apt-get update -qq"
for dep in "${DEPS[@]}"; do
depname="${dep%% *}"; depname="${depname,,}"
version=""; [[ "$dep" == *" "* ]] && version="${dep#* }"
IFS=$'\t' read -r display mode payload < <(depinfo "$depname")
[[ -n "$display" ]] || fail "Unknown dependency '$depname' — aborting"
logline "$display installation began"
case "$mode" in
cmd)
# An install-cmd ending in .sh is a local script (in the repo),
# run with the version as its argument; anything else is an
# inline shell one-liner with {version} substituted.
if [[ "$payload" == *.sh ]]; then
script="$HERE/$payload"
[[ -f "$script" ]] || fail "$display: install script not found ($payload)"
runlog "bash '$script' '$version'" || fail "$display installation failed"
else
runlog "${payload//\{version\}/$version}" || fail "$display installation failed"
fi
;;
apt)
if [[ -n "$version" ]]; then
runlog "DEBIAN_FRONTEND=noninteractive apt-get install -y '$payload=$version'" \
|| runlog "DEBIAN_FRONTEND=noninteractive apt-get install -y '$payload'" \
|| fail "$display installation failed"
else
runlog "DEBIAN_FRONTEND=noninteractive apt-get install -y '$payload'" \
|| fail "$display installation failed"
fi
;;
*)
fail "No install method for '$depname'"
;;
esac
# Enable + start service-type dependencies (they aren't reliably started
# on a non-interactive install, and the app needs them running).
svc=""
case "$depname" in
postgresql) svc=postgresql ;;
mysql) svc=mysql ;;
mariadb) svc=mariadb ;;
redis) svc=redis-server ;;
nginx) svc=nginx ;;
esac
if [[ -n "$svc" ]]; then
runlog "systemctl enable --now '$svc'" || logline "Could not enable/start $svc"
app_services="${app_services:+$app_services }${svc}.service"
fi
logline "$display installation completed"
done
fi
# ── Install the app (clone + setup) ──────────────────────────────────────────
if [[ -n "$repo" ]]; then
if [[ -d "$target/.git" ]]; then
logline "$name already present at $target"
else
logline "$name installation began"
clone_url="$repo"
if [[ -n "$key" ]]; then
case "$repo" in
https://github.com/*) clone_url="https://x-access-token:$key@github.com/${repo#https://github.com/}" ;;
https://*) clone_url="https://$key@${repo#https://}" ;;
esac
fi
# Target the requested ref (branch or tag); no version given → main. A
# semver also tries the common "v"-prefixed / unprefixed variant (so
# "1.2.3" matches a "v1.2.3" release tag).
ref_in="${branch:-main}"
refs=("$ref_in")
if [[ "$ref_in" =~ ^v?[0-9]+(\.[0-9]+){1,2}$ ]]; then
if [[ "$ref_in" == v* ]]; then refs+=("${ref_in#v}"); else refs+=("v$ref_in"); fi
fi
cloned=0
for ref in "${refs[@]}"; do
if runlog "git clone --depth 1 --branch '$ref' '$clone_url' '$target'"; then
cloned=1; break
fi
logline "ref '$ref' not found"
rm -rf "$target"
done
(( cloned )) || fail "$name clone failed (no branch or tag matching '$ref_in')"
fi
# Open the app's port in the firewall (everything else is denied by default).
if [[ "$port" =~ ^[0-9]+$ ]] && (( port >= 1 && port <= 65535 )); then
if command -v ufw >/dev/null 2>&1; then
logline "Opening firewall port $port for $name"
runlog "ufw allow '$port/tcp' comment '$name'"
fi
fi
setup=""
for candidate in setup.sh install.sh; do
[[ -f "$target/$candidate" ]] && { setup="$target/$candidate"; break; }
done
if [[ -n "$setup" ]]; then
# The repo ships its own installer — it builds and sets up its service.
# P5AGENT=1 tells the repo installer it is running under the agent, so it
# can skip droplet-level provisioning the agent already did (firewall,
# system upgrade, dependency install).
logline "Running ${setup##*/}"
( cd "$target" && P5AGENT=1 runlog "bash '$setup'" ) || fail "$name setup failed"
else
# No repo installer. Create a dedicated non-root user to run the app as,
# wire its database, generate a self-signed TLS cert, then run the per-type
# builder (which builds and creates the service, running as that user).
app_user="$name"
if ! id "$app_user" &>/dev/null; then
runlog "useradd --system --user-group --no-create-home --shell /usr/sbin/nologin '$app_user'" \
|| logline "Could not create user $app_user"
fi
db_type=""
for d in "${DEPS[@]}"; do
case "${d%% *}" in postgresql|mysql|mariadb|sqlite) db_type="${d%% *}"; break ;; esac
done
if [[ -n "$db_type" && -f "$SUPPORT/wire_${db_type}.sh" ]]; then
logline "Wiring $db_type database for $name"
( DB_NAME="$name" runlog "bash '$SUPPORT/wire_${db_type}.sh'" ) || logline "DB wiring failed ($db_type)"
fi
setup_tls
type_script="$SUPPORT/install_${app_type}_app.sh"
if [[ -n "$app_type" && -f "$type_script" ]]; then
logline "No setup.sh/install.sh — running install_${app_type}_app.sh"
( cd "$target" && APP_DIR="$target" APP_NAME="$name" APP_PORT="$port" APP_CMD="$app_cmd" \
APP_SERVICES="$app_services" APP_USER="$app_user" \
runlog "bash '$type_script'" ) || fail "$name install failed (install_${app_type}_app.sh)"
else
logline "No setup.sh/install.sh and no builder for app type '${app_type:-?}' — skipping"
fi
# Let the app user trigger its own redeploy if the repo ships those scripts.
if [[ -f "$target/refresh.sh" || -f "$target/update.sh" ]]; then
cat > "/etc/sudoers.d/$name" <<EOF
$app_user ALL=(root) NOPASSWD: $target/refresh.sh, /usr/bin/systemd-run --collect $target/update.sh
EOF
chmod 440 "/etc/sudoers.d/$name"
logline "Configured sudoers for $app_user"
fi
fi
# ── Record the installed app ─────────────────────────────────────────────
python3 - "$REQ" "$name" "$target" "$INSTALLED" <<'PY'
import json, os, sys
req_path, name, target, installed = sys.argv[1:5]
req = json.load(open(req_path))
apps = []
if os.path.exists(installed):
try:
apps = json.load(open(installed))
except Exception:
apps = []
apps = [a for a in apps if a.get("name") != name]
apps.append({
"name": name,
"product-name": req.get("product-name", ""),
"path": target,
"port": req.get("port", ""),
"dependencies": req.get("dependencies") or [],
})
os.makedirs(os.path.dirname(installed), exist_ok=True)
json.dump(apps, open(installed, "w"), indent=2)
PY
logline "Recorded $name in installed_apps.json"
fi
# ── Done ─────────────────────────────────────────────────────────────────────
# This MUST be the log's last line: it is the agent's completion marker —
# /progress reports completed=true when the log ends with it. Logging it
# releases the install lock (pending_install.json); the log itself stays in
# place as the record until the next accepted install archives it.
logline "$name installation completed"
rm -f "$PENDING"