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;
|
||||
|
||||
Reference in new issue
Block a user