Admin: Gameserver-Einstellungen, Docker-Hosts und Image-Update im Admin-Bereich
This commit is contained in:
1 parent
472bda31a9
commit
929134f2bc
10 files changed
+710
-12
No files matched your search
@@ -46,7 +46,7 @@ export class DockerServerAdapter {
|
||||
}
|
||||
|
||||
async docker(args, timeout = 60_000) {
|
||||
if (this.runDocker) return this.runDocker(args);
|
||||
if (this.runDocker) return this.runDocker(args, this.options.name);
|
||||
const env = this.options.dockerHost ? { ...process.env, DOCKER_HOST: this.options.dockerHost } : process.env;
|
||||
const { stdout } = await exec(this.options.bin, args, { timeout, maxBuffer: 4 * 1024 * 1024, env });
|
||||
return stdout.trim();
|
||||
@@ -220,7 +220,18 @@ export class DockerHostPool {
|
||||
this.name = 'docker';
|
||||
this.simulated = false;
|
||||
this.depsGetUsedPorts = getUsedPorts;
|
||||
const hosts = options.hosts ?? [{
|
||||
this.runDocker = runDocker;
|
||||
this.options = options;
|
||||
this.diskCache = new Map();
|
||||
this.diskInflight = new Map();
|
||||
this.pull = { running: false, startedAt: null, finishedAt: null, results: [] };
|
||||
this.setHosts(options.hosts);
|
||||
}
|
||||
|
||||
/** Baut die Host-Liste neu auf (beim Start und wenn der Admin die Hosts ändert). */
|
||||
setHosts(list) {
|
||||
const options = this.options;
|
||||
const hosts = list ?? [{
|
||||
name: 'default',
|
||||
dockerHost: process.env.DOCKER_HOST || null,
|
||||
publicHost: options.host,
|
||||
@@ -240,14 +251,64 @@ export class DockerHostPool {
|
||||
portRangeEnd: h.portRangeEnd ?? options.portRangeEnd,
|
||||
volume: h.volume ?? options.volume,
|
||||
};
|
||||
return { ...options, ...host, host: host.publicHost };
|
||||
const merged = { ...options, ...host, host: host.publicHost };
|
||||
// Die Startzeit kann der Admin zur Laufzeit ändern, deshalb immer aus den gemeinsamen Einstellungen lesen
|
||||
Object.defineProperty(merged, 'startTimeoutMinutes', { get: () => options.startTimeoutMinutes, enumerable: true });
|
||||
return merged;
|
||||
});
|
||||
this.adapters = new Map(this.hosts.map((h) => [h.name, new DockerServerAdapter(h, {
|
||||
getUsedPorts: () => (getUsedPorts ? getUsedPorts(h.publicHost) : new Set()),
|
||||
runDocker,
|
||||
getUsedPorts: () => (this.depsGetUsedPorts ? this.depsGetUsedPorts(h.publicHost) : new Set()),
|
||||
runDocker: this.runDocker,
|
||||
})]));
|
||||
this.diskCache = new Map();
|
||||
this.diskInflight = new Map();
|
||||
this.diskCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Docker-Befehl direkt auf dem Host ausführen. Bei ssh:// per SSH, damit der Pull die Anmeldedaten des Hosts
|
||||
* nutzt und nicht den Credential-Helper des Rechners, auf dem die App läuft.
|
||||
*/
|
||||
async hostDocker(name, args, timeout) {
|
||||
const host = this.hosts.find((h) => h.name === name);
|
||||
if (!this.runDocker && host?.dockerHost?.startsWith('ssh://')) {
|
||||
const { stdout } = await exec('ssh', [
|
||||
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', host.dockerHost.slice('ssh://'.length),
|
||||
['docker', ...args].map((a) => `'${a}'`).join(' '),
|
||||
], { timeout, maxBuffer: 4 * 1024 * 1024 });
|
||||
return stdout.trim();
|
||||
}
|
||||
return this.adapters.get(name).docker(args, timeout);
|
||||
}
|
||||
|
||||
/** Lädt das CS2-Image auf allen Hosts gleichzeitig neu. Laufende Server bleiben unberührt. */
|
||||
async pullImage() {
|
||||
if (this.pull.running) throw new Error('Das Image wird bereits aktualisiert');
|
||||
if (!/^[\w./:@-]+$/.test(this.options.image)) throw new Error('Ungültiger Image-Name');
|
||||
const hosts = this.hosts.map((h) => h.name);
|
||||
this.pull = {
|
||||
running: true,
|
||||
startedAt: Date.now(),
|
||||
finishedAt: null,
|
||||
image: this.options.image,
|
||||
results: hosts.map((name) => ({ name, state: 'running', message: null })),
|
||||
};
|
||||
await Promise.all(hosts.map(async (name, i) => {
|
||||
const result = this.pull.results[i];
|
||||
try {
|
||||
await this.hostDocker(name, ['pull', '--quiet', this.options.image], 20 * 60_000);
|
||||
const digest = await this.hostDocker(name, ['image', 'inspect', this.options.image, '--format', '{{index .RepoDigests 0}}'], 30_000)
|
||||
.catch(() => '');
|
||||
Object.assign(result, { state: 'ok', message: digest.split('@')[1]?.slice(0, 19) ?? 'aktualisiert' });
|
||||
} catch (err) {
|
||||
Object.assign(result, { state: 'failed', message: String(err.stderr || err.message).trim().split('\n').pop().slice(0, 200) });
|
||||
}
|
||||
}));
|
||||
this.pull.running = false;
|
||||
this.pull.finishedAt = Date.now();
|
||||
return this.pull;
|
||||
}
|
||||
|
||||
pullStatus() {
|
||||
return this.pull;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -179,6 +179,78 @@ export class ServerManager {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Server, die gerade laufen oder starten, mit dem Docker-Host aus ihrem Handle. */
|
||||
activeServers() {
|
||||
return this.db.prepare(`
|
||||
SELECT s.match_id, s.status, s.host, s.handle, m.mode
|
||||
FROM servers s JOIN matches m ON m.id = s.match_id
|
||||
WHERE s.status != 'stopped' AND s.handle IS NOT NULL
|
||||
`).all().map((r) => {
|
||||
let handle = null;
|
||||
try {
|
||||
handle = JSON.parse(r.handle);
|
||||
} catch {
|
||||
// kaputter Handle: Server zählt trotzdem als aktiv
|
||||
}
|
||||
return { ...r, handle, hostName: handle?.hostName ?? null };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Übernimmt eine neue Liste von Docker-Hosts. Hosts mit laufenden Servern dürfen nicht wegfallen
|
||||
* und ihre Adresse nicht ändern, sonst verliert die App den Kontakt zu diesen Servern.
|
||||
*/
|
||||
setDockerHosts(hosts) {
|
||||
if (!this.adapter.setHosts) throw new HttpError(409, 'Nur mit dem Docker-Adapter möglich', 'unsupported');
|
||||
const before = new Map(this.adapter.hosts.map((h) => [h.name, h]));
|
||||
// Ohne Liste gilt der eine Host aus DOCKER_HOST/CS2_PUBLIC_HOST
|
||||
const nextList = hosts ?? [{ name: 'default', publicHost: this.config.docker.host, dockerHost: process.env.DOCKER_HOST || null }];
|
||||
for (const server of this.activeServers()) {
|
||||
const name = server.hostName ?? this.adapter.hosts[0]?.name;
|
||||
const old = before.get(name);
|
||||
const next = nextList.find((h) => h.name === name);
|
||||
if (!next) {
|
||||
throw new HttpError(409, `Auf ${name} läuft noch ein Server. Erst stoppen, dann den Host entfernen.`, 'host_busy');
|
||||
}
|
||||
if (old && (next.publicHost !== old.publicHost || (next.dockerHost ?? null) !== (old.dockerHost ?? null))) {
|
||||
throw new HttpError(409, `Auf ${name} läuft noch ein Server. Adresse und Docker-Ziel erst danach ändern.`, 'host_busy');
|
||||
}
|
||||
}
|
||||
this.adapter.setHosts(hosts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schickt geänderte Match-Einstellungen an alle bereiten Server. Liefert, wie viele Server
|
||||
* aktualisiert wurden und welche nicht erreichbar waren.
|
||||
*/
|
||||
async applyLiveSettings(changed) {
|
||||
const keys = new Set(changed);
|
||||
const rows = this.activeServers().filter((r) => r.status === 'configured');
|
||||
if (!this.adapter.sendCommands || rows.length === 0) return { updated: 0, failed: [] };
|
||||
const m = this.config.match;
|
||||
const result = { updated: 0, failed: [] };
|
||||
for (const row of rows) {
|
||||
const wingman = row.mode === 'wingman';
|
||||
const commands = [];
|
||||
if (keys.has(wingman ? 'maxRoundsWingman' : 'maxRoundsCompetitive')) {
|
||||
commands.push(`mp_maxrounds ${wingman ? m.maxRoundsWingman : m.maxRoundsCompetitive}`);
|
||||
}
|
||||
if (keys.has('overtime')) commands.push(`mp_overtime_enable ${m.overtime ? 1 : 0}`);
|
||||
if (keys.has('tvDelaySeconds')) commands.push(`tv_delay ${m.tvDelaySeconds}`);
|
||||
if (keys.has('extraCommands')) commands.push(...m.extraCommands);
|
||||
if (commands.length === 0) continue;
|
||||
try {
|
||||
await this.adapter.sendCommands(row.handle, commands);
|
||||
result.updated += 1;
|
||||
this.service.log(row.match_id, 'server', `Einstellungen übernommen (Admin): ${commands.join('; ')}`);
|
||||
} catch (err) {
|
||||
result.failed.push(row.match_id);
|
||||
this.logger.warn?.(`Einstellungen für ${row.match_id} nicht übernommen: ${err.message}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Ports, die auf einem bestimmten Docker-Host belegt sind. */
|
||||
usedPortsOn(host) {
|
||||
const used = new Set();
|
||||
|
||||
Reference in new issue
Block a user