Add deploy + destroy server flow; migrate to t5_<mode>_<slug>_<n> naming

Full server lifecycle management from Electric Cherry: pick game/mode/map/pluto
key in the Fleet page, EC provisions the deployment via Komodo, allocates a
port in 28960-28980, creates the 0o777 host dir, and starts the container.
Destroy button tears everything down (Komodo Destroy+Delete, rm -rf host dir,
transactional DB cleanup) and surfaces any partial-failure warnings inline.

Same phase cuts over to the new naming scheme (t5_zombie_cosmodrome_1 etc.).
Komodo entity names now match EC server names 1:1 (no more _/- transform), and
the map catalog carries friendly/slug/canonical for each of the 11 t5 zombie
maps. Server rows gain game_mode, map_slug, host_dir, config_file, game_id,
image, created_by, and destroyed_at columns for provisioning + audit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 18:38:42 -04:00
co-authored by Claude Opus 4.7
parent 8c15cae80d
commit 13a29e5ace
25 changed files with 1103 additions and 123 deletions
+112
View File
@@ -407,6 +407,118 @@ export async function komodoInspectHealthy(serverName: string): Promise<KomodoHe
};
}
// ---------- Write / Execute (create + destroy deployments) ----------
export type KomodoWriteResult =
| { ok: true }
| { ok: false; kind: 'unconfigured' | 'http-error' | 'unreachable'; message: string; url?: string };
/**
* Env var pairs get flattened into a single newline-delimited string, matching
* Komodo's DeploymentConfig.environment shape.
*/
export interface DeploymentEnvVar {
variable: string;
value: string;
}
export interface CreateDeploymentSpec {
name: string; // Komodo deployment name (== EC server name)
server: string; // Komodo server (host) id or name
image: string; // 'ghcr.io/ayymoss/plutainer:latest'
network?: string; // 'komodo_default'
ports: string[]; // ['28960:28960/udp', '28960:28960/tcp']
volumes: string[]; // ['/host/path:/container/path', 'namedvol:/container/path:ro']
environment: DeploymentEnvVar[];
}
async function postAndClassify(path: string, body: unknown): Promise<KomodoWriteResult> {
const url = `${config.komodo.address.replace(/\/+$/, '')}/${path}`;
let res: Response;
try {
res = await komodoCall(path, body);
} catch (err) {
return { ok: false, kind: 'unreachable', message: describeFetchError(err, url), url };
}
if (!res.ok) {
const snippet = await res.text().catch(() => '');
return {
ok: false,
kind: 'http-error',
message: `HTTP ${res.status}${snippet ? ` — ${truncate(snippet)}` : ''}`,
url
};
}
// Some Komodo endpoints return `{ success: false }` with a 200. Treat that
// as a failure so callers can surface the real problem.
try {
const json = await res.json();
if (json && typeof json === 'object' && 'success' in json && json.success === false) {
const msg = typeof (json as { message?: unknown }).message === 'string'
? (json as { message: string }).message
: 'komodo returned success=false';
return { ok: false, kind: 'http-error', message: msg, url };
}
} catch {
// Non-JSON body — trust the 2xx.
}
return { ok: true };
}
/**
* Create a new deployment entity in Komodo. Doesn't start the container —
* caller must follow up with `komodoDeploy(name)` to actually run it.
*
* The config shape here targets Komodo's DeploymentConfig: newline-delimited
* ports/volumes strings and an environment array with `{ variable, value }`
* entries. If Komodo rejects the payload, the error surfaces in the http-error
* message so we can iterate on the shape without guessing.
*/
export async function komodoCreateDeployment(spec: CreateDeploymentSpec): Promise<KomodoWriteResult> {
if (!komodoConfigured()) {
return { ok: false, kind: 'unconfigured', message: 'komodo not configured' };
}
const body = {
name: spec.name,
config: {
server_id: spec.server,
image: { type: 'Image', params: { image: spec.image } },
network: spec.network ?? 'komodo_default',
ports: spec.ports.join('\n'),
volumes: spec.volumes.join('\n'),
environment: spec.environment,
restart: 'unless-stopped'
}
};
return postAndClassify('write/CreateDeployment', body);
}
/** Start (or restart) the container backing a Komodo deployment. */
export async function komodoDeploy(deployment: string): Promise<KomodoWriteResult> {
if (!komodoConfigured()) {
return { ok: false, kind: 'unconfigured', message: 'komodo not configured' };
}
return postAndClassify('execute/Deploy', { deployment });
}
/** Stop + remove the container backing a Komodo deployment. Entity remains. */
export async function komodoDestroyDeployment(deployment: string): Promise<KomodoWriteResult> {
if (!komodoConfigured()) {
return { ok: false, kind: 'unconfigured', message: 'komodo not configured' };
}
return postAndClassify('execute/DestroyDeployment', { deployment });
}
/** Remove the deployment entity from Komodo entirely. */
export async function komodoDeleteDeployment(deployment: string): Promise<KomodoWriteResult> {
if (!komodoConfigured()) {
return { ok: false, kind: 'unconfigured', message: 'komodo not configured' };
}
// Komodo's DeleteDeployment historically accepts `{ id }` where id may be
// the name; supply both to be forgiving.
return postAndClassify('write/DeleteDeployment', { id: deployment, name: deployment });
}
// ---------- Extractor ----------
// Property names Komodo/Docker use for the collection of port mappings.