import { config, komodoConfigured } from '../config.js'; import { getServer } from '../fleet/servers.js'; // Ports `komodo_restart` in push_scripts.sh:622. Three shapes depending on // the tracked komodo_type: deployment | container | stack. export type KomodoRestartResult = | { ok: true } | { ok: false; kind: 'unconfigured' | 'no-name' | 'http-error' | 'unreachable'; message: string }; async function komodoCall(path: string, body: unknown): Promise { const url = `${config.komodo.address.replace(/\/+$/, '')}/${path}`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), config.httpTimeoutMs); try { return await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Api-Key': config.komodo.apiKey, 'X-Api-Secret': config.komodo.apiSecret }, body: JSON.stringify(body), signal: controller.signal }); } finally { clearTimeout(timer); } } export async function komodoRestart(serverName: string): Promise { if (!komodoConfigured()) { return { ok: false, kind: 'unconfigured', message: 'komodo not configured' }; } const row = getServer(serverName); if (!row?.komodo_name) return { ok: false, kind: 'no-name', message: 'no komodo_name set for server' }; const type = row.komodo_type ?? 'deployment'; let path: string; let body: Record; switch (type) { case 'stack': path = 'execute/RestartStack'; body = { stack: row.komodo_name }; break; case 'container': if (!row.komodo_server) return { ok: false, kind: 'no-name', message: 'container type needs komodo_server' }; path = 'execute/RestartContainer'; body = { server: row.komodo_server, container: row.komodo_name }; break; case 'deployment': default: path = 'execute/RestartDeployment'; body = { deployment: row.komodo_name }; break; } 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) }; } if (!res.ok) { return { ok: false, kind: 'http-error', message: `HTTP ${res.status}` }; } // The script also checks the response body: if it's an object with // .success === false, treat as failure. If .success is absent, trust the // HTTP status (line 646). try { const json = await res.json(); if (json && typeof json === 'object' && 'success' in json && json.success === false) { return { ok: false, kind: 'http-error', message: 'komodo returned success=false' }; } } catch { // Non-JSON body is fine — HTTP 2xx already indicates success. } return { ok: true }; } // ---------- Port resolution ---------- export type KomodoPortFailure = | 'unconfigured' | 'no-name' | 'unreachable' // network / DNS / timeout | 'http-error' // non-2xx | 'unparseable' // 2xx but body wasn't JSON | 'no-udp-in-response'; // parsed but no UDP port found export interface KomodoPortSuccess { ok: true; port: number; snippet: string; url: string; } export interface KomodoPortFailureResult { ok: false; reason: KomodoPortFailure; detail: string; httpStatus?: number; snippet?: string; // URL we tried (or would have tried) — surfaced in the UI so operators // can `curl` it from the container to verify reachability. url?: string; } export type KomodoPortResult = KomodoPortSuccess | KomodoPortFailureResult; const SNIPPET_LIMIT = 4000; function truncate(s: string): string { if (s.length <= SNIPPET_LIMIT) return s; return s.slice(0, SNIPPET_LIMIT) + `\n… (${s.length - SNIPPET_LIMIT} more bytes)`; } /** * Turn a Node/Undici fetch failure into something an operator can act on. * The default `.message` for an aborted fetch is just "This operation was * aborted" — useless. For network failures Node hides the real cause under * `.cause` (e.g. `ECONNREFUSED`, `ENOTFOUND`, `EHOSTUNREACH`). Unwrap both * and always include the URL we tried. */ function describeFetchError(err: unknown, url: string): string { const e = err as (Error & { name?: string; cause?: unknown }) | undefined; if (e?.name === 'AbortError') { return `timed out after ${config.httpTimeoutMs}ms dialing ${url}`; } const cause = e?.cause as (Error & { code?: string }) | undefined; if (cause && (cause.code || cause.message)) { const code = cause.code ? `${cause.code}: ` : ''; return `${code}${cause.message ?? 'network error'} (dialing ${url})`; } return `${e?.message ?? String(err)} (dialing ${url})`; } function safeJsonStringify(value: unknown): string { try { return JSON.stringify(value, null, 2); } catch { return String(value); } } /** * Detailed variant of `komodoResolveGamePort` — returns a discriminated * union so callers can distinguish "network died" from "auth failed" from * "we parsed the JSON but couldn't find a UDP port in a shape we recognize". * * On success, the response snippet is included so the caller can render it * as "here's where we found the port". On no-udp-in-response, the snippet * is the only way we'll know what shape Komodo actually emitted — surface * it prominently in the UI so we can extend `extractUdpPort` for that shape. */ export async function komodoResolvePortDetailed(serverName: string): Promise { if (!komodoConfigured()) { return { ok: false, reason: 'unconfigured', detail: 'KOMODO_ADDRESS/KEY/SECRET are not all set' }; } const row = getServer(serverName); if (!row?.komodo_name) { return { ok: false, reason: 'no-name', detail: `server has no komodo_name: ${serverName}` }; } const type = row.komodo_type ?? 'deployment'; let path: string; let body: Record; switch (type) { case 'stack': path = 'read/GetStack'; body = { stack: row.komodo_name }; break; case 'container': if (!row.komodo_server) { return { ok: false, reason: 'no-name', detail: 'container type needs komodo_server' }; } path = 'read/InspectContainer'; body = { server: row.komodo_server, container: row.komodo_name }; break; case 'deployment': default: path = 'read/GetDeployment'; body = { deployment: row.komodo_name }; break; } const url = `${config.komodo.address.replace(/\/+$/, '')}/${path}`; let res: Response; try { res = await komodoCall(path, body); } catch (err) { return { ok: false, reason: 'unreachable', detail: describeFetchError(err, url), url }; } // Read the body up front — we want the snippet even on non-2xx so the // UI can show what the error page actually said. const rawText = await res.text().catch(() => ''); if (!res.ok) { return { ok: false, reason: 'http-error', detail: `HTTP ${res.status} ${res.statusText || ''}`.trim(), httpStatus: res.status, snippet: truncate(rawText), url }; } let json: unknown; try { json = JSON.parse(rawText); } catch (err) { return { ok: false, reason: 'unparseable', detail: `response body is not JSON: ${(err as Error).message}`, httpStatus: res.status, snippet: truncate(rawText), url }; } const port = extractUdpPort(json); const snippet = truncate(safeJsonStringify(json)); if (port === null) { return { ok: false, reason: 'no-udp-in-response', detail: 'parsed the response but no UDP port matched any known shape', httpStatus: res.status, snippet, url }; } return { ok: true, port, snippet, url }; } /** * Query Komodo's read API for a server's UDP game port. Called lazily by * the RCON client and cached into `servers.game_port` on success. Thin shim * over `komodoResolvePortDetailed` that swallows diagnostics — use the * detailed variant when you care why it failed. */ export async function komodoResolveGamePort(serverName: string): Promise { const result = await komodoResolvePortDetailed(serverName); return result.ok ? result.port : null; } // ---------- Health probe ---------- export type KomodoHealthResult = | { ok: true; state: string } | { ok: false; reason: string; state?: string; snippet?: string }; // Komodo's container summary has { state: "running", container: { status: // "Up 35 seconds (health: starting)" } }. Inspect responses instead have // State.Health.Status (or snake/lowercase equivalents). Runtime health wins // over a coarse running flag. Unknown/configuration strings are not readiness. export function extractContainerState(payload: unknown): string | null { const seen = new Set(); const known = new Set(['running','healthy','starting','restarting','unhealthy','stopped','exited','created','dead','paused','removing','notdeployed','unknown']); const stateOf = (value: unknown): string | null => { if (typeof value !== 'string') return null; const state = value.toLowerCase().trim(); if (known.has(state)) return state; if (/^up\b/.test(state)) { const health = state.match(/\((?:health:\s*)?(starting|unhealthy|healthy)\)/)?.[1]; return health && health !== 'healthy' ? health : 'running'; } if (/^exited\b/.test(state)) return 'exited'; if (/^restarting\b/.test(state)) return 'restarting'; return null; }; const walk = (value: unknown): string | null => { if (!value || typeof value !== 'object' || seen.has(value)) return null; seen.add(value); if (Array.isArray(value)) { const states = value.map(walk).filter((state): state is string => !!state); return states.find(state => !['running','healthy'].includes(state)) ?? states[0] ?? null; } const object = value as Record; const rawState = object.State ?? object.state; const state = stateOf(rawState) ?? stateOf(object.container_state ?? object.containerState ?? object.deployment_state ?? object.deploymentState); // A stopped process is never ready just because its previous health check passed. if (state && !['running','healthy','unknown'].includes(state)) return state; if (rawState && typeof rawState === 'object') { const nested = walk(rawState); if (nested) return nested; } for (const key of ['container','Container','containers','Containers']) { if (object[key] === null && state === 'running') return 'unknown'; const nested = walk(object[key]); if (nested) return nested; } const status = stateOf(object.Status ?? object.status ?? object.docker_status); if (status && !['running','healthy'].includes(status)) return status; const health = object.Health ?? object.health; const healthState = stateOf(typeof health === 'object' && health ? (health as Record).Status ?? (health as Record).status : health); if (healthState && healthState !== 'healthy') return healthState; if (state || status) return state ?? status; if (object.Running === true || object.running === true) return healthState ?? 'running'; for (const [key, item] of Object.entries(object)) { if (['config','Config','labels','Labels'].includes(key)) continue; const nested = walk(item); if (nested) return nested; } return null; }; return walk(payload); } const HEALTHY_STATES = new Set(['running', 'healthy', 'ok', 'started', 'up']); async function fetchJsonWithSnippet( path: string, body: Record ): Promise<{ ok: true; json: unknown; snippet: string } | { ok: false; reason: string; snippet?: string; unsupportedEndpoint?: boolean }> { const url = `${config.komodo.address.replace(/\/+$/, '')}/${path}`; let res: Response; try { res = await komodoCall(path, body); } catch (err) { return { ok: false, reason: describeFetchError(err, url) }; } const text = await res.text().catch(() => ''); if (!res.ok) { const unsupportedEndpoint = res.status === 404 || ( [400, 405, 422, 500, 501].includes(res.status) && /\b(?:unknown|unsupported|unrecognized)\s+(?:method|endpoint|variant)\b|\bmethod not found\b/i.test(text) ); return { ok: false, reason: `HTTP ${res.status}`, snippet: truncate(text), unsupportedEndpoint }; } let json: unknown; try { json = JSON.parse(text); } catch (err) { return { ok: false, reason: `unparseable json: ${(err as Error).message}`, snippet: truncate(text) }; } return { ok: true, json, snippet: truncate(safeJsonStringify(json)) }; } /** * Ask Komodo whether the server's container is currently running. Used by the * sweep during the health phase (post-restart) and by the beta runner. For * deployment-type servers we hit the container-inspect endpoint first because * GetDeployment returns config, not runtime state — falling back only when * the container endpoint isn't available. */ export async function komodoInspectHealthy(serverName: string): Promise { if (!komodoConfigured()) return { ok: false, reason: 'komodo not configured' }; const row = getServer(serverName); if (!row?.komodo_name) return { ok: false, reason: 'no komodo_name for server' }; const type = row.komodo_type ?? 'deployment'; const attempts: Array<{ path: string; body: Record }> = []; switch (type) { case 'stack': attempts.push({ path: 'read/GetStack', body: { stack: row.komodo_name } }); break; case 'container': if (!row.komodo_server) return { ok: false, reason: 'container type needs komodo_server' }; attempts.push({ path: 'read/InspectContainer', body: { server: row.komodo_server, container: row.komodo_name } }); break; case 'deployment': default: // Container-specific endpoints first — they return docker-inspect // shape with State.Status. GetDeployment is the last-resort // config-only fallback. attempts.push({ path: 'read/GetDeploymentContainer', body: { deployment: row.komodo_name } }); attempts.push({ path: 'read/InspectDeploymentContainer', body: { deployment: row.komodo_name } }); attempts.push({ path: 'read/GetDeploymentStatus', body: { deployment: row.komodo_name } }); attempts.push({ path: 'read/GetDeployment', body: { deployment: row.komodo_name } }); break; } let lastSnippet: string | undefined; for (const { path, body } of attempts) { const r = await fetchJsonWithSnippet(path, body); if (!r.ok) { // Alternate endpoints handle API-version differences. Retrying an // outage against every endpoint delays all other server probes. if (!r.unsupportedEndpoint) return { ok: false, reason: r.reason, snippet: r.snippet }; lastSnippet = r.snippet ?? lastSnippet; continue; } const state = extractContainerState(r.json); if (state) { const healthy = HEALTHY_STATES.has(state.toLowerCase()); if (!healthy) return { ok: false, state, reason: `container state: ${state}`, snippet: r.snippet }; return { ok: true, state }; } lastSnippet = r.snippet; } return { ok: false, reason: 'no container state found in any known Komodo response shape', snippet: lastSnippet }; } // ---------- Write / Execute (create + destroy deployments) ---------- export type KomodoWriteResult = | { ok: true } | { ok: false; kind: 'unconfigured' | 'http-error' | 'unreachable'; message: string; url?: string; httpStatus?: number }; /** * 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 { 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', httpStatus: res.status, 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, httpStatus: res.status }; } } 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 { 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 { if (!komodoConfigured()) { return { ok: false, kind: 'unconfigured', message: 'komodo not configured' }; } return postAndClassify('execute/Deploy', { deployment }); } /** Runtime controls use the saved Komodo resource, never the EC display name. */ export async function komodoRuntimeAction(server: string, action: 'start' | 'stop'): Promise { if (!komodoConfigured()) return { ok: false, kind: 'unconfigured', message: 'komodo not configured' }; const row = getServer(server); if (!row?.komodo_name) return { ok: false, kind: 'no-name', message: 'no komodo_name set for server' }; const type = row.komodo_type ?? 'deployment'; const verb = action === 'start' ? 'Start' : 'Stop'; if (type === 'container') { if (!row.komodo_server) return { ok: false, kind: 'no-name', message: 'container type needs komodo_server' }; return postAndClassify(`execute/${verb}Container`, { server: row.komodo_server, container: row.komodo_name }); } if (type === 'stack') return postAndClassify(`execute/${verb}Stack`, { stack: row.komodo_name }); return postAndClassify(`execute/${verb}Deployment`, { deployment: row.komodo_name }); } /** Stop + remove the container backing a Komodo deployment. Entity remains. */ export async function komodoDestroyDeployment(deployment: string): Promise { 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 { 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 }); } // ---------- Tags ---------- export type KomodoTagResult = | { ok: true; id: string } | { ok: false; message: string }; // Accept API string IDs and raw Mongo Extended JSON: { _id: { $oid: "..." } }. function extractTagId(t: unknown): string | null { if (!t || typeof t !== 'object') return null; const o = t as { id?: unknown; _id?: unknown }; if (typeof o.id === 'string' && o.id.length > 0) return o.id; if (typeof o._id === 'string' && o._id.length > 0) return o._id; if (o._id && typeof o._id === 'object') { const oid = (o._id as { $oid?: unknown }).$oid; if (typeof oid === 'string' && oid.length > 0) return oid; } return null; } // ListTags response shape varies by Komodo version: bare array `[...]`, or // wrapped under one of a few common keys. Peel back the common wrappers. function unwrapTagList(body: unknown): unknown[] { if (Array.isArray(body)) return body; if (body && typeof body === 'object') { for (const key of ['tags', 'data', 'results', 'items']) { const val = (body as Record)[key]; if (Array.isArray(val)) return val; } } return []; } /** * Fetch the tag list from Komodo. Two request styles are attempted in order: * * 1. `POST /read/ListTags` with an empty body — the per-endpoint style * already used by the rest of this file (see execute/RestartDeployment). * 2. `POST /read` with `{type: "ListTags", params: {}}` — the tagged-union * style used by mbecker20/komodo's canonical API. If your Komodo build * wires up only the tagged-union path, style #1 returns something we * can't parse (empty array or non-tag payload) and we fall through. * * The winning parsed list is returned. The raw response bodies get logged * only when both attempts fail to yield a match, so a fresh Komodo API shape * is diagnosable without spamming stderr on the happy path. */ async function fetchTagsWithFallback(): Promise<{ tags: unknown[]; rawBodies: string[] }> { const attempts: Array<{ path: string; body: unknown }> = [ { path: 'read/ListTags', body: {} }, { path: 'read', body: { type: 'ListTags', params: {} } } ]; const rawBodies: string[] = []; for (const attempt of attempts) { let res: Response; try { res = await komodoCall(attempt.path, attempt.body); } catch (err) { rawBodies.push(`${attempt.path}: network error — ${(err as Error).message}`); continue; } if (!res.ok) { const snippet = await res.text().catch(() => ''); rawBodies.push(`${attempt.path}: HTTP ${res.status}${snippet ? ` — ${truncate(snippet)}` : ''}`); continue; } const raw = await res.text().catch(() => ''); rawBodies.push(`${attempt.path}: ${truncate(raw)}`); let parsed: unknown; try { parsed = JSON.parse(raw); } catch { continue; } const tags = unwrapTagList(parsed); if (tags.length > 0) return { tags, rawBodies }; } return { tags: [], rawBodies }; } async function findTagByName(name: string): Promise { const { tags, rawBodies } = await fetchTagsWithFallback(); for (const t of tags) { if (!t || typeof t !== 'object') continue; const tagName = (t as { name?: unknown }).name; if (typeof tagName !== 'string' || tagName !== name) continue; const id = extractTagId(t); if (id) return { ok: true, id }; } // Miss — dump the raw bodies so the next iteration knows the real shape. console.warn( `[komodoEnsureTag] no matchable tag "${name}" in any ListTags variant. Bodies:\n ` + rawBodies.join('\n ') ); return null; } /** * Resolve a tag by name, creating it if it doesn't exist. Returns the tag's * Komodo id (Mongo ObjectId string) for use in UpdateResourceMeta. * * Best-effort: callers should treat failure as "skip tagging", not "roll back * the whole operation". Tags are cosmetic organization inside Komodo. */ export async function komodoEnsureTag(name: string): Promise { if (!komodoConfigured()) return { ok: false, message: 'komodo not configured' }; // 1. Look for an existing tag by name. const existing = await findTagByName(name); if (existing) return existing; // 2. Not found — create it. let createRes: Response; try { createRes = await komodoCall('write/CreateTag', { name }); } catch (err) { return { ok: false, message: `CreateTag ${name}: ${(err as Error).message}` }; } if (createRes.ok) { const body = await createRes.json().catch(() => null); const id = extractTagId(body); if (id) return { ok: true, id }; // 200 but shape we don't recognize — fall back to re-list rather than // fail, since the tag definitely exists now. const fresh = await findTagByName(name); if (fresh) return fresh; console.warn(`[komodoEnsureTag] CreateTag ${name}: unexpected 2xx body, re-list also missed`, body); return { ok: false, message: `CreateTag ${name}: unexpected response shape` }; } // Non-2xx. Duplicate-key 500 means the tag exists under a different // ListTags projection than we probed — re-list and try to find it. If // that still misses, surface the create error. const snippet = await createRes.text().catch(() => ''); const looksLikeDuplicate = createRes.status === 500 && (/duplicate key/i.test(snippet) || /E11000/i.test(snippet) || /dup key/i.test(snippet)); if (looksLikeDuplicate) { const fresh = await findTagByName(name); if (fresh) return fresh; return { ok: false, message: `CreateTag ${name}: duplicate reported but re-list missed it. Body: ${truncate(snippet)}` }; } return { ok: false, message: `CreateTag ${name}: HTTP ${createRes.status}${snippet ? ` — ${truncate(snippet)}` : ''}` }; } /** * Attach tags to a Komodo resource. `tagIds` is the array of tag Mongo IDs * from `komodoEnsureTag`. Replaces the resource's existing tag set — pass the * full desired list, not a delta. * * Komodo's target.id accepts either the true id or the resource name for * Deployments, so we pass the name here to save a lookup. */ export async function komodoUpdateTagsOnResource( targetType: 'Deployment' | 'Stack' | 'Server' | 'Repo' | 'Build' | 'Procedure', targetName: string, tagIds: string[] ): Promise { if (!komodoConfigured()) { return { ok: false, kind: 'unconfigured', message: 'komodo not configured' }; } // UpdateResourceMeta owns tags; omit the other optional metadata fields // so the deployment's description and template flag remain unchanged. return postAndClassify('write/UpdateResourceMeta', { target: { type: targetType, id: targetName }, tags: tagIds }); } // ---------- Extractor ---------- // Property names Komodo/Docker use for the collection of port mappings. const PORT_ARRAY_KEYS = new Set(['ports', 'Ports', 'port_bindings', 'PortBindings']); // Value field names for the host-facing port on an object entry. const HOST_PORT_KEYS = ['published', 'PublishedPort', 'public_port', 'PublicPort', 'HostPort', 'hostPort', 'host_port']; interface PortsCandidate { arr: unknown[]; /** True when the array was reached via a `ports`-flavored key — preferred. */ preferred: boolean; } /** * Walks a Komodo response looking for a UDP port. Handles the shapes we've * seen in the wild: * * docker-compose object: [{ protocol: "udp", published: 4976, target: 28960 }] * docker-compose caps: [{ Protocol: "udp", PublishedPort: 4976 }] * docker-run string: ["4976:28960/udp", "3074:3074/tcp"] * host-network string: ["28962/udp"] * docker-inspect object: { "28960/udp": [{ "HostIp": "0.0.0.0", "HostPort": "28962" }] } * * Arrays reached via a `ports`/`Ports`/`port_bindings`/`PortBindings` key * are preferred over arrays discovered elsewhere in the tree, so an * unrelated array (e.g. tags, labels) can't shadow a real port list. */ export function extractUdpPort(payload: unknown): number | null { const candidates: PortsCandidate[] = []; // Objects whose keys encode `/` — a la docker inspect. const containerKeyed: Record[] = []; const seen = new Set(); const walk = (v: unknown, viaPortsKey: boolean): void => { if (v === null || typeof v !== 'object' || seen.has(v)) return; seen.add(v); if (Array.isArray(v)) { for (const item of v) walk(item, false); candidates.push({ arr: v, preferred: viaPortsKey }); return; } const obj = v as Record; // Detect docker-inspect container-keyed objects: any key like `28960/udp`. for (const k of Object.keys(obj)) { if (/^\d+\/(udp|tcp)$/i.test(k)) { containerKeyed.push(obj); break; } } for (const [key, val] of Object.entries(obj)) { // Komodo's GetDeployment renders `config.ports` as a single // newline-delimited string ("28963:28963/udp\n28963:28963/tcp"). // Synthesize an array from it and treat as a preferred candidate. if (PORT_ARRAY_KEYS.has(key) && typeof val === 'string' && val.trim()) { const parts = val .split(/[\r\n,;]+/) .map((s) => s.trim()) .filter(Boolean); if (parts.length > 0) { candidates.push({ arr: parts, preferred: true }); } } walk(val, PORT_ARRAY_KEYS.has(key)); } }; walk(payload, false); // Sort so preferred `ports`-keyed arrays win over accidental hits. candidates.sort((a, b) => (a.preferred === b.preferred ? 0 : a.preferred ? -1 : 1)); // Try object-entry shape first — most Komodo/Docker responses use this. for (const { arr } of candidates) { for (const entry of arr) { if (entry && typeof entry === 'object' && !Array.isArray(entry)) { const e = entry as Record; const proto = String(e.protocol ?? e.Protocol ?? e.type ?? e.Type ?? '').toLowerCase(); if (proto !== 'udp') continue; for (const field of HOST_PORT_KEYS) { const raw = e[field]; if (raw === undefined || raw === null) continue; const num = typeof raw === 'string' ? Number.parseInt(raw, 10) : Number(raw); if (Number.isFinite(num) && num > 0) return num; } } } } // Then string shapes: "host:container/udp" or "container/udp" (host-net). for (const { arr } of candidates) { for (const entry of arr) { if (typeof entry !== 'string') continue; const hostMapped = /^(\d+):\d+\/udp$/i.exec(entry); if (hostMapped) return Number.parseInt(hostMapped[1], 10); const hostNet = /^(\d+)\/udp$/i.exec(entry); if (hostNet) return Number.parseInt(hostNet[1], 10); } } // Finally, docker-inspect container-keyed shape: // { "28960/udp": [{ HostIp, HostPort }] } for (const obj of containerKeyed) { for (const [key, val] of Object.entries(obj)) { if (!/^\d+\/udp$/i.test(key)) continue; if (!Array.isArray(val)) continue; for (const binding of val) { if (!binding || typeof binding !== 'object') continue; const b = binding as Record; for (const field of HOST_PORT_KEYS) { const raw = b[field]; if (raw === undefined || raw === null) continue; const num = typeof raw === 'string' ? Number.parseInt(raw, 10) : Number(raw); if (Number.isFinite(num) && num > 0) return num; } } } } return null; }