@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/CHANGELOG.md +9 -0
- package/LICENSE +202 -0
- package/README.md +72 -2
- package/dist/agent-config.d.ts +101 -0
- package/dist/agent-config.js +144 -0
- package/dist/agent-image.d.ts +12 -0
- package/dist/agent-image.js +12 -0
- package/dist/agent-socket.d.ts +3927 -0
- package/dist/agent-socket.js +1764 -0
- package/dist/agent-yml.d.ts +21 -0
- package/dist/agent-yml.js +71 -0
- package/dist/agent-yml.schema.json +160 -0
- package/dist/asyncapi.yaml +10569 -0
- package/dist/checks.d.ts +19 -0
- package/dist/checks.js +40 -0
- package/dist/container.d.ts +190 -0
- package/dist/container.js +144 -0
- package/dist/event-batch.d.ts +857 -0
- package/dist/event-batch.js +90 -0
- package/dist/game-events.d.ts +2137 -0
- package/dist/game-events.js +1082 -0
- package/dist/helpers.d.ts +176 -0
- package/dist/helpers.js +362 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +26 -0
- package/dist/launch.d.ts +316 -0
- package/dist/launch.js +287 -0
- package/dist/limits.d.ts +381 -0
- package/dist/limits.js +277 -0
- package/dist/paths.d.ts +73 -0
- package/dist/paths.js +73 -0
- package/dist/plugin-socket.d.ts +1946 -0
- package/dist/plugin-socket.js +160 -0
- package/dist/session.d.ts +127 -0
- package/dist/session.js +114 -0
- package/dist/values.d.ts +186 -0
- package/dist/values.js +230 -0
- package/node-setup.sh +1061 -0
- package/package.json +57 -4
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
|