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:
1 parent
2c57b8c170
commit
3c5a9b4162
7 files changed
+281
-5
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user