Repository navigation
Expand file tree
/
Copy pathsetup.sh
More file actions
executable file
·378 lines (348 loc) · 15.2 KB
/
Copy pathsetup.sh
File metadata and controls
executable file
·378 lines (348 loc) · 15.2 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
#!/usr/bin/env bash
#
# Sets up a deploy runner on a Forge server: a GitHub Actions runner under the unprivileged `builder` user
# that builds a project's frontends beside the site and then starts the site's Forge deploy, or with
# --no-hook only builds, for a site whose deploy a person starts in Forge.
# How it fits together and what stays manual: README.md of https://github.com/farbcodegmbh/deploy-runner
#
# Safe to re-run: every step checks what is already there, and the answers are saved per target, so a
# second run only asks for confirmation. It asks for two secrets and neither reaches a command line, the process list or
# the log: the runner registration token and the site's Forge deploy hook URL (not with --no-hook).
#
# Run as root from the Forge site's directory, where it reads repository and branch from the site's
# checkout and asks for the rest:
# cd /home/forge/example.com && sudo bash /path/to/setup.sh
# Every answer can also be passed, which skips its question:
# --repo OWNER/REPO the repository the runner registers with
# --branch BRANCH the branch that deploys to this site
# --target NAME runner label, /srv/builds/NAME and /etc/deploy-runner/NAME
# --workflow FILE the deploy workflow's file in .github/workflows; with the branch, the only thing
# the runner accepts
# --env APP=FILE an app's build-time .env, copied to /etc/deploy-runner/NAME/APP/.env; repeatable
# --npmrc FILE copied next to every --env, for private packages
# --runner-dir DIR defaults to /home/builder/runners/NAME
# --manual-only the runner refuses push runs and accepts only workflow_dispatch; kept on re-runs
# --no-hook stores no Forge deploy hook and removes one stored earlier, so no job can start a
# deploy; the workflow only builds and the deploy is started in Forge; kept on re-runs
# --yes skips the confirmation, required without a terminal
set -euo pipefail
user=builder
etc=/etc/deploy-runner
repo="" target="" workflow="" branch="" npmrc="" runner_dir="" assume_yes=0 manual_only=0 no_hook=0
envs=()
usage() {
sed -n '/^# Run as root/,/^$/s/^# \{0,1\}//p' "$0" >&2
exit 2
}
fail() {
echo "$*" >&2
exit 1
}
step() {
echo "== $*"
}
# ask VAR QUESTION [DEFAULT]: keeps a value that is already set, otherwise asks on the terminal
ask() {
local var=$1 question=$2 default=${3:-} answer
[ -z "${!var}" ] || return 0
if [ ! -t 0 ]; then
[ -n "$default" ] || fail "no --$var given and no terminal to ask on"
printf -v "$var" '%s' "$default"
return 0
fi
read -r -e -p "$question${default:+ [$default]}: " answer
printf -v "$var" '%s' "${answer:-$default}"
}
while [ $# -gt 0 ]; do
case "$1" in
--yes) assume_yes=1; shift; continue ;;
--manual-only) manual_only=1; shift; continue ;;
--no-hook) no_hook=1; shift; continue ;;
-h|--help) usage ;;
esac
[ $# -ge 2 ] || usage
case "$1" in
--repo) repo=$2 ;;
--target) target=$2 ;;
--workflow) workflow=$2 ;;
--branch) branch=$2 ;;
--env) envs+=("$2") ;;
--npmrc) npmrc=$2 ;;
--runner-dir) runner_dir=$2 ;;
*) usage ;;
esac
shift 2
done
[ "$(id -u)" = 0 ] || fail "run as root: sudo bash $0"
for tool in curl tar python3 sha256sum systemctl; do
command -v "$tool" >/dev/null || fail "missing on this server: $tool"
done
# a Forge site directory: its checkout names the repository and the branch
site="" site_repo="" site_branch=""
if [ -L current ] || [ -d releases ] || [ -d .git ]; then
site=$PWD
for git_dir in "$site/current/.git" "$site/.git"; do
[ -f "$git_dir/config" ] || continue
# -C / keeps git from looking at the surrounding checkout, which root does not own
if url=$(git -C / config -f "$git_dir/config" remote.origin.url); then
site_repo=$(sed -E 's#^(git@github\.com:|https://([^@/]+@)?github\.com/)##; s#\.git$##' <<< "$url")
[[ "$site_repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || site_repo=""
fi
site_branch=$(sed -n 's#^ref: refs/heads/##p' "$git_dir/HEAD")
break
done
fi
targets=()
for dir in "$etc"/*/; do
[ -d "$dir" ] && targets+=("$(basename "$dir")")
done
if [ ${#targets[@]} -gt 0 ]; then
echo "targets on this server: ${targets[*]}"
fi
target_default=""
if [ ${#targets[@]} -eq 1 ]; then
target_default=${targets[0]}
elif [ -n "$site" ]; then
target_default=$(basename "$site" | tr 'A-Z.' 'a-z-' | tr -cd 'a-z0-9-')
fi
ask target "Target name (runner label and folder name)" "$target_default"
[[ "$target" =~ ^[a-z0-9-]+$ ]] || fail "the target takes lowercase letters, digits and dashes"
# answers saved by an earlier run for this target
saved_repo="" saved_branch="" saved_workflow="" saved_runner_dir=""
if [ -f "$etc/$target/setup.conf" ]; then
while IFS='=' read -r key value; do
case "$key" in
repo) saved_repo=$value ;;
branch) saved_branch=$value ;;
workflow) saved_workflow=$value ;;
runner_dir) saved_runner_dir=$value ;;
manual_only) if [ "$value" = 1 ]; then manual_only=1; fi ;;
no_hook) if [ "$value" = 1 ]; then no_hook=1; fi ;;
esac
done < "$etc/$target/setup.conf"
fi
ask repo "GitHub repository (OWNER/REPO)" "${saved_repo:-$site_repo}"
[[ "$repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || fail "the repository has to be OWNER/REPO"
ask branch "Branch that deploys here" "${saved_branch:-${site_branch:-develop}}"
[[ "$branch" =~ ^[A-Za-z0-9._/-]+$ ]] || fail "not a branch name: $branch"
ask workflow "Deploy workflow file in .github/workflows" "${saved_workflow:-deploy-$target.yml}"
[[ "$workflow" =~ ^[A-Za-z0-9_.-]+\.ya?ml$ ]] || fail "the workflow is a file name such as deploy-testing.yml"
# a runner this repository already has on the server and no other target has claimed
if [ -z "$runner_dir" ] && [ -z "$saved_runner_dir" ]; then
found=()
claimed=""
for conf in "$etc"/*/setup.conf; do
if [ -f "$conf" ]; then
claimed+="$(sed -n 's/^runner_dir=//p' "$conf")"$'\n'
fi
done
while IFS= read -r file; do
grep -qxF -- "$(dirname "$file")" <<< "$claimed" && continue
if python3 -c 'import json, sys; sys.exit(json.load(open(sys.argv[1], encoding="utf-8-sig")).get("gitHubUrl") != sys.argv[2])' \
"$file" "https://github.com/$repo"; then
found+=("$(dirname "$file")")
fi
done < <(find "/home/$user" -maxdepth 3 -name .runner -type f 2>/dev/null)
if [ ${#found[@]} -eq 1 ]; then
saved_runner_dir=${found[0]}
elif [ ${#found[@]} -gt 1 ]; then
fail "several runners of $repo on this server (${found[*]}); pass --runner-dir"
fi
fi
runner_dir=${runner_dir:-${saved_runner_dir:-/home/$user/runners/$target}}
builds=/srv/builds/$target
env_dir=$etc/$target
runner_name="$(hostname)-$target"
if [ ${#envs[@]} -eq 0 ] && [ -t 0 ]; then
add_env=y apps=""
if [ -d "$env_dir" ]; then
apps=$(find "$env_dir" -mindepth 1 -maxdepth 1 -type d -printf '%f ')
fi
if [ -n "$apps" ]; then
echo "build env already stored for: $apps"
read -r -p "Add or replace an app's build env? [y/N] " add_env
fi
if [[ "$add_env" =~ ^[yY] ]]; then
echo "Build env, one frontend app at a time. The build user can read these files, so never give it"
echo "the site's Laravel .env or anything else holding secrets."
if [ -n "$site" ]; then
echo "env files in this site: $(find "$site" "$site/shared" -maxdepth 1 -type f \( -name '.env*' -o -name '.npmrc' \) 2>/dev/null | tr '\n' ' ')"
fi
while read -r -e -p "App directory in the repository (empty when done): " app && [ -n "$app" ]; do
read -r -e -p " build .env for $app: " file
envs+=("$app=$file")
done
if [ ${#envs[@]} -gt 0 ] && [ -z "$npmrc" ]; then
npmrc_default=""
for candidate in "$site/shared/.npmrc" "$site/.npmrc"; do
if [ -n "$site" ] && [ -f "$candidate" ]; then
npmrc_default=$candidate
break
fi
done
read -r -e -p ".npmrc for private packages, - for none${npmrc_default:+ [$npmrc_default]}: " npmrc
npmrc=${npmrc:-$npmrc_default}
[ "$npmrc" != "-" ] || npmrc=""
fi
fi
fi
[ -z "$npmrc" ] || [ -f "$npmrc" ] || fail "no such file: $npmrc"
for pair in "${envs[@]}"; do
[[ "${pair%%=*}" =~ ^[a-z0-9-]+$ ]] && [ -f "${pair#*=}" ] || fail "build env $pair: expected APP=existing file"
done
registered=" (registered)"
[ -f "$runner_dir/.runner" ] || registered=" (NEW: registers another runner and asks for a token)"
cat <<SUMMARY
repository $repo
branch $branch
workflow .github/workflows/$workflow
target $target: label, $builds, $env_dir
runner $runner_dir$registered
build env ${envs[*]:-unchanged}
npmrc ${npmrc:-none}
starts on $([ "$manual_only" = 1 ] && echo "workflow_dispatch only" || echo "push and workflow_dispatch")
deploys $([ "$no_hook" = 1 ] && echo "never: builds only, the deploy is started in Forge" || echo "through the site's Forge deploy hook")
SUMMARY
if [ "$assume_yes" = 0 ]; then
[ -t 0 ] || fail "no terminal to confirm on; pass --yes"
read -r -p "Set this up? [Y/n] " answer
[[ "$answer" =~ ^([yY].*)?$ ]] || fail "stopped, nothing changed"
fi
step "build user"
if id "$user" >/dev/null 2>&1; then
echo "$user exists"
else
adduser --system --group --shell /bin/bash --home "/home/$user" "$user"
fi
if ! sudo -u "$user" -i sh -c 'node -v && yarn -v' >/dev/null 2>&1; then
echo "warning: node or yarn is not on $user's PATH, and the builds need both" >&2
fi
step "directories"
install -d -o "$user" -g "$user" -m 755 "$builds"
install -d -o root -g "$user" -m 750 "$etc" "$env_dir"
step "guard"
# installed root-owned, so no job can change the check that decides which jobs run
cat > "$etc/pre-job.sh" <<'HOOK'
#!/usr/bin/env bash
# Pre-job hook of a deploy runner (ACTIONS_RUNNER_HOOK_JOB_STARTED), written by deploy-runner's setup.sh.
# A non-zero exit fails the job before its first step, so only the workflows in the allow file run on this
# server. GitHub sets the GITHUB_* values and a workflow cannot overwrite them.
set -euo pipefail
allow=/etc/deploy-runner/allow
job="${GITHUB_EVENT_NAME:-} ${GITHUB_WORKFLOW_REF:-}"
if grep -qxF -- "$job" "$allow"; then
exit 0
fi
echo "This runner only runs the workflows in $allow. Refused: $job" >&2
exit 1
HOOK
chown root:root "$etc/pre-job.sh"
chmod 755 "$etc/pre-job.sh"
touch "$etc/allow"
chmod 644 "$etc/allow"
workflow_ref="$repo/.github/workflows/$workflow@refs/heads/$branch"
events="push workflow_dispatch"
if [ "$manual_only" = 1 ]; then
events="workflow_dispatch"
# an earlier run may have allowed push for this workflow
grep -vxF -- "push $workflow_ref" "$etc/allow" > "$etc/allow.tmp" || true
cat "$etc/allow.tmp" > "$etc/allow"
rm "$etc/allow.tmp"
fi
for event in $events; do
line="$event $workflow_ref"
if ! grep -qxF -- "$line" "$etc/allow"; then
echo "$line" >> "$etc/allow"
fi
done
cat "$etc/allow"
step "build env"
for pair in "${envs[@]}"; do
app=${pair%%=*}
install -d -o root -g "$user" -m 750 "$env_dir/$app"
install -o root -g "$user" -m 640 "${pair#*=}" "$env_dir/$app/.env"
if [ -n "$npmrc" ]; then
install -o root -g "$user" -m 640 "$npmrc" "$env_dir/$app/.npmrc"
fi
echo "$app"
done
step "deploy hook"
hook_file=$env_dir/forge-deploy-hook
if [ "$no_hook" = 1 ]; then
# the build user can read this file, so with it every job could start the deploy
if [ -e "$hook_file" ]; then
rm -f "$hook_file"
echo "removed $hook_file; rotate the site's deploy hook token in Forge, the old one was readable here"
else
echo "none stored: the deploy is started in Forge"
fi
elif [ -s "$hook_file" ]; then
echo "already stored in $hook_file"
else
read -rs -p "Forge deploy hook URL of the site (Deployments > Deploy hook): " hook_url
echo
[[ "$hook_url" =~ ^https://forge\.laravel\.com/servers/[0-9]+/sites/[0-9]+/deploy/http\?token=[A-Za-z0-9]+$ ]] \
|| fail "that is not a Forge deploy hook URL"
install -o root -g "$user" -m 640 /dev/null "$hook_file"
printf '%s' "$hook_url" > "$hook_file"
unset hook_url
fi
step "runner"
if [ -f "$runner_dir/.runner" ]; then
echo "already registered in $runner_dir"
else
case "$(uname -m)" in
x86_64) arch=x64 ;;
aarch64) arch=arm64 ;;
*) fail "no runner build for $(uname -m)" ;;
esac
# the release API publishes each asset's sha256, so the download is checked against GitHub's own digest
read -r tarball_url digest < <(curl -fsSL https://api.github.com/repos/actions/runner/releases/latest | python3 -c '
import json, re, sys
for asset in json.load(sys.stdin)["assets"]:
if re.fullmatch(r"actions-runner-linux-" + sys.argv[1] + r"-[0-9.]+\.tar\.gz", asset["name"]):
print(asset["browser_download_url"], asset["digest"].removeprefix("sha256:"))
' "$arch") || fail "no runner release found for linux-$arch"
tarball=$(mktemp)
curl -fsSL -o "$tarball" "$tarball_url"
echo "$digest $tarball" | sha256sum -c --quiet || fail "runner download does not match its published sha256"
install -d -o "$user" -g "$user" -m 750 "$runner_dir"
tar -xzf "$tarball" -C "$runner_dir"
rm -f "$tarball"
chown -R "$user:$user" "$runner_dir"
echo "installed $(basename "$tarball_url")"
read -rs -p "Runner registration token (repo Settings > Actions > Runners > New self-hosted runner): " ACTIONS_RUNNER_INPUT_TOKEN
echo
export ACTIONS_RUNNER_INPUT_TOKEN
(cd "$runner_dir" && sudo -u "$user" --preserve-env=ACTIONS_RUNNER_INPUT_TOKEN \
./config.sh --unattended --url "https://github.com/$repo" --name "$runner_name" --labels "$target" --work _work --replace)
unset ACTIONS_RUNNER_INPUT_TOKEN
fi
hook_line="ACTIONS_RUNNER_HOOK_JOB_STARTED=$etc/pre-job.sh"
hook_added=0
if ! grep -qxF -- "$hook_line" "$runner_dir/.env" 2>/dev/null; then
echo "$hook_line" >> "$runner_dir/.env"
chown "$user:$user" "$runner_dir/.env"
hook_added=1
fi
printf 'repo=%s\nbranch=%s\nworkflow=%s\nrunner_dir=%s\nmanual_only=%s\nno_hook=%s\n' "$repo" "$branch" "$workflow" "$runner_dir" "$manual_only" "$no_hook" > "$env_dir/setup.conf"
chown root:"$user" "$env_dir/setup.conf"
chmod 640 "$env_dir/setup.conf"
step "service"
cd "$runner_dir"
if [ ! -f .service ]; then
./svc.sh install "$user"
./svc.sh start
elif [ "$hook_added" = 1 ]; then
# the runner reads .env only at start
./svc.sh stop
./svc.sh start
fi
echo "$(cat .service): $(systemctl is-active "$(cat .service)")"
cat <<DONE
Server side done. Still by hand (README.md of farbcodegmbh/deploy-runner):
- Forge: paste the site's deploy script, turn push to deploy off
- repo: .github/workflows/$workflow on [self-hosted, $target], BUILD_ROOT=$builds, BUILD_ENV_DIR=$env_dir
- Slack: /github subscribe $repo workflows:{name:"<workflow name>" branch:"$branch"}
- guard test: a workflow on another branch aimed at [self-hosted, $target] must fail in "Set up runner"
DONE