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

+2 -1
View File
@@ -480,13 +480,14 @@ Startet man den Server eines laufenden Matches neu, lädt die Anwendung danach d
### Reiter „Einstellungen“
Die Einstellungen sind in eine Seitenleiste gegliedert: **Veto** (Map-Pools), **Gameserver** (Match-Regeln, CSTV, Zeiten, Messer-Server), **Server-Betrieb** (Docker-Hosts und Image-Update) und **Benachrichtigungen** (Discord). Am Handy stehen die Menüpunkte als Knöpfe über dem Inhalt. Der zuletzt gewählte Punkt bleibt im Browser gespeichert.
Die Einstellungen sind in eine Seitenleiste gegliedert: **Veto** (Map-Pools), **Gameserver** (Match-Regeln, CSTV, Zeiten, Messer-Server), **Server-Betrieb** (Docker-Hosts, Image- und Spieldateien-Update) und **Benachrichtigungen** (Discord). Am Handy stehen die Menüpunkte als Knöpfe über dem Inhalt. Der zuletzt gewählte Punkt bleibt im Browser gespeichert.
- **Messer-Server:** starten (Modus 5v5 oder 2v2 und Map wählen), Runde starten, zurücksetzen, neu starten, entfernen. Siehe „Messer-Server für die Seitenwahl“.
- **Map-Pools:** pro Modus wählen, welche Maps im Veto stehen. Der Veto-Ablauf passt sich der Anzahl an, siehe „Maps im Admin-Bereich wählen“.
- **Gameserver:** Runden Competitive und Wingman, Verlängerung, zusätzliche Konsolenbefehle, CSTV an/aus und Verzögerung, Zeit pro Veto-Schritt, Leerlauf-Frist, maximale Laufzeit, Startzeit (inklusive Download) und Archiv-Frist. Die Werte aus der `.env` sind die Vorgabe. Was hier geändert wird, steht in der Datenbank und gilt auch nach einem Neustart. Mit „live“ markierte Werte gehen beim Speichern sofort per RCON an alle laufenden Server. Befehle, die Passwörter ändern oder den Server beenden, sind gesperrt.
- **Docker-Hosts:** oben die Auslastung pro Host (belegte und freie Plätze, freier Speicher dort, wo das Gameserver-Volume liegt, Matches und Messer-Server darauf), darunter Hosts hinzufügen, entfernen und bearbeiten (Name, Docker-Ziel, Adresse, Portbereich, LAN-Ports, Volume). Die Liste ersetzt `CS2_DOCKER_HOSTS`. „Liste aus .env verwenden“ schaltet zurück. Hosts mit laufenden Servern lassen sich nicht entfernen und ihre Adresse nicht ändern.
- **Image aktualisieren:** lädt das CS2-Image auf allen Hosts gleichzeitig neu (per SSH direkt auf dem Host). Laufende Server laufen weiter.
- **Spieldateien aktualisieren:** Holt das neueste CS2-Update per SteamCMD in das Spieldateien-Volume jedes Hosts, in einem kurzlebigen Container (`cs2mv-update-<host>`). Das Image-Update allein bringt keine neuen Spieldateien. Der Knopf zeigt den Fortschritt („Update läuft 43 %“), darunter steht pro Host das Ergebnis, z. B. „bereits aktuell (Build 26334290)“. Hosts mit laufenden Servern (Matches oder Messer-Server) werden ausgelassen, außer man setzt den Haken „auch Hosts mit laufenden Servern“. Laufende Server nutzen die neuen Dateien erst nach einem Neustart.
- **Discord-Meldungen:** welche Ereignisse gemeldet werden, plus Testmeldung.
## Umgebungsvariablen
+55 -3
View File
@@ -685,13 +685,15 @@ function DockerHostsPanel({ token, setNotice }) {
// Während das Image lädt, alle 3 Sekunden den Stand holen (ohne die bearbeiteten Zeilen zu überschreiben)
const pulling = !!data?.pull?.running;
const updating = !!data?.files?.running;
const [forceFiles, setForceFiles] = useState(false);
useEffect(() => {
if (!pulling) return undefined;
if (!pulling && !updating) return undefined;
const timer = setInterval(() => {
load().then((res) => setData((d) => ({ ...d, pull: res.pull }))).catch(() => {});
load().then((res) => setData((d) => ({ ...d, pull: res.pull, files: res.files }))).catch(() => {});
}, 3000);
return () => clearInterval(timer);
}, [pulling, load]);
}, [pulling, updating, load]);
if (!data) return null;
if (!data.editable) {
@@ -742,6 +744,14 @@ function DockerHostsPanel({ token, setNotice }) {
run(() => api('/admin/docker-hosts', { method: 'DELETE', token }), 'Hosts aus der .env aktiv');
};
const updateFiles = () => {
const warn = forceFiles
? 'Spieldateien auf ALLEN Hosts aktualisieren, auch wo Server laufen? Laufende Server können dabei abstürzen.'
: 'Spieldateien auf allen Hosts ohne laufende Server aktualisieren?';
if (!window.confirm(warn)) return;
run(() => api('/admin/docker-hosts/update-files', { method: 'POST', body: { force: forceFiles }, token }));
};
const pull = () => {
if (!window.confirm(`Image ${data.image} auf allen Hosts neu laden? Laufende Server laufen weiter.`)) return;
run(() => api('/admin/docker-hosts/pull', { method: 'POST', token }));
@@ -799,9 +809,51 @@ function DockerHostsPanel({ token, setNotice }) {
)}
</ul>
)}
<h3>Spieldateien aktualisieren</h3>
<p className="small muted">
Holt das neueste CS2-Update per SteamCMD in das Volume jedes Hosts. Das Image-Update oben reicht dafür nicht.
Hosts mit laufenden Servern werden ausgelassen, damit niemandem die Dateien unter dem laufenden Server ausgetauscht werden.
Bestehende Server nutzen die neuen Dateien erst nach einem Neustart.
</p>
<div className="admin-actions">
<button className="btn btn-sm btn-primary" disabled={busy || updating || dirty} onClick={updateFiles}>
{updating ? filesLabel(data.files) : 'Spieldateien aktualisieren'}
</button>
<label className="admin-check small">
<input type="checkbox" checked={forceFiles} onChange={(e) => setForceFiles(e.target.checked)} disabled={updating} />
auch Hosts mit laufenden Servern
</label>
</div>
{data.files?.results?.length > 0 && (
<ul className="pull-results">
{data.files.results.map((r) => (
<li key={r.name} className={`pull-${r.state === 'skipped' ? 'skipped' : r.state}`}>
<strong>{r.name}</strong>
<span>{r.state === 'running' ? 'läuft' : r.state === 'ok' ? 'fertig' : r.state === 'skipped' ? 'ausgelassen' : 'fehlgeschlagen'}</span>
{r.message && <code className="small">{r.message}</code>}
{r.state === 'running' && r.progress != null && (
<span className="files-bar" role="progressbar" aria-valuenow={Math.round(r.progress)} aria-valuemin={0} aria-valuemax={100}>
<i style={{ width: `${Math.min(100, r.progress)}%` }} />
</span>
)}
</li>
))}
{data.files.finishedAt && (
<li className="small muted">Zuletzt fertig: {new Date(data.files.finishedAt).toLocaleString('de-DE')}</li>
)}
</ul>
)}
</section>
);
}
/** Knopftext während des Spieldateien-Updates: "Update läuft 43 %" (niedrigster Stand der laufenden Hosts) */
function filesLabel(files) {
const running = files?.results?.filter((r) => r.state === 'running') ?? [];
const known = running.filter((r) => r.progress != null).map((r) => r.progress);
return known.length ? `Update läuft ${Math.floor(Math.min(...known))} %` : 'Update läuft …';
}
/** Datum und Uhrzeit auf Deutsch, z. B. 29.10.2026, 12:54 Uhr */
function formatDateTime(ms) {
return `${new Date(ms).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })} Uhr`;
+3
View File
@@ -769,3 +769,6 @@ input:focus, select:focus { border-color: var(--link); outline: none; }
.console-backdrop { padding: 0; }
.console-dialog { height: 100%; border-radius: 0; }
}
.pull-skipped span { color: var(--muted); }
.files-bar { flex: 1 1 160px; height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; }
.files-bar i { display: block; height: 100%; background: var(--primary); transition: width .4s ease; }
+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');
});