@ankorcs/protocol 0.0.0-stage → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/node-setup.sh ADDED
@@ -0,0 +1,1061 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Ankor game-node setup. Turns a fresh x86_64 Linux box into a CS2 game host:
4
+ # Docker Engine, the shared CS2 install volume, `agent.yml`, and the `ankor-agent` container
5
+ # that dials the controller and waits for matches.
6
+ #
7
+ # Docker comes from Docker's own repositories on Debian, Ubuntu, Fedora, CentOS and RHEL-compatible
8
+ # systems, and from the distribution on Arch, openSUSE and Alpine. A Docker Engine already on the
9
+ # host is kept when it can run the containers as this script lays them out (see check_docker).
10
+ #
11
+ # The script asks for the node token the controller shows when you add the node (or takes it from
12
+ # ANKOR_NODE_TOKEN) and posts it to `<controller url><setup path>` (`/api/node/setup` unless
13
+ # --setup-path says otherwise), which answers with the node's whole
14
+ # `agent.yml` as text/yaml — token, the address this node dials and both image references.
15
+ # The script saves that file and starts the agent on it. Game servers are added in the controller
16
+ # once the node is connected. Everything else is fixed (`/srv/ankor`, `ankor_cs2_data`) or comes
17
+ # out of the file the controller rendered.
18
+ #
19
+ # curl -fsSL https://ankor.sh/node-setup.sh | bash
20
+ #
21
+ # Root is acquired by the script itself: started unprivileged it re-runs through sudo (or su when
22
+ # there is no sudo), downloading itself again; the address and the token go over stdin, never in
23
+ # argv.
24
+ #
25
+ # Every step is idempotent and logged, so running it again is how you fix a half-finished run, and
26
+ # a re-run asks for nothing: an existing `agent.yml` is the node's identity. See
27
+ # https://docs.ankor.sh/install/ for the whole flow. `--dry-run` prints what would run without
28
+ # touching anything.
29
+
30
+ set -euo pipefail
31
+
32
+ # DEFAULT_CONTROLLER_URL and SETUP_SCRIPT_URL in @ankorcs/protocol's paths.ts.
33
+ readonly DEFAULT_CONTROLLER_URL="https://ankor.sh"
34
+ readonly DEFAULT_SETUP_PATH="/api/node/setup"
35
+ readonly SETUP_SCRIPT_URL="https://ankor.sh/node-setup.sh"
36
+
37
+ # Off the environment at once, so no command this script runs inherits it.
38
+ node_token="${ANKOR_NODE_TOKEN:-}"
39
+ unset ANKOR_NODE_TOKEN
40
+
41
+ # The protocol version this script sends with the node-setup request; PROTOCOL_VERSION in
42
+ # @ankorcs/protocol's paths.ts.
43
+ readonly PROTOCOL_VERSION=0.6
44
+ # The official images' repositories, OFFICIAL_IMAGES in @ankorcs/protocol's values.ts. Unless
45
+ # agent.yml sets official_images_only to false, this script runs no other image.
46
+ readonly OFFICIAL_AGENT_IMAGE=ghcr.io/ankorcs/ankor-agent
47
+ readonly OFFICIAL_CS2_IMAGE=ghcr.io/ankorcs/ankor-cs2
48
+ readonly CS2_VOLUME="ankor_cs2_data"
49
+ readonly AGENT_CONTAINER="ankor-agent"
50
+ # Where a node keeps agent.yml and its per-slot files. Fixed: the agent's own default is this, so
51
+ # nothing has to be told about it, and a node that needs another path is a hand edit plus a
52
+ # `docker rm -f ankor-agent` and a re-run.
53
+ readonly DATA_DIR="/srv/ankor"
54
+ readonly AGENT_CONFIG="${DATA_DIR}/agent.yml"
55
+ # agentUpdateFailurePath in the agent's hostLayout.ts: a failed agent swap, which a start here ends.
56
+ readonly AGENT_UPDATE_FAILURE="${DATA_DIR}/agent-update-failed.json"
57
+ # The game runs as uid 1000 inside the cs2 image; bind mounts keep the host's ownership.
58
+ readonly GAME_UID=1000
59
+ readonly GAME_GID=1000
60
+ readonly CONNECT_TIMEOUT_SECONDS=60
61
+ readonly DAEMON_TIMEOUT_SECONDS=60
62
+ # What start_agent mounts, at the path the agent image expects it.
63
+ readonly DOCKER_SOCKET="/var/run/docker.sock"
64
+ # DOCKER_API_VERSION and MIN_DOCKER_ENGINE in the agent's dockerApi.ts: the API version it names.
65
+ readonly DOCKER_API_VERSION="1.44"
66
+ readonly MIN_DOCKER_ENGINE="25.0"
67
+
68
+ # The CLI talks to the daemon the agent will, whatever context or DOCKER_HOST root's shell carries.
69
+ export DOCKER_HOST="unix://${DOCKER_SOCKET}"
70
+ unset DOCKER_CONTEXT
71
+
72
+ # Filled by detect_os.
73
+ os_id=""
74
+ os_name=""
75
+ # apt, dnf, pacman, zypper or apk.
76
+ os_family=""
77
+ # The download.docker.com/linux/<repo> tree, or empty where the distribution packages Docker.
78
+ docker_repo=""
79
+ docker_suite=""
80
+ in_lxc=0
81
+ readonly CONTROLLER_HINT="--controller takes the controller's address with its protocol, like https://cs.example.com: use https:// for a controller on the internet; http:// is only for a controller on your LAN"
82
+ readonly SETUP_PATH_HINT="--setup-path takes the path on the controller that answers with agent.yml, like /api/node/setup"
83
+ readonly LXC_HINT="in a Proxmox LXC, enable nesting on the Proxmox host (pct set <vmid> --features nesting=1) and restart the container"
84
+
85
+ # From --controller; empty means DEFAULT_CONTROLLER_URL, or the address in an existing agent.yml.
86
+ controller_url=""
87
+ # From --setup-path; empty means DEFAULT_SETUP_PATH.
88
+ setup_path=""
89
+ do_update=0
90
+ no_pull=0
91
+ dry_run=0
92
+
93
+ # Read out of the agent.yml the controller rendered (or the one already on disk), never assembled
94
+ # here.
95
+ agent_image=""
96
+ cs2_image=""
97
+
98
+ # ---------------------------------------------------------------------------------------------
99
+ # Output and command execution
100
+ # ---------------------------------------------------------------------------------------------
101
+
102
+ # `paint <color> <text>`: the text in one terminal color, for the helpers below.
103
+ paint() {
104
+ local code
105
+ case "$1" in
106
+ cyan) code=36 ;;
107
+ yellow) code=33 ;;
108
+ red) code=31 ;;
109
+ dim) code=2 ;;
110
+ esac
111
+ printf '\033[%sm%s\033[0m' "$code" "$2"
112
+ }
113
+
114
+ log() { printf '%s %s\n' "$(paint cyan '[node-setup]')" "$*"; }
115
+ skip() { printf '%s %s %s\n' "$(paint cyan '[node-setup]')" "$*" "$(paint dim '(already done)')"; }
116
+ warn() { printf '%s %s\n' "$(paint yellow '[node-setup] warning:')" "$*" >&2; }
117
+ die() {
118
+ printf '%s %s\n' "$(paint red '[node-setup] error:')" "$*" >&2
119
+ exit 1
120
+ }
121
+ # What a dry run would do, dimmed.
122
+ shown() { printf '%s\n' "$(paint dim "+ $*")"; }
123
+
124
+ # Runs a command, or prints it under --dry-run. Every mutating step goes through this, so a
125
+ # dry run is a complete, readable list of what the script would do to the machine.
126
+ run() {
127
+ if ((dry_run)); then
128
+ shown "$*"
129
+ return 0
130
+ fi
131
+ "$@"
132
+ }
133
+
134
+ # Same, for a shell snippet that needs a pipe or a redirect.
135
+ run_sh() {
136
+ if ((dry_run)); then
137
+ shown "sh -c $(printf '%q' "$1")"
138
+ return 0
139
+ fi
140
+ sh -c "$1"
141
+ }
142
+
143
+ have() { command -v "$1" >/dev/null 2>&1; }
144
+
145
+ usage() {
146
+ cat <<'EOF'
147
+ Usage: node-setup.sh [--controller <url>] [--setup-path <path>] [--no-pull] [--dry-run] # set this host up as a node
148
+ node-setup.sh --update [--no-pull] [--dry-run] # update the agent
149
+
150
+ curl -fsSL https://ankor.sh/node-setup.sh | bash
151
+
152
+ Setup asks for the node token the controller shows when you add the node. For an unattended
153
+ install, set it in ANKOR_NODE_TOKEN instead:
154
+
155
+ curl -fsSL https://ankor.sh/node-setup.sh | ANKOR_NODE_TOKEN=<token> bash
156
+
157
+ Options
158
+ --controller <url> The controller the node belongs to; https://ankor.sh when absent.
159
+ https:// for one on the internet, http:// only for one on your LAN.
160
+ --setup-path <path> Where that controller answers setup; /api/node/setup when absent.
161
+ --update Re-create the agent from the image agent.yml names; running games keep
162
+ running. Needs no token, and fails on a host that was never set up.
163
+ --no-pull Use the agent image already on this host instead of pulling it.
164
+ --dry-run Print every command instead of running it. Asks for no token.
165
+ -h, --help This text.
166
+
167
+ A re-run keeps /srv/ankor/agent.yml and asks for no token.
168
+ More: https://docs.ankor.sh/setup-script/
169
+ EOF
170
+ }
171
+
172
+ # ---------------------------------------------------------------------------------------------
173
+ # Arguments
174
+ # ---------------------------------------------------------------------------------------------
175
+
176
+ # Kept verbatim so an unprivileged start can re-run itself as root with the same arguments.
177
+ original_args=("$@")
178
+
179
+ while (($# > 0)); do
180
+ case "$1" in
181
+ --update) do_update=1 && shift ;;
182
+ --no-pull) no_pull=1 && shift ;;
183
+ --dry-run) dry_run=1 && shift ;;
184
+ --controller)
185
+ [[ -n "${2:-}" ]] || die "$CONTROLLER_HINT"
186
+ controller_url="$2"
187
+ shift 2
188
+ ;;
189
+ --setup-path)
190
+ [[ -n "${2:-}" ]] || die "$SETUP_PATH_HINT"
191
+ setup_path="$2"
192
+ shift 2
193
+ ;;
194
+ -h | --help)
195
+ usage
196
+ exit 0
197
+ ;;
198
+ *) die "unknown argument: $1 (--help for the list)" ;;
199
+ esac
200
+ done
201
+
202
+ if [[ -n "$controller_url" ]]; then
203
+ controller_url="${controller_url%/}"
204
+ [[ "$controller_url" =~ ^https?://[^/[:space:]]+$ ]] || die "${CONTROLLER_HINT} (got \"${controller_url}\")"
205
+ # The docs' example address: a command pasted unchanged would send the node token there.
206
+ if [[ "$(printf '%s' "$controller_url" | tr '[:upper:]' '[:lower:]')" =~ ^https?://cs\.example\.com(:[0-9]+)?$ ]]; then
207
+ die "--controller ${controller_url} is the example address from the docs; use your controller's own address, or remove --controller to connect to the official ankor.sh platform"
208
+ fi
209
+ fi
210
+
211
+ # ankor.sh answers at the default path only.
212
+ if [[ -n "$setup_path" && ( -z "$controller_url" || "$controller_url" == "$DEFAULT_CONTROLLER_URL" ) ]]; then
213
+ warn "--setup-path is ignored for ${DEFAULT_CONTROLLER_URL}, which answers at ${DEFAULT_SETUP_PATH}"
214
+ setup_path=""
215
+ fi
216
+
217
+ # Kept to characters that need no quoting in curl's config.
218
+ if [[ -n "$setup_path" ]]; then
219
+ [[ "$setup_path" =~ ^/[A-Za-z0-9._~/%-]*$ ]] || die "${SETUP_PATH_HINT} (got \"${setup_path}\")"
220
+ fi
221
+
222
+ # ---------------------------------------------------------------------------------------------
223
+ # Preconditions
224
+ # ---------------------------------------------------------------------------------------------
225
+
226
+ require_root() {
227
+ [[ "$(id -u)" == 0 ]] && return 0
228
+ if ((dry_run)); then
229
+ warn "not root; a real run escalates through sudo or su"
230
+ return 0
231
+ fi
232
+
233
+ # Started unprivileged. Re-run as root: the script arrived over a pipe, so download it again and
234
+ # hand over the same arguments.
235
+ #
236
+ # The address and the token go over **stdin** of the elevated shell, the address into curl's
237
+ # config: argv is world readable in `ps`, and sudo strips the environment. sudo and su both read
238
+ # their password from the terminal, so stdin is free.
239
+ local -a args=("${original_args[@]}")
240
+ # Expanded by the *elevated* shell, not by this one: `"$@"` are its arguments.
241
+ # shellcheck disable=SC2016
242
+ local relay='set -o pipefail
243
+ IFS= read -r url || true
244
+ IFS= read -r token || true
245
+ printf "url = \"%s\"\n" "$url" | curl -fsSL -K - | ANKOR_NODE_TOKEN="$token" bash -s -- "$@"'
246
+
247
+ # A plain call rather than `exec`: the elevated shell is on the far side of a pipe, so exec
248
+ # would replace that subshell and leave *this* one to carry on unprivileged. The status of the
249
+ # elevated run is this run's status.
250
+ local status=0
251
+ if have sudo; then
252
+ log "not root; re-running through sudo"
253
+ printf '%s\n%s\n' "$SETUP_SCRIPT_URL" "$node_token" |
254
+ sudo bash -c "$relay" _ ${args[@]+"${args[@]}"} || status=$?
255
+ exit "$status"
256
+ fi
257
+ if have su; then
258
+ log "not root and no sudo; re-running through su (root password prompt follows)"
259
+ local quoted=""
260
+ ((${#args[@]} == 0)) || quoted="$(printf '%q ' "${args[@]}")"
261
+ # `-s /bin/bash`: `printf %q` emits bash's `$'…'` form, which root's *login* shell has to
262
+ # understand — and root's shell is very often /bin/sh or /bin/dash, which would take the
263
+ # `$'…'` literally. Both util-linux `su` and shadow's accept `-s`.
264
+ printf '%s\n%s\n' "$SETUP_SCRIPT_URL" "$node_token" |
265
+ su root -s /bin/bash -c "bash -c $(printf '%q' "$relay") _ ${quoted}" || status=$?
266
+ exit "$status"
267
+ fi
268
+ die "run this script as root: neither sudo nor su is available"
269
+ }
270
+
271
+ require_x86_64() {
272
+ local arch
273
+ arch="$(uname -m)"
274
+ [[ "$arch" == x86_64 ]] && return 0
275
+ ((dry_run)) && {
276
+ warn "this host is ${arch}; a real run refuses anything but x86_64"
277
+ return 0
278
+ }
279
+ die "CS2 and steamcmd are x86_64-only; this host is ${arch}"
280
+ }
281
+
282
+ # ---------------------------------------------------------------------------------------------
283
+ # agent.yml
284
+ #
285
+ # The controller builds the file: `POST <controller url><setup path>` with the node token renders
286
+ # the whole of it from the registry row and answers with it as text/yaml. What is read back out of
287
+ # it here is what the rest of the run needs: the configured image references.
288
+ # ---------------------------------------------------------------------------------------------
289
+
290
+ # Read the rendered top-level image fields, including preserved comments.
291
+ # These fields contain no whitespace or # characters; the controller writes one unindented key per
292
+ # line.
293
+ yaml_scalar() {
294
+ local key="$1" file="$2" value
295
+ value="$(sed -n "s/^${key}:[[:space:]]*//p" "$file" | head -n 1 |
296
+ sed 's/^#.*$//; s/[[:space:]]#.*$//; s/[[:space:]]*$//')"
297
+ value="${value%\"}"
298
+ value="${value#\"}"
299
+ value="${value%\'}"
300
+ value="${value#\'}"
301
+ printf '%s' "$value"
302
+ }
303
+
304
+ # An image reference without its digest, and without a tag after its last `/`.
305
+ image_repository() {
306
+ local name="${1%%@*}"
307
+ if [[ "${name##*/}" == *:* ]]; then
308
+ name="${name%:*}"
309
+ fi
310
+ printf '%s' "$name"
311
+ }
312
+
313
+ # Everything the run needs from the file, whether the controller just sent it or it was already
314
+ # here.
315
+ read_agent_config() {
316
+ local file="$1"
317
+ cs2_image="$(yaml_scalar cs2_image "$file")"
318
+ agent_image="$(yaml_scalar agent_image "$file")"
319
+
320
+ [[ -n "$cs2_image" ]] || die "${file} has no \"cs2_image:\" line (the cs2 image the slots run)"
321
+ [[ -n "$agent_image" ]] || die "${file} has no \"agent_image:\" line (the agent image this node runs)"
322
+
323
+ if [[ "$(yaml_scalar official_images_only "$file")" != false ]]; then
324
+ [[ "$(image_repository "$agent_image")" == "$OFFICIAL_AGENT_IMAGE" ]] ||
325
+ die "${file} names the agent image ${agent_image}, which is not from ${OFFICIAL_AGENT_IMAGE}; set official_images_only: false in it to run images of your own"
326
+ [[ "$(image_repository "$cs2_image")" == "$OFFICIAL_CS2_IMAGE" ]] ||
327
+ die "${file} names the cs2 image ${cs2_image}, which is not from ${OFFICIAL_CS2_IMAGE}; set official_images_only: false in it to run images of your own"
328
+ fi
329
+ }
330
+
331
+ have_tty() { { : </dev/tty; } 2>/dev/null; }
332
+
333
+ # The shape AgentConfigSchema.token requires.
334
+ valid_token() {
335
+ [[ "$1" =~ ^[A-Za-z0-9]{32,128}$ ]]
336
+ }
337
+
338
+ # On the terminal, hidden: stdin is the script itself when it arrives through a pipe.
339
+ ask_token() {
340
+ have_tty ||
341
+ die "no terminal to ask for the node token on; set ANKOR_NODE_TOKEN: curl -fsSL ${SETUP_SCRIPT_URL} | ANKOR_NODE_TOKEN=<token> bash"
342
+ printf 'Enter token: ' >/dev/tty
343
+ IFS= read -r -s node_token </dev/tty || true
344
+ printf '\n' >/dev/tty
345
+ }
346
+
347
+ # Fetches this node's agent.yml from `$1` with the node token and saves it.
348
+ fetch_agent_config() {
349
+ local origin="$1" answer
350
+
351
+ if ((dry_run)); then
352
+ log "a real run fetches this node's configuration from ${origin}; using a placeholder"
353
+ answer="$(placeholder_config "$origin")"
354
+ shown "write ${AGENT_CONFIG}:"
355
+ printf '%s\n' "$answer" | sed 's/^/ /'
356
+ # The rest of the dry run reads the file, so it goes somewhere this run may write.
357
+ local scratch
358
+ scratch="$(mktemp "${TMPDIR:-/tmp}/ankor-agent-yml.XXXXXX")"
359
+ printf '%s\n' "$answer" >"$scratch"
360
+ read_agent_config "$scratch"
361
+ rm -f "$scratch"
362
+ return 0
363
+ fi
364
+
365
+ answer="$(request_agent_config "$origin")"
366
+
367
+ # Written before Docker is installed, so the data directory may not exist yet.
368
+ install -d -m 0755 "$DATA_DIR"
369
+ chown "${GAME_UID}:${GAME_GID}" "$DATA_DIR"
370
+ # Not a pipe: a pipeline would run it in a subshell and lose the images read_agent_config sets.
371
+ save_agent_config <<<"$answer"
372
+ }
373
+
374
+ # The node's agent.yml as `$1` renders it for the node token. The token goes into curl's config on
375
+ # stdin, so it stays out of `ps`; the answer is the file, verbatim.
376
+ request_agent_config() {
377
+ local origin="$1" answer
378
+ log "asking ${origin} for this node's configuration" >&2
379
+
380
+ local response status=000 escaped="${node_token//\\/\\\\}"
381
+ escaped="${escaped//\"/\\\"}"
382
+ if response="$(printf 'url = "%s"\nheader = "Authorization: Bearer %s"\n' "${origin}${setup_path:-$DEFAULT_SETUP_PATH}" "$escaped" |
383
+ curl -sS --data-binary '' -H 'Accept: text/yaml' -H "Ankor-Protocol: ${PROTOCOL_VERSION}" \
384
+ -w '\n%{http_code}' -K - 2>/dev/null)"; then
385
+ status="${response##*$'\n'}"
386
+ answer="${response%$'\n'*}"
387
+ fi
388
+
389
+ case "$status" in
390
+ 2[0-9][0-9]) ;;
391
+ 401) die "${origin} does not recognise this node token; copy it again from where you saved it" ;;
392
+ 409) refused_conflict "$origin" "$answer" ;;
393
+ 426) die "${origin} uses a protocol version incompatible with this script's" ;;
394
+ 429) die "${origin} refused too many setup attempts from this address; wait a minute and run the command again" ;;
395
+ 000) die "could not reach ${origin}; check that this host can reach it and run the command again" ;;
396
+ *) die "${origin} did not answer with this node's configuration (HTTP ${status})" ;;
397
+ esac
398
+ printf '%s' "$answer"
399
+ }
400
+
401
+ # The two 409s: the node is connected from another host, or the controller cannot read its
402
+ # configuration.
403
+ refused_conflict() {
404
+ local origin="$1" body="$2"
405
+ if [[ "$body" == *'"node_connected"'* ]]; then
406
+ die "This token belongs to a node that is connected from another host."
407
+ fi
408
+ die "${origin} cannot read this node's stored configuration; ask the controller's operator"
409
+ }
410
+
411
+ # Replaces AGENT_CONFIG with stdin in one step.
412
+ save_agent_config() {
413
+ local next="${AGENT_CONFIG}.new"
414
+ (umask 077 && cat >"$next")
415
+ # Only the node's operator may let the node run other images, so the controller may not.
416
+ if [[ "$(yaml_scalar official_images_only "$next")" == false ]]; then
417
+ rm -f "$next"
418
+ die "the controller's configuration turns official_images_only off, which only this node's operator may do"
419
+ fi
420
+ # The node token is in here, and the agent reads it as uid 1000.
421
+ chown "${GAME_UID}:${GAME_GID}" "$next"
422
+ # A wrong answer stays out of agent.yml, where every later run would refuse it.
423
+ read_agent_config "$next"
424
+ mv -f "$next" "$AGENT_CONFIG"
425
+ }
426
+
427
+ # What a dry run shows: the shape of the answer, with no real values.
428
+ placeholder_config() {
429
+ cat <<EOF
430
+ # placeholder: a real run saves what ${1} rendered
431
+ token: <redacted>
432
+ controller:
433
+ host: <the controller's host>
434
+ tls: false
435
+ cs2_image: ${OFFICIAL_CS2_IMAGE}:<the release this deployment runs>
436
+ agent_image: ${OFFICIAL_AGENT_IMAGE}:<the release this deployment runs>
437
+ slots: []
438
+ EOF
439
+ }
440
+
441
+ # The file on disk is the node's identity, so a re-run reads it rather than fetching it again.
442
+ load_or_fetch_agent_config() {
443
+ if [[ -e "$AGENT_CONFIG" ]]; then
444
+ read_agent_config "$AGENT_CONFIG"
445
+ skip "${AGENT_CONFIG} exists; keeping it"
446
+ return 0
447
+ fi
448
+ fetch_agent_config "${controller_url:-$DEFAULT_CONTROLLER_URL}"
449
+ }
450
+ # ---------------------------------------------------------------------------------------------
451
+ # Distribution
452
+ # ---------------------------------------------------------------------------------------------
453
+
454
+ # ID first, then ID_LIKE, so a derivative lands on the family it is built from: Linux Mint on
455
+ # Ubuntu (with UBUNTU_CODENAME, which names the Ubuntu release it tracks), Rocky on RHEL,
456
+ # Manjaro on Arch.
457
+ detect_os() {
458
+ [[ -r /etc/os-release ]] || die "cannot read /etc/os-release to tell which distribution this is"
459
+
460
+ local like codename ubuntu_codename candidate
461
+ {
462
+ IFS= read -r os_id
463
+ IFS= read -r like
464
+ IFS= read -r codename
465
+ IFS= read -r ubuntu_codename
466
+ IFS= read -r os_name
467
+ } < <(
468
+ # shellcheck source=/dev/null
469
+ . /etc/os-release
470
+ printf '%s\n' "${ID:-}" "${ID_LIKE:-}" "${VERSION_CODENAME:-}" "${UBUNTU_CODENAME:-}" "${PRETTY_NAME:-${ID:-unknown}}"
471
+ )
472
+
473
+ for candidate in $os_id $like; do
474
+ case "$candidate" in
475
+ ubuntu) os_family=apt docker_repo=ubuntu docker_suite="${ubuntu_codename:-$codename}" ;;
476
+ debian) os_family=apt docker_repo=debian docker_suite="$codename" ;;
477
+ fedora) os_family=dnf docker_repo=fedora ;;
478
+ centos) os_family=dnf docker_repo=centos ;;
479
+ rhel | rocky | almalinux | ol) os_family=dnf docker_repo=rhel ;;
480
+ arch) os_family=pacman ;;
481
+ opensuse* | suse | sles) os_family=zypper ;;
482
+ alpine) os_family=apk ;;
483
+ *) continue ;;
484
+ esac
485
+ break
486
+ done
487
+
488
+ # Docker in LXC depends on features only the LXC host can grant; failures below name them.
489
+ if [[ "$(systemd-detect-virt -c 2>/dev/null || true)" == lxc* ]] || grep -qa 'container=lxc' /proc/1/environ 2>/dev/null; then
490
+ in_lxc=1
491
+ log "this host runs ${os_name} in an LXC container"
492
+ else
493
+ log "this host runs ${os_name}"
494
+ fi
495
+ }
496
+
497
+ lxc_hint() {
498
+ if ((in_lxc)); then
499
+ printf '; %s' "$LXC_HINT"
500
+ fi
501
+ }
502
+
503
+ # Docker and the agent come back after a reboot only through an init system that starts dockerd.
504
+ require_init_system() {
505
+ [[ "$(init_system)" == none ]] || return 0
506
+ if ((dry_run)); then
507
+ warn "no systemd or OpenRC runs as PID 1; a real run stops here"
508
+ return 0
509
+ fi
510
+ die "no systemd or OpenRC runs as PID 1 on this host, so Docker could not start at boot"
511
+ }
512
+
513
+ # Without nesting, the LXC refuses the overlay mounts containerd unpacks every image with. The same
514
+ # mount in a throwaway namespace answers before setup changes anything, Docker installed or not.
515
+ require_lxc_nesting() {
516
+ ((in_lxc)) || return 0
517
+ have unshare || return 0
518
+
519
+ local dir mounted=0
520
+ dir="$(mktemp -d /var/tmp/ankor-overlay.XXXXXX)"
521
+ mkdir "${dir}/lower" "${dir}/upper" "${dir}/work" "${dir}/merged"
522
+ if unshare --mount sh -c "mount -t overlay overlay -o lowerdir=${dir}/lower,upperdir=${dir}/upper,workdir=${dir}/work ${dir}/merged" 2>/dev/null; then
523
+ mounted=1
524
+ fi
525
+ rm -rf "$dir"
526
+
527
+ ((mounted)) || die "this LXC cannot mount the overlay filesystems Docker runs every container on$(lxc_hint)"
528
+ log "this LXC can mount overlay filesystems"
529
+ }
530
+
531
+ init_system() {
532
+ if [[ -d /run/systemd/system ]]; then
533
+ printf systemd
534
+ elif have rc-service && have rc-update; then
535
+ printf openrc
536
+ else
537
+ printf none
538
+ fi
539
+ }
540
+
541
+ pkg_installed() {
542
+ case "$os_family" in
543
+ apt) dpkg-query -W -f='${Status}' "$1" 2>/dev/null | grep -q 'install ok installed' ;;
544
+ dnf | zypper) rpm -q --quiet "$1" 2>/dev/null ;;
545
+ pacman) pacman -Q "$1" >/dev/null 2>&1 ;;
546
+ apk) apk info -e "$1" >/dev/null 2>&1 ;;
547
+ *) return 1 ;;
548
+ esac
549
+ }
550
+
551
+ pkg_install() {
552
+ case "$os_family" in
553
+ apt) run env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@" ;;
554
+ dnf) run dnf install -y -q "$@" ;;
555
+ # Arch supports no partial upgrades: refreshing the database means upgrading with it.
556
+ pacman) run pacman -Syu --needed --noconfirm "$@" ;;
557
+ zypper) run zypper --non-interactive --quiet install "$@" ;;
558
+ apk) run apk add --quiet "$@" ;;
559
+ *) die "no package manager this script knows; install $* yourself and re-run" ;;
560
+ esac
561
+ }
562
+
563
+ pkg_remove() {
564
+ case "$os_family" in
565
+ apt) run env DEBIAN_FRONTEND=noninteractive apt-get remove -y -qq "$@" ;;
566
+ dnf) run dnf remove -y -q "$@" ;;
567
+ pacman) run pacman -Rns --noconfirm "$@" ;;
568
+ zypper) run zypper --non-interactive --quiet remove "$@" ;;
569
+ apk) run apk del --quiet "$@" ;;
570
+ esac
571
+ }
572
+
573
+ # ---------------------------------------------------------------------------------------------
574
+ # Steps
575
+ # ---------------------------------------------------------------------------------------------
576
+
577
+ # curl for Docker's keys and repositories, flock for lock_setup.
578
+ install_prerequisites() {
579
+ local -a wanted=()
580
+ [[ "$os_family" != apt ]] || pkg_installed ca-certificates || wanted+=(ca-certificates)
581
+ have curl || wanted+=(curl)
582
+ have flock || wanted+=(util-linux)
583
+ if ((${#wanted[@]} == 0)); then
584
+ skip "base tools (curl, flock, CA certificates)"
585
+ return 0
586
+ fi
587
+ [[ -n "$os_family" ]] || die "missing ${wanted[*]} and no package manager this script knows; install them and re-run"
588
+
589
+ log "base packages: ${wanted[*]}"
590
+ case "$os_family" in
591
+ apt) run apt-get update -qq ;;
592
+ apk) run apk update --quiet ;;
593
+ esac
594
+ pkg_install "${wanted[@]}"
595
+ }
596
+
597
+ # What an existing Docker has to be for the agent: a rootful Docker Engine on DOCKER_SOCKET, which
598
+ # the agent container mounts, running containers with the host's uids and without SELinux labels,
599
+ # because the slots bind ${DATA_DIR} files owned by uid ${GAME_UID} and share the host network.
600
+ # Sets docker_state to ready, missing (install it) or podman (replace the Podman shim).
601
+ docker_state=""
602
+ # Whether an API version, major.minor, is at least the one the agent names.
603
+ docker_api_supported() {
604
+ [[ "$1" =~ ^([0-9]+)\.([0-9]+)$ ]] || return 1
605
+ local major="${BASH_REMATCH[1]}" minor="${BASH_REMATCH[2]}"
606
+ [[ "$DOCKER_API_VERSION" =~ ^([0-9]+)\.([0-9]+)$ ]]
607
+ ((major > BASH_REMATCH[1] || (major == BASH_REMATCH[1] && minor >= BASH_REMATCH[2])))
608
+ }
609
+
610
+ check_docker() {
611
+ docker_state=missing
612
+ have docker || return 0
613
+
614
+ if [[ "$(command -v docker)" == /snap/* ]]; then
615
+ die "Docker is the snap package, whose confinement cannot bind ${DATA_DIR}: remove it (snap remove docker) and re-run to install Docker Engine"
616
+ fi
617
+ if docker --version 2>/dev/null | grep -qi podman; then
618
+ docker_state=podman
619
+ return 0
620
+ fi
621
+
622
+ if ! docker info >/dev/null 2>&1; then
623
+ docker_service_exists || return 0
624
+ log "Docker Engine is installed but not answering on ${DOCKER_SOCKET}; starting it"
625
+ start_docker
626
+ if ((dry_run)); then
627
+ docker_state=ready
628
+ return 0
629
+ fi
630
+ wait_for_daemon || die "Docker Engine did not start; see the output of: $(docker_log_hint)$(lxc_hint)"
631
+ fi
632
+
633
+ if docker version --format '{{range .Server.Components}}{{.Name}} {{end}}' 2>/dev/null | grep -qi podman; then
634
+ docker_state=podman
635
+ return 0
636
+ fi
637
+
638
+ local security
639
+ security="$(docker info --format '{{range .SecurityOptions}}{{.}} {{end}}' 2>/dev/null)"
640
+ [[ "$security" != *name=rootless* ]] ||
641
+ die "the daemon on ${DOCKER_SOCKET} is rootless Docker; the agent needs the system daemon (systemctl enable --now docker)"
642
+ [[ "$security" != *name=userns* ]] ||
643
+ die "the daemon runs with userns-remap, which maps uid ${GAME_UID} away from the owner of ${DATA_DIR} and refuses host networking; remove \"userns-remap\" from /etc/docker/daemon.json, restart Docker and re-run"
644
+ [[ "$security" != *name=selinux* ]] ||
645
+ die "the daemon runs with SELinux labelling, which denies containers the Docker socket and ${DATA_DIR}; set \"selinux-enabled\": false in /etc/docker/daemon.json (or drop --selinux-enabled from the daemon's options), restart Docker and re-run"
646
+
647
+ local api
648
+ api="$(docker version --format '{{.Server.APIVersion}}' 2>/dev/null)"
649
+ docker_api_supported "$api" ||
650
+ die "Docker Engine $(docker version --format '{{.Server.Version}}' 2>/dev/null) supports API ${api:-unknown}; the agent needs Docker Engine ${MIN_DOCKER_ENGINE} or newer (API ${DOCKER_API_VERSION}): upgrade Docker and re-run"
651
+
652
+ local limits
653
+ limits="$(docker info --format '{{.MemoryLimit}} {{.CPUShares}}' 2>/dev/null)"
654
+ [[ "$limits" != false* ]] || warn "the daemon reports no memory limit support; a memory_mb cap in agent.yml is not enforced"
655
+ [[ "$limits" != *" false" ]] || warn "the daemon reports no CPU shares support; game servers get no CPU priority over other containers"
656
+
657
+ docker_state=ready
658
+ }
659
+
660
+ docker_service_exists() {
661
+ case "$(init_system)" in
662
+ systemd) systemctl cat docker.service >/dev/null 2>&1 ;;
663
+ openrc) [[ -e /etc/init.d/docker ]] ;;
664
+ *) return 1 ;;
665
+ esac
666
+ }
667
+
668
+ docker_log_hint() {
669
+ case "$(init_system)" in
670
+ systemd) printf 'journalctl -u docker.service' ;;
671
+ openrc) printf 'cat /var/log/docker.log' ;;
672
+ *) printf 'dockerd' ;;
673
+ esac
674
+ }
675
+
676
+ # Starts the daemon now and at every boot: the agent and the slots come back through their
677
+ # restart policy, which only runs once dockerd does. On systemd that is docker.service itself;
678
+ # docker.socket alone would start the daemon on first use, and nothing uses it after a reboot.
679
+ start_docker() {
680
+ case "$(init_system)" in
681
+ systemd)
682
+ mask_wait_online_in_container
683
+ run systemctl enable --now docker.service
684
+ ;;
685
+ openrc)
686
+ run rc-update add docker default
687
+ run rc-service docker start
688
+ ;;
689
+ *) warn "no systemd or OpenRC on this host: start dockerd yourself, and make it start at boot" ;;
690
+ esac
691
+ }
692
+
693
+ ensure_docker_at_boot() {
694
+ case "$(init_system)" in
695
+ systemd)
696
+ if ! systemctl cat docker.service >/dev/null 2>&1; then
697
+ warn "no docker.service unit; make sure dockerd starts at boot"
698
+ elif [[ "$(systemctl is-enabled docker.service 2>/dev/null)" == enabled ]]; then
699
+ skip "docker.service starts at boot"
700
+ else
701
+ log "enabling docker.service at boot"
702
+ run systemctl enable docker.service
703
+ fi
704
+ ;;
705
+ openrc)
706
+ if rc-update show default 2>/dev/null | grep -qw docker; then
707
+ skip "docker starts at boot (OpenRC default runlevel)"
708
+ else
709
+ log "adding docker to the OpenRC default runlevel"
710
+ run rc-update add docker default
711
+ fi
712
+ ;;
713
+ *) warn "no systemd or OpenRC on this host: make sure dockerd starts at boot" ;;
714
+ esac
715
+ }
716
+
717
+ wait_for_daemon() {
718
+ ((dry_run)) && return 0
719
+ local deadline=$((SECONDS + DAEMON_TIMEOUT_SECONDS))
720
+ until docker info >/dev/null 2>&1; do
721
+ ((SECONDS < deadline)) || return 1
722
+ sleep 1
723
+ done
724
+ }
725
+
726
+ # Inside an LXC (Proxmox and friends) the host configures the network before PID 1 starts, so
727
+ # systemd-networkd sees every link as "unmanaged" and systemd-networkd-wait-online never
728
+ # finishes. docker.service is After=network-online.target, and docker-ce's postinst starts it
729
+ # with a blocking `systemctl start` — the install hangs on that job forever. Masking the unit is
730
+ # correct here rather than a workaround: in a container the network is online by definition.
731
+ mask_wait_online_in_container() {
732
+ systemd-detect-virt --container --quiet || return 0
733
+ systemctl cat systemd-networkd-wait-online.service >/dev/null 2>&1 || return 0
734
+ [[ "$(systemctl is-enabled systemd-networkd-wait-online.service 2>/dev/null)" != masked ]] || return 0
735
+ log "container detected ($(systemd-detect-virt -c)); masking systemd-networkd-wait-online so docker.service can start"
736
+ run systemctl mask --now systemd-networkd-wait-online.service
737
+ }
738
+
739
+ # The distribution's own Docker and Podman's docker shim conflict with Docker's packages, or
740
+ # would answer on the socket instead of Docker Engine.
741
+ remove_conflicting_packages() {
742
+ local -a candidates=() installed=()
743
+ case "$os_family" in
744
+ apt) candidates=(docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc) ;;
745
+ dnf)
746
+ candidates=(docker docker-client docker-client-latest docker-common docker-latest docker-latest-logrotate
747
+ docker-logrotate docker-selinux docker-engine-selinux docker-engine moby-engine podman-docker)
748
+ # Docker's RHEL and CentOS guides remove Podman and runc as well: containerd.io replaces runc.
749
+ [[ "$docker_repo" == fedora ]] || candidates+=(podman runc)
750
+ ;;
751
+ pacman | zypper | apk) candidates=(podman-docker) ;;
752
+ esac
753
+
754
+ local candidate
755
+ for candidate in "${candidates[@]}"; do
756
+ pkg_installed "$candidate" && installed+=("$candidate")
757
+ done
758
+ if ((${#installed[@]} > 0)); then
759
+ log "removing packages that conflict with Docker Engine: ${installed[*]}"
760
+ pkg_remove "${installed[@]}"
761
+ fi
762
+
763
+ # podman-docker's tmpfiles entry points the Docker socket at Podman until the next boot.
764
+ if [[ -L "$DOCKER_SOCKET" ]]; then
765
+ log "removing ${DOCKER_SOCKET}, a link to $(readlink "$DOCKER_SOCKET")"
766
+ run rm -f "$DOCKER_SOCKET"
767
+ fi
768
+ }
769
+
770
+ # docs.docker.com/engine/install/{debian,ubuntu}
771
+ install_docker_apt() {
772
+ [[ -n "$docker_suite" ]] || die "cannot read VERSION_CODENAME from /etc/os-release"
773
+ local arch
774
+ arch="$(dpkg --print-architecture)"
775
+ log "installing Docker Engine from download.docker.com (${docker_repo} ${docker_suite})"
776
+ run install -m 0755 -d /etc/apt/keyrings
777
+ run curl -fsSL "https://download.docker.com/linux/${docker_repo}/gpg" -o /etc/apt/keyrings/docker.asc
778
+ run chmod a+r /etc/apt/keyrings/docker.asc
779
+ run_sh "printf 'Types: deb\nURIs: https://download.docker.com/linux/%s\nSuites: %s\nComponents: stable\nArchitectures: %s\nSigned-By: /etc/apt/keyrings/docker.asc\n' \
780
+ '${docker_repo}' '${docker_suite}' '${arch}' > /etc/apt/sources.list.d/docker.sources"
781
+ run apt-get update -qq ||
782
+ die "apt could not read Docker's ${docker_repo} repository for \"${docker_suite}\"; check https://docs.docker.com/engine/install/${docker_repo}/ for the supported releases"
783
+ pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
784
+ }
785
+
786
+ # docs.docker.com/engine/install/{fedora,centos,rhel}. The repo file is fetched directly, which
787
+ # works the same under dnf 4 and dnf 5, whose config-manager syntaxes differ.
788
+ install_docker_dnf() {
789
+ log "installing Docker Engine from download.docker.com (${docker_repo})"
790
+ run curl -fsSL "https://download.docker.com/linux/${docker_repo}/docker-ce.repo" -o /etc/yum.repos.d/docker-ce.repo
791
+ pkg_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ||
792
+ die "Docker's ${docker_repo} repository has no packages for ${os_name}; check https://docs.docker.com/engine/install/${docker_repo}/ for the supported releases"
793
+ }
794
+
795
+ # wiki.alpinelinux.org/wiki/Docker: the packages live in the community repository.
796
+ enable_alpine_community() {
797
+ grep -Eq '^[^#]*/community/?$' /etc/apk/repositories && return 0
798
+ local community
799
+ community="$(sed -n 's#^\([^#].*\)/main/\{0,1\}$#\1/community#p' /etc/apk/repositories | head -n 1)"
800
+ [[ -n "$community" ]] || die "no main repository in /etc/apk/repositories to derive the community one from"
801
+ log "enabling the Alpine community repository (${community})"
802
+ run_sh "printf '%s\n' '${community}' >> /etc/apk/repositories"
803
+ run apk update --quiet
804
+ }
805
+
806
+ install_docker() {
807
+ check_docker
808
+ case "$docker_state" in
809
+ ready)
810
+ skip "Docker Engine $(docker version --format '{{.Server.Version}}' 2>/dev/null) answers on ${DOCKER_SOCKET}"
811
+ ensure_docker_at_boot
812
+ return 0
813
+ ;;
814
+ podman) log "the docker command on this host is Podman's; replacing it with Docker Engine" ;;
815
+ esac
816
+
817
+ [[ -n "$os_family" ]] ||
818
+ die "Docker is missing and ${os_name} is not a distribution this script installs it on; install Docker Engine (https://docs.docker.com/engine/install/) and re-run"
819
+
820
+ # Before the packages: docker-ce's postinst starts the daemon on Debian and Ubuntu.
821
+ if [[ "$(init_system)" == systemd ]]; then
822
+ mask_wait_online_in_container
823
+ fi
824
+ remove_conflicting_packages
825
+
826
+ case "$os_family" in
827
+ apt) install_docker_apt ;;
828
+ dnf) install_docker_dnf ;;
829
+ # wiki.archlinux.org/title/Docker
830
+ pacman)
831
+ log "installing Docker from the Arch repositories"
832
+ pkg_install docker docker-buildx docker-compose
833
+ ;;
834
+ zypper)
835
+ log "installing Docker from the openSUSE repositories"
836
+ pkg_install docker docker-compose
837
+ ;;
838
+ apk)
839
+ enable_alpine_community
840
+ log "installing Docker from the Alpine repositories"
841
+ pkg_install docker docker-cli-compose
842
+ ;;
843
+ esac
844
+
845
+ start_docker
846
+ ((dry_run)) && return 0
847
+ wait_for_daemon || die "Docker Engine did not start after the install; see the output of: $(docker_log_hint)$(lxc_hint)"
848
+ check_docker
849
+ [[ "$docker_state" == ready ]] || die "the docker command still does not reach Docker Engine on ${DOCKER_SOCKET}"
850
+ log "Docker Engine $(docker version --format '{{.Server.Version}}') is running"
851
+ }
852
+
853
+ # `docker` must work before anything below it does.
854
+ require_docker() {
855
+ have docker && return 0
856
+ ((dry_run)) && return 0
857
+ die "docker is still not on PATH after the install step"
858
+ }
859
+
860
+ create_storage() {
861
+ # The operator copies node plugins and cfgs in as root; the agent creates folders inside them.
862
+ local dirs=("$DATA_DIR" "${DATA_DIR}/slots" "${DATA_DIR}/cfg" "${DATA_DIR}/swiftlys2"
863
+ "${DATA_DIR}/swiftlys2/plugins" "${DATA_DIR}/swiftlys2/configs" "${DATA_DIR}/swiftlys2/configs/plugins")
864
+ log "data directory ${DATA_DIR} (agent.yml, per-slot files, node plugins and cfgs), owned by uid ${GAME_UID}"
865
+ # chown, not `install -o`: uutils install (Ubuntu 26.04) refuses a uid with no passwd entry.
866
+ run install -d -m 0755 "${dirs[@]}"
867
+ run chown "${GAME_UID}:${GAME_GID}" "${dirs[@]}"
868
+
869
+ if docker volume inspect "$CS2_VOLUME" >/dev/null 2>&1; then
870
+ skip "Docker volume ${CS2_VOLUME} exists"
871
+ else
872
+ log "creating the shared CS2 install volume ${CS2_VOLUME}"
873
+ run docker volume create "$CS2_VOLUME"
874
+ fi
875
+ }
876
+
877
+ # One setup or update at a time on a host.
878
+ lock_setup() {
879
+ ((dry_run)) && return 0
880
+ exec 9>/var/lock/ankor-node-setup.lock
881
+ flock -n 9 || die "another node setup/update is running"
882
+ }
883
+
884
+ # Setup can reuse a loaded image; updates refresh it unless --no-pull was requested.
885
+ # The cs2 image belongs to the agent: it pulls it when the first install needs it.
886
+ prepare_agent_image() {
887
+ if ((no_pull)); then
888
+ log "using the local agent image ${agent_image} (--no-pull)"
889
+ if ((dry_run)); then
890
+ run docker image inspect "$agent_image"
891
+ elif ! docker image inspect "$agent_image" >/dev/null; then
892
+ die "cannot use ${agent_image}: --no-pull requires an available local image (load it first)"
893
+ fi
894
+ return 0
895
+ fi
896
+ if ((do_update == 0 && dry_run == 0)) && docker image inspect "$agent_image" >/dev/null 2>&1; then
897
+ skip "${agent_image} is on this host; not pulled"
898
+ return 0
899
+ fi
900
+ log "pulling ${agent_image}"
901
+ run docker pull "$agent_image"
902
+ }
903
+
904
+ # The agent's own flags on a throwaway container that prints the agent's version and exits, before
905
+ # start_agent removes the running agent. `--cpu-shares` needs the cpu controller delegated to the
906
+ # LXC; without it Docker fails here, with a clearer error than the agent's restart loop.
907
+ probe_lxc_containers() {
908
+ ((in_lxc)) || return 0
909
+ if ((dry_run)); then
910
+ shown "docker run --pull=never --rm --network host --cpu-shares 1024 ${agent_image} --version"
911
+ return 0
912
+ fi
913
+ local output
914
+ if ! output="$(docker run --pull=never --rm --network host --cpu-shares 1024 "$agent_image" --version 2>&1)"; then
915
+ printf '%s\n' "$output" >&2
916
+ die "a test container could not start in this LXC$(lxc_hint)"
917
+ fi
918
+ log "containers start in this LXC"
919
+ }
920
+
921
+ start_agent() {
922
+ if docker inspect "$AGENT_CONTAINER" >/dev/null 2>&1; then
923
+ log "replacing the existing ${AGENT_CONTAINER} container"
924
+ run docker rm -f "$AGENT_CONTAINER"
925
+ fi
926
+
927
+ # The agent this starts is no failed swap's, so the record of one goes; first, as the agent reads it at boot.
928
+ run rm -f "$AGENT_UPDATE_FAILURE"
929
+
930
+ log "starting ${AGENT_CONTAINER} from ${agent_image}"
931
+ # `--network host`: the agent dials the controller out of this box and its game containers want the
932
+ # host's network anyway. The data dir is mounted at *the same host path* because the game
933
+ # containers are siblings — dockerd resolves their bind mounts by host path, not ours.
934
+ # agent.yml lives in it, where the agent replaces it whole when the controller edits the node. meminfo, the cgroup tree and os-release are the host's, read-only, for the
935
+ # node's resource ticks and hello. The CS2 volume is mounted read-only at /cs2-install, where the
936
+ # agent reads the installed build. The docker group is the image's business: the agent
937
+ # reads the gid off the mounted socket and drops to its unprivileged user with it,
938
+ # which is one fewer host detail for this script to guess.
939
+ # The label is how the agent finds its own container when it replaces itself.
940
+ # Non-blocking logging: dockerd drops console lines it cannot keep up with, rather than block the
941
+ # agent on a write. The agent gives its replacement and its game containers the same options.
942
+ run docker run --pull=never -d \
943
+ --name "$AGENT_CONTAINER" \
944
+ --label ankor.role=agent \
945
+ --cpu-shares 2598 \
946
+ --restart unless-stopped \
947
+ --log-driver local \
948
+ --log-opt mode=non-blocking \
949
+ --log-opt max-buffer-size=4m \
950
+ --network host \
951
+ -v "${DOCKER_SOCKET}:${DOCKER_SOCKET}" \
952
+ -v /proc/meminfo:/host/proc/meminfo:ro \
953
+ -v /sys/fs/cgroup:/host/sys/fs/cgroup:ro \
954
+ -v /etc/os-release:/host/etc/os-release:ro \
955
+ -v "${DATA_DIR}:${DATA_DIR}" \
956
+ -v "${CS2_VOLUME}:/cs2-install:ro" \
957
+ "$agent_image"
958
+ }
959
+
960
+ # Wait for the agent to say it reached the controller. The agent retries forever on its own, so a
961
+ # timeout here is a hint to go read the logs, not a failure of the setup.
962
+ wait_for_hello() {
963
+ if ((dry_run)); then
964
+ shown "docker logs -f --tail 50 ${AGENT_CONTAINER} # until \"connected to\", max ${CONNECT_TIMEOUT_SECONDS}s"
965
+ return 0
966
+ fi
967
+
968
+ log "waiting up to ${CONNECT_TIMEOUT_SECONDS}s for the agent to connect"
969
+ local deadline=$((SECONDS + CONNECT_TIMEOUT_SECONDS)) logs
970
+ while ((SECONDS < deadline)); do
971
+ logs="$(docker logs --tail 200 "$AGENT_CONTAINER" 2>&1 || true)"
972
+ if printf '%s' "$logs" | grep -q 'connected to '; then
973
+ printf '%s\n' "$logs" | tail -n 20
974
+ log "agent connected."
975
+ return 0
976
+ fi
977
+ if printf '%s' "$logs" | grep -qE 'unauthorized|401|invalid agent\.yml|invalid /'; then
978
+ printf '%s\n' "$logs" | tail -n 20
979
+ die "the agent rejected its configuration or its token; check ${AGENT_CONFIG}; a node deleted in the controller has to be added again"
980
+ fi
981
+ sleep 2
982
+ done
983
+
984
+ docker logs --tail 30 "$AGENT_CONTAINER" 2>&1 || true
985
+ warn "the agent has not reported a connection yet. It keeps retrying;"
986
+ warn "watch it with: docker logs -f ${AGENT_CONTAINER}"
987
+ }
988
+
989
+ summary() {
990
+ cat <<EOF
991
+
992
+ The node is set up. Before it can host games, you must configure server slots for it.
993
+
994
+ Next:
995
+ follow the agent: docker logs -f ${AGENT_CONTAINER}
996
+ update the agent image: run this script again with --update
997
+ install/update CS2: Install CS2 (later Update CS2) for this node in the controller
998
+ re-run this setup: safe, and asks for nothing — agent.yml stays as it is
999
+
1000
+ You must ensure connectivity from the clients to the assigned ports on this host yourself.
1001
+
1002
+ EOF
1003
+ }
1004
+
1005
+ # ---------------------------------------------------------------------------------------------
1006
+ # Main
1007
+ # ---------------------------------------------------------------------------------------------
1008
+
1009
+ if ((do_update)); then
1010
+ log "update mode"
1011
+ require_root
1012
+ require_docker
1013
+ if [[ -e "$AGENT_CONFIG" ]]; then
1014
+ read_agent_config "$AGENT_CONFIG"
1015
+ elif ((dry_run)); then
1016
+ warn "no ${AGENT_CONFIG} on this host; a real run needs one (set the node up first)"
1017
+ agent_image="<from agent.yml>"
1018
+ cs2_image="<from agent.yml>"
1019
+ else
1020
+ die "no ${AGENT_CONFIG} on this host: run the setup form of this script first"
1021
+ fi
1022
+ # Checked before the pull, so a host that was never set up is left exactly as it was.
1023
+ docker inspect "$AGENT_CONTAINER" >/dev/null 2>&1 || ((dry_run)) ||
1024
+ die "no ${AGENT_CONTAINER} container on this host: run the setup form of this script first"
1025
+ lock_setup
1026
+ prepare_agent_image
1027
+ log "restarting ${AGENT_CONTAINER} onto ${agent_image}"
1028
+ # Re-created from the agent.yml already on disk, so an update needs no arguments at all.
1029
+ start_agent
1030
+ log "update done."
1031
+ exit 0
1032
+ fi
1033
+
1034
+ # The token is asked for before root, so the elevated run gets it over the relay; nothing else runs
1035
+ # unprivileged, because the elevated run would do it again. agent.yml is fetched once the host
1036
+ # passed its checks and before Docker is installed, so a wrong token stops the run before it
1037
+ # changes much.
1038
+ if [[ -e "$AGENT_CONFIG" ]]; then
1039
+ [[ -z "$node_token" ]] || warn "${AGENT_CONFIG} exists and is kept; ANKOR_NODE_TOKEN is not used"
1040
+ node_token=""
1041
+ else
1042
+ [[ -n "$node_token" ]] || ((dry_run)) || ask_token
1043
+ [[ -z "$node_token" ]] || valid_token "$node_token" ||
1044
+ die "that is not a node token (32 to 128 letters and digits); copy it again from the controller"
1045
+ fi
1046
+ require_root
1047
+ require_x86_64
1048
+ detect_os
1049
+ require_init_system
1050
+ require_lxc_nesting
1051
+ install_prerequisites
1052
+ lock_setup
1053
+ load_or_fetch_agent_config
1054
+ install_docker
1055
+ require_docker
1056
+ create_storage
1057
+ prepare_agent_image
1058
+ probe_lxc_containers
1059
+ start_agent
1060
+ wait_for_hello
1061
+ summary