Replace IW4MAdmin with direct RCON for player counts
IW4M kept hitting a network wall (container -> host LAN IP -> IW4M loopback bind) that we couldn't cleanly punch through, and the two-minute !rt token added enough operational friction that the pause-all-deployments gate never got a fair test. RCON gives us the same fact — how many players are on a server right now — direct from each game server over UDP, with no session, no token, no proxy. - Add integrations/rcon.ts: dgram UDP client. rconGetPlayerCount() and rconGetStatus() parse both `status` (print + scoreboard) and `statusResponse` (info line + rows) dialects. Timeout via RCON_TIMEOUT_MS. - Add komodoResolveGamePort() + extractUdpPort(). Reads Komodo's read API for a server, walks both docker-compose-style port objects and docker-run-style "host:container/udp" strings, caches into servers.game_port on first success. - Replace pipelineReady() guard in sweep with a simple integrations-configured check. Same held/failed/manual outcomes, but keyed on RCON+Komodo instead of IW4M+auth-state. - Worker's status refresh fans out per-server over RCON at concurrency=4 so a 15-server fleet doesn't spike UDP. - Schema: drop iw4m_match/iw4m_value, add game_port + rcon_host. - Delete every auth artifact: TokenModal, auth store, auth-state module, /api/auth/token, /api/events/auth, iw4m.test.ts. Layout no longer mounts the modal, pipeline store no longer tracks a frozen state. - README + .env.example + docker-compose.yml + komodo/stack.toml swap IW4M_* env for RCON_PASSWORD / RCON_HOST / RCON_TIMEOUT_MS. Same host.docker.internal:host-gateway extra_hosts entry, repurposed for RCON reach. - Tests: add rcon.test.ts and komodo.test.ts, update sweep.test.ts to drop the auth-freeze case. 23/23 passing.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
// One in-process event bus, multiplexed by all three SSE endpoints. Kept
|
||||
// separate from any specific subscriber so push/beta/sweep can emit without
|
||||
// pulling the whole worker module in.
|
||||
// One in-process event bus, multiplexed by the SSE endpoints. Kept separate
|
||||
// from any specific subscriber so push/beta/sweep can emit without pulling
|
||||
// the whole worker module in.
|
||||
|
||||
export type PipelineEvent =
|
||||
| { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] }
|
||||
@@ -16,7 +16,6 @@ export type PipelineEvent =
|
||||
error: string | null;
|
||||
}
|
||||
| { type: 'sweep-tick'; frozen: boolean; reason?: string }
|
||||
| { type: 'auth-state'; state: import('../integrations/auth-state.js').AuthState; msRemaining?: number }
|
||||
| { type: 'server-status'; entries: Array<{ server: string; players: number | null; name?: string }> };
|
||||
|
||||
class Bus extends EventEmitter {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { config, iw4mConfigured, komodoConfigured } from '../config.js';
|
||||
import { config, komodoConfigured, rconConfigured } from '../config.js';
|
||||
import { db } from '../db/index.js';
|
||||
import { emit } from './bus.js';
|
||||
import { fetchStatus, playersOn } from '../integrations/iw4m.js';
|
||||
import { rconGetPlayerCount } from '../integrations/rcon.js';
|
||||
import { komodoRestart } from '../integrations/komodo.js';
|
||||
import { pipelineReady } from '../integrations/auth-state.js';
|
||||
import { setServerField } from '../fleet/servers.js';
|
||||
|
||||
interface PendingRow {
|
||||
deployment_id: string;
|
||||
@@ -16,24 +16,21 @@ function nowIso(): string {
|
||||
}
|
||||
|
||||
function integrationsOn(): boolean {
|
||||
return komodoConfigured() && iw4mConfigured();
|
||||
return komodoConfigured() && rconConfigured();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-check every pending/failed deployment_server row, restart the ones with
|
||||
* zero players, mark the busy ones held. Same-server dedup is per-sweep, so
|
||||
* one restart resolves every pending row for that server. Ports `deploy_sweep`
|
||||
* in push_scripts.sh:705.
|
||||
* zero players, hold the busy ones. Same-server dedup is per-sweep, so one
|
||||
* restart resolves every pending row for that server. Ports `deploy_sweep`
|
||||
* in push_scripts.sh:705 but talks RCON directly instead of routing through
|
||||
* IW4MAdmin.
|
||||
*/
|
||||
export async function sweep(): Promise<{ checked: number; restarted: number; held: number; failed: number; frozen: boolean }> {
|
||||
if (!pipelineReady()) {
|
||||
emit({ type: 'sweep-tick', frozen: true, reason: 'iw4m token stale' });
|
||||
return { checked: 0, restarted: 0, held: 0, failed: 0, frozen: true };
|
||||
}
|
||||
|
||||
export async function sweep(): Promise<{ checked: number; restarted: number; held: number; failed: number }> {
|
||||
if (!integrationsOn()) {
|
||||
// Same behavior as line 714 — mark rows manual so they don't hang
|
||||
// forever if integrations were never configured.
|
||||
// Same shape as the shell script's line 714: mark rows manual so
|
||||
// they don't hang forever waiting for something that will never
|
||||
// arrive.
|
||||
db()
|
||||
.prepare(
|
||||
`UPDATE deployment_servers
|
||||
@@ -41,7 +38,8 @@ export async function sweep(): Promise<{ checked: number; restarted: number; hel
|
||||
WHERE restart_state IN ('pending','failed')`
|
||||
)
|
||||
.run(nowIso());
|
||||
return { checked: 0, restarted: 0, held: 0, failed: 0, frozen: false };
|
||||
emit({ type: 'sweep-tick', frozen: false, reason: 'integrations not configured' });
|
||||
return { checked: 0, restarted: 0, held: 0, failed: 0 };
|
||||
}
|
||||
|
||||
const pending = db()
|
||||
@@ -51,19 +49,9 @@ export async function sweep(): Promise<{ checked: number; restarted: number; hel
|
||||
WHERE restart_state IN ('pending','failed')`
|
||||
)
|
||||
.all() as PendingRow[];
|
||||
if (pending.length === 0) return { checked: 0, restarted: 0, held: 0, failed: 0, frozen: false };
|
||||
|
||||
// Warm the /api/status cache so per-server calls just hit memory
|
||||
const status = await fetchStatus();
|
||||
if (!status.ok) {
|
||||
// Same as line 742 — mark held with the iw4m error
|
||||
const stmt = db().prepare(
|
||||
`UPDATE deployment_servers
|
||||
SET checked_at = ?, error = ?, attempts = attempts + 1
|
||||
WHERE restart_state IN ('pending','failed')`
|
||||
);
|
||||
stmt.run(nowIso(), `iw4m: ${status.message}`);
|
||||
return { checked: 0, restarted: 0, held: pending.length, failed: 0, frozen: false };
|
||||
if (pending.length === 0) {
|
||||
emit({ type: 'sweep-tick', frozen: false });
|
||||
return { checked: 0, restarted: 0, held: 0, failed: 0 };
|
||||
}
|
||||
|
||||
// Dedup across deployments: if we resolved server X once this tick,
|
||||
@@ -87,9 +75,9 @@ export async function sweep(): Promise<{ checked: number; restarted: number; hel
|
||||
let outcome = seen.get(row.server);
|
||||
if (!outcome) {
|
||||
checked++;
|
||||
const players = await playersOn(row.server);
|
||||
const players = await rconGetPlayerCount(row.server);
|
||||
if (players === null) {
|
||||
outcome = { state: 'pending', players: null, error: 'no iw4m match' };
|
||||
outcome = { state: 'pending', players: null, error: 'rcon unreachable' };
|
||||
held++;
|
||||
} else if (players > 0) {
|
||||
outcome = { state: 'pending', players, error: null };
|
||||
@@ -108,6 +96,12 @@ export async function sweep(): Promise<{ checked: number; restarted: number; hel
|
||||
}
|
||||
}
|
||||
seen.set(row.server, outcome);
|
||||
// Cache the last known player count on the server row so the
|
||||
// dashboard can render a value between rcon polls.
|
||||
if (outcome.players !== null) {
|
||||
setServerField(row.server, 'last_players', outcome.players);
|
||||
setServerField(row.server, 'last_checked_at', nowIso());
|
||||
}
|
||||
}
|
||||
const now = nowIso();
|
||||
applyOutcome.run(
|
||||
@@ -159,5 +153,5 @@ export async function sweep(): Promise<{ checked: number; restarted: number; hel
|
||||
.run(cutoff);
|
||||
|
||||
emit({ type: 'sweep-tick', frozen: false });
|
||||
return { checked, restarted, held, failed, frozen: false };
|
||||
return { checked, restarted, held, failed };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { sweep } from './sweep.js';
|
||||
import { emit } from './bus.js';
|
||||
import { fetchStatus } from '../integrations/iw4m.js';
|
||||
import { rconGetStatus } from '../integrations/rcon.js';
|
||||
import { listServers, seedServerRegistry } from '../fleet/servers.js';
|
||||
import { getAuthState } from '../integrations/auth-state.js';
|
||||
|
||||
const TICK_MS = 15_000;
|
||||
const STATUS_REFRESH_MS = 30_000;
|
||||
// Cap concurrent UDP RCON calls so a 15-server fleet doesn't blast the
|
||||
// host all at once.
|
||||
const STATUS_CONCURRENCY = 4;
|
||||
|
||||
let started = false;
|
||||
let tickTimer: NodeJS.Timeout | null = null;
|
||||
@@ -19,44 +21,41 @@ async function tick(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function pushServerStatus(): Promise<void> {
|
||||
const st = await fetchStatus();
|
||||
if (!st.ok) return;
|
||||
const rows = listServers();
|
||||
const byName = new Map(rows.map((r) => [r.name, r]));
|
||||
// For the dashboard: emit a lightweight snapshot. Actual per-server
|
||||
// resolution is handled by playersOn() during a sweep, which caches
|
||||
// iw4m_match/value into the servers table.
|
||||
emit({
|
||||
type: 'server-status',
|
||||
entries: st.entries.map((e) => ({
|
||||
server: e.name ?? String(e.port ?? ''),
|
||||
players: e.currentPlayers ?? null,
|
||||
name: e.name
|
||||
}))
|
||||
});
|
||||
// Also fold current auth ttl into the auth channel so subscribers get a
|
||||
// heartbeat and a countdown without waiting for a transition.
|
||||
const auth = getAuthState();
|
||||
emit({
|
||||
type: 'auth-state',
|
||||
state: auth,
|
||||
msRemaining: auth.kind === 'ready' ? Math.max(0, auth.expiresAt - Date.now()) : undefined
|
||||
});
|
||||
void byName; // reserved: future join between /api/status and server registry
|
||||
async function fanoutStatus(): Promise<void> {
|
||||
const servers = listServers();
|
||||
if (servers.length === 0) return;
|
||||
|
||||
const entries: Array<{ server: string; players: number | null; name?: string }> = new Array(servers.length);
|
||||
let cursor = 0;
|
||||
|
||||
async function pump(): Promise<void> {
|
||||
while (true) {
|
||||
const i = cursor++;
|
||||
if (i >= servers.length) return;
|
||||
const srv = servers[i];
|
||||
const status = await rconGetStatus(srv.name).catch(() => null);
|
||||
entries[i] = {
|
||||
server: srv.name,
|
||||
players: status?.players ?? null,
|
||||
name: status?.map ?? undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: STATUS_CONCURRENCY }, pump));
|
||||
|
||||
emit({ type: 'server-status', entries });
|
||||
}
|
||||
|
||||
export function startWorker(): void {
|
||||
if (started) return;
|
||||
started = true;
|
||||
|
||||
// Seed the servers table from the fs so first-boot has rows to reference.
|
||||
seedServerRegistry().catch((err) => console.error('[worker] seed error:', err));
|
||||
|
||||
void tick();
|
||||
void pushServerStatus();
|
||||
void fanoutStatus();
|
||||
tickTimer = setInterval(tick, TICK_MS);
|
||||
statusTimer = setInterval(pushServerStatus, STATUS_REFRESH_MS);
|
||||
statusTimer = setInterval(fanoutStatus, STATUS_REFRESH_MS);
|
||||
|
||||
console.log('[worker] started — sweep every', TICK_MS, 'ms, status every', STATUS_REFRESH_MS, 'ms');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user