Admin: Spieldateien auf allen Hosts per SteamCMD aktualisieren, mit Prozentanzeige

Ein kurzlebiger Container je Host aktualisiert CS2 im Spieldateien-Volume. Hosts mit
laufenden Servern werden ausgelassen (erzwingbar), während des Updates startet dort
kein neuer Server. Fertig nur, wenn Steam die App als vollständig installiert meldet.
This commit is contained in:
mregosar committed 2026-11-01 18:22:23 +01:00
1 parent 2c57b8c170
commit 3c5a9b4162
7 files changed
+281 -5

No files matched your search

+30
View File
@@ -104,6 +104,7 @@ export function createAdminRouter({ service, manager, config, settings, knife, p
editable: !!manager.adapter.setHosts,
image: config.docker.image,
pull: manager.adapter.pullStatus?.() ?? null,
files: manager.adapter.filesStatus?.() ?? null,
});
router.get('/docker-hosts', requireAdmin, wrap(() => hostView()));
router.put('/docker-hosts', requireAdmin, wrap((req) => {
@@ -119,6 +120,35 @@ export function createAdminRouter({ service, manager, config, settings, knife, p
return hostView();
}));
// Image auf allen Hosts neu laden. Läuft im Hintergrund, Stand über GET /docker-hosts.
// Spieldateien auf allen Hosts per SteamCMD aktualisieren. Hosts mit laufenden Servern werden ausgelassen,
// außer mit { force: true }. Läuft im Hintergrund, Stand über GET /docker-hosts.
router.post('/docker-hosts/update-files', requireAdmin, wrap((req) => {
if (!manager.adapter.updateGameFiles) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
if (manager.adapter.filesStatus().running) throw new HttpError(409, 'Die Spieldateien werden bereits aktualisiert', 'busy');
const skip = new Map();
if (!req.body?.force) {
const byAddress = new Map(manager.adapter.hosts.map((h) => [h.publicHost, h.name]));
const busy = new Map();
for (const s of manager.activeServers()) {
const name = s.hostName ?? byAddress.get(s.host);
if (name) busy.set(name, (busy.get(name) ?? 0) + 1);
}
for (const k of knife.rows()) {
if (!k.handle) continue;
let name = byAddress.get(k.host);
try {
name = JSON.parse(k.handle).hostName ?? name;
} catch {
// kaputter Handle: Adresse reicht
}
if (name) busy.set(name, (busy.get(name) ?? 0) + 1);
}
for (const [name, n] of busy) skip.set(name, `ausgelassen: ${n} laufende${n === 1 ? 'r' : ''} Server`);
}
manager.adapter.updateGameFiles({ skip }).catch(() => {});
return hostView();
}));
router.post('/docker-hosts/pull', requireAdmin, wrap(() => {
if (!manager.adapter.pullImage) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
if (manager.adapter.pullStatus().running) throw new HttpError(409, 'Das Image wird bereits aktualisiert', 'busy');
+121 -1
View File
@@ -66,6 +66,41 @@ export function formatDockerEvent(line) {
return `${time} ${EVENT_TYPES[e.Type]} ${name}${app} ${label}${extra}`;
}
/**
* Skript für den Update-Container: dasselbe SteamCMD-Update wie beim Serverstart des Images (entry.sh),
* nur ohne danach den Server zu starten. Vorher und nachher wird die Build-Nummer ausgegeben.
*/
export const GAMEFILES_UPDATE_SCRIPT = [
'f=/home/steam/cs2-dedicated/steamapps/appmanifest_730.acf',
'b() { grep -m1 \'"buildid"\' "$f" 2>/dev/null | tr -dc 0-9; }',
'echo "BUILD_BEFORE=$(b)"',
'bash /home/steam/steamcmd/steamcmd.sh +force_install_dir /home/steam/cs2-dedicated +@bClientTryRequestManifestWithoutCode 1'
+ ' +login anonymous +app_update 730 -beta public +quit',
'rc=$?',
'echo "BUILD_AFTER=$(b)"',
// StateFlags 4 = vollständig installiert; SteamCMD meldet auch bei Abbruch oft Exit-Code 0
'echo "STATE_AFTER=$(grep -m1 \'"StateFlags"\' "$f" 2>/dev/null | tr -dc 0-9)"',
'exit $rc',
].join('; ');
/** Fortschritt und Build-Nummern aus der Ausgabe des Update-Containers lesen. */
export function parseUpdateOutput(text, into = {}) {
for (const line of String(text).split(/\r?\n/)) {
const before = /^BUILD_BEFORE=(\d*)/.exec(line);
const after = /^BUILD_AFTER=(\d*)/.exec(line);
const state = /^STATE_AFTER=(\d*)/.exec(line);
const error = /(Error!.*|ERROR!.*)/.exec(line);
const progress = /progress:\s*([\d.]+)/.exec(line);
if (before) into.buildBefore = before[1] || null;
else if (after) into.buildAfter = after[1] || null;
else if (state) into.stateAfter = state[1] || null;
else if (error) into.error = error[1].trim().slice(0, 200);
else if (progress) into.progress = Number(progress[1]);
if (line.trim()) into.lastLine = line.trim().slice(0, 200);
}
return into;
}
/** Fehler von "docker run", wenn ein Port auf dem Host schon von etwas anderem belegt ist. */
const PORT_BUSY = /port is already allocated|address already in use/i;
const DISK_CACHE_MS = 60_000;
@@ -342,6 +377,7 @@ export class DockerHostPool {
this.diskCache = new Map();
this.diskInflight = new Map();
this.pull = { running: false, startedAt: null, finishedAt: null, results: [] };
this.files = { running: false, startedAt: null, finishedAt: null, results: [] };
this.setHosts(options.hosts);
}
@@ -430,6 +466,83 @@ export class DockerHostPool {
return this.pull;
}
/**
* CS2-Spieldateien im Volume jedes Hosts per SteamCMD aktualisieren (eigener, kurzlebiger Container).
* Hosts in `skip` (z. B. mit laufenden Servern) werden ausgelassen, damit niemandem die Dateien unter
* einem laufenden Server ausgetauscht werden.
*/
async updateGameFiles({ skip = new Map() } = {}) {
if (this.files.running) throw new Error('Die Spieldateien werden bereits aktualisiert');
const hosts = this.hosts.map((h) => h.name);
this.files = {
running: true,
startedAt: Date.now(),
finishedAt: null,
results: hosts.map((name) => (skip.has(name)
? { name, state: 'skipped', message: skip.get(name) }
: { name, state: 'running', message: 'SteamCMD prüft auf Updates …', progress: null })),
};
await Promise.all(this.files.results.filter((r) => r.state === 'running').map((r) => this.runFileUpdate(r)));
this.files.running = false;
this.files.finishedAt = Date.now();
return this.files;
}
async runFileUpdate(result) {
const host = this.hosts.find((h) => h.name === result.name);
const args = [
'run', '--rm', '--name', `cs2mv-update-${host.name.replace(/[^a-zA-Z0-9_.-]/g, '')}`, '--label', 'cs2-mapveto-update=1',
'-v', `${host.volume}:/home/steam/cs2-dedicated`, '--entrypoint', 'bash', this.options.image, '-c', GAMEFILES_UPDATE_SCRIPT,
];
const info = {};
const finish = (exitOk, error) => {
const { buildBefore: from, buildAfter: to } = info;
// Nur "ok", wenn Steam die App als vollständig installiert meldet
const complete = info.stateAfter == null || info.stateAfter === '4';
const ok = exitOk && complete && !info.error;
if (!ok && exitOk && !error) {
error = info.error ?? `nicht vollständig installiert (StateFlags ${info.stateAfter}), bitte erneut starten`;
}
if (ok) {
Object.assign(result, {
state: 'ok',
progress: 100,
message: from && to && from === to ? `bereits aktuell (Build ${to})` : to ? `aktualisiert auf Build ${to}${from ? ` (vorher ${from})` : ''}` : 'aktualisiert',
});
} else {
Object.assign(result, { state: 'failed', message: String(error || info.lastLine || 'SteamCMD-Update fehlgeschlagen').slice(0, 200) });
}
};
if (this.runDocker) {
// Tests: Ausgabe kommt gesammelt zurück
try {
parseUpdateOutput(await this.runDocker(args, host.name), info);
finish(true);
} catch (err) {
finish(false, err.message);
}
return;
}
await new Promise((resolve) => {
const env = host.dockerHost ? { ...process.env, DOCKER_HOST: host.dockerHost } : process.env;
const proc = spawn(this.options.bin, args, { env, windowsHide: true });
const onData = (chunk) => {
parseUpdateOutput(chunk.toString('utf8'), info);
if (info.progress != null) Object.assign(result, { progress: info.progress, message: `Update läuft ${Math.floor(info.progress)} %` });
};
proc.stdout.on('data', onData);
proc.stderr.on('data', onData);
// Ein großes Update dauert, aber nicht ewig
const timer = setTimeout(() => proc.kill(), 90 * 60_000);
proc.on('error', (err) => { clearTimeout(timer); finish(false, err.message); resolve(); });
proc.on('close', (code) => { clearTimeout(timer); finish(code === 0, code === null ? 'abgebrochen (Zeitlimit)' : null); resolve(); });
});
}
filesStatus() {
return this.files;
}
/**
* Freier Speicher auf einem Host, per SSH: dort, wo das Gameserver-Volume liegt. Ergebnis wird eine Minute lang
* zwischengespeichert, weil die Admin-Seite alle paar Sekunden abfragt. Ohne ssh:// gibt es null.
@@ -476,12 +589,19 @@ export class DockerHostPool {
return this.depsGetUsedPorts?.(publicHost) ?? new Set();
}
/** Hosts, auf denen gerade die Spieldateien aktualisiert werden: dort startet kein neuer Server. */
updatingHosts() {
return new Set(this.files.running ? this.files.results.filter((r) => r.state === 'running').map((r) => r.name) : []);
}
pickHost() {
const updating = this.updatingHosts();
const best = this.hosts
.filter((h) => !updating.has(h.name))
.map((h) => ({ host: h, free: this.freeSlots(h) }))
.filter((x) => x.free > 0)
.sort((a, b) => b.free - a.free)[0];
if (!best) throw new Error('Kein Docker-Host mit freien Ports');
if (!best) throw new Error(updating.size ? 'Kein Docker-Host frei, auf den anderen werden gerade die Spieldateien aktualisiert' : 'Kein Docker-Host mit freien Ports');
return best.host;
}
+5
View File
@@ -253,6 +253,11 @@ export class KnifeServers {
/** Ausgefallenen Server neu starten, damit immer Messer-Server laufen. */
async recover(row, reason) {
// Während des Spieldateien-Updates nicht neu starten (zählt auch nicht als Fehlversuch)
if (this.adapter.filesStatus?.().running) {
if (row.message !== 'Wartet auf das Spieldateien-Update') this.update(row.id, { status: 'starting', message: 'Wartet auf das Spieldateien-Update' });
return;
}
if (row.restarts >= MAX_RESTARTS) {
if (row.status !== 'failed') this.update(row.id, { status: 'failed', message: `Ausgefallen, ${MAX_RESTARTS} Neustarts ohne Erfolg: ${reason ?? ''}`.trim() });
return;
+65
View File
@@ -0,0 +1,65 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DockerHostPool, GAMEFILES_UPDATE_SCRIPT, parseUpdateOutput } from '../src/servers/dockerAdapter.js';
const options = (runDocker) => new DockerHostPool({
bin: 'docker', image: 'joedwards32/cs2:latest', host: '10.0.0.9', rconHost: '10.0.0.9', portRangeStart: 27100, portRangeEnd: 27139,
volume: 'cs2-mapveto-gamefiles', startTimeoutMinutes: 1, lanPorts: [],
hosts: [{ name: 'a', publicHost: '10.0.0.1' }, { name: 'b', publicHost: '10.0.0.2', volume: 'andere-dateien' }],
}, { runDocker });
test('Ausgabe von SteamCMD: Fortschritt und Build-Nummern', () => {
const info = parseUpdateOutput('BUILD_BEFORE=100\n Update state (0x61) downloading, progress: 42.50 (1 / 2)\n');
assert.equal(info.buildBefore, '100');
assert.equal(info.progress, 42.5);
parseUpdateOutput('Success! App \'730\' fully installed.\nBUILD_AFTER=105\n', info);
assert.equal(info.buildAfter, '105');
assert.match(GAMEFILES_UPDATE_SCRIPT, /\+app_update 730 -beta public \+quit/);
});
test('Spieldateien-Update: eigener Container je Host, Hosts mit Servern ausgelassen', async () => {
const calls = [];
const pool = options(async (args, host) => {
calls.push({ args, host });
return 'BUILD_BEFORE=100\nBUILD_AFTER=105\n';
});
const res = await pool.updateGameFiles({ skip: new Map([['b', 'ausgelassen: 1 laufender Server']]) });
assert.equal(calls.length, 1);
assert.equal(calls[0].host, 'a');
const args = calls[0].args;
assert.deepEqual(args.slice(0, 2), ['run', '--rm']);
assert.ok(args.includes('cs2-mapveto-gamefiles:/home/steam/cs2-dedicated'));
assert.equal(args[args.indexOf('--entrypoint') + 1], 'bash');
assert.ok(!args.includes('cs2-mapveto=1'), 'Update-Container zählen nicht als Gameserver');
assert.deepEqual(res.results.map((r) => [r.name, r.state]), [['a', 'ok'], ['b', 'skipped']]);
assert.match(res.results[0].message, /Build 105 \(vorher 100\)/);
assert.equal(res.running, false);
});
test('Bereits aktuell und Fehler werden gemeldet', async () => {
const same = options(async () => 'BUILD_BEFORE=105\nBUILD_AFTER=105\n');
assert.match((await same.updateGameFiles()).results[0].message, /bereits aktuell \(Build 105\)/);
const broken = options(async () => { throw new Error('SteamCMD failed (rc 8)'); });
const r = (await broken.updateGameFiles()).results;
assert.deepEqual(r.map((x) => x.state), ['failed', 'failed']);
assert.match(r[0].message, /rc 8/);
});
test('Während des Updates startet auf dem Host kein neuer Server', () => {
const pool = options(async () => '');
pool.files = { running: true, results: [{ name: 'a', state: 'running' }, { name: 'b', state: 'ok' }] };
assert.equal(pool.pickHost().name, 'b');
pool.files.results[1].state = 'running';
assert.throws(() => pool.pickHost(), /Spieldateien/);
});
test('Unvollständige Installation oder SteamCMD-Fehler gelten als fehlgeschlagen, auch mit Exit-Code 0', async () => {
const partial = options(async () => 'BUILD_BEFORE=0\nBUILD_AFTER=0\nSTATE_AFTER=1026\n');
const [a] = (await partial.updateGameFiles()).results;
assert.equal(a.state, 'failed');
assert.match(a.message, /StateFlags 1026/);
const err = options(async () => "Error! App '730' state is 0x202 after update job.\nBUILD_BEFORE=1\nBUILD_AFTER=1\nSTATE_AFTER=4\n");
assert.match((await err.updateGameFiles()).results[0].message, /Error! App '730'/);
const fine = options(async () => 'BUILD_BEFORE=1\nBUILD_AFTER=2\nSTATE_AFTER=4\n');
assert.equal((await fine.updateGameFiles()).results[0].state, 'ok');
});