diff --git a/apps/dokploy/__test__/server/model-runner.test.ts b/apps/dokploy/__test__/server/model-runner.test.ts index c52d3f755..0b1821bbd 100644 --- a/apps/dokploy/__test__/server/model-runner.test.ts +++ b/apps/dokploy/__test__/server/model-runner.test.ts @@ -1,5 +1,5 @@ import { execFileSync, execSync } from "node:child_process"; -import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { @@ -12,7 +12,7 @@ import { execAsync, execAsyncRemote, } from "@dokploy/server/utils/process/execAsync"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const cloud = { enabled: false }; @@ -38,18 +38,25 @@ vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({ const resolveBin = (name: string) => execSync(`command -v ${name}`, { encoding: "utf8" }).trim(); +const b64 = (value: unknown) => + Buffer.from( + typeof value === "string" ? value : JSON.stringify(value), + ).toString("base64"); + const envelope = (opts: { dockerPresent?: boolean; - infoExit?: number; - info?: unknown; + engineExit?: number; + engine?: unknown; + pluginsExit?: number; + plugins?: unknown; containerStatus?: string; }) => JSON.stringify({ dockerPresent: opts.dockerPresent ?? true, - infoExit: opts.infoExit ?? 0, - infoBase64: opts.info - ? Buffer.from(JSON.stringify(opts.info)).toString("base64") - : "", + engineExit: opts.engineExit ?? 0, + engineBase64: opts.engine === undefined ? "" : b64(opts.engine), + pluginsExit: opts.pluginsExit ?? 0, + pluginsBase64: opts.plugins === undefined ? "" : b64(opts.plugins), containerStatus: opts.containerStatus ?? "", }); @@ -65,19 +72,20 @@ const plugin = ( ...extra, }); -const info = ( - plugins: ReturnType[] | null | undefined, +const engine = ( server: { version?: string; os?: string; arch?: string } = {}, ) => ({ serverVersion: server.version ?? "28.5.2", os: server.os ?? "linux", arch: server.arch ?? "amd64", - ...(plugins === undefined ? {} : { plugins }), }); +const sandboxes: string[] = []; + const makeSandbox = (dockerShim?: string) => { const dir = mkdtempSync(path.join(tmpdir(), "dokploy-model-runner-")); - for (const tool of ["tr", "base64", "printf"]) { + sandboxes.push(dir); + for (const tool of ["tr", "base64"]) { const shim = path.join(dir, tool); writeFileSync(shim, `#!/bin/sh\nexec ${resolveBin(tool)} "$@"\n`); chmodSync(shim, 0o755); @@ -90,21 +98,38 @@ const makeSandbox = (dockerShim?: string) => { return dir; }; +afterEach(() => { + for (const dir of sandboxes) { + rmSync(dir, { recursive: true, force: true }); + } + sandboxes.length = 0; +}); + const fakeDocker = (opts: { - info?: unknown; - infoExit?: number; + engine?: unknown; + engineExit?: number; + plugins?: unknown; + pluginsExit?: number; inspect?: string; }) => { - const infoJson = opts.info === undefined ? "" : JSON.stringify(opts.info); - const infoExit = opts.infoExit ?? 0; + const engineJson = + opts.engine === undefined ? "" : JSON.stringify(opts.engine); + const pluginsJson = + opts.plugins === undefined ? "" : JSON.stringify(opts.plugins); const inspect = opts.inspect === undefined ? "exit 1" : `printf '%s\\n' ${JSON.stringify(opts.inspect)}`; return `#!/bin/sh if [ "$1" = "info" ]; then - ${infoJson ? `printf '%s\\n' ${JSON.stringify(infoJson)}` : ":"} - exit ${infoExit} + case "$*" in + *ClientInfo.Plugins*) + ${pluginsJson ? `printf '%s\\n' ${JSON.stringify(pluginsJson)}` : ":"} + exit ${opts.pluginsExit ?? 0} + ;; + esac + ${engineJson ? `printf '%s\\n' ${JSON.stringify(engineJson)}` : ":"} + exit ${opts.engineExit ?? 0} fi if [ "$1" = "inspect" ]; then ${inspect} @@ -121,11 +146,13 @@ const runScript = (sandboxPath: string) => }); describe("buildModelRunnerScript", () => { - it("only uses docker info and inspect, and keeps docker info's exit status", () => { + it("separates engine and plugin docker info probes and keeps their exit statuses", () => { const script = buildModelRunnerScript(); expect(script).toContain("command -v docker"); - expect(script).toContain("docker info --format"); - expect(script).toContain("infoExit=$?"); + expect(script).toContain("{{json .ServerVersion}}"); + expect(script).toContain("{{json .ClientInfo.Plugins}}"); + expect(script).toContain("engineExit=$?"); + expect(script).toContain("pluginsExit=$?"); expect(script).toContain( "docker inspect -f '{{.State.Status}}' docker-model-runner", ); @@ -143,10 +170,13 @@ describe("buildModelRunnerScript", () => { describe("composeModelsSupported", () => { it.each([ [null, false], - ["not-a-version", false], + ["garbage", false], ["2.37.3", false], + ["v2.38.0-rc.1", false], + ["2.38.0-beta.1", false], ["2.38.0", true], ["v2.39.1", true], + ["v2.40.3-desktop.1", true], ["v5.0.0", true], ] as const)("%s => %s", (version, supported) => { expect(composeModelsSupported(version)).toBe(supported); @@ -164,11 +194,11 @@ describe("parseModelRunnerCapability", () => { expect(result.modelRunner.cliAvailable).toBe(false); }); - it("treats a present CLI with a failed docker info as an operational error", () => { + it("treats a failed engine probe as unreachable even if plugins succeed", () => { const result = parseModelRunnerCapability( envelope({ - infoExit: 1, - info: info([plugin("compose", "v2.38.0")]), + engineExit: 1, + plugins: [plugin("compose", "v2.38.0")], containerStatus: "exited", }), ); @@ -178,30 +208,33 @@ describe("parseModelRunnerCapability", () => { expect(result.modelRunner.standaloneRunnerContainerStatus).toBe("exited"); }); + it("keeps the engine available when plugin metadata fails", () => { + const result = parseModelRunnerCapability( + envelope({ + engine: engine(), + pluginsExit: 1, + }), + ); + expect(result.error).toBeUndefined(); + expect(result.docker).toEqual({ + available: true, + version: "28.5.2", + os: "linux", + arch: "amd64", + }); + expect(result.compose.available).toBe(false); + expect(result.modelRunner.cliAvailable).toBe(false); + }); + it("returns error for a malformed probe envelope", () => { const result = parseModelRunnerCapability("not-json"); expect(result.error).toBe("Could not parse model runner probe output"); expect(result.docker.available).toBe(false); }); - it("returns error for malformed docker info when the engine call succeeded", () => { + it("treats missing plugins as compose and model unavailable", () => { const result = parseModelRunnerCapability( - JSON.stringify({ - dockerPresent: true, - infoExit: 0, - infoBase64: Buffer.from("not-json").toString("base64"), - containerStatus: "", - }), - ); - expect(result.error).toBe("Could not parse docker info output"); - expect(result.docker.available).toBe(false); - }); - - it("treats missing ClientInfo/Plugins as compose and model unavailable", () => { - const result = parseModelRunnerCapability( - envelope({ - info: { serverVersion: "28.5.2", os: "linux", arch: "amd64" }, - }), + envelope({ engine: engine(), plugins: [] }), ); expect(result.error).toBeUndefined(); expect(result.docker.available).toBe(true); @@ -215,7 +248,7 @@ describe("parseModelRunnerCapability", () => { ["malformed version", [plugin("compose", "not-a-version")]], ] as const)("compose plugin %s", (_name, plugins) => { const result = parseModelRunnerCapability( - envelope({ info: info([...plugins]) }), + envelope({ engine: engine(), plugins: [...plugins] }), ); if (_name === "malformed version") { expect(result.compose.available).toBe(true); @@ -230,12 +263,18 @@ describe("parseModelRunnerCapability", () => { it.each([ ["2.37.3", false], + ["v2.38.0-rc.1", false], + ["2.38.0-beta.1", false], ["2.38.0", true], ["v2.39.1", true], + ["v2.40.3-desktop.1", true], ["v5.0.0", true], ] as const)("compose %s => modelsSupported=%s", (version, supported) => { const result = parseModelRunnerCapability( - envelope({ info: info([plugin("compose", version)]) }), + envelope({ + engine: engine(), + plugins: [plugin("compose", version)], + }), ); expect(result.compose).toEqual({ available: true, @@ -249,7 +288,7 @@ describe("parseModelRunnerCapability", () => { ["invalid Err", [plugin("model", "v1.0.2", { message: "broken" })]], ] as const)("model plugin %s", (_name, plugins) => { const result = parseModelRunnerCapability( - envelope({ info: info([...plugins]) }), + envelope({ engine: engine(), plugins: [...plugins] }), ); expect(result.modelRunner.cliAvailable).toBe(false); expect(result.modelRunner.cliVersion).toBeNull(); @@ -263,7 +302,8 @@ describe("parseModelRunnerCapability", () => { ] as const)("standalone runner %s", (status, expected) => { const result = parseModelRunnerCapability( envelope({ - info: info([plugin("model", "v1.0.2")]), + engine: engine(), + plugins: [plugin("model", "v1.0.2")], containerStatus: status, }), ); @@ -274,20 +314,16 @@ describe("parseModelRunnerCapability", () => { it("does not leak plugin Path or unrelated docker-info fields", () => { const result = parseModelRunnerCapability( envelope({ - info: { - ...info([ - plugin("compose", "v2.38.0", undefined, { - Path: "/usr/libexec/docker/cli-plugins/docker-compose", - Vendor: "Docker Inc.", - }), - plugin("model", "v1.0.2", undefined, { - Path: "/usr/libexec/docker/cli-plugins/docker-model", - }), - ]), - HttpProxy: "http://proxy.internal:8080", - RegistryConfig: { IndexConfigs: {} }, - Labels: ["secret=1"], - }, + engine: engine(), + plugins: [ + plugin("compose", "v2.38.0", undefined, { + Path: "/usr/libexec/docker/cli-plugins/docker-compose", + Vendor: "Docker Inc.", + }), + plugin("model", "v1.0.2", undefined, { + Path: "/usr/libexec/docker/cli-plugins/docker-model", + }), + ], containerStatus: "running", }), ); @@ -295,9 +331,6 @@ describe("parseModelRunnerCapability", () => { expect(serialized).not.toContain("Path"); expect(serialized).not.toContain("/usr/libexec"); expect(serialized).not.toContain("Vendor"); - expect(serialized).not.toContain("proxy.internal"); - expect(serialized).not.toContain("RegistryConfig"); - expect(serialized).not.toContain("secret=1"); expect(result.modelRunner).toEqual({ cliAvailable: true, cliVersion: "v1.0.2", @@ -314,13 +347,13 @@ describe("model runner probe script", () => { expect(result.modelRunner.cliAvailable).toBe(false); }); - it("records docker info's non-zero exit as an unreachable engine", () => { + it("records a non-zero engine probe as unreachable", () => { const result = parseModelRunnerCapability( runScript( makeSandbox( fakeDocker({ - info: info([plugin("compose", "v2.38.0")]), - infoExit: 1, + engineExit: 1, + plugins: [plugin("compose", "v2.38.0")], }), ), ), @@ -330,12 +363,31 @@ describe("model runner probe script", () => { expect(result.compose.available).toBe(true); }); + it("does not report engine unreachable when only ClientInfo.Plugins fails", () => { + const result = parseModelRunnerCapability( + runScript( + makeSandbox( + fakeDocker({ + engine: engine(), + pluginsExit: 1, + }), + ), + ), + ); + expect(result.error).toBeUndefined(); + expect(result.docker.available).toBe(true); + expect(result.docker.version).toBe("28.5.2"); + expect(result.compose.available).toBe(false); + expect(result.modelRunner.cliAvailable).toBe(false); + }); + it("reads inspect status even when the model plugin is absent", () => { const result = parseModelRunnerCapability( runScript( makeSandbox( fakeDocker({ - info: info([plugin("compose", "v2.38.0")]), + engine: engine(), + plugins: [plugin("compose", "v2.38.0")], inspect: "running", }), ), @@ -349,7 +401,8 @@ describe("model runner probe script", () => { describe("getModelRunnerCapability", () => { const probeJson = envelope({ - info: info([plugin("compose", "v2.38.0"), plugin("model", "v1.0.2")]), + engine: engine(), + plugins: [plugin("compose", "v2.38.0"), plugin("model", "v1.0.2")], containerStatus: "running", }); @@ -378,7 +431,7 @@ describe("getModelRunnerCapability", () => { const result = await getModelRunnerCapability("remote-server"); expect(execAsyncRemote).toHaveBeenCalledWith( "remote-server", - expect.stringContaining("docker info --format"), + expect.stringContaining("{{json .ClientInfo.Plugins}}"), ); expect(execAsync).not.toHaveBeenCalled(); expect(result.modelRunner.cliAvailable).toBe(true); diff --git a/packages/server/src/services/model-runner.ts b/packages/server/src/services/model-runner.ts index 2a541c406..089013ade 100644 --- a/packages/server/src/services/model-runner.ts +++ b/packages/server/src/services/model-runner.ts @@ -40,17 +40,17 @@ export interface ModelRunnerCapability { interface ProbeEnvelope { dockerPresent?: boolean; - infoExit?: number; - infoBase64?: string; + engineExit?: number; + engineBase64?: string; + pluginsExit?: number; + pluginsBase64?: string; containerStatus?: string; } -interface DockerInfoProbe { +interface EngineProbe { serverVersion?: string | null; os?: string | null; arch?: string | null; - plugins?: DockerInfoPlugin[] | null; - clientInfo?: { plugins?: DockerInfoPlugin[] | null } | null; } interface DockerInfoPlugin { @@ -115,7 +115,7 @@ const findPlugin = ( export const composeModelsSupported = (version: string | null): boolean => { if (!version) return false; - const parsed = semver.coerce(version); + const parsed = semver.clean(version); if (!parsed) return false; return semver.gte(parsed, COMPOSE_MODELS_MIN_VERSION); }; @@ -129,28 +129,42 @@ const b64Decode = (value?: string): string => { } }; +const parseJson = (text: string): unknown => { + try { + return JSON.parse(text); + } catch { + return null; + } +}; + /** - * Read-only capability probe. Commands are `docker info` (narrow format) and - * `docker inspect` of `docker-model-runner`. Plugin discovery uses Docker's + * Read-only capability probe. Engine reachability and CLI plugin metadata are + * separate `docker info` templates so an older CLI missing ClientInfo.Plugins + * cannot be reported as an unreachable engine. Plugin discovery uses Docker's * metadata subcommand. No install/configuration/model-lifecycle/server-mutation - * commands. `docker info` stdout is captured before base64 so its exit status + * commands. Each docker info stdout is captured before base64 so exit status * is preserved. */ export const buildModelRunnerScript = () => ` dockerPresent=false -infoExit=0 -infoB64="" +engineExit=0 +engineB64="" +pluginsExit=0 +pluginsB64="" containerStatus="" if command -v docker >/dev/null 2>&1; then dockerPresent=true - infoOutput=$(docker info --format '{"serverVersion":{{json .ServerVersion}},"os":{{json .OSType}},"arch":{{json .Architecture}},"plugins":{{json .ClientInfo.Plugins}}}' 2>/dev/null) - infoExit=$? - infoB64=$(printf '%s' "$infoOutput" | base64 2>/dev/null | tr -d '\\n') + engineOutput=$(docker info --format '{"serverVersion":{{json .ServerVersion}},"os":{{json .OSType}},"arch":{{json .Architecture}}}' 2>/dev/null) + engineExit=$? + engineB64=$(printf '%s' "$engineOutput" | base64 2>/dev/null | tr -d '\\n') + pluginsOutput=$(docker info --format '{{json .ClientInfo.Plugins}}' 2>/dev/null) + pluginsExit=$? + pluginsB64=$(printf '%s' "$pluginsOutput" | base64 2>/dev/null | tr -d '\\n') containerStatus=$(docker inspect -f '{{.State.Status}}' docker-model-runner 2>/dev/null | tr -d '\\n') fi -printf '{"dockerPresent":%s,"infoExit":%s,"infoBase64":"%s","containerStatus":"%s"}' "$dockerPresent" "$infoExit" "$infoB64" "$containerStatus" +printf '{"dockerPresent":%s,"engineExit":%s,"engineBase64":"%s","pluginsExit":%s,"pluginsBase64":"%s","containerStatus":"%s"}' "$dockerPresent" "$engineExit" "$engineB64" "$pluginsExit" "$pluginsB64" "$containerStatus" `; export const parseModelRunnerCapability = ( @@ -177,32 +191,25 @@ export const parseModelRunnerCapability = ( }); } - const infoExit = Number(envelope.infoExit ?? 0); - const infoText = b64Decode(envelope.infoBase64).trim(); - let info: DockerInfoProbe | null = null; - if (infoText) { - try { - info = JSON.parse(infoText); - } catch { - if (infoExit === 0) { - return emptyCapability( - new Error("Could not parse docker info output"), - { - modelRunner: { - cliAvailable: false, - cliVersion: null, - standaloneRunnerContainerStatus: containerStatus, - }, - }, - ); - } - } - } + const engineExit = Number(envelope.engineExit ?? 0); + const pluginsExit = Number(envelope.pluginsExit ?? 0); + const engineText = b64Decode(envelope.engineBase64).trim(); + const pluginsText = b64Decode(envelope.pluginsBase64).trim(); - const plugins = capabilityFromPlugins(info); + const engine = + engineExit === 0 && engineText + ? (parseJson(engineText) as EngineProbe | null) + : null; + const pluginList = + pluginsExit === 0 && pluginsText + ? (parseJson(pluginsText) as DockerInfoPlugin[] | null) + : null; + const plugins = capabilityFromPlugins( + Array.isArray(pluginList) ? pluginList : null, + ); plugins.modelRunner.standaloneRunnerContainerStatus = containerStatus; - if (infoExit !== 0 || !info) { + if (engineExit !== 0) { return emptyCapability(ENGINE_UNREACHABLE, plugins); } @@ -210,9 +217,9 @@ export const parseModelRunnerCapability = ( checkedAt: new Date().toISOString(), docker: { available: true, - version: nullIfEmpty(info.serverVersion ?? undefined), - os: nullIfEmpty(info.os ?? undefined), - arch: nullIfEmpty(info.arch ?? undefined), + version: nullIfEmpty(engine?.serverVersion ?? undefined), + os: nullIfEmpty(engine?.os ?? undefined), + arch: nullIfEmpty(engine?.arch ?? undefined), }, compose: plugins.compose, modelRunner: plugins.modelRunner, @@ -220,9 +227,8 @@ export const parseModelRunnerCapability = ( }; const capabilityFromPlugins = ( - info: DockerInfoProbe | null, + plugins: DockerInfoPlugin[] | null, ): Pick => { - const plugins = info?.plugins ?? info?.clientInfo?.plugins; const composePlugin = findPlugin(plugins, "compose"); const modelPlugin = findPlugin(plugins, "model"); const composeVersion = nullIfEmpty(composePlugin?.Version);