@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
|
@@ -0,0 +1,1764 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound agent WebSocket authenticated by bearer token.
|
|
3
|
+
*
|
|
4
|
+
* Requests carry `id`. A reply echoes it in `reply_to` and names the request's `type` in `request`.
|
|
5
|
+
* Unsolicited frames omit correlation fields.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { MatchId, MAX_SERVER_PLAYERS, PlayerName, PlayingSide, RoundBackup, RoundNumber, Side, SteamId64, } from './game-events.js';
|
|
9
|
+
import { addGsltIssues, addSlotLayoutIssues } from './helpers.js';
|
|
10
|
+
import { ServerLaunchConfig } from './launch.js';
|
|
11
|
+
import { MAX_REPORTED_BACKLOGS, MAX_REPORTED_DEMOS, MAX_SLOTS, } from './limits.js';
|
|
12
|
+
import { AGENT_MIN_SLOT_PORT, AllocationId, DemoUploadEdit, Gslt, GsltState, ImageReference, Ipv4, MemoryLimit, ReportedDemoUpload, ReportedSlot, ServerSlot, SlotId, ControllerAddress, } from './values.js';
|
|
13
|
+
/** The `code` of a failed {@link AgentReply | reply}. `detail` carries the human-readable rest. */
|
|
14
|
+
export const AGENT_ERRORS = {
|
|
15
|
+
/** No slot is idle, unreserved and on a token Steam has not refused. */
|
|
16
|
+
noFreeSlot: 'no_free_slot',
|
|
17
|
+
/**
|
|
18
|
+
* A newer public CS2 build is out and the node has not updated to it yet, so its `cs2.build_id`
|
|
19
|
+
* is behind `cs2.latest_build_id`. The node takes launches again once it has updated. A node
|
|
20
|
+
* whose `auto_update` is `false` never refuses with this.
|
|
21
|
+
*/
|
|
22
|
+
cs2Outdated: 'cs2_outdated',
|
|
23
|
+
/**
|
|
24
|
+
* CS2 is not installed on the node, so its `cs2.build_id` is `null`. The node takes launches once
|
|
25
|
+
* an {@link ControllerUpdateCs2 | update_cs2} has installed it.
|
|
26
|
+
*/
|
|
27
|
+
cs2NotInstalled: 'cs2_not_installed',
|
|
28
|
+
/** The node reports `unavailable` for a reason other than `maintenance`. */
|
|
29
|
+
nodeUnavailable: 'node_unavailable',
|
|
30
|
+
/**
|
|
31
|
+
* An operation that needs every slot idle found one held: {@link ControllerUpdateCs2 | update_cs2}, or a
|
|
32
|
+
* {@link ControllerSetConfig | set_config} that changes any key under `controller`.
|
|
33
|
+
*/
|
|
34
|
+
slotsHeld: 'slots_held',
|
|
35
|
+
/**
|
|
36
|
+
* A slot holds a server that is `starting`, `running` or `stopping`, and
|
|
37
|
+
* {@link ControllerSetConfig | set_config} would remove or re-port the slot or give its server's token
|
|
38
|
+
* to another slot, or {@link ControllerReserveSlot | reserve_slot} asked for it. `detail` names the
|
|
39
|
+
* slots.
|
|
40
|
+
*/
|
|
41
|
+
slotHeld: 'slot_held',
|
|
42
|
+
/** The node has no slot with that id. */
|
|
43
|
+
unknownSlot: 'unknown_slot',
|
|
44
|
+
/**
|
|
45
|
+
* {@link ControllerSetConfig | set_config} kept the token of a slot the node does not have yet. A new
|
|
46
|
+
* slot needs a new token.
|
|
47
|
+
*/
|
|
48
|
+
slotTokenRequired: 'slot_token_required',
|
|
49
|
+
/**
|
|
50
|
+
* {@link ControllerSetConfig | set_config} kept the value of a demo upload header the node does not
|
|
51
|
+
* send. A new header needs its value.
|
|
52
|
+
*/
|
|
53
|
+
demoHeaderValueRequired: 'demo_header_value_required',
|
|
54
|
+
/** No server with that allocation id. */
|
|
55
|
+
unknownAllocation: 'unknown_allocation',
|
|
56
|
+
/**
|
|
57
|
+
* A maintenance operation runs on the node. That operation is unaffected, and `unavailable` shows
|
|
58
|
+
* it as `maintenance`.
|
|
59
|
+
*/
|
|
60
|
+
maintenanceRunning: 'maintenance_running',
|
|
61
|
+
/**
|
|
62
|
+
* The image is not on the node and the request asked for no pull. Asking again with `pull`
|
|
63
|
+
* fetches it.
|
|
64
|
+
*/
|
|
65
|
+
imageMissing: 'image_missing',
|
|
66
|
+
/**
|
|
67
|
+
* The image is not an official one, and the node's `official_images_only` is on. The node's
|
|
68
|
+
* operator decides which images it runs.
|
|
69
|
+
*/
|
|
70
|
+
imageNotAllowed: 'image_not_allowed',
|
|
71
|
+
/** The demo is uploading. It can be deleted once the upload ends. */
|
|
72
|
+
demoUploading: 'demo_uploading',
|
|
73
|
+
/** The slot has no demo of that name, such as one an upload or a delete has already removed. */
|
|
74
|
+
unknownDemo: 'unknown_demo',
|
|
75
|
+
/** The demo is still being recorded. It can be deleted once its server ends the recording. */
|
|
76
|
+
demoRecording: 'demo_recording',
|
|
77
|
+
/** The server's plugin is not connected to the agent, or left before it answered. */
|
|
78
|
+
pluginOffline: 'plugin_offline',
|
|
79
|
+
/**
|
|
80
|
+
* The plugin did not answer {@link ControllerGetServerStatus | get_server_status},
|
|
81
|
+
* {@link ControllerConsoleCommand | console_command}, {@link ControllerStartMatch | start_match} or
|
|
82
|
+
* {@link ControllerRestoreRound | restore_round} within {@link SERVER_STATUS_TIMEOUT_MS}.
|
|
83
|
+
*/
|
|
84
|
+
pluginStatusTimeout: 'plugin_status_timeout',
|
|
85
|
+
/** The server runs without a match. */
|
|
86
|
+
noMatch: 'no_match',
|
|
87
|
+
/** The match is past its warmup. */
|
|
88
|
+
notInWarmup: 'not_in_warmup',
|
|
89
|
+
/** A team has no player connected. */
|
|
90
|
+
teamEmpty: 'team_empty',
|
|
91
|
+
/** The match is not live, such as in warmup, during the knife round or after it ended. */
|
|
92
|
+
notLive: 'not_live',
|
|
93
|
+
/**
|
|
94
|
+
* The match is in its halftime break, while the sides are about to swap. Ask again once the next
|
|
95
|
+
* round has started.
|
|
96
|
+
*/
|
|
97
|
+
atHalftime: 'at_halftime',
|
|
98
|
+
/**
|
|
99
|
+
* The node has no backup of the round that {@link ControllerRestoreRound | restore_round} named for that
|
|
100
|
+
* server.
|
|
101
|
+
*/
|
|
102
|
+
noBackup: 'no_backup',
|
|
103
|
+
/** The request did not match its schema. */
|
|
104
|
+
invalidRequest: 'invalid_request',
|
|
105
|
+
/**
|
|
106
|
+
* {@link ControllerStartServer | start_server} named an allocation this node is already launching or
|
|
107
|
+
* running. Its server exists or is on its way, as after a {@link ControllerStartServer | start_server}
|
|
108
|
+
* sent again when its reply was slow.
|
|
109
|
+
*/
|
|
110
|
+
allocationInUse: 'allocation_in_use',
|
|
111
|
+
/**
|
|
112
|
+
* {@link ControllerStartServer | start_server} named an allocation that this node launched before, among
|
|
113
|
+
* the last {@link REMEMBERED_ALLOCATIONS}. The node files a server's events and demos under its
|
|
114
|
+
* allocation, so every launch needs a new one.
|
|
115
|
+
*/
|
|
116
|
+
allocationReused: 'allocation_reused',
|
|
117
|
+
/** The agent does not know the request's `type`, or the plugin the command's. */
|
|
118
|
+
unsupportedRequest: 'unsupported_request',
|
|
119
|
+
/**
|
|
120
|
+
* The agent is stopping and started no work for the request, as after its
|
|
121
|
+
* {@link AgentShutdown | shutdown}. Send the request again once the node reconnects.
|
|
122
|
+
*/
|
|
123
|
+
shuttingDown: 'shutting_down',
|
|
124
|
+
/** Any failure that no other code names, such as a Docker failure. */
|
|
125
|
+
failed: 'failed',
|
|
126
|
+
};
|
|
127
|
+
/** The `code` of a failed reply, one of the values of {@link AGENT_ERRORS}. */
|
|
128
|
+
export const AgentErrorCode = z.enum(AGENT_ERRORS);
|
|
129
|
+
/** Longest `detail` a refused request carries. The agent cuts the message to it. */
|
|
130
|
+
export const AGENT_ERROR_DETAIL_MAX_CHARS = 512;
|
|
131
|
+
/** Why a request failed. Branch on `code`. `detail` is for people. */
|
|
132
|
+
export const AgentError = z.object({
|
|
133
|
+
/** What went wrong. Branch on it. */
|
|
134
|
+
code: AgentErrorCode,
|
|
135
|
+
/**
|
|
136
|
+
* The rest of the reason, such as Docker's message, up to {@link AGENT_ERROR_DETAIL_MAX_CHARS}
|
|
137
|
+
* characters.
|
|
138
|
+
*/
|
|
139
|
+
detail: z.string().max(AGENT_ERROR_DETAIL_MAX_CHARS),
|
|
140
|
+
});
|
|
141
|
+
/**
|
|
142
|
+
* The {@link ControllerStartServer | start_server} refusals the agent sends before reserving a slot.
|
|
143
|
+
* Nothing started, so another node may take the launch.
|
|
144
|
+
*/
|
|
145
|
+
export const StartRefusal = z.enum([
|
|
146
|
+
AGENT_ERRORS.noFreeSlot, AGENT_ERRORS.maintenanceRunning, AGENT_ERRORS.cs2NotInstalled, AGENT_ERRORS.cs2Outdated, AGENT_ERRORS.nodeUnavailable,
|
|
147
|
+
AGENT_ERRORS.shuttingDown,
|
|
148
|
+
]);
|
|
149
|
+
/**
|
|
150
|
+
* What a slot is doing.
|
|
151
|
+
*
|
|
152
|
+
* - `idle`: the slot has no server.
|
|
153
|
+
* - `starting`: the slot holds a server that its plugin has not yet reported ready for players with
|
|
154
|
+
* a `server_status` with `ready`. A server that a restarted agent took over starts here too.
|
|
155
|
+
* - `running`: the plugin has reported the server ready.
|
|
156
|
+
* - `stopping`: the server is being stopped.
|
|
157
|
+
*/
|
|
158
|
+
export const SlotState = z.enum(['idle', 'starting', 'running', 'stopping']);
|
|
159
|
+
/**
|
|
160
|
+
* Why the agent stopped a server on its own.
|
|
161
|
+
*
|
|
162
|
+
* - `idle_empty`: the server stayed empty for its launch's `idle_shutdown_seconds`.
|
|
163
|
+
* - `config_rejected`: the plugin refused its match config, as `config_error` says.
|
|
164
|
+
* - `start_timeout`: the server was still `starting` {@link START_TIMEOUT_SECONDS} after its
|
|
165
|
+
* launch, or after a restarted agent took it over.
|
|
166
|
+
* - `quit_timeout`: the server was still running {@link QUIT_TIMEOUT_SECONDS} after its plugin sent
|
|
167
|
+
* `quitting`.
|
|
168
|
+
* - `slot_removed`: a restarted agent found the server running on a slot that `agent.yml` no longer
|
|
169
|
+
* has.
|
|
170
|
+
*/
|
|
171
|
+
export const AgentStopReason = z.enum(['idle_empty', 'config_rejected', 'start_timeout', 'quit_timeout', 'slot_removed']);
|
|
172
|
+
/**
|
|
173
|
+
* Why a server ended: one of the {@link AgentStopReason} values when the agent stopped it on its
|
|
174
|
+
* own, or one of these.
|
|
175
|
+
*
|
|
176
|
+
* - `requested`: a {@link ControllerStopServer | stop_server} from the controller stopped it.
|
|
177
|
+
* - `clean_exit`: it ended on its own with exit code `0`, as after its match or a console `quit`.
|
|
178
|
+
* - `crashed`: it ended on its own with another exit code.
|
|
179
|
+
* - `launch_failed`: its container never started.
|
|
180
|
+
* - `lost`: it ended and the agent did not see how, such as a container removed by hand.
|
|
181
|
+
*/
|
|
182
|
+
export const ServerExitReason = z.enum([...AgentStopReason.options, 'requested', 'clean_exit', 'crashed', 'launch_failed', 'lost']);
|
|
183
|
+
/**
|
|
184
|
+
* Up to {@link MAX_SLOTS} slots. Rejected when two slots share an `id`, or when two ports are equal.
|
|
185
|
+
* Every slot's `port` and `tv_port` count, so a slot's two ports differ too. It is also rejected
|
|
186
|
+
* when a port is {@link EVENT_RELAY_PORT}, where the agent listens for plugins.
|
|
187
|
+
*/
|
|
188
|
+
const SlotLayout = z.array(ReportedSlot).max(MAX_SLOTS).superRefine((slots, ctx) => addSlotLayoutIssues(ctx, slots, AGENT_MIN_SLOT_PORT));
|
|
189
|
+
/**
|
|
190
|
+
* A slot as {@link ControllerSetConfig | set_config} asks for it.
|
|
191
|
+
*
|
|
192
|
+
* `gslt` is a new token, which may replace the token of a slot that runs a server. That server
|
|
193
|
+
* keeps the token it started with, and no other slot may use that token until the server ends
|
|
194
|
+
* (`slot_held`). Without `gslt` the slot keeps its current token, so a slot the node does not have
|
|
195
|
+
* yet needs one (`slot_token_required`), and a slot from a {@link AgentHello | hello} can be sent
|
|
196
|
+
* back unchanged.
|
|
197
|
+
*/
|
|
198
|
+
export const SlotEdit = ServerSlot.extend({
|
|
199
|
+
/** The slot's new token. Without one, the slot keeps the token it has. */
|
|
200
|
+
gslt: Gslt.optional(),
|
|
201
|
+
});
|
|
202
|
+
/** The layout rules of `SlotLayout`, and no token twice among the slots that carry one. */
|
|
203
|
+
const SlotEditLayout = z.array(SlotEdit).max(MAX_SLOTS).superRefine((slots, ctx) => {
|
|
204
|
+
addSlotLayoutIssues(ctx, slots, AGENT_MIN_SLOT_PORT);
|
|
205
|
+
addGsltIssues(ctx, slots);
|
|
206
|
+
});
|
|
207
|
+
/** The id Docker gives an image: `sha256:` and 64 hex digits. */
|
|
208
|
+
export const ImageId = z.string().regex(/^sha256:[0-9a-f]{64}$/, 'must be a sha256 image id');
|
|
209
|
+
/** A Steam build id, in decimal. */
|
|
210
|
+
export const BuildId = z.string().regex(/^\d{1,20}$/);
|
|
211
|
+
const slotStatusFields = {
|
|
212
|
+
/** The slot this describes. */
|
|
213
|
+
slot_id: SlotId,
|
|
214
|
+
/** What the slot is doing. */
|
|
215
|
+
state: SlotState,
|
|
216
|
+
/** The server on the slot. Null when the slot is `idle`. */
|
|
217
|
+
allocation_id: AllocationId.nullable(),
|
|
218
|
+
/** The server's match id. Null for a server without a match or a match launched without one. */
|
|
219
|
+
match_id: MatchId.nullable(),
|
|
220
|
+
/**
|
|
221
|
+
* Whether the server's plugin is connected to the agent.
|
|
222
|
+
* {@link ControllerGetServerStatus | get_server_status}, {@link ControllerConsoleCommand | console_command},
|
|
223
|
+
* {@link ControllerRestoreRound | restore_round} and {@link ControllerStartMatch | start_match} reach it only
|
|
224
|
+
* while it is.
|
|
225
|
+
*/
|
|
226
|
+
plugin_online: z.boolean(),
|
|
227
|
+
/**
|
|
228
|
+
* Whether Steam accepts the slot's token. {@link ControllerStartServer | start_server} never picks a
|
|
229
|
+
* slot whose token is `invalid`.
|
|
230
|
+
*/
|
|
231
|
+
gslt_state: GsltState,
|
|
232
|
+
/**
|
|
233
|
+
* Whether the idle slot is kept from launches, while the controller replaces its token with
|
|
234
|
+
* {@link ControllerReserveSlot | reserve_slot} or while the agent logs its token on to Steam.
|
|
235
|
+
*/
|
|
236
|
+
reserved: z.boolean(),
|
|
237
|
+
/**
|
|
238
|
+
* The `server_steam_id` that the server's plugin reported. The agent sends a
|
|
239
|
+
* {@link AgentSlotStatus | slot_status} as soon as it learns the ID. Null when the slot is `idle`
|
|
240
|
+
* or until the server is `ready`.
|
|
241
|
+
*/
|
|
242
|
+
server_steam_id: SteamId64.nullable(),
|
|
243
|
+
};
|
|
244
|
+
function addSlotStatusIssues(slot, ctx) {
|
|
245
|
+
if (slot.state === 'idle' ? slot.allocation_id !== null || slot.match_id !== null : slot.allocation_id === null) {
|
|
246
|
+
ctx.addIssue({ code: 'custom', message: 'a held slot names its allocation, an idle one none' });
|
|
247
|
+
}
|
|
248
|
+
if (slot.state !== 'idle' && slot.reserved) {
|
|
249
|
+
ctx.addIssue({ code: 'custom', message: 'only an idle slot is reserved' });
|
|
250
|
+
}
|
|
251
|
+
if (slot.state === 'idle' && slot.server_steam_id !== null) {
|
|
252
|
+
ctx.addIssue({ code: 'custom', message: 'an idle slot names no server_steam_id' });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* One slot as the agent sees it.
|
|
257
|
+
*
|
|
258
|
+
* Rejected when an `idle` slot names an `allocation_id`, a `match_id` or a `server_steam_id`, or
|
|
259
|
+
* when a slot in any other state has no `allocation_id`. It is also rejected when a slot that is
|
|
260
|
+
* not `idle` is `reserved`.
|
|
261
|
+
*/
|
|
262
|
+
export const SlotStatus = z.object(slotStatusFields).superRefine(addSlotStatusIssues);
|
|
263
|
+
/**
|
|
264
|
+
* The part of `agent.yml` that the controller may edit, as {@link AgentHello | hello} reports it
|
|
265
|
+
* applied.
|
|
266
|
+
*/
|
|
267
|
+
export const NodeSettings = z.object({
|
|
268
|
+
/** The slots servers run in. */
|
|
269
|
+
slots: SlotLayout,
|
|
270
|
+
/**
|
|
271
|
+
* The address players connect to. Null for the node's `public_ip`. Servers already running keep
|
|
272
|
+
* the address their launch announced.
|
|
273
|
+
*/
|
|
274
|
+
pinned_ip: Ipv4.nullable(),
|
|
275
|
+
/** Where the agent dials the controller. It redials when any part of it changes. */
|
|
276
|
+
controller: ControllerAddress,
|
|
277
|
+
/**
|
|
278
|
+
* Whether the node keeps itself up to date: CS2 on `cs2.latest_build_id`, and its agent and cs2
|
|
279
|
+
* images on the newest release of their major. When false, the node stays on `cs2.build_id` until
|
|
280
|
+
* an {@link ControllerUpdateCs2 | update_cs2}, and accepts launches while it is behind
|
|
281
|
+
* `cs2.latest_build_id`, so players on a newer client cannot join its servers. Its images then
|
|
282
|
+
* change only with {@link ControllerUpdateAgent | update_agent} and
|
|
283
|
+
* {@link ControllerUpdateCs2Image | update_cs2_image}.
|
|
284
|
+
*/
|
|
285
|
+
auto_update: z.boolean(),
|
|
286
|
+
/**
|
|
287
|
+
* Where the agent uploads demos. Null for no uploads, which keeps demos on the node. The header's
|
|
288
|
+
* value is reported as its SHA-256.
|
|
289
|
+
*/
|
|
290
|
+
demo_upload: ReportedDemoUpload.nullable(),
|
|
291
|
+
/**
|
|
292
|
+
* MiB of memory each game container may use. Null for no cap. A change applies to servers launched
|
|
293
|
+
* afterwards, and CS2 installs and updates are exempt.
|
|
294
|
+
*
|
|
295
|
+
* Rejected when it is not a whole number from {@link MIN_MEMORY_MB} to {@link MAX_MEMORY_MB}.
|
|
296
|
+
*/
|
|
297
|
+
memory_mb: MemoryLimit.nullable(),
|
|
298
|
+
});
|
|
299
|
+
/**
|
|
300
|
+
* {@link NodeSettings} as {@link ControllerSetConfig | set_config} asks for them. A missing field keeps
|
|
301
|
+
* the node's value. Null is a value, as {@link NodeSettings} describes. `slots`, when present, is
|
|
302
|
+
* the whole layout.
|
|
303
|
+
*/
|
|
304
|
+
export const NodeSettingsEdit = NodeSettings.extend({
|
|
305
|
+
/**
|
|
306
|
+
* The slots servers run in. {@link SlotEdit} describes their tokens.
|
|
307
|
+
*
|
|
308
|
+
* Rejected when two slots share an `id` or a `gslt`, or when two ports are equal. Every slot's
|
|
309
|
+
* `port` and `tv_port` count, so a slot's two ports differ too. It is also rejected when a port
|
|
310
|
+
* is {@link EVENT_RELAY_PORT}, where the agent listens for plugins. The agent fills in the kept
|
|
311
|
+
* tokens and refuses a layout in which two slots then share one.
|
|
312
|
+
*/
|
|
313
|
+
slots: SlotEditLayout,
|
|
314
|
+
/**
|
|
315
|
+
* Where the agent uploads demos. Null for no uploads, which keeps demos on the node.
|
|
316
|
+
* {@link DemoUploadEdit} describes its header.
|
|
317
|
+
*/
|
|
318
|
+
demo_upload: DemoUploadEdit.nullable(),
|
|
319
|
+
}).partial();
|
|
320
|
+
/** Longer os-release names are cut to this many characters. */
|
|
321
|
+
export const OS_NAME_MAX_CHARS = 256;
|
|
322
|
+
/** Longest demo file name a node reports, `.dem` included. */
|
|
323
|
+
export const DEMO_FILE_NAME_MAX_CHARS = 160;
|
|
324
|
+
/**
|
|
325
|
+
* A demo's file name, as it sits in its launch's directory, `demos/<allocation_id>/` in its slot.
|
|
326
|
+
*/
|
|
327
|
+
export const DemoFileName = z.string().regex(new RegExp(`^[A-Za-z0-9._-]{1,${DEMO_FILE_NAME_MAX_CHARS - 4}}\\.dem$`), `must be a .dem file name of up to ${DEMO_FILE_NAME_MAX_CHARS} letters, digits, dots, dashes and underscores`);
|
|
328
|
+
/** Longest `upload_error` a demo carries. The agent cuts the failure's message to it. */
|
|
329
|
+
export const DEMO_UPLOAD_ERROR_MAX_CHARS = 512;
|
|
330
|
+
/** A demo kept on the node. */
|
|
331
|
+
export const DemoFile = z.object({
|
|
332
|
+
/** The slot whose directory holds it. */
|
|
333
|
+
slot_id: SlotId,
|
|
334
|
+
/** The file's name. */
|
|
335
|
+
file_name: DemoFileName,
|
|
336
|
+
/** Bytes in the file. */
|
|
337
|
+
size_bytes: z.number().int().nonnegative(),
|
|
338
|
+
/** Unix milliseconds when the file was last written. */
|
|
339
|
+
modified_at: z.number().int(),
|
|
340
|
+
/**
|
|
341
|
+
* Whether its server is still recording it. While it is, the demo grows with each report and is
|
|
342
|
+
* neither uploaded nor deleted.
|
|
343
|
+
*/
|
|
344
|
+
recording: z.boolean(),
|
|
345
|
+
/**
|
|
346
|
+
* Whether it is uploading now. {@link AgentDemoProgress | demo_progress} reports how far, and a
|
|
347
|
+
* node uploads one demo at a time.
|
|
348
|
+
*/
|
|
349
|
+
uploading: z.boolean(),
|
|
350
|
+
/**
|
|
351
|
+
* Why its upload failed or why it waits, up to {@link DEMO_UPLOAD_ERROR_MAX_CHARS} characters: a
|
|
352
|
+
* failure being retried, a refusal as invalid, or every demo held while the target refuses the
|
|
353
|
+
* node or has failed for a day. Null once an upload works again or the target changes.
|
|
354
|
+
*/
|
|
355
|
+
upload_error: z.string().min(1).max(DEMO_UPLOAD_ERROR_MAX_CHARS).nullable(),
|
|
356
|
+
});
|
|
357
|
+
/** Longer reasons a node cannot run servers are cut to this many characters. */
|
|
358
|
+
export const NODE_UNAVAILABLE_DETAIL_MAX_CHARS = 512;
|
|
359
|
+
/** Free space a node needs on its data disk to take launches, in bytes. */
|
|
360
|
+
export const DISK_SPACE_MIN_BYTES = 1 << 30;
|
|
361
|
+
/** Why a node cannot run servers. */
|
|
362
|
+
export const NODE_UNAVAILABLE = {
|
|
363
|
+
/** The {@link MaintenanceOperation} in `operation` runs. It clears when the operation ends. */
|
|
364
|
+
maintenance: 'maintenance',
|
|
365
|
+
/**
|
|
366
|
+
* The agent cannot use the local store that keeps gameplay events until the controller takes
|
|
367
|
+
* them. `detail` says why.
|
|
368
|
+
* - If a call to the store waits past its limit, as on a disk that hangs, the code clears once
|
|
369
|
+
* the store answers. Deleting the store or restarting the agent loses what waits to be written.
|
|
370
|
+
* - If the agent cannot open the store, such as one that a newer agent wrote, a corrupt file or
|
|
371
|
+
* one it cannot write, the code lasts until a restart. Update the agent, or delete the store on
|
|
372
|
+
* the host and restart the agent. Deleting it loses the events and server exits it keeps.
|
|
373
|
+
*/
|
|
374
|
+
eventStorage: 'event_storage',
|
|
375
|
+
/**
|
|
376
|
+
* The disk under the agent's data directory has less than {@link DISK_SPACE_MIN_BYTES} free, too
|
|
377
|
+
* little for a match's events and demo. The agent asks the disk every 30 seconds and before each
|
|
378
|
+
* launch, so the code clears within 30 seconds of space being freed, and a launch that arrives
|
|
379
|
+
* sooner is taken if the disk has room by then.
|
|
380
|
+
*/
|
|
381
|
+
diskSpace: 'disk_space',
|
|
382
|
+
};
|
|
383
|
+
/** Why a node cannot run servers, one of the values of {@link NODE_UNAVAILABLE}. */
|
|
384
|
+
export const NodeUnavailableCode = z.enum(NODE_UNAVAILABLE);
|
|
385
|
+
/**
|
|
386
|
+
* The maintenance operation that runs on the node.
|
|
387
|
+
*
|
|
388
|
+
* - `cs2_update`: a CS2 install or update, also one an earlier agent started.
|
|
389
|
+
* - `agent_update`: an {@link ControllerUpdateAgent | update_agent}, until its replacement has proved it
|
|
390
|
+
* stays up.
|
|
391
|
+
* - `cs2_image_update`: an {@link ControllerUpdateCs2Image | update_cs2_image}.
|
|
392
|
+
* - `image_cleanup`: the agent's removal of replaced images when it starts.
|
|
393
|
+
*/
|
|
394
|
+
export const MaintenanceOperation = z.enum(['cs2_update', 'agent_update', 'cs2_image_update', 'image_cleanup']);
|
|
395
|
+
/**
|
|
396
|
+
* Why the node cannot run servers now, with `maintenance` first while it lasts. Meanwhile
|
|
397
|
+
* {@link ControllerStartServer | start_server} is refused with `maintenance_running` for `maintenance` and
|
|
398
|
+
* `node_unavailable` otherwise. Everything else works.
|
|
399
|
+
*
|
|
400
|
+
* Rejected when `operation` is null while `code` is `maintenance`, or set while `code` is another
|
|
401
|
+
* value.
|
|
402
|
+
*/
|
|
403
|
+
export const NodeUnavailable = z.object({
|
|
404
|
+
code: NodeUnavailableCode,
|
|
405
|
+
/**
|
|
406
|
+
* The maintenance operation that runs when `code` is `maintenance`. Null for any other `code`.
|
|
407
|
+
*/
|
|
408
|
+
operation: MaintenanceOperation.nullable(),
|
|
409
|
+
/** The agent's message, up to {@link NODE_UNAVAILABLE_DETAIL_MAX_CHARS} characters. */
|
|
410
|
+
detail: z.string().min(1).max(NODE_UNAVAILABLE_DETAIL_MAX_CHARS),
|
|
411
|
+
}).superRefine((unavailable, ctx) => {
|
|
412
|
+
if ((unavailable.code === NODE_UNAVAILABLE.maintenance) !== (unavailable.operation !== null)) {
|
|
413
|
+
ctx.addIssue({ code: 'custom', path: ['operation'], message: 'names the operation exactly when the code is maintenance' });
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
/** The running agent. */
|
|
417
|
+
export const AgentInfo = z.object({
|
|
418
|
+
/** The agent package version. */
|
|
419
|
+
version: z.string().min(1).max(64),
|
|
420
|
+
/** The image this agent's container was created from. */
|
|
421
|
+
image: ImageReference,
|
|
422
|
+
/** The id Docker holds for that image. Two builds of one tag differ here. */
|
|
423
|
+
image_id: ImageId,
|
|
424
|
+
});
|
|
425
|
+
/** Longest `detail` an agent update carries. The agent cuts the swap's message to it. */
|
|
426
|
+
export const AGENT_UPDATE_DETAIL_MAX_CHARS = 512;
|
|
427
|
+
/**
|
|
428
|
+
* Where an {@link ControllerUpdateAgent | update_agent} or {@link ControllerUpdateCs2Image | update_cs2_image}
|
|
429
|
+
* is.
|
|
430
|
+
*
|
|
431
|
+
* - `pulling`: the image is being pulled.
|
|
432
|
+
* - `switching`: the image is on the node and is put into use. A switch of the agent replaces its
|
|
433
|
+
* container, so the node goes offline and comes back. A switch of the cs2 image extracts its
|
|
434
|
+
* plugin files for future launches.
|
|
435
|
+
*/
|
|
436
|
+
export const ImageUpdateStep = z.enum(['pulling', 'switching']);
|
|
437
|
+
/**
|
|
438
|
+
* How an {@link ControllerUpdateAgent | update_agent} ended.
|
|
439
|
+
*
|
|
440
|
+
* - `completed`: the replacement agent stayed up and the old container is removed.
|
|
441
|
+
* - `unchanged`: the node already ran that image, so nothing was swapped.
|
|
442
|
+
* - `pull_failed`: the image could not be pulled, so the node never tried it.
|
|
443
|
+
* - `failed`: the swap did not hold and the old agent was put back, or its helper did not start.
|
|
444
|
+
*/
|
|
445
|
+
export const AgentUpdateOutcome = z.enum(['completed', 'unchanged', 'pull_failed', 'failed']);
|
|
446
|
+
/** An agent update: the one in progress, one this agent saw end, or the last one to fail. */
|
|
447
|
+
export const AgentUpdate = z.object({
|
|
448
|
+
/** The agent image the update was to. */
|
|
449
|
+
image: ImageReference,
|
|
450
|
+
/**
|
|
451
|
+
* Where the update is while it runs, with the replacement still on trial in `switching`. Null
|
|
452
|
+
* once it ended.
|
|
453
|
+
*/
|
|
454
|
+
step: ImageUpdateStep.nullable(),
|
|
455
|
+
/** How the update ended. Null while it runs. */
|
|
456
|
+
outcome: AgentUpdateOutcome.nullable(),
|
|
457
|
+
/**
|
|
458
|
+
* Why the update failed, up to {@link AGENT_UPDATE_DETAIL_MAX_CHARS} characters. Null unless it
|
|
459
|
+
* failed.
|
|
460
|
+
*/
|
|
461
|
+
detail: z.string().min(1).max(AGENT_UPDATE_DETAIL_MAX_CHARS).nullable(),
|
|
462
|
+
});
|
|
463
|
+
/** The machine the agent runs on. */
|
|
464
|
+
export const HostInfo = z.object({
|
|
465
|
+
/** Logical CPUs the agent can use. */
|
|
466
|
+
cpu_count: z.number().int().positive(),
|
|
467
|
+
/**
|
|
468
|
+
* The host's os-release `PRETTY_NAME`, up to {@link OS_NAME_MAX_CHARS} characters. Null when
|
|
469
|
+
* unreadable.
|
|
470
|
+
*/
|
|
471
|
+
os_name: z.string().min(1).max(OS_NAME_MAX_CHARS).nullable(),
|
|
472
|
+
/** The kernel release, which the agent's container shares with the host. */
|
|
473
|
+
kernel: z.string().min(1).max(256),
|
|
474
|
+
});
|
|
475
|
+
/**
|
|
476
|
+
* How thoroughly a CS2 install or update runs.
|
|
477
|
+
*
|
|
478
|
+
* - `update`: downloads what changed.
|
|
479
|
+
* - `verify`: also re-hashes every file.
|
|
480
|
+
* - `reinstall`: wipes the install volume first and downloads all of it.
|
|
481
|
+
*/
|
|
482
|
+
export const Cs2UpdateMode = z.enum(['update', 'verify', 'reinstall']);
|
|
483
|
+
/**
|
|
484
|
+
* How a CS2 install or update ended.
|
|
485
|
+
*
|
|
486
|
+
* - `completed`: the game files are installed.
|
|
487
|
+
* - `pull_failed`: the cs2 image could not be pulled.
|
|
488
|
+
* - `overlay_failed`: its addon overlay could not be extracted.
|
|
489
|
+
* - `failed`: any other failure, such as the update container exiting with an error.
|
|
490
|
+
* - `unobserved`: an update container that an earlier agent process started exited, and the agent
|
|
491
|
+
* did not see the exit.
|
|
492
|
+
*
|
|
493
|
+
* An update container that an earlier agent process started reports progress again until it exits.
|
|
494
|
+
* It ends `completed` or `failed` by its exit code, or `unobserved`.
|
|
495
|
+
*/
|
|
496
|
+
export const Cs2UpdateOutcome = z.enum(['completed', 'pull_failed', 'overlay_failed', 'failed', 'unobserved']);
|
|
497
|
+
/** Longest `detail` a CS2 update carries. The agent cuts the failure's message to it. */
|
|
498
|
+
export const CS2_UPDATE_DETAIL_MAX_CHARS = 512;
|
|
499
|
+
/**
|
|
500
|
+
* Chosen by the agent for each CS2 install or update it runs or finds running. A lowercase UUID.
|
|
501
|
+
*/
|
|
502
|
+
export const UpdateId = z.uuid().regex(/^[^A-F]*$/, 'must be lowercase');
|
|
503
|
+
/**
|
|
504
|
+
* A CS2 install or update on the node: one running, or the last one to end since the agent started.
|
|
505
|
+
*/
|
|
506
|
+
export const Cs2Update = z.object({
|
|
507
|
+
/**
|
|
508
|
+
* Identifies the update and its output, the `cs2_update` {@link LogSource} with this `update_id`.
|
|
509
|
+
*/
|
|
510
|
+
id: UpdateId,
|
|
511
|
+
/** How the update runs. */
|
|
512
|
+
mode: Cs2UpdateMode,
|
|
513
|
+
/**
|
|
514
|
+
* Whether the agent started the update on its own, to reach the newest public build. When false,
|
|
515
|
+
* an {@link ControllerUpdateCs2 | update_cs2} started it.
|
|
516
|
+
*/
|
|
517
|
+
automatic: z.boolean(),
|
|
518
|
+
/** How the update ended. Null while it runs. */
|
|
519
|
+
outcome: Cs2UpdateOutcome.nullable(),
|
|
520
|
+
/**
|
|
521
|
+
* Why the update failed, up to {@link CS2_UPDATE_DETAIL_MAX_CHARS} characters. Null unless it
|
|
522
|
+
* failed.
|
|
523
|
+
*/
|
|
524
|
+
detail: z.string().min(1).max(CS2_UPDATE_DETAIL_MAX_CHARS).nullable(),
|
|
525
|
+
});
|
|
526
|
+
/**
|
|
527
|
+
* How an {@link ControllerUpdateCs2Image | update_cs2_image} ended.
|
|
528
|
+
*
|
|
529
|
+
* - `completed`: future launches use the image.
|
|
530
|
+
* - `pull_failed`: the image could not be pulled.
|
|
531
|
+
* - `overlay_failed`: its addon overlay could not be extracted.
|
|
532
|
+
* - `failed`: anything else, such as the agent not saving the image setting.
|
|
533
|
+
*/
|
|
534
|
+
export const Cs2ImageUpdateOutcome = z.enum(['completed', 'pull_failed', 'overlay_failed', 'failed']);
|
|
535
|
+
/**
|
|
536
|
+
* A switch of the cs2 image: the one running now, or the last one to end since the agent started.
|
|
537
|
+
*/
|
|
538
|
+
export const Cs2ImageUpdate = z.object({
|
|
539
|
+
/** The image the switch is to. */
|
|
540
|
+
image: ImageReference,
|
|
541
|
+
/** Where the switch is while it runs. Null once it ended. */
|
|
542
|
+
step: ImageUpdateStep.nullable(),
|
|
543
|
+
/** How the switch ended. Null while it runs. */
|
|
544
|
+
outcome: Cs2ImageUpdateOutcome.nullable(),
|
|
545
|
+
/**
|
|
546
|
+
* Why the switch failed, up to {@link CS2_UPDATE_DETAIL_MAX_CHARS} characters. Null unless it
|
|
547
|
+
* failed.
|
|
548
|
+
*/
|
|
549
|
+
detail: z.string().min(1).max(CS2_UPDATE_DETAIL_MAX_CHARS).nullable(),
|
|
550
|
+
});
|
|
551
|
+
/** The node's game install and image. */
|
|
552
|
+
export const Cs2State = z.object({
|
|
553
|
+
/** The installed game build. Null before the first install. */
|
|
554
|
+
build_id: BuildId.nullable(),
|
|
555
|
+
/**
|
|
556
|
+
* The newest public CS2 build the agent read from Steam. Null until its first successful read.
|
|
557
|
+
* The agent updates an installed node to it on its own once every slot is idle, unless the
|
|
558
|
+
* node's `auto_update` is `false`.
|
|
559
|
+
*/
|
|
560
|
+
latest_build_id: BuildId.nullable(),
|
|
561
|
+
/** The cs2 image set in `agent.yml`, which future launches use. */
|
|
562
|
+
image: ImageReference,
|
|
563
|
+
/** The local id that `image` resolves to on the node. Null until the image is there. */
|
|
564
|
+
image_id: ImageId.nullable(),
|
|
565
|
+
/**
|
|
566
|
+
* The install or update that runs now, or else the last one to end since the agent started. Null
|
|
567
|
+
* before any. An agent that starts while an update that it started earlier still runs reports
|
|
568
|
+
* that update.
|
|
569
|
+
*/
|
|
570
|
+
update: Cs2Update.nullable(),
|
|
571
|
+
/**
|
|
572
|
+
* The switch of `image` that runs now, or else the last one to end since the agent started. Null
|
|
573
|
+
* before any.
|
|
574
|
+
*/
|
|
575
|
+
image_update: Cs2ImageUpdate.nullable(),
|
|
576
|
+
});
|
|
577
|
+
/** An ISO 3166-1 alpha-2 country code: `DK`, `FR`, or `GB` for the United Kingdom. */
|
|
578
|
+
export const CountryCode = z.string().regex(/^[A-Z]{2}$/);
|
|
579
|
+
/** Longest `detail` a server's exit carries. The agent cuts the message to it. */
|
|
580
|
+
export const SERVER_EXIT_DETAIL_MAX_CHARS = 512;
|
|
581
|
+
/** A server whose container is gone, and why. */
|
|
582
|
+
export const ServerExit = z.object({
|
|
583
|
+
/** The slot it ran in. */
|
|
584
|
+
slot_id: SlotId,
|
|
585
|
+
/** The server that ended. */
|
|
586
|
+
allocation_id: AllocationId,
|
|
587
|
+
/** Why it ended. */
|
|
588
|
+
reason: ServerExitReason,
|
|
589
|
+
/** The container's exit code. Null when the agent did not see one. */
|
|
590
|
+
exit_code: z.number().int().nullable(),
|
|
591
|
+
/**
|
|
592
|
+
* What the reason does not say, up to {@link SERVER_EXIT_DETAIL_MAX_CHARS} characters: the
|
|
593
|
+
* plugin's `config_error` for `config_rejected`, and Docker's message for `launch_failed`. Null
|
|
594
|
+
* for every other reason.
|
|
595
|
+
*/
|
|
596
|
+
detail: z.string().min(1).max(SERVER_EXIT_DETAIL_MAX_CHARS).nullable(),
|
|
597
|
+
});
|
|
598
|
+
/** Longest `reason` an event backlog carries. The agent cuts the failure's message to it. */
|
|
599
|
+
export const EVENT_BACKLOG_REASON_MAX_CHARS = 512;
|
|
600
|
+
/** A server whose events wait on the node. */
|
|
601
|
+
export const StuckServer = z.object({
|
|
602
|
+
/** The server whose plugin sent them. */
|
|
603
|
+
allocation_id: AllocationId,
|
|
604
|
+
/** Events of the server still queued on the node. */
|
|
605
|
+
pending: z.number().int().positive(),
|
|
606
|
+
});
|
|
607
|
+
const eventBacklogFields = {
|
|
608
|
+
/**
|
|
609
|
+
* The servers with the oldest stuck events, oldest first, up to {@link MAX_REPORTED_BACKLOGS}.
|
|
610
|
+
*/
|
|
611
|
+
servers: z.array(StuckServer).max(MAX_REPORTED_BACKLOGS),
|
|
612
|
+
/** Servers with stuck events on the node, those past `servers` too. */
|
|
613
|
+
server_count: z.number().int().nonnegative(),
|
|
614
|
+
/** Events that every stuck server holds together. */
|
|
615
|
+
event_count: z.number().int().nonnegative(),
|
|
616
|
+
/**
|
|
617
|
+
* Why the last upload failed, up to {@link EVENT_BACKLOG_REASON_MAX_CHARS} characters. Null when
|
|
618
|
+
* nothing is stuck, and only then.
|
|
619
|
+
*/
|
|
620
|
+
reason: z.string().min(1).max(EVENT_BACKLOG_REASON_MAX_CHARS).nullable(),
|
|
621
|
+
};
|
|
622
|
+
function addBacklogIssues(backlog, ctx) {
|
|
623
|
+
if (backlog.servers.length !== Math.min(backlog.server_count, MAX_REPORTED_BACKLOGS)) {
|
|
624
|
+
ctx.addIssue({ code: 'custom', path: ['servers'], message: `lists the oldest ${MAX_REPORTED_BACKLOGS} of server_count` });
|
|
625
|
+
}
|
|
626
|
+
if (backlog.event_count < backlog.servers.reduce((sum, server) => sum + server.pending, 0)) {
|
|
627
|
+
ctx.addIssue({ code: 'custom', path: ['event_count'], message: 'counts at least the listed servers\' events' });
|
|
628
|
+
}
|
|
629
|
+
if ((backlog.server_count === 0) !== (backlog.reason === null)) {
|
|
630
|
+
ctx.addIssue({ code: 'custom', path: ['reason'], message: 'null exactly when nothing is stuck' });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* The events that wait on the node because uploading them fails, as the agent read them. While
|
|
635
|
+
* uploads work, nothing is stuck, so `servers` is empty, the counts are zero and `reason` is
|
|
636
|
+
* `null`.
|
|
637
|
+
*
|
|
638
|
+
* Rejected unless `servers` holds as many entries as `server_count`, or
|
|
639
|
+
* {@link MAX_REPORTED_BACKLOGS} when `server_count` is larger. It is also rejected when
|
|
640
|
+
* `event_count` is below the sum of `pending` over `servers`. A `reason` is rejected when
|
|
641
|
+
* `server_count` is zero, and a null `reason` when it is not.
|
|
642
|
+
*/
|
|
643
|
+
export const EventBacklog = z.object(eventBacklogFields).superRefine(addBacklogIssues);
|
|
644
|
+
/**
|
|
645
|
+
* A server has ended and its container is gone. Sent ahead of the `idle`
|
|
646
|
+
* {@link AgentSlotStatus | slot_status} of its slot, also for a launch that failed before or while
|
|
647
|
+
* its container started.
|
|
648
|
+
*
|
|
649
|
+
* A server that ended while no connection was welcomed goes in `server_exits` of the next
|
|
650
|
+
* {@link AgentHello | hello} instead. If it ended after that {@link AgentHello | hello} went out,
|
|
651
|
+
* it is sent after the {@link ControllerWelcome | welcome}, ahead of the slot statuses.
|
|
652
|
+
*
|
|
653
|
+
* The agent keeps a {@link AgentServerExited | server_exited} until the socket takes its write,
|
|
654
|
+
* across its restarts, for up to {@link EVENT_SPOOL_RETENTION_MS}, so one may also come again after
|
|
655
|
+
* a restart. One the socket took is not sent again, even if the connection drops before the
|
|
656
|
+
* controller reads it. The agent does not keep them while it cannot open that store, which it reports as
|
|
657
|
+
* `unavailable` with `event_storage`. While the store does not answer, the agent keeps one only
|
|
658
|
+
* once the store answers again. A server that ends while the agent is not running is missing
|
|
659
|
+
* from its next {@link AgentHello | hello}.
|
|
660
|
+
*/
|
|
661
|
+
export const AgentServerExited = ServerExit.extend({
|
|
662
|
+
type: z.literal('server_exited'),
|
|
663
|
+
});
|
|
664
|
+
/**
|
|
665
|
+
* The agent's first message on every connection, and sent only then. An applied
|
|
666
|
+
* {@link ControllerSetConfig | set_config} answers with the settings instead.
|
|
667
|
+
*
|
|
668
|
+
* Answer an accepted {@link AgentHello | hello} with {@link ControllerWelcome | welcome}. Until then the
|
|
669
|
+
* agent sends only replies, or a {@link AgentShutdown | shutdown} when it stops. Runtime changes
|
|
670
|
+
* use status messages. Reconnecting never changes the node's configuration.
|
|
671
|
+
*
|
|
672
|
+
* Rejected unless `slot_status` names each slot of `config.slots` once and names no other slot.
|
|
673
|
+
*/
|
|
674
|
+
export const AgentHello = z.object({
|
|
675
|
+
type: z.literal('hello'),
|
|
676
|
+
/** The agent sending this. */
|
|
677
|
+
agent: AgentInfo,
|
|
678
|
+
/** The host the agent runs on. */
|
|
679
|
+
host: HostInfo,
|
|
680
|
+
/**
|
|
681
|
+
* The node's public IPv4 address as the agent looked it up at start. Null when the lookup failed,
|
|
682
|
+
* and players then reach the node only at `config.pinned_ip`. Players connect to
|
|
683
|
+
* `config.pinned_ip` whenever it is set. The agent looks again only when it restarts.
|
|
684
|
+
*/
|
|
685
|
+
public_ip: Ipv4.nullable(),
|
|
686
|
+
/**
|
|
687
|
+
* Where the node's address is, as a geolocation lookup reports it when the agent starts. Null
|
|
688
|
+
* when that lookup failed. The agent tries again only when it restarts.
|
|
689
|
+
*/
|
|
690
|
+
country: CountryCode.nullable(),
|
|
691
|
+
/** The configuration the agent runs with. */
|
|
692
|
+
config: NodeSettings,
|
|
693
|
+
/** One per configured slot. */
|
|
694
|
+
slot_status: z.array(SlotStatus).max(MAX_SLOTS),
|
|
695
|
+
/** The game install and image. */
|
|
696
|
+
cs2: Cs2State,
|
|
697
|
+
/**
|
|
698
|
+
* Why the node cannot run servers. Null when it can. Changes arrive as
|
|
699
|
+
* {@link AgentNodeStatus | node_status}.
|
|
700
|
+
*/
|
|
701
|
+
unavailable: NodeUnavailable.nullable(),
|
|
702
|
+
/**
|
|
703
|
+
* The demos kept on the node, up to {@link MAX_REPORTED_DEMOS}. {@link AgentDemo | demo} reports
|
|
704
|
+
* each change afterwards.
|
|
705
|
+
*/
|
|
706
|
+
demos: z.array(DemoFile).max(MAX_REPORTED_DEMOS),
|
|
707
|
+
/**
|
|
708
|
+
* Servers that ended while no connection was welcomed, oldest first, each with its reason. Their
|
|
709
|
+
* slots already show idle here, so a {@link AgentServerExited | server_exited} after the
|
|
710
|
+
* {@link ControllerWelcome | welcome} would come too late. It carries the oldest {@link MAX_SLOTS} at
|
|
711
|
+
* most, and the rest follow as {@link AgentServerExited | server_exited} after the
|
|
712
|
+
* {@link ControllerWelcome | welcome}.
|
|
713
|
+
*/
|
|
714
|
+
server_exits: z.array(ServerExit).max(MAX_SLOTS),
|
|
715
|
+
/**
|
|
716
|
+
* The node's event backlog when the {@link AgentHello | hello} was built.
|
|
717
|
+
* {@link AgentEventBacklog | event_backlog} replaces it.
|
|
718
|
+
*/
|
|
719
|
+
event_backlog: EventBacklog,
|
|
720
|
+
/**
|
|
721
|
+
* The swap in progress, or else the one this agent saw end, or else the last that failed. A
|
|
722
|
+
* failure is kept, across agent restarts, until a swap holds or `node-setup.sh` starts the agent.
|
|
723
|
+
* Null when there is none of these. A swap that held shows in `agent.image`.
|
|
724
|
+
*/
|
|
725
|
+
agent_update: AgentUpdate.nullable(),
|
|
726
|
+
}).superRefine((hello, ctx) => {
|
|
727
|
+
const slots = new Set(hello.config.slots.map((slot) => slot.id));
|
|
728
|
+
const seen = new Set();
|
|
729
|
+
for (const [index, status] of hello.slot_status.entries()) {
|
|
730
|
+
if (!slots.has(status.slot_id) || seen.has(status.slot_id)) {
|
|
731
|
+
ctx.addIssue({ code: 'custom', path: ['slot_status', index, 'slot_id'], message: 'slot_status names each configured slot once' });
|
|
732
|
+
}
|
|
733
|
+
seen.add(status.slot_id);
|
|
734
|
+
}
|
|
735
|
+
if (seen.size !== slots.size) {
|
|
736
|
+
ctx.addIssue({ code: 'custom', path: ['slot_status'], message: 'slot_status names every configured slot' });
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
/**
|
|
740
|
+
* Replaces `cs2`, `unavailable` and `agent_update` of the last {@link AgentHello | hello}.
|
|
741
|
+
*
|
|
742
|
+
* Sent when they change:
|
|
743
|
+
*
|
|
744
|
+
* - When a maintenance operation starts or ends, so also when a CS2 install or update finishes,
|
|
745
|
+
* whoever started it, or {@link ControllerUpdateCs2Image | update_cs2_image} has switched the image.
|
|
746
|
+
* - When `agent_update` or `cs2.image_update` moves from `pulling` to `switching`.
|
|
747
|
+
* - When the newest public build changes.
|
|
748
|
+
* - When an agent swap that is still proving its replacement ends.
|
|
749
|
+
*
|
|
750
|
+
* Each frame is the node's state when it was read, not a step in a history, so a change that is
|
|
751
|
+
* over before its report reads the state may go unreported.
|
|
752
|
+
*/
|
|
753
|
+
export const AgentNodeStatus = z.object({
|
|
754
|
+
type: z.literal('node_status'),
|
|
755
|
+
/** The game install and image. */
|
|
756
|
+
cs2: Cs2State,
|
|
757
|
+
/** Why the node cannot run servers. Null when it can. */
|
|
758
|
+
unavailable: NodeUnavailable.nullable(),
|
|
759
|
+
/** The last agent swap, as {@link AgentHello | hello} has it. */
|
|
760
|
+
agent_update: AgentUpdate.nullable(),
|
|
761
|
+
});
|
|
762
|
+
/**
|
|
763
|
+
* A slot's status changed since the last {@link AgentHello | hello} or
|
|
764
|
+
* {@link AgentSlotStatus | slot_status} for it. Rejected when it breaks a rule of
|
|
765
|
+
* {@link SlotStatus}.
|
|
766
|
+
*/
|
|
767
|
+
export const AgentSlotStatus = z.object({
|
|
768
|
+
type: z.literal('slot_status'),
|
|
769
|
+
...slotStatusFields,
|
|
770
|
+
}).superRefine(addSlotStatusIssues);
|
|
771
|
+
/** How serious an agent log line is. */
|
|
772
|
+
export const AgentLogLevel = z.enum(['info', 'warn', 'error']);
|
|
773
|
+
/** Longer log lines, from the agent, a server or a CS2 update, are cut to this many characters. */
|
|
774
|
+
export const LOG_LINE_MAX_CHARS = 2_000;
|
|
775
|
+
/** The agent keeps this many lines of its log. */
|
|
776
|
+
export const AGENT_LOG_LINES = 500;
|
|
777
|
+
/**
|
|
778
|
+
* The agent keeps this many lines of each server's output and of each CS2 install or update's
|
|
779
|
+
* output.
|
|
780
|
+
*/
|
|
781
|
+
export const SERVER_LOG_LINES = 2_000;
|
|
782
|
+
/** Servers that ended and whose output the agent still keeps, oldest dropped first. */
|
|
783
|
+
export const ENDED_SERVER_LOGS = 10;
|
|
784
|
+
/**
|
|
785
|
+
* CS2 installs and updates that ended and whose output the agent still keeps, oldest dropped first.
|
|
786
|
+
*/
|
|
787
|
+
export const ENDED_UPDATE_LOGS = 1;
|
|
788
|
+
/**
|
|
789
|
+
* A {@link AgentLogBatch | log_batch} stays under this many bytes of JSON, so a replay spans as
|
|
790
|
+
* many frames as it needs.
|
|
791
|
+
*/
|
|
792
|
+
export const LOG_BATCH_MAX_BYTES = 256 * 1024;
|
|
793
|
+
/**
|
|
794
|
+
* One log of a node: the agent's log, one CS2 install or update's output, or one server's output.
|
|
795
|
+
*/
|
|
796
|
+
export const LogSource = z.discriminatedUnion('kind', [
|
|
797
|
+
z.object({ kind: z.literal('agent') }),
|
|
798
|
+
z.object({
|
|
799
|
+
kind: z.literal('cs2_update'),
|
|
800
|
+
/** The install or update that printed it, the `id` of its `cs2.update`. */
|
|
801
|
+
update_id: UpdateId,
|
|
802
|
+
}),
|
|
803
|
+
z.object({
|
|
804
|
+
kind: z.literal('server'),
|
|
805
|
+
/** The server that printed it. */
|
|
806
|
+
allocation_id: AllocationId,
|
|
807
|
+
}),
|
|
808
|
+
]);
|
|
809
|
+
/** One log line. */
|
|
810
|
+
export const LogLine = z.object({
|
|
811
|
+
/** Unix milliseconds when the agent logged or read the line. */
|
|
812
|
+
at: z.number().int().nonnegative(),
|
|
813
|
+
/** How serious the agent rated the line. Null for server and CS2 update output. */
|
|
814
|
+
level: AgentLogLevel.nullable(),
|
|
815
|
+
/**
|
|
816
|
+
* The line without its newline. The agent's lines carry the prefix of the component that logged
|
|
817
|
+
* them. A line keeps its ANSI colour escapes: SGR, `ESC[` with digits and `;`, then `m`. The agent
|
|
818
|
+
* removes every other escape, every control character but tab, and the Unicode characters that
|
|
819
|
+
* reorder text or break a line. The length limit may cut the last escape short.
|
|
820
|
+
*/
|
|
821
|
+
line: z.string().max(LOG_LINE_MAX_CHARS),
|
|
822
|
+
});
|
|
823
|
+
/** Identifies one watcher's replay, so the frames answering it reach only that watcher. */
|
|
824
|
+
export const ReplayId = z.string().min(1).max(64);
|
|
825
|
+
/**
|
|
826
|
+
* One source's lines, sent only while a {@link ControllerWatchLogs | watch_logs} holds the lease of that
|
|
827
|
+
* source. A replay of the source starts with `reset` and may span several frames. Between replays,
|
|
828
|
+
* new lines follow in batches. A replay larger than the remaining log budget of the agent leaves
|
|
829
|
+
* out the oldest lines.
|
|
830
|
+
*/
|
|
831
|
+
export const AgentLogBatch = z.object({
|
|
832
|
+
type: z.literal('log_batch'),
|
|
833
|
+
/** The log these lines belong to. */
|
|
834
|
+
source: LogSource,
|
|
835
|
+
/**
|
|
836
|
+
* The {@link ControllerWatchLogs | watch_logs} replay these frames answer, for that watcher alone. Null
|
|
837
|
+
* for new lines and for a replay sent to every watcher.
|
|
838
|
+
*/
|
|
839
|
+
replay_id: ReplayId.nullable(),
|
|
840
|
+
/** Whether a replay starts, so the controller drops every line it holds of this source first. */
|
|
841
|
+
reset: z.boolean(),
|
|
842
|
+
/** The lines, oldest first. */
|
|
843
|
+
lines: z.array(LogLine),
|
|
844
|
+
});
|
|
845
|
+
/** One source the agent keeps. */
|
|
846
|
+
export const LogSourceSummary = z.object({
|
|
847
|
+
/** Which log. */
|
|
848
|
+
source: LogSource,
|
|
849
|
+
/** Lines kept of it. */
|
|
850
|
+
line_count: z.number().int().nonnegative(),
|
|
851
|
+
/** Unix milliseconds of the newest line kept of it. Null for a source with no lines kept. */
|
|
852
|
+
last_line_at: z.number().int().nonnegative().nullable(),
|
|
853
|
+
/**
|
|
854
|
+
* Whether the source has ended, as a server whose slot went idle or a CS2 install or update that
|
|
855
|
+
* ended. Always `false` for the agent's log.
|
|
856
|
+
*/
|
|
857
|
+
ended: z.boolean(),
|
|
858
|
+
});
|
|
859
|
+
/**
|
|
860
|
+
* Milliseconds between {@link AgentLogIndex | log_index} frames that only bring counts and
|
|
861
|
+
* `last_line_at` up to date while lines arrive. A source's newest line shows in an index within
|
|
862
|
+
* this long.
|
|
863
|
+
*/
|
|
864
|
+
export const LOG_INDEX_REFRESH_MS = 30_000;
|
|
865
|
+
/**
|
|
866
|
+
* Every source the agent keeps, whole every time: its log, each CS2 install or update running or
|
|
867
|
+
* among the last {@link ENDED_UPDATE_LOGS} ended, and each server with output, running or among the
|
|
868
|
+
* last {@link ENDED_SERVER_LOGS} ended.
|
|
869
|
+
*
|
|
870
|
+
* Sent while any log lease runs, with each lease that starts and each replay asked for. Also sent
|
|
871
|
+
* when an update starts or ends, a server's first line arrives, a server ends, or a source's output
|
|
872
|
+
* is dropped, and every {@link LOG_INDEX_REFRESH_MS} while lines arrive. Counts and `last_line_at`
|
|
873
|
+
* are as of that frame. A source missing from it is no longer kept.
|
|
874
|
+
*/
|
|
875
|
+
export const AgentLogIndex = z.object({
|
|
876
|
+
type: z.literal('log_index'),
|
|
877
|
+
/**
|
|
878
|
+
* The agent's log first, then CS2 updates in the order they started, then servers in the order
|
|
879
|
+
* their output began.
|
|
880
|
+
*/
|
|
881
|
+
sources: z.array(LogSourceSummary).max(2 + ENDED_UPDATE_LOGS + MAX_SLOTS + ENDED_SERVER_LOGS),
|
|
882
|
+
});
|
|
883
|
+
/** Longest `message` a CS2 update's progress carries. The agent cuts the line to it. */
|
|
884
|
+
export const CS2_UPDATE_MESSAGE_MAX_CHARS = 512;
|
|
885
|
+
/**
|
|
886
|
+
* Progress of a CS2 install or update while it runs. `cs2.update` in
|
|
887
|
+
* {@link AgentNodeStatus | node_status} says how it ended.
|
|
888
|
+
*/
|
|
889
|
+
export const AgentCs2UpdateProgress = z.object({
|
|
890
|
+
type: z.literal('cs2_update_progress'),
|
|
891
|
+
/**
|
|
892
|
+
* The update it reports on, as `cs2.update.id`. A frame can arrive before the
|
|
893
|
+
* {@link AgentNodeStatus | node_status} that names the update.
|
|
894
|
+
*/
|
|
895
|
+
update_id: UpdateId,
|
|
896
|
+
/** Percent done, as `steamcmd` reports it. Null when `steamcmd` reports none. */
|
|
897
|
+
percent: z.number().min(0).max(100).nullable(),
|
|
898
|
+
/** What the install is doing, up to {@link CS2_UPDATE_MESSAGE_MAX_CHARS} characters. */
|
|
899
|
+
message: z.string().max(CS2_UPDATE_MESSAGE_MAX_CHARS),
|
|
900
|
+
});
|
|
901
|
+
/**
|
|
902
|
+
* One demo as it is now, sent when it appears or changes: its file, its upload starting or
|
|
903
|
+
* stopping, or its `upload_error`. It replaces the demo with the same `slot_id` and `file_name`, or
|
|
904
|
+
* adds it. {@link AgentHello | hello} lists the newest {@link MAX_REPORTED_DEMOS}. A demo that
|
|
905
|
+
* appears after the {@link AgentHello | hello} is added, however many the node keeps.
|
|
906
|
+
*/
|
|
907
|
+
export const AgentDemo = DemoFile.extend({
|
|
908
|
+
type: z.literal('demo'),
|
|
909
|
+
});
|
|
910
|
+
/**
|
|
911
|
+
* Why a demo is no longer on the node.
|
|
912
|
+
*
|
|
913
|
+
* - `uploaded`: the target took it and the agent removed it.
|
|
914
|
+
* - `deleted`: {@link ControllerDeleteDemo | delete_demo} or anything else removed it.
|
|
915
|
+
*/
|
|
916
|
+
export const DemoRemovedReason = z.enum(['uploaded', 'deleted']);
|
|
917
|
+
/** A demo that the controller knows of is no longer on the node. */
|
|
918
|
+
export const AgentDemoRemoved = z.object({
|
|
919
|
+
type: z.literal('demo_removed'),
|
|
920
|
+
/** The slot whose directory held it. */
|
|
921
|
+
slot_id: SlotId,
|
|
922
|
+
/** The file's name. */
|
|
923
|
+
file_name: DemoFileName,
|
|
924
|
+
/** Why it is no longer on the node. */
|
|
925
|
+
reason: DemoRemovedReason,
|
|
926
|
+
});
|
|
927
|
+
/** How far the uploading demo is, at most once a second while it uploads. */
|
|
928
|
+
export const AgentDemoProgress = z.object({
|
|
929
|
+
type: z.literal('demo_progress'),
|
|
930
|
+
/** The slot whose directory holds it. */
|
|
931
|
+
slot_id: SlotId,
|
|
932
|
+
/** The file's name. */
|
|
933
|
+
file_name: DemoFileName,
|
|
934
|
+
/** Bytes sent so far. */
|
|
935
|
+
sent_bytes: z.number().int().nonnegative(),
|
|
936
|
+
/**
|
|
937
|
+
* Bytes a second since the previous {@link AgentDemoProgress | demo_progress} of this upload, or
|
|
938
|
+
* since it began.
|
|
939
|
+
*/
|
|
940
|
+
bytes_per_second: z.number().int().nonnegative(),
|
|
941
|
+
});
|
|
942
|
+
/**
|
|
943
|
+
* Milliseconds between the agent's pings to the controller. The agent drops the connection when
|
|
944
|
+
* the last ping went unanswered.
|
|
945
|
+
*/
|
|
946
|
+
export const AGENT_PING_INTERVAL_MS = 30_000;
|
|
947
|
+
/**
|
|
948
|
+
* Milliseconds an agent may send nothing before the controller drops it, which allows two pings
|
|
949
|
+
* and slack.
|
|
950
|
+
*/
|
|
951
|
+
export const AGENT_PING_TIMEOUT_MS = 65_000;
|
|
952
|
+
/** Milliseconds each agent dial has to finish the WebSocket upgrade. */
|
|
953
|
+
export const AGENT_HANDSHAKE_TIMEOUT_MS = 10_000;
|
|
954
|
+
/**
|
|
955
|
+
* Milliseconds of the agent's first redial delay. Each failed attempt doubles it, and a
|
|
956
|
+
* {@link ControllerWelcome | welcome} or a redial to a new address resets it.
|
|
957
|
+
*/
|
|
958
|
+
export const AGENT_RECONNECT_MIN_MS = 1_000;
|
|
959
|
+
/** Milliseconds of the agent's longest redial delay, before jitter. */
|
|
960
|
+
export const AGENT_RECONNECT_MAX_MS = 30_000;
|
|
961
|
+
/**
|
|
962
|
+
* Each redial delay is randomised by up to this fraction either way, never below
|
|
963
|
+
* {@link AGENT_RECONNECT_MIN_MS}.
|
|
964
|
+
*/
|
|
965
|
+
export const AGENT_RECONNECT_JITTER = 0.25;
|
|
966
|
+
/** Milliseconds between the agent's resource ticks, taken while it runs, watched or not. */
|
|
967
|
+
export const RESOURCE_TICK_MS = 3_000;
|
|
968
|
+
/** Ticks that the agent holds and the controller shows per node, the newest last. */
|
|
969
|
+
export const RESOURCE_TICKS_HELD = 100;
|
|
970
|
+
/**
|
|
971
|
+
* Milliseconds a {@link ControllerWatchResources | watch_resources} or {@link ControllerWatchLogs | watch_logs}
|
|
972
|
+
* keeps its stream flowing. The next one resets it.
|
|
973
|
+
*/
|
|
974
|
+
export const WATCH_LEASE_MS = 120_000;
|
|
975
|
+
/** A capacity and how much of it is in use, in bytes. */
|
|
976
|
+
export const ResourceUsage = z.object({
|
|
977
|
+
/** Bytes of capacity. */
|
|
978
|
+
cap: z.number().int().nonnegative(),
|
|
979
|
+
/** Bytes in use. */
|
|
980
|
+
used: z.number().int().nonnegative(),
|
|
981
|
+
});
|
|
982
|
+
/** One sample of the node's disk, memory and CPU, taken every {@link RESOURCE_TICK_MS}. */
|
|
983
|
+
export const ResourceTick = z.object({
|
|
984
|
+
/** Unix milliseconds when the tick was taken. */
|
|
985
|
+
at: z.number().int().nonnegative(),
|
|
986
|
+
/**
|
|
987
|
+
* The filesystem holding the node's data directory, as `df` counts it. Null when it could not be
|
|
988
|
+
* read.
|
|
989
|
+
*/
|
|
990
|
+
disk: ResourceUsage.nullable(),
|
|
991
|
+
/**
|
|
992
|
+
* The memory the node may use, and how much it uses. Cache the kernel can free counts as free.
|
|
993
|
+
*/
|
|
994
|
+
memory: ResourceUsage,
|
|
995
|
+
/**
|
|
996
|
+
* How busy the node's CPUs are, in percent of all the CPUs it may use. Null in the first tick.
|
|
997
|
+
*/
|
|
998
|
+
cpu: z.object({
|
|
999
|
+
/** Percent busy at the busiest moment of the tick. */
|
|
1000
|
+
max: z.number().int().min(0).max(100),
|
|
1001
|
+
/** Percent busy on average over the tick. */
|
|
1002
|
+
average: z.number().int().min(0).max(100),
|
|
1003
|
+
}).nullable(),
|
|
1004
|
+
});
|
|
1005
|
+
/**
|
|
1006
|
+
* The agent's resource ticks, oldest first. A {@link ControllerWatchResources | watch_resources} lease
|
|
1007
|
+
* that starts receives every tick the agent holds, then each new tick while it runs. The agent
|
|
1008
|
+
* sends nothing while nobody watches, and nothing is stored.
|
|
1009
|
+
*/
|
|
1010
|
+
export const AgentResourceBatch = z.object({
|
|
1011
|
+
type: z.literal('resource_batch'),
|
|
1012
|
+
/**
|
|
1013
|
+
* The ticks, oldest first: every tick the agent holds, up to the last
|
|
1014
|
+
* {@link RESOURCE_TICKS_HELD}, or the ticks since the previous frame.
|
|
1015
|
+
*/
|
|
1016
|
+
ticks: z.array(ResourceTick).min(1).max(RESOURCE_TICKS_HELD),
|
|
1017
|
+
/**
|
|
1018
|
+
* Whether `ticks` is every tick the agent holds and replaces the ticks the controller holds. It
|
|
1019
|
+
* is `true` when a {@link ControllerWatchResources | watch_resources} starts the lease or asks for a
|
|
1020
|
+
* replay, and on the first tick after each accepted {@link AgentHello | hello} while the lease
|
|
1021
|
+
* runs. When false, `ticks` adds to them.
|
|
1022
|
+
*/
|
|
1023
|
+
reset: z.boolean(),
|
|
1024
|
+
});
|
|
1025
|
+
/**
|
|
1026
|
+
* The node's {@link EventBacklog} now, replacing the backlog of the last {@link AgentHello | hello}
|
|
1027
|
+
* or {@link AgentEventBacklog | event_backlog}. Sent when it changes: after each failed upload, as
|
|
1028
|
+
* its counts grow, and once, with nothing stuck, when uploads work again. Rejected when it breaks a
|
|
1029
|
+
* rule of {@link EventBacklog}.
|
|
1030
|
+
*/
|
|
1031
|
+
export const AgentEventBacklog = z.object({
|
|
1032
|
+
type: z.literal('event_backlog'),
|
|
1033
|
+
...eventBacklogFields,
|
|
1034
|
+
}).superRefine(addBacklogIssues);
|
|
1035
|
+
/**
|
|
1036
|
+
* Chosen by the controller. The reply repeats it as `reply_to`. While a request is running, the
|
|
1037
|
+
* agent ignores another with the same id on that connection, because a reply could not tell the two
|
|
1038
|
+
* apart.
|
|
1039
|
+
*/
|
|
1040
|
+
const RequestId = z.string().min(1).max(64);
|
|
1041
|
+
/**
|
|
1042
|
+
* Milliseconds a stopping agent waits for the requests it is running before it closes the socket,
|
|
1043
|
+
* counted from its {@link AgentShutdown | shutdown}. A request still running then is cut off.
|
|
1044
|
+
*/
|
|
1045
|
+
export const SHUTDOWN_REQUEST_WAIT_MS = 5_000;
|
|
1046
|
+
/**
|
|
1047
|
+
* The agent is stopping cleanly and starts no new work. It answers a request that arrives after
|
|
1048
|
+
* this frame with `shutting_down` at once. Replies to the requests it is running may still follow
|
|
1049
|
+
* for up to {@link SHUTDOWN_REQUEST_WAIT_MS}, and then it closes with code `1000`.
|
|
1050
|
+
*
|
|
1051
|
+
* Send no new requests after it. A request without a reply when the socket closes may or may not
|
|
1052
|
+
* have run; the node's reports after it reconnects say which. Its servers keep running, and the
|
|
1053
|
+
* agent takes them over when it returns. It is sent whether or not the controller has welcomed the
|
|
1054
|
+
* {@link AgentHello | hello}.
|
|
1055
|
+
*/
|
|
1056
|
+
export const AgentShutdown = z.object({
|
|
1057
|
+
type: z.literal('shutdown'),
|
|
1058
|
+
/** The signal that stopped the agent. */
|
|
1059
|
+
signal: z.enum(['SIGINT', 'SIGTERM', 'SIGQUIT']),
|
|
1060
|
+
});
|
|
1061
|
+
const request = {
|
|
1062
|
+
/**
|
|
1063
|
+
* Chosen by the controller and unique among its open requests. The reply repeats it as
|
|
1064
|
+
* `reply_to`. The agent ignores a request whose id is still running on the connection.
|
|
1065
|
+
*/
|
|
1066
|
+
id: RequestId,
|
|
1067
|
+
};
|
|
1068
|
+
/**
|
|
1069
|
+
* Launch a server on an idle slot the agent picks. The server logs in with the slot's token. The
|
|
1070
|
+
* agent reports the slot `starting` before any container work, so two launches never share one.
|
|
1071
|
+
*
|
|
1072
|
+
* The reply comes once the container has started, before the server is up, and
|
|
1073
|
+
* {@link AgentSlotStatus | slot_status} reports it `running` later. The agent pulls no image here.
|
|
1074
|
+
* A launch on a `cs2_image` whose plugin files the agent has not extracted yet waits while it does.
|
|
1075
|
+
* Refused with:
|
|
1076
|
+
*
|
|
1077
|
+
* - A {@link StartRefusal} when the node cannot take the launch.
|
|
1078
|
+
* - `allocation_in_use` when the node is already launching the allocation or its server holds a
|
|
1079
|
+
* slot.
|
|
1080
|
+
* - `allocation_reused` when the node launched the allocation before.
|
|
1081
|
+
* - `failed` when Docker fails the launch, once its container is gone, after a
|
|
1082
|
+
* {@link AgentServerExited | server_exited} with `launch_failed`.
|
|
1083
|
+
*/
|
|
1084
|
+
export const ControllerStartServer = z.object({
|
|
1085
|
+
type: z.literal('start_server'),
|
|
1086
|
+
...request,
|
|
1087
|
+
/**
|
|
1088
|
+
* Identifies the server from now on. Every launch needs a new one, a relaunch too, because the
|
|
1089
|
+
* node files the server's events and demos under it. A launch that a {@link StartRefusal} turned
|
|
1090
|
+
* away used nothing, so the same id may be sent again. Any other failure uses it up.
|
|
1091
|
+
*/
|
|
1092
|
+
allocation_id: AllocationId,
|
|
1093
|
+
/** What to launch. */
|
|
1094
|
+
launch: ServerLaunchConfig,
|
|
1095
|
+
});
|
|
1096
|
+
/** The server that started, and the slot the agent picked for it. */
|
|
1097
|
+
export const StartServerReply = z.object({
|
|
1098
|
+
/** The allocation the request launched. */
|
|
1099
|
+
allocation_id: AllocationId,
|
|
1100
|
+
/** The slot the server runs in. */
|
|
1101
|
+
slot_id: SlotId,
|
|
1102
|
+
});
|
|
1103
|
+
/** Seconds of grace before `SIGKILL` for an ordinary stop. */
|
|
1104
|
+
export const STOP_TIMEOUT_SECONDS = 10;
|
|
1105
|
+
/**
|
|
1106
|
+
* Milliseconds the agent waits for an answer from the Docker daemon on its host before it fails the
|
|
1107
|
+
* call. A stop waits its grace on top.
|
|
1108
|
+
*/
|
|
1109
|
+
export const DOCKER_ANSWER_TIMEOUT_MS = 30_000;
|
|
1110
|
+
/**
|
|
1111
|
+
* Seconds a stop's reply may take beyond its grace: Docker's answer to the stop, which the agent
|
|
1112
|
+
* waits for up to {@link DOCKER_ANSWER_TIMEOUT_MS}, and the reply.
|
|
1113
|
+
*/
|
|
1114
|
+
export const STOP_REPLY_MARGIN_SECONDS = 35;
|
|
1115
|
+
/**
|
|
1116
|
+
* Stop an allocation's server. Once asked, the agent stops it: a stop that Docker fails is answered
|
|
1117
|
+
* with `failed`, and the agent tries it again until the server is gone. The slot stays held until
|
|
1118
|
+
* then.
|
|
1119
|
+
*
|
|
1120
|
+
* The reply comes once its container has stopped: up to {@link STOP_TIMEOUT_SECONDS} before the kill, then up
|
|
1121
|
+
* to {@link STOP_REPLY_MARGIN_SECONDS} seconds for the kill and the reply. A stop during a launch
|
|
1122
|
+
* of the allocation first waits for the launch's current step to end. That step can take minutes on
|
|
1123
|
+
* the first launch after an image switch, which extracts the overlay, so the reply can come after
|
|
1124
|
+
* the session's wait. If the launch is stopped between its steps, it starts no server and its
|
|
1125
|
+
* `start_server` fails. If it is stopped while Docker starts its container, its `start_server`
|
|
1126
|
+
* succeeds and the server is then stopped. Refused with `unknown_allocation` for an allocation the
|
|
1127
|
+
* node does not run.
|
|
1128
|
+
*/
|
|
1129
|
+
export const ControllerStopServer = z.object({
|
|
1130
|
+
type: z.literal('stop_server'),
|
|
1131
|
+
/** The server to stop. */
|
|
1132
|
+
allocation_id: AllocationId,
|
|
1133
|
+
...request,
|
|
1134
|
+
});
|
|
1135
|
+
/** The server that stopped. */
|
|
1136
|
+
export const StopServerReply = z.object({
|
|
1137
|
+
/** The allocation the request stopped. */
|
|
1138
|
+
allocation_id: AllocationId,
|
|
1139
|
+
});
|
|
1140
|
+
/**
|
|
1141
|
+
* Install or update CS2 on the node's shared volume. The first install happens only through this
|
|
1142
|
+
* request.
|
|
1143
|
+
*
|
|
1144
|
+
* The reply comes immediately with the update's id, as {@link UpdateCs2Reply}, and the update runs
|
|
1145
|
+
* after it, which can take tens of minutes. Refused with `slots_held` while any slot is held and
|
|
1146
|
+
* `maintenance_running` while a maintenance operation runs.
|
|
1147
|
+
*
|
|
1148
|
+
* `cs2.update` in {@link AgentNodeStatus | node_status} reports the update from `outcome: null` to
|
|
1149
|
+
* how it ended, a failed pull of the cs2 image included. Meanwhile
|
|
1150
|
+
* {@link AgentCs2UpdateProgress | cs2_update_progress} and {@link AgentLogBatch | log_batch} report
|
|
1151
|
+
* on it, each naming it by that id.
|
|
1152
|
+
*
|
|
1153
|
+
* An installed node also updates itself to `cs2.latest_build_id` once every slot is idle, unless
|
|
1154
|
+
* its `auto_update` is `false`.
|
|
1155
|
+
*/
|
|
1156
|
+
export const ControllerUpdateCs2 = z.object({
|
|
1157
|
+
type: z.literal('update_cs2'),
|
|
1158
|
+
...request,
|
|
1159
|
+
/** How thoroughly to update. */
|
|
1160
|
+
mode: Cs2UpdateMode,
|
|
1161
|
+
});
|
|
1162
|
+
/** The update an accepted {@link ControllerUpdateCs2 | update_cs2} started. */
|
|
1163
|
+
export const UpdateCs2Reply = z.object({
|
|
1164
|
+
/**
|
|
1165
|
+
* The update's id, as `cs2.update.id`, {@link AgentCs2UpdateProgress | cs2_update_progress} and
|
|
1166
|
+
* its log source carry it.
|
|
1167
|
+
*/
|
|
1168
|
+
update_id: UpdateId,
|
|
1169
|
+
});
|
|
1170
|
+
/**
|
|
1171
|
+
* Read the newest public CS2 build from Steam now instead of at the next scheduled read, which
|
|
1172
|
+
* comes every {@link CS2_BUILD_POLL_INTERVAL_MS}.
|
|
1173
|
+
*
|
|
1174
|
+
* It receives no reply. The agent handles at most one per {@link CS2_BUILD_CHECK_INTERVAL_MS} and
|
|
1175
|
+
* drops any sooner one. One sent during a scheduled read shares it. Neither busy slots nor
|
|
1176
|
+
* maintenance stop it. A changed build arrives as {@link AgentNodeStatus | node_status}, and an
|
|
1177
|
+
* installed node updates to it once every slot is idle, as it always does.
|
|
1178
|
+
*/
|
|
1179
|
+
export const ControllerCheckCs2Build = z.object({
|
|
1180
|
+
type: z.literal('check_cs2_build'),
|
|
1181
|
+
});
|
|
1182
|
+
/**
|
|
1183
|
+
* Delete one demo by slot and file name.
|
|
1184
|
+
*
|
|
1185
|
+
* The reply comes once the file is removed. Refused with:
|
|
1186
|
+
*
|
|
1187
|
+
* - `unknown_slot` when the node has no directory for the slot.
|
|
1188
|
+
* - `unknown_demo` when the slot has no demo of that name.
|
|
1189
|
+
* - `demo_recording` while its server records the demo.
|
|
1190
|
+
* - `demo_uploading` while the demo uploads.
|
|
1191
|
+
* - `invalid_request` when the name is outside the slot's `demos/` directory.
|
|
1192
|
+
* - `failed` when the removal fails.
|
|
1193
|
+
*/
|
|
1194
|
+
export const ControllerDeleteDemo = z.object({
|
|
1195
|
+
type: z.literal('delete_demo'),
|
|
1196
|
+
...request,
|
|
1197
|
+
/** The slot whose directory holds it. */
|
|
1198
|
+
slot_id: SlotId,
|
|
1199
|
+
/** The file to delete. */
|
|
1200
|
+
file_name: DemoFileName,
|
|
1201
|
+
});
|
|
1202
|
+
/** The demo that was removed. */
|
|
1203
|
+
export const DeleteDemoReply = z.object({
|
|
1204
|
+
/** The slot whose directory held it. */
|
|
1205
|
+
slot_id: SlotId,
|
|
1206
|
+
/** The file removed. */
|
|
1207
|
+
file_name: DemoFileName,
|
|
1208
|
+
});
|
|
1209
|
+
/**
|
|
1210
|
+
* Seconds a {@link ControllerReserveSlot | reserve_slot} lasts, unless a {@link ControllerSetConfig | set_config}
|
|
1211
|
+
* with the slot's new token ends it first.
|
|
1212
|
+
*/
|
|
1213
|
+
export const SLOT_RESERVATION_SECONDS = 60;
|
|
1214
|
+
/**
|
|
1215
|
+
* Keep an idle slot from launches while the controller replaces its token, so that no server
|
|
1216
|
+
* starts on a token that the controller is about to revoke at Steam.
|
|
1217
|
+
*
|
|
1218
|
+
* The agent marks the slot `reserved` before it replies, the same way a launch marks its slot
|
|
1219
|
+
* `starting`, so no {@link ControllerStartServer | start_server} can take the slot once the reply is sent.
|
|
1220
|
+
* The reply comes immediately. Refused with `slot_held` for a slot holding a server, and with
|
|
1221
|
+
* `unknown_slot` for a slot the node does not have.
|
|
1222
|
+
*
|
|
1223
|
+
* The reservation ends with the {@link ControllerSetConfig | set_config} that gives the slot a new token,
|
|
1224
|
+
* with the slot's removal, after {@link SLOT_RESERVATION_SECONDS}, or with the agent's restart.
|
|
1225
|
+
* Asking again restarts the wait.
|
|
1226
|
+
*/
|
|
1227
|
+
export const ControllerReserveSlot = z.object({
|
|
1228
|
+
type: z.literal('reserve_slot'),
|
|
1229
|
+
...request,
|
|
1230
|
+
/** The slot to reserve. */
|
|
1231
|
+
slot_id: SlotId,
|
|
1232
|
+
});
|
|
1233
|
+
/** The slot that is reserved. */
|
|
1234
|
+
export const ReserveSlotReply = z.object({
|
|
1235
|
+
/** The slot reserved. */
|
|
1236
|
+
slot_id: SlotId,
|
|
1237
|
+
});
|
|
1238
|
+
/**
|
|
1239
|
+
* Apply an edit to `agent.yml`, or refuse it without saving any part. Nothing is queued.
|
|
1240
|
+
*
|
|
1241
|
+
* The reply comes once the edit is saved and applied, and carries the node's current settings, the
|
|
1242
|
+
* {@link NodeSettings} that `hello.config` reports. An edit that changes nothing is not saved
|
|
1243
|
+
* again. A slot missing from the reply's `slots` has been removed. Refused with:
|
|
1244
|
+
*
|
|
1245
|
+
* - `slot_held` when the edit would remove or re-port a held slot, or give its server's token to
|
|
1246
|
+
* another slot.
|
|
1247
|
+
* - `slots_held` when the edit changes any key under `controller` (its address, TLS or paths) while any
|
|
1248
|
+
* slot is held.
|
|
1249
|
+
* - `slot_token_required` or `demo_header_value_required` when the edit keeps a value that the
|
|
1250
|
+
* node does not have.
|
|
1251
|
+
* - `invalid_request` when two slots have one token or anything else fails validation.
|
|
1252
|
+
*
|
|
1253
|
+
* After the reply, the agent sends a {@link AgentSlotStatus | slot_status} for each slot that the
|
|
1254
|
+
* edit added or gave a new token. The slot reads `unchecked` until the agent has logged the new
|
|
1255
|
+
* token on to Steam, which the agent starts then unless a server on the node is using the token. A
|
|
1256
|
+
* change to `controller` closes the socket with `1000` once the reply has gone, and the agent sends its
|
|
1257
|
+
* {@link AgentHello | hello} to the new address.
|
|
1258
|
+
*
|
|
1259
|
+
* Required of a controller that hands nodes an `agent.yml` without slots, because it is how they
|
|
1260
|
+
* receive any.
|
|
1261
|
+
*/
|
|
1262
|
+
export const ControllerSetConfig = z.object({
|
|
1263
|
+
type: z.literal('set_config'),
|
|
1264
|
+
...request,
|
|
1265
|
+
/** The settings to change. */
|
|
1266
|
+
config: NodeSettingsEdit,
|
|
1267
|
+
});
|
|
1268
|
+
/**
|
|
1269
|
+
* Replace the agent container with another image. Running servers keep running.
|
|
1270
|
+
*
|
|
1271
|
+
* The reply comes immediately with what happens next, as {@link UpdateAgentReply}, and
|
|
1272
|
+
* `agent_update` reports the rest: its `step` while it runs, then how it ended. Refused with
|
|
1273
|
+
* `maintenance_running` while a maintenance operation runs, with `image_missing` for an image the
|
|
1274
|
+
* node lacks without `pull`, and with `image_not_allowed` for an image outside the official agent
|
|
1275
|
+
* repository while the node's `official_images_only` is on.
|
|
1276
|
+
*
|
|
1277
|
+
* Switching stops this agent, and the agent that comes up, the new one or the old one put back,
|
|
1278
|
+
* reports how it went. The update counts as a maintenance operation until it ends, and a switch
|
|
1279
|
+
* until its replacement has proved it stays up.
|
|
1280
|
+
*/
|
|
1281
|
+
export const ControllerUpdateAgent = z.object({
|
|
1282
|
+
type: z.literal('update_agent'),
|
|
1283
|
+
...request,
|
|
1284
|
+
/** The agent image to run. */
|
|
1285
|
+
image: ImageReference,
|
|
1286
|
+
/** Whether to pull the image. When false, the agent uses an image already on the node. */
|
|
1287
|
+
pull: z.boolean(),
|
|
1288
|
+
});
|
|
1289
|
+
/**
|
|
1290
|
+
* What follows an accepted {@link ControllerUpdateAgent | update_agent}: the update's first
|
|
1291
|
+
* {@link ImageUpdateStep}, or `unchanged` when the node runs that image already, as the registry or
|
|
1292
|
+
* the local image says, and nothing more happens.
|
|
1293
|
+
*/
|
|
1294
|
+
export const UpdateAgentReply = z.object({
|
|
1295
|
+
/** The agent image the update is to. */
|
|
1296
|
+
image: ImageReference,
|
|
1297
|
+
/** The next step. */
|
|
1298
|
+
step: z.enum([...ImageUpdateStep.options, 'unchanged']),
|
|
1299
|
+
});
|
|
1300
|
+
/**
|
|
1301
|
+
* Switch future launches to another cs2 image. Running servers keep theirs, and so does a launch
|
|
1302
|
+
* that took its slot before the switch.
|
|
1303
|
+
*
|
|
1304
|
+
* The reply comes immediately with what happens next, as {@link UpdateCs2ImageReply}. Refused with
|
|
1305
|
+
* `maintenance_running` while a maintenance operation runs, with `image_missing` for an image the
|
|
1306
|
+
* node lacks without `pull`, and with `image_not_allowed` for an image outside the official cs2
|
|
1307
|
+
* repository while the node's `official_images_only` is on.
|
|
1308
|
+
*
|
|
1309
|
+
* The agent then pulls the image when asked to, extracts its plugin files and removes the images
|
|
1310
|
+
* it supersedes. `cs2.image_update` in {@link AgentNodeStatus | node_status} reports its `step`
|
|
1311
|
+
* while it runs, then how it ended, in the same {@link AgentNodeStatus | node_status} as the new
|
|
1312
|
+
* `cs2.image`.
|
|
1313
|
+
*/
|
|
1314
|
+
export const ControllerUpdateCs2Image = z.object({
|
|
1315
|
+
type: z.literal('update_cs2_image'),
|
|
1316
|
+
...request,
|
|
1317
|
+
/** The cs2 image future launches use. */
|
|
1318
|
+
image: ImageReference,
|
|
1319
|
+
/** Whether to pull the image. When false, the agent uses an image already on the node. */
|
|
1320
|
+
pull: z.boolean(),
|
|
1321
|
+
});
|
|
1322
|
+
/**
|
|
1323
|
+
* What follows an accepted {@link ControllerUpdateCs2Image | update_cs2_image}: its first
|
|
1324
|
+
* {@link ImageUpdateStep}.
|
|
1325
|
+
*/
|
|
1326
|
+
export const UpdateCs2ImageReply = z.object({
|
|
1327
|
+
/** The cs2 image the switch is to. */
|
|
1328
|
+
image: ImageReference,
|
|
1329
|
+
/** The next step. */
|
|
1330
|
+
step: ImageUpdateStep,
|
|
1331
|
+
});
|
|
1332
|
+
/**
|
|
1333
|
+
* Start or renew the resource lease, which lasts {@link WATCH_LEASE_MS}. It receives no reply.
|
|
1334
|
+
*
|
|
1335
|
+
* While the lease runs, the agent streams {@link AgentResourceBatch | resource_batch}. A lease that
|
|
1336
|
+
* starts first sends every tick the agent holds.
|
|
1337
|
+
*/
|
|
1338
|
+
export const ControllerWatchResources = z.object({
|
|
1339
|
+
type: z.literal('watch_resources'),
|
|
1340
|
+
/** Whether to send every tick the agent holds now, also while the lease runs. */
|
|
1341
|
+
replay: z.boolean(),
|
|
1342
|
+
});
|
|
1343
|
+
/**
|
|
1344
|
+
* Start or renew the lease of one log source, which lasts {@link WATCH_LEASE_MS}. It receives no
|
|
1345
|
+
* reply.
|
|
1346
|
+
*
|
|
1347
|
+
* While the lease runs, the agent streams the source's {@link AgentLogBatch | log_batch} frames.
|
|
1348
|
+
* While any source's lease runs, the agent also sends {@link AgentLogIndex | log_index}. A lease
|
|
1349
|
+
* that starts replays the source, to every watcher unless a `replay_id` names the one that asked.
|
|
1350
|
+
* Any number of sources can be watched together, each on a lease of its own.
|
|
1351
|
+
*/
|
|
1352
|
+
export const ControllerWatchLogs = z.object({
|
|
1353
|
+
type: z.literal('watch_logs'),
|
|
1354
|
+
/** The log to stream. A source the agent does not keep streams nothing until it prints. */
|
|
1355
|
+
source: LogSource,
|
|
1356
|
+
/**
|
|
1357
|
+
* The id that the replay frames carry, for the watcher that asked for the replay. Null for no
|
|
1358
|
+
* replay, as in a renewal. A lease that starts still replays the source, to every watcher.
|
|
1359
|
+
*/
|
|
1360
|
+
replay_id: ReplayId.nullable(),
|
|
1361
|
+
});
|
|
1362
|
+
/**
|
|
1363
|
+
* The controller's answer to a {@link AgentHello | hello} it accepted, before anything else on the
|
|
1364
|
+
* socket. Without one the agent redials after {@link AGENT_WELCOME_TIMEOUT_MS}.
|
|
1365
|
+
*
|
|
1366
|
+
* After each {@link ControllerWelcome | welcome} the agent sends what changed since the
|
|
1367
|
+
* {@link AgentHello | hello}, its pending {@link AgentServerExited | server_exited} first and then
|
|
1368
|
+
* the changed statuses, demos, backlog and watched streams. It sends each state once, so a slot
|
|
1369
|
+
* that went from `starting` to `running` arrives as `running`.
|
|
1370
|
+
*/
|
|
1371
|
+
export const ControllerWelcome = z.object({ type: z.literal('welcome') });
|
|
1372
|
+
/**
|
|
1373
|
+
* Why the controller refused a {@link AgentHello | hello}: `invalid_hello`, the
|
|
1374
|
+
* {@link AgentHello | hello} fails its schema. A controller refuses nothing else at hello: an agent
|
|
1375
|
+
* on a version it does not support connects, so it can still be updated, and receives no launches.
|
|
1376
|
+
*/
|
|
1377
|
+
export const HelloRefusedReason = z.enum(['invalid_hello']);
|
|
1378
|
+
/**
|
|
1379
|
+
* The controller's answer to a {@link AgentHello | hello} it will not accept, followed by a close
|
|
1380
|
+
* with code `1008` and reason `hello_refused`. The agent stops dialling until it is restarted,
|
|
1381
|
+
* because the same {@link AgentHello | hello} would be refused again.
|
|
1382
|
+
*/
|
|
1383
|
+
export const ControllerHelloRefused = z.object({
|
|
1384
|
+
type: z.literal('hello_refused'),
|
|
1385
|
+
reason: HelloRefusedReason,
|
|
1386
|
+
/** A reason for the operator reading the agent's log, such as the field that failed. */
|
|
1387
|
+
detail: z.string().max(512),
|
|
1388
|
+
});
|
|
1389
|
+
/**
|
|
1390
|
+
* Longest console command line, in characters. CS2 refuses a longer one with `Command too long...
|
|
1391
|
+
* ignoring!` somewhere past 500 (tested in October 2026), and reports it only in its log.
|
|
1392
|
+
*/
|
|
1393
|
+
export const CONSOLE_COMMAND_MAX_CHARS = 500;
|
|
1394
|
+
/**
|
|
1395
|
+
* Run a console command on a server through its plugin.
|
|
1396
|
+
*
|
|
1397
|
+
* The reply comes once the plugin has handed the command to the engine, which reports nothing
|
|
1398
|
+
* back, so the reply cannot say that it worked. Refused with:
|
|
1399
|
+
*
|
|
1400
|
+
* - `unknown_allocation` when the node does not run the server.
|
|
1401
|
+
* - `plugin_offline` while the slot's `plugin_online` is `false`.
|
|
1402
|
+
* - `plugin_status_timeout` when the plugin does not answer.
|
|
1403
|
+
*
|
|
1404
|
+
* The output of the command joins the server's log, which {@link AgentLogBatch | log_batch} streams
|
|
1405
|
+
* while a {@link ControllerWatchLogs | watch_logs} lease covers it and replays when one starts.
|
|
1406
|
+
*/
|
|
1407
|
+
export const ControllerConsoleCommand = z.object({
|
|
1408
|
+
type: z.literal('console_command'),
|
|
1409
|
+
...request,
|
|
1410
|
+
/** The server to run it on. */
|
|
1411
|
+
allocation_id: AllocationId,
|
|
1412
|
+
/**
|
|
1413
|
+
* The console command line, up to {@link CONSOLE_COMMAND_MAX_CHARS} characters, handed to the
|
|
1414
|
+
* engine as it is, so a newline or `;` runs more than one command. It must not be blank, and tabs
|
|
1415
|
+
* and newlines are its only control characters.
|
|
1416
|
+
*/
|
|
1417
|
+
command: z.string().max(CONSOLE_COMMAND_MAX_CHARS)
|
|
1418
|
+
.regex(/\S/u, 'must not be blank')
|
|
1419
|
+
.regex(/^(?:[^\p{Cc}]|[\t\n])*$/u, 'must not contain control characters other than tabs and newlines'),
|
|
1420
|
+
});
|
|
1421
|
+
/** The server the command was handed to. */
|
|
1422
|
+
export const ConsoleCommandReply = z.object({
|
|
1423
|
+
/** The allocation the command went to. */
|
|
1424
|
+
allocation_id: AllocationId,
|
|
1425
|
+
});
|
|
1426
|
+
/**
|
|
1427
|
+
* Play a running match on from one of its earlier rounds, on the same server, without a new
|
|
1428
|
+
* launch. The engine writes a backup as each live round starts, and the node keeps the backups for
|
|
1429
|
+
* the server's life. This request loads the one for `round`.
|
|
1430
|
+
*
|
|
1431
|
+
* The reply comes once the plugin has asked the engine to load the backup. The
|
|
1432
|
+
* {@link MatchRestored | match_restored} event, which the plugin sends ahead of the restored
|
|
1433
|
+
* round's {@link RoundStart | round_start}, confirms that the engine did. Refused with:
|
|
1434
|
+
*
|
|
1435
|
+
* - `unknown_allocation` when the node does not run the server.
|
|
1436
|
+
* - `no_backup` when the node has no backup of that round, as in a game mode without backups
|
|
1437
|
+
* ({@link GameMode}).
|
|
1438
|
+
* - `plugin_offline` while the slot's `plugin_online` is `false`.
|
|
1439
|
+
* - `plugin_status_timeout` when the plugin does not answer.
|
|
1440
|
+
* - `no_match`, `not_live` or `at_halftime` when the plugin refuses the restore.
|
|
1441
|
+
*
|
|
1442
|
+
* The node keeps the backup of every round it has played, including rounds after one it was
|
|
1443
|
+
* restored to. If `round` is past the current round, the server loads play that the match has
|
|
1444
|
+
* since left. Whether to allow that is the controller's rule.
|
|
1445
|
+
*/
|
|
1446
|
+
export const ControllerRestoreRound = z.object({
|
|
1447
|
+
type: z.literal('restore_round'),
|
|
1448
|
+
...request,
|
|
1449
|
+
/** The server whose match to restore. */
|
|
1450
|
+
allocation_id: AllocationId,
|
|
1451
|
+
/** The one-based round to play on from, whose backup the engine wrote as the round started. */
|
|
1452
|
+
round: RoundNumber,
|
|
1453
|
+
});
|
|
1454
|
+
/** The server and round the restore was handed to. */
|
|
1455
|
+
export const RestoreRoundReply = z.object({
|
|
1456
|
+
/** The allocation the restore went to. */
|
|
1457
|
+
allocation_id: AllocationId,
|
|
1458
|
+
/** The round it loads. */
|
|
1459
|
+
round: RoundNumber,
|
|
1460
|
+
});
|
|
1461
|
+
/**
|
|
1462
|
+
* Start a match in warmup whatever its `ready_mode`, without waiting for anyone to be ready. An
|
|
1463
|
+
* admin can always start the match this way, and under `external` it is the only way to.
|
|
1464
|
+
*
|
|
1465
|
+
* The reply comes once the plugin has started the countdown to the knife round or live play.
|
|
1466
|
+
* Refused with:
|
|
1467
|
+
*
|
|
1468
|
+
* - `unknown_allocation` when the node does not run the server.
|
|
1469
|
+
* - `plugin_offline` while the slot's `plugin_online` is `false`.
|
|
1470
|
+
* - `plugin_status_timeout` when the plugin does not answer.
|
|
1471
|
+
* - `no_match`, `not_in_warmup` or `team_empty` when the plugin refuses the start.
|
|
1472
|
+
*
|
|
1473
|
+
* A repeat while the countdown runs is accepted and changes nothing.
|
|
1474
|
+
*/
|
|
1475
|
+
export const ControllerStartMatch = z.object({
|
|
1476
|
+
type: z.literal('start_match'),
|
|
1477
|
+
...request,
|
|
1478
|
+
/** The server whose match to start. */
|
|
1479
|
+
allocation_id: AllocationId,
|
|
1480
|
+
});
|
|
1481
|
+
/** The server the start was handed to. */
|
|
1482
|
+
export const StartMatchReply = z.object({
|
|
1483
|
+
/** The allocation the start went to. */
|
|
1484
|
+
allocation_id: AllocationId,
|
|
1485
|
+
});
|
|
1486
|
+
/**
|
|
1487
|
+
* Milliseconds the agent waits for the plugin to answer
|
|
1488
|
+
* {@link ControllerGetServerStatus | get_server_status}, {@link ControllerConsoleCommand | console_command},
|
|
1489
|
+
* {@link ControllerRestoreRound | restore_round} or {@link ControllerStartMatch | start_match}. Past it, the
|
|
1490
|
+
* request fails with `plugin_status_timeout`.
|
|
1491
|
+
*/
|
|
1492
|
+
export const SERVER_STATUS_TIMEOUT_MS = 10_000;
|
|
1493
|
+
/**
|
|
1494
|
+
* Read a server's state as its plugin reports it now.
|
|
1495
|
+
*
|
|
1496
|
+
* The reply comes within {@link SERVER_STATUS_TIMEOUT_MS}. Refused with:
|
|
1497
|
+
*
|
|
1498
|
+
* - `unknown_allocation` when the node does not run the server.
|
|
1499
|
+
* - `plugin_offline` while the slot's `plugin_online` is `false` or when the plugin leaves before
|
|
1500
|
+
* answering.
|
|
1501
|
+
* - `plugin_status_timeout` after {@link SERVER_STATUS_TIMEOUT_MS}.
|
|
1502
|
+
*/
|
|
1503
|
+
export const ControllerGetServerStatus = z.object({
|
|
1504
|
+
type: z.literal('get_server_status'),
|
|
1505
|
+
...request,
|
|
1506
|
+
/** The server to ask. */
|
|
1507
|
+
allocation_id: AllocationId,
|
|
1508
|
+
});
|
|
1509
|
+
/**
|
|
1510
|
+
* Where a match is.
|
|
1511
|
+
*
|
|
1512
|
+
* - `loading`: its map is loading.
|
|
1513
|
+
* - `warmup`: warmup.
|
|
1514
|
+
* - `pending_restore`: a launch with a restore went here from warmup while the engine loads the
|
|
1515
|
+
* backup, and goes to `live` once the restored round starts. A load that fails leaves the match
|
|
1516
|
+
* here, where the plugin reports nothing, until a load is tried again or the server is stopped.
|
|
1517
|
+
* - `knife`: the knife round.
|
|
1518
|
+
* - `side_choice`: the side choice.
|
|
1519
|
+
* - `live`: live play.
|
|
1520
|
+
* - `post_live`: after the last round.
|
|
1521
|
+
*/
|
|
1522
|
+
export const MatchPhase = z.enum(['loading', 'warmup', 'pending_restore', 'knife', 'side_choice', 'live', 'post_live']);
|
|
1523
|
+
/** Longest `map_name` a server's state carries. The plugin cuts the engine's name to it. */
|
|
1524
|
+
export const MAP_NAME_MAX_CHARS = 64;
|
|
1525
|
+
/** Longest `config_error` a server's state carries. The plugin cuts its reason to it. */
|
|
1526
|
+
export const CONFIG_ERROR_MAX_CHARS = 512;
|
|
1527
|
+
/** A game server's state as its plugin reports it. */
|
|
1528
|
+
export const ServerState = z.object({
|
|
1529
|
+
/** The map the server is on, up to {@link MAP_NAME_MAX_CHARS} characters. */
|
|
1530
|
+
map_name: z.string().max(MAP_NAME_MAX_CHARS),
|
|
1531
|
+
/**
|
|
1532
|
+
* The loaded match and its team state. Null on a server without a match or after a refused
|
|
1533
|
+
* config.
|
|
1534
|
+
*/
|
|
1535
|
+
match: z.object({
|
|
1536
|
+
/**
|
|
1537
|
+
* The `match_id` the server was launched with. Null for a match without one. The launch's
|
|
1538
|
+
* config is the controller's to keep, so it is not echoed.
|
|
1539
|
+
*/
|
|
1540
|
+
match_id: MatchId.nullable(),
|
|
1541
|
+
/** Where the match is. */
|
|
1542
|
+
phase: MatchPhase,
|
|
1543
|
+
/** Team 1's current side. */
|
|
1544
|
+
team1_side: PlayingSide,
|
|
1545
|
+
/**
|
|
1546
|
+
* Team 2's current side, always the other side, sent so a reader shows each team without
|
|
1547
|
+
* deriving it.
|
|
1548
|
+
*/
|
|
1549
|
+
team2_side: PlayingSide,
|
|
1550
|
+
/** Team 1's score. */
|
|
1551
|
+
team1_score: z.number().int().nonnegative(),
|
|
1552
|
+
/** Team 2's score. */
|
|
1553
|
+
team2_score: z.number().int().nonnegative(),
|
|
1554
|
+
}).nullable(),
|
|
1555
|
+
/** Everyone connected, up to {@link MAX_SERVER_PLAYERS}. */
|
|
1556
|
+
players: z.array(z.object({
|
|
1557
|
+
/** The player's SteamID64. */
|
|
1558
|
+
steam_id: SteamId64,
|
|
1559
|
+
/** The player's name. */
|
|
1560
|
+
name: PlayerName,
|
|
1561
|
+
/** The player's side. */
|
|
1562
|
+
side: Side,
|
|
1563
|
+
})).max(MAX_SERVER_PLAYERS),
|
|
1564
|
+
/**
|
|
1565
|
+
* Why the plugin could not read or use the match config it was launched with, up to
|
|
1566
|
+
* {@link CONFIG_ERROR_MAX_CHARS} characters. Null when the server has no match config or the
|
|
1567
|
+
* plugin accepted it.
|
|
1568
|
+
*/
|
|
1569
|
+
config_error: z.string().max(CONFIG_ERROR_MAX_CHARS).nullable(),
|
|
1570
|
+
/**
|
|
1571
|
+
* Whether players can join. When `true`, the server is logged on to Steam and has loaded the map
|
|
1572
|
+
* that its launch asked for, the workshop map when it has one. It stays `true` once set.
|
|
1573
|
+
*
|
|
1574
|
+
* CS2 does not fall back to an anonymous logon when Steam refuses its token, so `true` also
|
|
1575
|
+
* indicates that Steam accepted the GSLT and that its last use can be updated.
|
|
1576
|
+
*/
|
|
1577
|
+
ready: z.boolean(),
|
|
1578
|
+
/**
|
|
1579
|
+
* The SteamID64 that Steam gave the server when it logged on with its GSLT. Each token keeps
|
|
1580
|
+
* one SteamID across logons, so a controller can look the server up in Steam's server list by
|
|
1581
|
+
* it. The plugin also adds the `allocation_id` without its dashes to `sv_tags`, which that list
|
|
1582
|
+
* shows. Null until `ready`.
|
|
1583
|
+
*/
|
|
1584
|
+
server_steam_id: SteamId64.nullable(),
|
|
1585
|
+
});
|
|
1586
|
+
/** The server asked about, and its state as its plugin reports it now. */
|
|
1587
|
+
export const GetServerStatusReply = ServerState.extend({
|
|
1588
|
+
/** The allocation the request asked about. */
|
|
1589
|
+
allocation_id: AllocationId,
|
|
1590
|
+
});
|
|
1591
|
+
/**
|
|
1592
|
+
* A match moved to another phase, as its plugin reports it, whether or not the match sends
|
|
1593
|
+
* statistics. An {@link EventBatch | event_batch} carries the events behind it, such as
|
|
1594
|
+
* {@link GoingLive | going_live} and {@link MapEnd | map_end}.
|
|
1595
|
+
*
|
|
1596
|
+
* The agent sends it once. If the plugin is not connected to the agent, or the controller has not
|
|
1597
|
+
* welcomed the agent's {@link AgentHello | hello}, it is lost, and
|
|
1598
|
+
* {@link ControllerGetServerStatus | get_server_status} reads the phase again.
|
|
1599
|
+
*/
|
|
1600
|
+
export const AgentPhaseChange = z.object({
|
|
1601
|
+
type: z.literal('phase_change'),
|
|
1602
|
+
/** The server whose match changed phase. */
|
|
1603
|
+
allocation_id: AllocationId,
|
|
1604
|
+
/** The phase the match is in now. */
|
|
1605
|
+
phase: MatchPhase,
|
|
1606
|
+
});
|
|
1607
|
+
/**
|
|
1608
|
+
* A match's score changed, as its plugin reads it off the scoreboard, whether or not the match
|
|
1609
|
+
* sends statistics: after a round, when going live resets it, or after a restore. An
|
|
1610
|
+
* {@link EventBatch | event_batch} carries the rounds behind it as {@link RoundEnd | round_end}
|
|
1611
|
+
* and {@link MatchRestored | match_restored}.
|
|
1612
|
+
*
|
|
1613
|
+
* Like {@link AgentPhaseChange | phase_change}, it is sent once, and
|
|
1614
|
+
* {@link ControllerGetServerStatus | get_server_status} reads the score again.
|
|
1615
|
+
*/
|
|
1616
|
+
export const AgentScore = z.object({
|
|
1617
|
+
type: z.literal('score'),
|
|
1618
|
+
/** The server whose match scored. */
|
|
1619
|
+
allocation_id: AllocationId,
|
|
1620
|
+
/** Team 1's score. */
|
|
1621
|
+
team1_score: z.number().int().nonnegative(),
|
|
1622
|
+
/** Team 2's score. */
|
|
1623
|
+
team2_score: z.number().int().nonnegative(),
|
|
1624
|
+
});
|
|
1625
|
+
/**
|
|
1626
|
+
* The {@link RoundBackup | round_backup} of a match that sends no statistics, which a match with
|
|
1627
|
+
* statistics carries in its {@link EventBatch | event_batch} instead. Keep the newest of each match
|
|
1628
|
+
* to restore it on a new server.
|
|
1629
|
+
*
|
|
1630
|
+
* The message wraps the complete event, unchanged, so code that reads a `round_backup` from the
|
|
1631
|
+
* event stream reads `backup` the same way. The wrapper adds only the server that sent it.
|
|
1632
|
+
*
|
|
1633
|
+
* Like {@link AgentPhaseChange | phase_change}, it is sent once, so a backup is lost if the plugin
|
|
1634
|
+
* or the agent is not connected when the round starts. The next round's backup replaces it.
|
|
1635
|
+
*/
|
|
1636
|
+
export const AgentBackup = z.object({
|
|
1637
|
+
type: z.literal('backup'),
|
|
1638
|
+
/** The server whose match the backup is of. */
|
|
1639
|
+
allocation_id: AllocationId,
|
|
1640
|
+
/** The {@link RoundBackup | round_backup} event, as the event stream would carry it. */
|
|
1641
|
+
backup: RoundBackup,
|
|
1642
|
+
});
|
|
1643
|
+
/**
|
|
1644
|
+
* What the controller sends:
|
|
1645
|
+
*
|
|
1646
|
+
* - The {@link ControllerWelcome | welcome} that accepts a {@link AgentHello | hello}, or the
|
|
1647
|
+
* {@link ControllerHelloRefused | hello_refused} that refuses it.
|
|
1648
|
+
* - Requests, which carry an `id` and receive one {@link AgentReply | reply} each.
|
|
1649
|
+
* - The watches that start or renew a stream of logs or resource samples.
|
|
1650
|
+
* - {@link ControllerCheckCs2Build | check_cs2_build}, which receives no answer.
|
|
1651
|
+
*/
|
|
1652
|
+
export const ControllerToAgentMessage = z.discriminatedUnion('type', [
|
|
1653
|
+
ControllerStartServer,
|
|
1654
|
+
ControllerStopServer,
|
|
1655
|
+
ControllerGetServerStatus,
|
|
1656
|
+
ControllerUpdateCs2,
|
|
1657
|
+
ControllerCheckCs2Build,
|
|
1658
|
+
ControllerDeleteDemo,
|
|
1659
|
+
ControllerReserveSlot,
|
|
1660
|
+
ControllerSetConfig,
|
|
1661
|
+
ControllerUpdateAgent,
|
|
1662
|
+
ControllerUpdateCs2Image,
|
|
1663
|
+
ControllerWatchResources,
|
|
1664
|
+
ControllerWatchLogs,
|
|
1665
|
+
ControllerWelcome,
|
|
1666
|
+
ControllerHelloRefused,
|
|
1667
|
+
ControllerConsoleCommand,
|
|
1668
|
+
ControllerRestoreRound,
|
|
1669
|
+
ControllerStartMatch,
|
|
1670
|
+
]);
|
|
1671
|
+
/**
|
|
1672
|
+
* The `type` of each request the controller sends, which its {@link AgentReply | reply} repeats as
|
|
1673
|
+
* `request`.
|
|
1674
|
+
*/
|
|
1675
|
+
export const RequestType = z.enum([
|
|
1676
|
+
'start_server',
|
|
1677
|
+
'stop_server',
|
|
1678
|
+
'get_server_status',
|
|
1679
|
+
'update_cs2',
|
|
1680
|
+
'delete_demo',
|
|
1681
|
+
'reserve_slot',
|
|
1682
|
+
'set_config',
|
|
1683
|
+
'update_agent',
|
|
1684
|
+
'update_cs2_image',
|
|
1685
|
+
'console_command',
|
|
1686
|
+
'restore_round',
|
|
1687
|
+
'start_match',
|
|
1688
|
+
]);
|
|
1689
|
+
/**
|
|
1690
|
+
* What every successful reply carries. Each request's branch of {@link AgentReplySuccess} sets
|
|
1691
|
+
* `request` and `data`.
|
|
1692
|
+
*/
|
|
1693
|
+
const replySuccess = z.object({
|
|
1694
|
+
type: z.literal('reply'),
|
|
1695
|
+
/** The `id` of the request this answers. */
|
|
1696
|
+
reply_to: RequestId,
|
|
1697
|
+
/** The `type` of the request this answers, which says what `data` holds. */
|
|
1698
|
+
request: RequestType,
|
|
1699
|
+
/** The request succeeded. */
|
|
1700
|
+
ok: z.literal(true),
|
|
1701
|
+
/** What the request returns, which names the object it acted on. */
|
|
1702
|
+
data: z.unknown(),
|
|
1703
|
+
});
|
|
1704
|
+
/**
|
|
1705
|
+
* A request that succeeded. `request` names the request, so `data` can be read without the request
|
|
1706
|
+
* that `reply_to` answers. The settings in a {@link ControllerSetConfig | set_config} reply are the
|
|
1707
|
+
* node's, whichever edit asked.
|
|
1708
|
+
*/
|
|
1709
|
+
export const AgentReplySuccess = z.discriminatedUnion('request', [
|
|
1710
|
+
replySuccess.extend({ request: z.literal('start_server'), data: StartServerReply }),
|
|
1711
|
+
replySuccess.extend({ request: z.literal('stop_server'), data: StopServerReply }),
|
|
1712
|
+
replySuccess.extend({ request: z.literal('get_server_status'), data: GetServerStatusReply }),
|
|
1713
|
+
replySuccess.extend({ request: z.literal('update_cs2'), data: UpdateCs2Reply }),
|
|
1714
|
+
replySuccess.extend({ request: z.literal('delete_demo'), data: DeleteDemoReply }),
|
|
1715
|
+
replySuccess.extend({ request: z.literal('reserve_slot'), data: ReserveSlotReply }),
|
|
1716
|
+
replySuccess.extend({ request: z.literal('set_config'), data: NodeSettings }),
|
|
1717
|
+
replySuccess.extend({ request: z.literal('update_agent'), data: UpdateAgentReply }),
|
|
1718
|
+
replySuccess.extend({ request: z.literal('update_cs2_image'), data: UpdateCs2ImageReply }),
|
|
1719
|
+
replySuccess.extend({ request: z.literal('console_command'), data: ConsoleCommandReply }),
|
|
1720
|
+
replySuccess.extend({ request: z.literal('restore_round'), data: RestoreRoundReply }),
|
|
1721
|
+
replySuccess.extend({ request: z.literal('start_match'), data: StartMatchReply }),
|
|
1722
|
+
]);
|
|
1723
|
+
/** A request that failed. */
|
|
1724
|
+
export const AgentReplyFailure = z.object({
|
|
1725
|
+
type: z.literal('reply'),
|
|
1726
|
+
/** The `id` of the request this answers. */
|
|
1727
|
+
reply_to: RequestId,
|
|
1728
|
+
/** The `type` of the request this answers. Null when the agent did not know it. */
|
|
1729
|
+
request: RequestType.nullable(),
|
|
1730
|
+
/** The request failed. */
|
|
1731
|
+
ok: z.literal(false),
|
|
1732
|
+
/** Why it failed. */
|
|
1733
|
+
error: AgentError,
|
|
1734
|
+
});
|
|
1735
|
+
/**
|
|
1736
|
+
* The answer to a request, in any order. A success carries the request's data, and a failure its
|
|
1737
|
+
* reason.
|
|
1738
|
+
*/
|
|
1739
|
+
export const AgentReply = z.discriminatedUnion('ok', [AgentReplySuccess, AgentReplyFailure]);
|
|
1740
|
+
/**
|
|
1741
|
+
* What the agent sends: its {@link AgentHello | hello}, one {@link AgentReply | reply} to each
|
|
1742
|
+
* request, and reports of the node as it changes. Until a {@link ControllerWelcome | welcome} answers the
|
|
1743
|
+
* {@link AgentHello | hello}, only replies follow it, and a {@link AgentShutdown | shutdown} if the
|
|
1744
|
+
* agent stops.
|
|
1745
|
+
*/
|
|
1746
|
+
export const AgentToControllerMessage = z.discriminatedUnion('type', [
|
|
1747
|
+
AgentHello,
|
|
1748
|
+
AgentNodeStatus,
|
|
1749
|
+
AgentShutdown,
|
|
1750
|
+
AgentSlotStatus,
|
|
1751
|
+
AgentServerExited,
|
|
1752
|
+
AgentLogBatch,
|
|
1753
|
+
AgentLogIndex,
|
|
1754
|
+
AgentCs2UpdateProgress,
|
|
1755
|
+
AgentDemo,
|
|
1756
|
+
AgentDemoRemoved,
|
|
1757
|
+
AgentDemoProgress,
|
|
1758
|
+
AgentResourceBatch,
|
|
1759
|
+
AgentEventBacklog,
|
|
1760
|
+
AgentPhaseChange,
|
|
1761
|
+
AgentScore,
|
|
1762
|
+
AgentBackup,
|
|
1763
|
+
AgentReply,
|
|
1764
|
+
]);
|