diff --git a/hasura/metadata/databases/default/tables/public_game_plugin_cvars.yaml b/hasura/metadata/databases/default/tables/public_game_plugin_cvars.yaml new file mode 100644 index 00000000..84062761 --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_game_plugin_cvars.yaml @@ -0,0 +1,22 @@ +table: + name: game_plugin_cvars + schema: public +object_relationships: + - name: plugin + using: + foreign_key_constraint_on: plugin_slug +select_permissions: + - role: administrator + permission: + columns: + - plugin_slug + - name + - runtime + - version + - kind + - default_value + - description + - flags + - reported_at + filter: {} + comment: "" diff --git a/hasura/metadata/databases/default/tables/public_game_plugin_installs.yaml b/hasura/metadata/databases/default/tables/public_game_plugin_installs.yaml index 0537f28b..21b32f75 100644 --- a/hasura/metadata/databases/default/tables/public_game_plugin_installs.yaml +++ b/hasura/metadata/databases/default/tables/public_game_plugin_installs.yaml @@ -15,6 +15,7 @@ select_permissions: - enabled - disable_server_guidelines - cfg + - config - load_ranked - load_tournaments - load_custom @@ -34,6 +35,7 @@ insert_permissions: - enabled - disable_server_guidelines - cfg + - config - load_ranked - load_tournaments - load_custom @@ -48,6 +50,7 @@ update_permissions: - enabled - disable_server_guidelines - cfg + - config - load_ranked - load_tournaments - load_custom diff --git a/hasura/metadata/databases/default/tables/public_game_plugins.yaml b/hasura/metadata/databases/default/tables/public_game_plugins.yaml index 9ddd7258..a551214d 100644 --- a/hasura/metadata/databases/default/tables/public_game_plugins.yaml +++ b/hasura/metadata/databases/default/tables/public_game_plugins.yaml @@ -16,6 +16,13 @@ array_relationships: table: name: game_mode_plugins schema: public + - name: reported_cvars + using: + foreign_key_constraint_on: + column: plugin_slug + table: + name: game_plugin_cvars + schema: public - name: node_installs using: manual_configuration: @@ -60,7 +67,11 @@ select_permissions: - source - config_schema - config_path + - config_cvar + - config_default + - config_shipped - cvars + - forced_cvars - map_rotation - pairs_with - panel @@ -92,7 +103,11 @@ insert_permissions: - source - config_schema - config_path + - config_cvar + - config_default + - config_shipped - cvars + - forced_cvars - map_rotation - pairs_with - panel @@ -121,7 +136,11 @@ update_permissions: - source - config_schema - config_path + - config_cvar + - config_default + - config_shipped - cvars + - forced_cvars - map_rotation - pairs_with - panel diff --git a/hasura/metadata/databases/default/tables/tables.yaml b/hasura/metadata/databases/default/tables/tables.yaml index 0e7079a4..d31b4f05 100644 --- a/hasura/metadata/databases/default/tables/tables.yaml +++ b/hasura/metadata/databases/default/tables/tables.yaml @@ -87,6 +87,7 @@ - "!include public_friends.yaml" - "!include public_game_mode_plugins.yaml" - "!include public_game_modes.yaml" +- "!include public_game_plugin_cvars.yaml" - "!include public_game_plugin_installs.yaml" - "!include public_game_plugin_versions.yaml" - "!include public_game_plugins.yaml" diff --git a/hasura/migrations/default/1889000001700_plugin_settings/down.sql b/hasura/migrations/default/1889000001700_plugin_settings/down.sql new file mode 100644 index 00000000..58fa8c3d --- /dev/null +++ b/hasura/migrations/default/1889000001700_plugin_settings/down.sql @@ -0,0 +1,10 @@ +DROP TABLE IF EXISTS public.game_plugin_cvars; + +ALTER TABLE public.game_plugin_installs + DROP COLUMN IF EXISTS config; + +ALTER TABLE public.game_plugins + DROP COLUMN IF EXISTS config_cvar, + DROP COLUMN IF EXISTS config_default, + DROP COLUMN IF EXISTS config_shipped, + DROP COLUMN IF EXISTS forced_cvars; diff --git a/hasura/migrations/default/1889000001700_plugin_settings/up.sql b/hasura/migrations/default/1889000001700_plugin_settings/up.sql new file mode 100644 index 00000000..69c976b9 --- /dev/null +++ b/hasura/migrations/default/1889000001700_plugin_settings/up.sql @@ -0,0 +1,28 @@ +ALTER TABLE public.game_plugins + ADD COLUMN IF NOT EXISTS config_cvar text, + ADD COLUMN IF NOT EXISTS config_default jsonb, + ADD COLUMN IF NOT EXISTS config_shipped jsonb, + ADD COLUMN IF NOT EXISTS forced_cvars text[] NOT NULL DEFAULT '{}'; + +ALTER TABLE public.game_plugin_installs + ADD COLUMN IF NOT EXISTS config jsonb; + +-- One row per cvar the catalog lists, as a server running the plugin reported +-- it. default_value is only ever read from a server where none of the panel's +-- configs set that cvar, so it stays NULL until such a server reports. +CREATE TABLE IF NOT EXISTS public.game_plugin_cvars ( + plugin_slug text NOT NULL, + name text NOT NULL, + runtime text NOT NULL, + version text NOT NULL, + kind text NOT NULL, + default_value text, + description text NOT NULL DEFAULT '', + flags text NOT NULL DEFAULT '', + reported_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (plugin_slug, name), + CONSTRAINT game_plugin_cvars_plugin_slug_fkey FOREIGN KEY (plugin_slug) + REFERENCES public.game_plugins (slug) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT game_plugin_cvars_kind_check + CHECK (kind IN ('bool', 'int', 'float', 'string')) +); diff --git a/src/dedicated-servers/dedicated-servers.service.spec.ts b/src/dedicated-servers/dedicated-servers.service.spec.ts index 86281c02..e94e6652 100644 --- a/src/dedicated-servers/dedicated-servers.service.spec.ts +++ b/src/dedicated-servers/dedicated-servers.service.spec.ts @@ -20,6 +20,7 @@ describe("DedicatedServersService.rebuildDedicatedServer", () => { null as never, null as never, postgres as never, + null as never, ); const steps: Array = []; @@ -188,6 +189,8 @@ describe("DedicatedServersService.pingDedicatedServer", () => { let reachable: boolean; let hasura: { query: jest.Mock; mutation: jest.Mock }; let notifications: { send: jest.Mock }; + let pluginCvars: { harvest: jest.Mock }; + let rcon: Record; let service: DedicatedServersService; beforeEach(() => { @@ -210,7 +213,8 @@ describe("DedicatedServersService.pingDedicatedServer", () => { }), }; notifications = { send: jest.fn().mockResolvedValue(undefined) }; - const rcon = { + pluginCvars = { harvest: jest.fn().mockResolvedValue(undefined) }; + rcon = { connect: jest.fn(async () => reachable ? { @@ -226,6 +230,7 @@ describe("DedicatedServersService.pingDedicatedServer", () => { : null, ), disconnect: jest.fn(), + listCvars: jest.fn(async () => []), }; service = new DedicatedServersService( @@ -241,6 +246,7 @@ describe("DedicatedServersService.pingDedicatedServer", () => { null as never, notifications as never, null as never, + pluginCvars as never, ); }); @@ -280,6 +286,56 @@ describe("DedicatedServersService.pingDedicatedServer", () => { expect(row.connected).toBe(true); }); + it("reads plugin cvars over the open connection before closing it", async () => { + const order: Array = []; + pluginCvars.harvest.mockImplementation( + async (_: string, list: (name: string) => Promise) => { + await list("dm_replenish_health"); + order.push("harvest"); + }, + ); + rcon.disconnect.mockImplementation(async () => { + order.push("disconnect"); + }); + + await service.pingDedicatedServer("server-1"); + + expect(rcon.listCvars).toHaveBeenCalledWith( + "server-1", + "dm_replenish_health", + ); + expect(order).toEqual(["harvest", "disconnect"]); + }); + + it("tells the harvest a server that just came up has restarted", async () => { + row.connected = false; + + await service.pingDedicatedServer("server-1"); + + expect(pluginCvars.harvest).toHaveBeenCalledWith( + "server-1", + expect.any(Function), + { restarted: true }, + ); + }); + + it("does not ask an unreachable server about its plugins", async () => { + reachable = false; + + await service.pingDedicatedServer("server-1"); + + expect(pluginCvars.harvest).not.toHaveBeenCalled(); + }); + + it("still marks the server connected when reading its cvars fails", async () => { + row.connected = false; + pluginCvars.harvest.mockRejectedValue(new Error("rcon dropped")); + + await service.pingDedicatedServer("server-1"); + + expect(row.connected).toBe(true); + }); + it("stays quiet about a server that just stopped answering", async () => { reachable = false; @@ -425,6 +481,7 @@ describe("DedicatedServersService.getServerPlayerList", () => { null as never, null as never, null as never, + null as never, ); return { service, rcon }; @@ -711,6 +768,7 @@ describe("DedicatedServersService.setupDedicatedServer", () => { { forServer: jest.fn().mockResolvedValue({ maps: [] }) } as never, null as never, null as never, + null as never, ); Object.assign(service, { apps: { createNamespacedDeployment } }); diff --git a/src/dedicated-servers/dedicated-servers.service.ts b/src/dedicated-servers/dedicated-servers.service.ts index aabe8479..c4e690d2 100644 --- a/src/dedicated-servers/dedicated-servers.service.ts +++ b/src/dedicated-servers/dedicated-servers.service.ts @@ -16,6 +16,7 @@ import { ResolvedGameMode, } from "../game-plugins/game-modes.service"; import { MapRotationService } from "../game-plugins/map-rotation.service"; +import { PluginCvarsService } from "../game-plugins/plugin-cvars.service"; import { NotificationsService } from "src/notifications/notifications.service"; import { DISCORD_COLORS } from "src/notifications/utilities/constants"; import { MarkDedicatedServerOffline } from "src/game-server-node/jobs/MarkDedicatedServerOffline"; @@ -78,6 +79,7 @@ export class DedicatedServersService { private readonly mapRotationService: MapRotationService, private readonly notifications: NotificationsService, private readonly postgres: PostgresService, + private readonly pluginCvars: PluginCvarsService, ) { this.redis = this.redisManager.getConnection(); @@ -1112,9 +1114,30 @@ export class DedicatedServersService { }); } + // Not connected before this ping means it just came up, possibly with a + // different plugin set than the last time it was asked. + await this.readPluginCvars(serverId, !server.connected); + await this.RconService.disconnect(serverId); } + private async readPluginCvars( + serverId: string, + restarted: boolean, + ): Promise { + try { + await this.pluginCvars.harvest( + serverId, + (name) => this.RconService.listCvars(serverId, name), + { restarted }, + ); + } catch (error) { + this.logger.warn( + `[${serverId}] could not read plugin cvars: ${error?.message ?? error}`, + ); + } + } + public async expectRestart(serverId: string): Promise { await MarkDedicatedServerOffline.expectRestart(this.redis, serverId); } diff --git a/src/game-plugins/game-modes.service.ts b/src/game-plugins/game-modes.service.ts index 854f9ea3..95652e04 100644 --- a/src/game-plugins/game-modes.service.ts +++ b/src/game-plugins/game-modes.service.ts @@ -156,10 +156,16 @@ export class GameModesService { } if (!matchId && row?.is_community_server) { - return await this.withServerPlugins(serverId, mode, scope); + return await this.withInstallConfigs( + await this.withServerPlugins(serverId, mode, scope), + scope.runtime, + ); } - return await this.withAutoLoad(mode, scope); + return await this.withInstallConfigs( + await this.withAutoLoad(mode, scope), + scope.runtime, + ); } // What a server would boot with if it ran this mode right now. Auto-load @@ -593,6 +599,62 @@ export class GameModesService { return "custom"; } + // A plugin's config file as set on its page goes to every server that loads + // the plugin. A mode's or a rotation's file at the same path is laid over it, + // being the more specific of the two. + private async withInstallConfigs( + resolved: ResolvedGameMode | null, + runtime: string, + ): Promise { + const slugs = GameModesService.entriesOf(resolved).map( + GameModesService.slugOf, + ); + + if (!resolved || slugs.length === 0) { + return resolved; + } + + const rows = await this.postgres.query< + Array<{ config: unknown; config_path: string }> + >( + `SELECT i.config, p.config_path + FROM game_plugins p + INNER JOIN game_plugin_installs i ON i.plugin_slug = p.slug + WHERE p.slug = ANY($1::text[]) + AND i.enabled = true + AND i.config IS NOT NULL + AND p.config_path IS NOT NULL`, + [slugs], + ); + + if (rows.length === 0) { + return resolved; + } + + const files = Object.fromEntries( + rows.map((row) => [ + row.config_path.replace("{runtime}", runtime), + JSON.stringify(row.config, null, 2), + ]), + ); + + return { + ...resolved, + pluginConfigs: GameModesService.withConfigFiles( + Buffer.from(JSON.stringify(files)).toString("base64"), + GameModesService.configFilesOf(resolved), + ), + }; + } + + private static configFilesOf( + mode: ResolvedGameMode | null, + ): Record { + return mode?.pluginConfigs + ? JSON.parse(Buffer.from(mode.pluginConfigs, "base64").toString()) + : {}; + } + // The cvars each loading plugin carries, in the order the plugins load. // // Keyed off what the mode actually resolved to rather than off the install @@ -613,18 +675,51 @@ export class GameModesService { } const rows = await this.postgres.query< - Array<{ plugin_slug: string; cfg: string }> + Array<{ + plugin_slug: string; + cfg: string | null; + config_cvar: string | null; + config_path: string | null; + }> >( - `SELECT plugin_slug, cfg - FROM game_plugin_installs - WHERE plugin_slug = ANY($1::text[]) - AND enabled = true - AND cfg IS NOT NULL - AND btrim(cfg) <> ''`, + // From the catalog, not the installs: a hand-placed plugin a mode loads + // has no install row, and its file still needs the cvar pointing at it. + `SELECT p.slug AS plugin_slug, + CASE WHEN i.enabled THEN i.cfg END AS cfg, + p.config_cvar, + p.config_path + FROM game_plugins p + LEFT JOIN game_plugin_installs i ON i.plugin_slug = p.slug + WHERE p.slug = ANY($1::text[])`, [slugs], ); - const cfgs = new Map(rows.map((row) => [row.plugin_slug, row.cfg])); + const written = new Set(Object.keys(GameModesService.configFilesOf(mode))); + + const cfgs = new Map(); + + for (const row of rows) { + const lines = row.cfg?.trim() ? [row.cfg] : []; + + // A plugin that finds its file through a cvar reads its own copy until + // told otherwise, so the cvar comes with the file wherever it is written. + const path = ["swiftlys2", "counterstrikesharp"] + .map((runtime) => row.config_path?.replace("{runtime}", runtime)) + .find((candidate) => candidate && written.has(candidate)); + + if (row.config_cvar && path) { + lines.push(`${row.config_cvar} "${path}"`); + } + + if (lines.length > 0) { + cfgs.set( + row.plugin_slug, + lines.length === 1 + ? lines[0] + : lines.map((line) => line.replace(/\s+$/, "")).join("\n"), + ); + } + } return slugs .filter((slug) => cfgs.has(slug)) diff --git a/src/game-plugins/game-plugins.module.ts b/src/game-plugins/game-plugins.module.ts index 9b863022..280f93f9 100644 --- a/src/game-plugins/game-plugins.module.ts +++ b/src/game-plugins/game-plugins.module.ts @@ -14,6 +14,7 @@ import { GamePluginQueues } from "./enums/GamePluginQueues"; import { GamePluginsService } from "./game-plugins.service"; import { GameModesService } from "./game-modes.service"; import { MapRotationService } from "./map-rotation.service"; +import { PluginCvarsService } from "./plugin-cvars.service"; import { GamePluginsController } from "./game-plugins.controller"; import { SyncGamePluginRegistry } from "./jobs/SyncGamePluginRegistry"; import { CheckGamePluginUpdates } from "./jobs/CheckGamePluginUpdates"; @@ -24,6 +25,7 @@ import { NotifyGamePluginUpdate } from "./jobs/NotifyGamePluginUpdate"; GamePluginsService, GameModesService, MapRotationService, + PluginCvarsService, SyncGamePluginRegistry, CheckGamePluginUpdates, NotifyGamePluginUpdate, @@ -37,20 +39,22 @@ import { NotifyGamePluginUpdate } from "./jobs/NotifyGamePluginUpdate"; PostgresModule, NotificationsModule, PluginRuntimeModule, - BullModule.registerQueue( - { name: GamePluginQueues.Registry }, - ), - BullBoardModule.forFeature( - { name: GamePluginQueues.Registry, adapter: BullMQAdapter }, - ), + BullModule.registerQueue({ name: GamePluginQueues.Registry }), + BullBoardModule.forFeature({ + name: GamePluginQueues.Registry, + adapter: BullMQAdapter, + }), + ], + exports: [ + GamePluginsService, + GameModesService, + MapRotationService, + PluginCvarsService, ], - exports: [GamePluginsService, GameModesService, MapRotationService], controllers: [GamePluginsController], }) export class GamePluginsModule { - constructor( - @InjectQueue(GamePluginQueues.Registry) registry: Queue, - ) { + constructor(@InjectQueue(GamePluginQueues.Registry) registry: Queue) { if (process.env.RUN_MIGRATIONS) { return; } @@ -66,6 +70,5 @@ export class GamePluginsModule { {}, { repeat: { pattern: "7 * * * *" } }, ); - } } diff --git a/src/game-plugins/game-plugins.service.ts b/src/game-plugins/game-plugins.service.ts index 8db39df6..d1355e0b 100644 --- a/src/game-plugins/game-plugins.service.ts +++ b/src/game-plugins/game-plugins.service.ts @@ -128,8 +128,10 @@ export class GamePluginsService { (slug, kind, name, author, description, homepage, tags, verified, hot_swappable, requires_service, requires_server_guidelines_disabled, config_schema, config_path, cvars, panel, wiring, pairs_with, - map_rotation, synced_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18, now()) + map_rotation, config_cvar, config_default, config_shipped, + forced_cvars, synced_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18, + $19,$20,$21,$22, now()) ON CONFLICT (slug) DO UPDATE SET kind = EXCLUDED.kind, name = EXCLUDED.name, @@ -148,6 +150,10 @@ export class GamePluginsService { wiring = EXCLUDED.wiring, pairs_with = EXCLUDED.pairs_with, map_rotation = EXCLUDED.map_rotation, + config_cvar = EXCLUDED.config_cvar, + config_default = EXCLUDED.config_default, + config_shipped = EXCLUDED.config_shipped, + forced_cvars = EXCLUDED.forced_cvars, synced_at = now()`, [ plugin.slug, @@ -168,6 +174,10 @@ export class GamePluginsService { plugin.wiring ? JSON.stringify(plugin.wiring) : null, plugin.pairs_with ?? [], plugin.map_rotation ? JSON.stringify(plugin.map_rotation) : null, + plugin.config_cvar ?? null, + plugin.config_default ? JSON.stringify(plugin.config_default) : null, + plugin.config_shipped ? JSON.stringify(plugin.config_shipped) : null, + plugin.forced_cvars ?? [], ], ); diff --git a/src/game-plugins/plugin-cvars.service.ts b/src/game-plugins/plugin-cvars.service.ts new file mode 100644 index 00000000..97ccf9bf --- /dev/null +++ b/src/game-plugins/plugin-cvars.service.ts @@ -0,0 +1,247 @@ +import { createHash } from "node:crypto"; +import { Injectable, Logger } from "@nestjs/common"; +import { PostgresService } from "../postgres/postgres.service"; +import { CacheService } from "../cache/cache.service"; + +export type ListedCvar = { + name: string; + kind: string; + flags: string; + description: string; +}; + +export type CvarKind = "bool" | "int" | "float" | "string"; + +type PendingPlugin = { + slug: string; + version: string; + runtime: string; + cvars: Array; +}; + +@Injectable() +export class PluginCvarsService { + // A plugin installed on a node is not necessarily loaded on every server + // there, so a server that does not run it is asked again only this often -- + // or as soon as it restarts, which is when its plugin set can change. + private static readonly RETRY_AFTER_SECONDS = 6 * 60 * 60; + + // Each release is read once per runtime. Nodes on different versions would + // otherwise overwrite each other's rows and both re-read every minute. + private static readonly REPORTED_SECONDS = 30 * 24 * 60 * 60; + + private static readonly NAME = /^[A-Za-z0-9_.]+$/; + + // Never shown as a plugin's default, whatever a server says it holds. + private static readonly SECRET = /api_?key|password|passwd|secret|token/i; + + constructor( + private readonly logger: Logger, + private readonly postgres: PostgresService, + private readonly cache: CacheService, + ) {} + + // The catalog only names a plugin's cvars. What each one is for and what it + // defaults to is only knowable from a server that has the plugin loaded. + public async harvest( + serverId: string, + list: (name: string) => Promise>, + options: { restarted?: boolean } = {}, + ): Promise { + for (const plugin of await this.pending(serverId)) { + const reported = PluginCvarsService.reportedKey(plugin); + + if (await this.cache.has(reported)) { + continue; + } + + const attempt = `plugin-cvars:${serverId}:${plugin.slug}:${plugin.version}`; + + if (!options.restarted && (await this.cache.has(attempt))) { + continue; + } + + const found: Array = []; + + for (const name of plugin.cvars) { + const match = (await list(name)).find( + (entry) => entry.name.toLowerCase() === name.toLowerCase(), + ); + + if (match && match.kind !== "cmd") { + found.push({ ...match, name }); + } + } + + if (found.length === 0) { + await this.cache.put( + attempt, + true, + PluginCvarsService.RETRY_AFTER_SECONDS, + ); + continue; + } + + await this.record(plugin, found); + await this.cache.put(reported, true, PluginCvarsService.REPORTED_SECONDS); + + this.logger.log( + `read ${found.length} ${plugin.slug}@${plugin.version} cvars from ${serverId}`, + ); + } + } + + private async pending(serverId: string): Promise> { + const rows = await this.postgres.query>( + `SELECT n.plugin_slug AS slug, n.version, n.runtime, p.cvars + FROM public.servers s + INNER JOIN public.game_server_node_plugins n + ON n.game_server_node_id = s.game_server_node_id + INNER JOIN public.game_plugins p ON p.slug = n.plugin_slug + WHERE s.id = $1 + AND n.status = 'Installed' + AND n.detected = true + AND n.version IS NOT NULL + AND cardinality(p.cvars) > 0`, + [serverId], + ); + + return rows.map((row) => ({ + ...row, + cvars: row.cvars.filter((name) => PluginCvarsService.NAME.test(name)), + })); + } + + // The catalog's list is part of the key, so a cvar it adds later is read + // without waiting for the plugin's next release. + private static reportedKey(plugin: PendingPlugin): string { + const listed = createHash("sha1") + .update([...plugin.cvars].sort().join(",")) + .digest("hex") + .slice(0, 12); + + return `plugin-cvars:reported:${plugin.slug}:${plugin.runtime}:${plugin.version}:${listed}`; + } + + // A value counts as the default only where nothing the panel delivers set + // it, so a server running an operator's config never records their value as + // what the plugin ships with. An earlier default is kept rather than lost. + private async record( + plugin: PendingPlugin, + found: Array, + ): Promise { + const setByPanel = await this.cvarsSetByPanel(); + + for (const cvar of found) { + const unknown = + setByPanel.has(cvar.name.toLowerCase()) || + PluginCvarsService.SECRET.test(cvar.name); + + await this.postgres.query( + `INSERT INTO public.game_plugin_cvars + (plugin_slug, name, runtime, version, kind, default_value, + description, flags, reported_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now()) + ON CONFLICT (plugin_slug, name) DO UPDATE SET + runtime = EXCLUDED.runtime, + version = EXCLUDED.version, + kind = EXCLUDED.kind, + default_value = COALESCE( + EXCLUDED.default_value, game_plugin_cvars.default_value), + description = EXCLUDED.description, + flags = EXCLUDED.flags, + reported_at = now()`, + [ + plugin.slug, + cvar.name, + plugin.runtime, + plugin.version, + PluginCvarsService.kindOf(cvar.kind), + unknown ? null : cvar.kind, + cvar.description, + cvar.flags, + ], + ); + } + + // By the catalog's list rather than by what this server had: two releases + // can differ, and whichever reported last would delete the other's. + await this.postgres.query( + `DELETE FROM public.game_plugin_cvars + WHERE plugin_slug = $1 AND name <> ALL($2::text[])`, + [plugin.slug, plugin.cvars], + ); + } + + private async cvarsSetByPanel(): Promise> { + const rows = await this.postgres.query>( + `SELECT cfg FROM public.game_plugin_installs WHERE cfg IS NOT NULL + UNION ALL + SELECT cfg FROM public.game_modes WHERE cfg IS NOT NULL + UNION ALL + SELECT cfg FROM public.match_type_cfgs + UNION ALL + SELECT p.config_cvar + FROM public.game_plugins p + WHERE p.config_cvar IS NOT NULL + AND (EXISTS (SELECT 1 FROM public.game_plugin_installs i + WHERE i.plugin_slug = p.slug AND i.config IS NOT NULL) + OR EXISTS (SELECT 1 FROM public.game_mode_plugins m + WHERE m.plugin_slug = p.slug AND m.config IS NOT NULL))`, + ); + + const launch = await this.postgres.query>( + `SELECT extra_game_params AS params + FROM public.game_modes + WHERE extra_game_params IS NOT NULL`, + ); + + return new Set([ + ...rows.flatMap((row) => PluginCvarsService.cvarNames(row.cfg)), + ...launch.flatMap((row) => + PluginCvarsService.launchCvarNames(row.params), + ), + ]); + } + + // Commands can share a line with ";" and a name can be quoted; both still + // set the cvar when the server execs the file. + public static cvarNames(cfg: string): Array { + return cfg + .split(/[\n;]/) + .map((command) => + command + .replace(/\/\/.*$/, "") + .trim() + .split(/\s+/)[0] + .replace(/^"(.*)"$/, "$1"), + ) + .filter((name) => name && PluginCvarsService.NAME.test(name)) + .map((name) => name.toLowerCase()); + } + + public static launchCvarNames(params: string): Array { + return params + .split(/\s+/) + .filter((token) => token.startsWith("+")) + .map((token) => token.slice(1)) + .filter((name) => PluginCvarsService.NAME.test(name)) + .map((name) => name.toLowerCase()); + } + + public static kindOf(value: string): CvarKind { + if (value === "true" || value === "false") { + return "bool"; + } + + if (/^-?\d+$/.test(value)) { + return "int"; + } + + if (/^-?(\d+\.\d*|\.\d+)(e[-+]?\d+)?$/i.test(value)) { + return "float"; + } + + return "string"; + } +} diff --git a/src/game-plugins/types/Registry.ts b/src/game-plugins/types/Registry.ts index a6b55d15..d250bcbd 100644 --- a/src/game-plugins/types/Registry.ts +++ b/src/game-plugins/types/Registry.ts @@ -29,7 +29,11 @@ export type RegistryPlugin = { requires_server_guidelines_disabled?: boolean; config_schema?: Record; config_path?: string; + config_cvar?: string; + config_default?: Record | Array; + config_shipped?: { path?: string; repo_path?: string }; cvars?: Array; + forced_cvars?: Array; map_rotation?: MapRotationSpec; panel?: Record; wiring?: Record; diff --git a/src/game-server-node/game-server-node.controller.spec.ts b/src/game-server-node/game-server-node.controller.spec.ts index ed2d9843..f382b53d 100644 --- a/src/game-server-node/game-server-node.controller.spec.ts +++ b/src/game-server-node/game-server-node.controller.spec.ts @@ -32,6 +32,7 @@ describe("GameServerNodeController ping disk alerts", () => { queue as any, queue as any, {} as any, + {} as any, ); }); diff --git a/src/game-server-node/game-server-node.controller.ts b/src/game-server-node/game-server-node.controller.ts index b30782ec..c45ac80f 100644 --- a/src/game-server-node/game-server-node.controller.ts +++ b/src/game-server-node/game-server-node.controller.ts @@ -30,6 +30,7 @@ import { NotificationsService } from "../notifications/notifications.service"; import { DISCORD_COLORS } from "../notifications/utilities/constants"; import { GameStreamerService } from "../matches/game-streamer/game-streamer.service"; import { GamePluginsService } from "../game-plugins/game-plugins.service"; +import { PluginCvarsService } from "../game-plugins/plugin-cvars.service"; import { MapAssetsService } from "../map-assets/map-assets.service"; import { User } from "../auth/types/User"; @@ -59,6 +60,7 @@ export class GameServerNodeController { @InjectQueue(GameServerQueues.ValidateGamedata) private readonly validateGamedataQueue: Queue, protected readonly mapAssets: MapAssetsService, + protected readonly pluginCvars: PluginCvarsService, ) { this.appConfig = this.config.get("app"); } @@ -960,13 +962,19 @@ UNIT // identical on the filesystem -- so ask the server itself, once, as it // comes up. if (!server.connected) { - void this.recordLoadedPlugins(String(serverId), pluginRuntime).catch( - (error: Error) => { + void this.recordLoadedPlugins(String(serverId), pluginRuntime) + .then(() => + this.pluginCvars.harvest( + String(serverId), + (name) => this.rcon.listCvars(String(serverId), name), + { restarted: true }, + ), + ) + .catch((error: Error) => { this.logger.warn( `could not read loaded plugins from ${serverId}: ${error.message}`, ); - }, - ); + }); } } diff --git a/src/rcon/rcon.service.spec.ts b/src/rcon/rcon.service.spec.ts index 181fd881..1ea33734 100644 --- a/src/rcon/rcon.service.spec.ts +++ b/src/rcon/rcon.service.spec.ts @@ -102,3 +102,80 @@ describe("RconService connect failure", () => { expect(notifications.send).not.toHaveBeenCalled(); }); }); + +describe("RconService.listCvars", () => { + const service = new RconService( + {} as any, + {} as any, + {} as any, + { warn: jest.fn(), log: jest.fn(), error: jest.fn() } as any, + {} as any, + { getConnection: () => ({}) } as any, + {} as any, + ); + + const answer = (output: string) => { + const send = jest.fn().mockResolvedValue(output); + jest.spyOn(service, "connect").mockResolvedValue({ send } as any); + return send; + }; + + // A plugin's cvar can hold a URL. Splitting the row on every colon cut the + // value at "https" and pushed the rest of it into the flags. + it("keeps a value that contains colons whole", async () => { + const send = answer( + "invsim_url : https://inventory.cstrike.app : sv, release : Inventory Simulator API URL", + ); + + await expect(service.listCvars("server-1", "invsim_url")).resolves.toEqual([ + { + name: "invsim_url", + kind: "https://inventory.cstrike.app", + flags: "sv, release", + description: "Inventory Simulator API URL", + }, + ]); + expect(send).toHaveBeenCalledWith("Cvarlist invsim_url"); + }); + + it("reads an empty value, empty flags and a command", async () => { + answer( + [ + "cvar list", + "dm_pro_ratio : : sv : Target K/D ratio.", + "dm_replenish_health : 10 : : Amount of health replenished on kill.", + "sw_guns : cmd : sv : Display available weapons.", + "--- 3 convars/concommands for [dm_] ---", + ].join("\n"), + ); + + await expect(service.listCvars("server-1", "dm_")).resolves.toEqual([ + { + name: "dm_pro_ratio", + kind: "", + flags: "sv", + description: "Target K/D ratio.", + }, + { + name: "dm_replenish_health", + kind: "10", + flags: "", + description: "Amount of health replenished on kill.", + }, + { + name: "sw_guns", + kind: "cmd", + flags: "sv", + description: "Display available weapons.", + }, + ]); + }); + + it("fails instead of answering for a server it cannot reach", async () => { + jest.spyOn(service, "connect").mockResolvedValue(null); + + await expect(service.listCvars("server-1", "dm_")).rejects.toThrow( + "unable to connect", + ); + }); +}); diff --git a/src/rcon/rcon.service.ts b/src/rcon/rcon.service.ts index 73b2e381..be1dcfe7 100644 --- a/src/rcon/rcon.service.ts +++ b/src/rcon/rcon.service.ts @@ -391,6 +391,21 @@ export class RconService { } } + public async listCvars( + serverId: string, + prefix: string, + ): Promise< + Array<{ name: string; kind: string; flags: string; description: string }> + > { + const rcon = await this.connect(serverId); + + if (!rcon) { + throw new Error(`unable to connect to server ${serverId}`); + } + + return this.parseCvarList(await rcon.send(`Cvarlist ${prefix}`)); + } + private parseCvarList( output: string, ): Array<{ name: string; kind: string; flags: string; description: string }> { @@ -423,10 +438,9 @@ export class RconService { continue; } - // Match 4 columns split by ':' allowing optional spaces and empty description - const match = line.match( - /^\s*([^:]+)\s*:\s*([^:]+)\s*:\s*([^:]*)\s*:\s*(.*)$/, - ); + // Columns are separated by " : ", never a bare colon: a value can be a + // URL, and splitting on every colon cut it at "https". + const match = line.match(/^\s*(\S+)\s+:\s?(.*?)\s+:\s?(.*?)\s+:\s?(.*)$/); if (!match) { this.logger.warn(`unable to parse cvar list: ${line}`); continue; diff --git a/test/game-mode-resolution.spec.ts b/test/game-mode-resolution.spec.ts index 174154c4..364b1fab 100644 --- a/test/game-mode-resolution.spec.ts +++ b/test/game-mode-resolution.spec.ts @@ -839,6 +839,115 @@ describe("game mode resolution (SQL-driven)", () => { ); }); + // Set once on the plugin's page, the file reaches every server running it; + // a rotation on this server still wins the keys it owns. + it("writes the plugin's config file under the rotation's keys", async () => { + await postgres.query( + `UPDATE game_plugins SET config_path = $1 WHERE slug = 'map-chooser'`, + ["addons/{runtime}/configs/plugins/MapChooser/config.jsonc"], + ); + await postgres.query( + `UPDATE game_plugin_installs SET config = $1 WHERE plugin_slug = 'map-chooser'`, + [ + JSON.stringify({ + MapChooser: { + Rtv: { VotePercentage: 60 }, + Cycle: { Enabled: false, RandomOrder: false }, + }, + }), + ], + ); + await rotate(publicServer, [ + await workshopMap("rotation-mirage", "Prophunt Mirage", "3615968422"), + ]); + + const resolved = await service.resolveForServer(publicServer); + + expect( + files(resolved?.pluginConfigs ?? null)[ + "addons/swiftlys2/configs/plugins/MapChooser/config.jsonc" + ], + ).toEqual({ + MapChooser: { + Rtv: { VotePercentage: 60 }, + Cycle: { Enabled: true, RandomOrder: true }, + }, + }); + }); + + describe("a plugin that reads its file from a cvar", () => { + const modes = [{ name: "Rifles", weapons: ["ak47"], duration: 600 }]; + const path = "addons/swiftlys2/configs/plugins/Deathmatch/modes.json"; + + beforeEach(async () => { + await catalog("deathmatch"); + await postgres.query( + `UPDATE game_plugins SET config_path = $1, config_cvar = 'dm_modes_file' + WHERE slug = 'deathmatch'`, + [path], + ); + await installed("deathmatch"); + await onNode("node-a", "deathmatch", "1.1.2"); + await override(publicServer, "deathmatch", true); + }); + + it("writes the file and points the cvar at it", async () => { + await postgres.query( + `UPDATE game_plugin_installs SET config = $1, cfg = 'dm_replenish_health 20' + WHERE plugin_slug = 'deathmatch'`, + [JSON.stringify(modes)], + ); + + const resolved = await service.resolveForServer(publicServer); + + expect(files(resolved?.pluginConfigs ?? null)[path]).toEqual(modes); + expect(await service.pluginCfgLayers(resolved)).toEqual([ + { + slug: "deathmatch", + cfg: `dm_replenish_health 20\ndm_modes_file "${path}"`, + }, + ]); + }); + + // Placed on the node by hand, so the panel has no install row for it. + it("points the cvar at a mode's file for a plugin with no install row", async () => { + await postgres.query( + `DELETE FROM game_plugin_installs WHERE plugin_slug = 'deathmatch'`, + ); + await postgres.query( + `UPDATE servers SET type = 'Custom' WHERE id = $1`, + [publicServer], + ); + const [mode] = await postgres.query>( + `INSERT INTO game_modes (slug, name) VALUES ('dm', 'DM') RETURNING id`, + ); + await postgres.query( + `INSERT INTO game_mode_plugins (game_mode_id, plugin_slug, config) + VALUES ($1, 'deathmatch', $2)`, + [mode.id, JSON.stringify(modes)], + ); + await postgres.query( + `UPDATE servers SET game_mode_id = $1 WHERE id = $2`, + [mode.id, publicServer], + ); + + const resolved = await service.resolveForServer(publicServer); + + expect(files(resolved?.pluginConfigs ?? null)[path]).toEqual(modes); + expect(await service.pluginCfgLayers(resolved)).toEqual([ + { slug: "deathmatch", cfg: `dm_modes_file "${path}"` }, + ]); + }); + + // The cvar only ever names a file this server is actually given. + it("leaves the cvar alone while the plugin keeps its own file", async () => { + const resolved = await service.resolveForServer(publicServer); + + expect(files(resolved?.pluginConfigs ?? null)[path]).toBeUndefined(); + expect(await service.pluginCfgLayers(resolved)).toEqual([]); + }); + }); + it("plays no rotation on a Practice server", async () => { await rotate(publicServer, [ await workshopMap("rotation-mirage", "Prophunt Mirage", "3615968422"), diff --git a/test/game-plugin-registry-sync.spec.ts b/test/game-plugin-registry-sync.spec.ts index 13b5cd69..b36bb799 100644 --- a/test/game-plugin-registry-sync.spec.ts +++ b/test/game-plugin-registry-sync.spec.ts @@ -108,6 +108,37 @@ describe("game plugin registry sync (SQL-driven)", () => { expect(versions.map((row) => row.version)).toEqual(["2.0.0"]); }); + it("keeps what the registry says about the plugin's config", async () => { + Object.assign(published[0], { + cvars: ["dm_modes_file", "dm_replenish_health"], + forced_cvars: ["mp_timelimit", "bot_quota"], + config_path: "addons/swiftlys2/configs/plugins/Deathmatch/modes.json", + config_cvar: "dm_modes_file", + config_default: [{ name: "Pistols", weapons: ["deagle"], duration: 300 }], + config_shipped: { + path: "addons/swiftlys2/plugins/Deathmatch/resources/configs/default.json", + repo_path: "resources/configs/default.json", + }, + }); + + await service.syncRegistry(); + + const [plugin] = await postgres.query>>( + `SELECT config_cvar, config_default, config_shipped, forced_cvars + FROM game_plugins WHERE slug = 'retakes'`, + ); + + expect(plugin).toEqual({ + config_cvar: "dm_modes_file", + config_default: [{ name: "Pistols", weapons: ["deagle"], duration: 300 }], + config_shipped: { + path: "addons/swiftlys2/plugins/Deathmatch/resources/configs/default.json", + repo_path: "resources/configs/default.json", + }, + forced_cvars: ["mp_timelimit", "bot_quota"], + }); + }); + it("still prunes a registry plugin that stopped being published", async () => { await postgres.query( `INSERT INTO game_plugins (slug, kind, name, author, description) diff --git a/test/node-scheduling.spec.ts b/test/node-scheduling.spec.ts index d5d69aff..2d554d2c 100644 --- a/test/node-scheduling.spec.ts +++ b/test/node-scheduling.spec.ts @@ -106,6 +106,7 @@ describe("node scheduling across an outage (SQL-driven)", () => { {} as never, {} as never, {} as never, + {} as never, ); }); diff --git a/test/plugin-cvars.spec.ts b/test/plugin-cvars.spec.ts new file mode 100644 index 00000000..2b85abe6 --- /dev/null +++ b/test/plugin-cvars.spec.ts @@ -0,0 +1,376 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { + ListedCvar, + PluginCvarsService, +} from "./../src/game-plugins/plugin-cvars.service"; +import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; + +// The catalog names a plugin's cvars; a server running the plugin is what says +// what each one is for and what it defaults to. +describe("plugin cvar harvest (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let service: PluginCvarsService; + let cache: Map; + let serverId: string; + + beforeAll(async () => { + db = await bootMigratedDb("PluginCvars"); + postgres = db.postgres; + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + beforeEach(async () => { + cache = new Map(); + service = new PluginCvarsService( + { log: jest.fn(), warn: jest.fn() } as never, + postgres, + { + has: async (key: string) => cache.has(key), + put: async (key: string, value: unknown) => { + cache.set(key, value); + }, + } as never, + ); + + await postgres.query("DELETE FROM servers"); + await postgres.query("DELETE FROM game_server_node_plugins"); + await postgres.query("DELETE FROM game_plugin_installs"); + await postgres.query("DELETE FROM game_mode_plugins"); + await postgres.query("DELETE FROM game_modes"); + await postgres.query("DELETE FROM game_plugins"); + await postgres.query("DELETE FROM game_server_nodes"); + await postgres.query( + `INSERT INTO server_regions (value, description) + VALUES ('TestRegion', 'TestRegion') ON CONFLICT (value) DO NOTHING`, + ); + await postgres.query( + `INSERT INTO game_server_nodes (id, status, enabled, region) + VALUES ('node-a', 'Online', true, 'TestRegion')`, + ); + + const [server] = await postgres.query>( + `INSERT INTO servers + (host, label, rcon_password, port, region, type, is_dedicated, enabled, + game_server_node_id) + VALUES ('127.0.0.1', 'dm', $1, 27015, 'TestRegion', 'Custom', false, true, + 'node-a') + RETURNING id`, + [Buffer.from("password")], + ); + serverId = server.id; + + await postgres.query( + `INSERT INTO game_plugins (slug, kind, name, author, description, cvars) + VALUES ('deathmatch', 'game', 'Deathmatch', 'ianlucas', 'dm', + ARRAY['dm_replenish_health', 'dm_chat_prefix', 'dm_pro_ratio'])`, + ); + await postgres.query( + `INSERT INTO game_server_node_plugins + (game_server_node_id, plugin_slug, runtime, version, detected, status) + VALUES ('node-a', 'deathmatch', 'swiftlys2', '1.1.2', true, 'Installed')`, + ); + }); + + const server: Record = { + dm_replenish_health: { + name: "dm_replenish_health", + kind: "10", + flags: "sv", + description: "Amount of health replenished on kill.", + }, + dm_replenish_health_headshot: { + name: "dm_replenish_health_headshot", + kind: "25", + flags: "sv", + description: "Amount of health replenished on headshot kill.", + }, + dm_chat_prefix: { + name: "dm_chat_prefix", + kind: "[{red}Deathmatch{default}]", + flags: "sv", + description: "Prefix displayed before chat messages.", + }, + dm_pro_ratio: { + name: "dm_pro_ratio", + kind: "", + flags: "sv", + description: "Target K/D ratio.", + }, + }; + + // Cvarlist matches by prefix, so asking for one name can answer with others. + const list = jest.fn(async (prefix: string) => + Object.values(server).filter((cvar) => cvar.name.startsWith(prefix)), + ); + + const reported = async () => + await postgres.query>>( + `SELECT name, kind, default_value, description, version, runtime + FROM game_plugin_cvars ORDER BY name`, + ); + + it("records what a server running the plugin says about each cvar", async () => { + await service.harvest(serverId, list); + + expect(await reported()).toEqual([ + { + name: "dm_chat_prefix", + kind: "string", + default_value: "[{red}Deathmatch{default}]", + description: "Prefix displayed before chat messages.", + version: "1.1.2", + runtime: "swiftlys2", + }, + expect.objectContaining({ + name: "dm_pro_ratio", + kind: "string", + default_value: "", + }), + expect.objectContaining({ + name: "dm_replenish_health", + kind: "int", + default_value: "10", + }), + ]); + }); + + // An operator's value is not what the plugin ships with. + it("does not take a value the panel set as the default", async () => { + await postgres.query( + `INSERT INTO game_plugin_installs (plugin_slug, version, channel, cfg) + VALUES ('deathmatch', NULL, 'Auto', 'dm_replenish_health 50 // more')`, + ); + + await service.harvest(serverId, list); + + const [health] = (await reported()).filter( + (row) => row.name === "dm_replenish_health", + ); + expect(health).toEqual( + expect.objectContaining({ kind: "int", default_value: null }), + ); + }); + + it.each([ + ["a quoted name", `"dm_replenish_health" "50"`], + ["commands sharing a line", "sv_cheats 0; dm_replenish_health 50"], + ])("does not take a value set by %s as the default", async (_, cfg) => { + await postgres.query( + `INSERT INTO game_plugin_installs (plugin_slug, version, channel, cfg) + VALUES ('deathmatch', NULL, 'Auto', $1)`, + [cfg], + ); + + await service.harvest(serverId, list); + + const [health] = (await reported()).filter( + (row) => row.name === "dm_replenish_health", + ); + expect(health.default_value).toBeNull(); + }); + + it("does not take a value a mode's launch parameters set as the default", async () => { + await postgres.query( + `INSERT INTO game_modes (slug, name, extra_game_params) + VALUES ('dm', 'DM', '+dm_replenish_health 50 +sv_cheats 0')`, + ); + + await service.harvest(serverId, list); + + const [health] = (await reported()).filter( + (row) => row.name === "dm_replenish_health", + ); + expect(health.default_value).toBeNull(); + }); + + // The panel points the cvar at the file it writes, so what the server holds + // is the panel's path, not the plugin's. + it("does not take a config file's path as the default", async () => { + await postgres.query( + `UPDATE game_plugins SET config_cvar = 'dm_chat_prefix' WHERE slug = 'deathmatch'`, + ); + await postgres.query( + `INSERT INTO game_plugin_installs (plugin_slug, version, channel, config) + VALUES ('deathmatch', NULL, 'Auto', '[]')`, + ); + + await service.harvest(serverId, list); + + const [prefix] = (await reported()).filter( + (row) => row.name === "dm_chat_prefix", + ); + expect(prefix.default_value).toBeNull(); + }); + + it("never records a secret as a default", async () => { + await postgres.query( + `UPDATE game_plugins SET cvars = ARRAY['invsim_apikey'] WHERE slug = 'deathmatch'`, + ); + const keyed = jest.fn(async () => [ + { + name: "invsim_apikey", + kind: "inv_123", + flags: "sv", + description: "API key.", + }, + ]); + + await service.harvest(serverId, keyed); + + expect(await reported()).toEqual([ + expect.objectContaining({ + name: "invsim_apikey", + kind: "string", + default_value: null, + }), + ]); + }); + + it("keeps a default it already knew when the panel now sets the cvar", async () => { + await service.harvest(serverId, list); + await postgres.query( + `UPDATE game_server_node_plugins SET version = '1.2.0'`, + ); + await postgres.query( + `INSERT INTO game_modes (slug, name, cfg) + VALUES ('dm', 'DM', 'dm_replenish_health 50')`, + ); + + await service.harvest(serverId, list); + + const [health] = (await reported()).filter( + (row) => row.name === "dm_replenish_health", + ); + expect(health).toEqual( + expect.objectContaining({ version: "1.2.0", default_value: "10" }), + ); + }); + + it("asks once per installed version", async () => { + await service.harvest(serverId, list); + list.mockClear(); + + await service.harvest(serverId, list); + + expect(list).not.toHaveBeenCalled(); + }); + + // Installed on the node is not loaded on this server; another server that + // does run it will answer. + it("records nothing and waits when the plugin is not loaded here", async () => { + const none = jest.fn(async () => [] as Array); + + await service.harvest(serverId, none); + await service.harvest(serverId, none); + + expect(await reported()).toEqual([]); + expect(none).toHaveBeenCalledTimes(3); + }); + + it("drops a cvar the catalog stops listing", async () => { + await service.harvest(serverId, list); + await postgres.query( + `UPDATE game_plugins SET cvars = ARRAY['dm_replenish_health', 'dm_chat_prefix']`, + ); + + await service.harvest(serverId, list); + + expect((await reported()).map((row) => row.name)).toEqual([ + "dm_chat_prefix", + "dm_replenish_health", + ]); + }); + + // The catalog's list is part of what was read, so a cvar it adds is picked + // up without waiting for the plugin's next release. + it("reads a cvar the catalog starts listing", async () => { + await postgres.query( + `UPDATE game_plugins SET cvars = ARRAY['dm_replenish_health']`, + ); + await service.harvest(serverId, list); + await postgres.query( + `UPDATE game_plugins SET cvars = ARRAY['dm_replenish_health', 'dm_chat_prefix']`, + ); + + await service.harvest(serverId, list); + + expect((await reported()).map((row) => row.name)).toEqual([ + "dm_chat_prefix", + "dm_replenish_health", + ]); + }); + + // Two nodes on different releases used to overwrite each other's rows and + // re-read every minute, forever. + it("settles when servers run different releases", async () => { + await postgres.query( + `INSERT INTO game_server_nodes (id, status, enabled, region) + VALUES ('node-b', 'Online', true, 'TestRegion')`, + ); + await postgres.query( + `INSERT INTO game_server_node_plugins + (game_server_node_id, plugin_slug, runtime, version, detected, status) + VALUES ('node-b', 'deathmatch', 'swiftlys2', '1.2.0', true, 'Installed')`, + ); + const [other] = await postgres.query>( + `INSERT INTO servers + (host, label, rcon_password, port, region, type, is_dedicated, enabled, + game_server_node_id) + VALUES ('127.0.0.2', 'dm-b', $1, 27016, 'TestRegion', 'Custom', false, true, + 'node-b') + RETURNING id`, + [Buffer.from("password")], + ); + + await service.harvest(serverId, list); + await service.harvest(other.id, list); + list.mockClear(); + + await service.harvest(serverId, list); + await service.harvest(other.id, list); + + expect(list).not.toHaveBeenCalled(); + }); + + it("asks a server that just restarted, which may load the plugin now", async () => { + const none = jest.fn(async () => [] as Array); + await service.harvest(serverId, none); + + await service.harvest(serverId, list, { restarted: true }); + + expect((await reported()).length).toEqual(3); + }); + + it("only asks about plugins on the server's own node", async () => { + await postgres.query( + `INSERT INTO game_server_nodes (id, status, enabled, region) + VALUES ('node-b', 'Online', true, 'TestRegion')`, + ); + await postgres.query( + `UPDATE game_server_node_plugins SET game_server_node_id = 'node-b'`, + ); + + await service.harvest(serverId, list); + + expect(await reported()).toEqual([]); + }); +}); + +describe("PluginCvarsService.kindOf", () => { + it.each([ + ["true", "bool"], + ["false", "bool"], + ["10", "int"], + ["-3", "int"], + ["0.5", "float"], + ["1.000000", "float"], + ["https://inventory.5stack.gg", "string"], + ["", "string"], + ])("reads %p as %p", (value, kind) => { + expect(PluginCvarsService.kindOf(value)).toEqual(kind); + }); +});