diff --git a/packages/api/README.md b/packages/api/README.md index c70823c6..1b1d6f91 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -38,9 +38,11 @@ glance and the CLI now distinguishes them in its error output: (`docker` group / rootless Docker / socket ownership). This is a host configuration problem, not a `docker-git` outage. - **Controller container not running / unreachable** – the API at - `DOCKER_GIT_API_URL` (default `http://127.0.0.1:3334`) does not answer. - Bring the controller up with `docker compose up -d --build` or point the - CLI at an existing controller via `DOCKER_GIT_API_URL`. + a custom `DOCKER_GIT_API_URL` does not answer. Bring the controller up + with `docker compose up -d --build` or point the CLI at an existing + controller via `DOCKER_GIT_API_URL`. The default local value + (`http://127.0.0.1:3334`, `http://localhost:3334`, or `http://[::1]:3334`) + does not block local Docker bootstrap. Diagnostic classification + remediation messages live in `packages/app/src/docker-git/controller-docker-diagnostics.ts` and are diff --git a/packages/app/src/docker-git/browser-frontend.ts b/packages/app/src/docker-git/browser-frontend.ts index 4adfa803..c2f6ced9 100644 --- a/packages/app/src/docker-git/browser-frontend.ts +++ b/packages/app/src/docker-git/browser-frontend.ts @@ -13,7 +13,12 @@ import { shouldReuseBrowserFrontend } from "./browser-frontend-state.js" import { findReachableApiBaseUrl } from "./controller-health.js" -import { resolveConfiguredApiBaseUrl, resolveExplicitApiBaseUrl } from "./controller-reachability.js" +import { + resolveConfiguredApiBaseUrl, + resolveDefaultLocalApiBaseUrl, + resolveExplicitApiBaseUrl, + uniqueStrings +} from "./controller-reachability.js" import { type ControllerRuntime, ensureControllerReady, resolveApiBaseUrl } from "./controller.js" import { runCommandCapture, @@ -153,19 +158,19 @@ const readBrowserFrontendRuntimeState = ( // QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать" // REF: PR #344 E2E (Browser command) regression. // SOURCE: n/a -// FORMAT THEOREM: explicit_api -> explicit_api; reachable(configured_api) -> configured_api; otherwise -> selected_api +// FORMAT THEOREM: strict_explicit_api -> strict_explicit_api; reachable(local_api) -> local_api; otherwise -> selected_api // PURITY: SHELL // EFFECT: Effect -// INVARIANT: explicit DOCKER_GIT_API_URL is never overridden by auto-discovery. +// INVARIANT: strict explicit DOCKER_GIT_API_URL is never overridden by auto-discovery. // COMPLEXITY: O(1) probes/O(1) space. /** * Resolves the API URL used by the browser frontend proxy. * - * @returns Effect with the explicit API URL, the reachable configured host URL, or the selected controller URL. + * @returns Effect with the strict explicit API URL, a reachable local host URL, or the selected controller URL. * * @pure false * @effect FetchHttpClient through controller health probing. - * @invariant Explicit `DOCKER_GIT_API_URL` has precedence over all inferred endpoints. + * @invariant Strict explicit `DOCKER_GIT_API_URL` has precedence over all inferred endpoints. * @precondition `ensureControllerReady` has already completed for inferred endpoints. * @postcondition A configured host URL is used only after a successful health probe. * @complexity O(1) time and O(1) space for the bounded candidate set. @@ -179,11 +184,15 @@ const resolveBrowserFrontendApiBaseUrl = (): Effect.Effect value.length > 0)) + if (candidateApiBaseUrls.includes(selectedApiBaseUrl)) { return Effect.succeed(selectedApiBaseUrl) } - return findReachableApiBaseUrl([configuredApiBaseUrl]).pipe( + return findReachableApiBaseUrl(candidateApiBaseUrls).pipe( Effect.match({ onFailure: () => selectedApiBaseUrl, onSuccess: (apiBaseUrl) => apiBaseUrl diff --git a/packages/app/src/docker-git/controller-health.ts b/packages/app/src/docker-git/controller-health.ts index 04fb852f..d5574be7 100644 --- a/packages/app/src/docker-git/controller-health.ts +++ b/packages/app/src/docker-git/controller-health.ts @@ -100,11 +100,13 @@ export const findReachableApiBaseUrl = ( export const findReachableDirectHealthProbe = (options: { readonly explicitApiBaseUrl: string | undefined + readonly defaultLocalApiBaseUrl: string | undefined readonly cachedApiBaseUrl: string | undefined }): Effect.Effect => findReachableHealthProbeOrNull( buildApiBaseUrlCandidates({ explicitApiBaseUrl: options.explicitApiBaseUrl, + defaultLocalApiBaseUrl: options.defaultLocalApiBaseUrl, cachedApiBaseUrl: options.cachedApiBaseUrl, defaultApiBaseUrl: resolveConfiguredApiBaseUrl(), currentContainerNetworks: {}, diff --git a/packages/app/src/docker-git/controller-reachability.ts b/packages/app/src/docker-git/controller-reachability.ts index 262b8dc9..d7bf4c0a 100644 --- a/packages/app/src/docker-git/controller-reachability.ts +++ b/packages/app/src/docker-git/controller-reachability.ts @@ -5,6 +5,7 @@ export type DockerNetworkIps = Readonly> export type ApiBaseUrlCandidatesInput = { readonly explicitApiBaseUrl?: string | undefined + readonly defaultLocalApiBaseUrl?: string | undefined readonly cachedApiBaseUrl?: string | undefined readonly defaultApiBaseUrl: string readonly currentContainerNetworks: DockerNetworkIps @@ -28,11 +29,69 @@ const normalizePort = (value: string | undefined): string => { return trimmed.length > 0 ? trimmed : defaultApiPort } +const normalizeApiBaseUrl = (value: string | undefined): string | undefined => { + const trimmed = value?.trim() + return trimmed !== undefined && trimmed.length > 0 ? trimTrailingSlashes(trimmed) : undefined +} + export const resolveApiPort = (): string => normalizePort(process.env["DOCKER_GIT_API_PORT"]) +const defaultLocalHostnames = new Set(["127.0.0.1", "localhost", "[::1]"]) + +const isRootApiUrlPath = (url: URL): boolean => url.pathname === "/" && url.search.length === 0 && url.hash.length === 0 + +const isDefaultLocalApiUrlObject = (url: URL, port: string): boolean => + url.protocol === "http:" && + defaultLocalHostnames.has(url.hostname) && + url.port === port && + isRootApiUrlPath(url) + +// CHANGE: classify default localhost API URLs as non-strict bootstrap hints. +// WHY: Windows shells can persist DOCKER_GIT_API_URL=http://127.0.0.1:3334, which should not block local controller startup. +// QUOTE(ТЗ): "сделать из коробки что бы всё само работало" +// REF: user-request-2026-05-29-default-local-api-url-bootstrap +// SOURCE: n/a +// FORMAT THEOREM: local_http(url, port) and empty(path, query, hash) -> default_local(url) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: only localhost loopback HTTP URLs on the configured API port are default-local. +// COMPLEXITY: O(n) where n = |value|. +export const isDefaultLocalApiBaseUrl = (value: string, port = resolveApiPort()): boolean => { + const normalized = normalizeApiBaseUrl(value) + if (normalized === undefined || !URL.canParse(normalized)) { + return false + } + return isDefaultLocalApiUrlObject(new URL(normalized), port) +} + +// CHANGE: preserve default-local DOCKER_GIT_API_URL as an endpoint candidate instead of a strict override. +// WHY: a stale default localhost env var should still allow compose bootstrap when nothing is listening yet. +// QUOTE(ТЗ): "fallback только для дефолтного localhost URL" +// REF: user-request-2026-05-29-default-local-api-url-bootstrap +// SOURCE: n/a +// FORMAT THEOREM: default_local(env) -> env; otherwise -> undefined +// PURITY: SHELL +// EFFECT: reads process.env +// INVARIANT: custom DOCKER_GIT_API_URL values are never returned here. +// COMPLEXITY: O(n) where n = |DOCKER_GIT_API_URL|. +export const resolveDefaultLocalApiBaseUrl = (): string | undefined => { + const explicit = normalizeApiBaseUrl(process.env["DOCKER_GIT_API_URL"]) + return explicit !== undefined && isDefaultLocalApiBaseUrl(explicit) ? explicit : undefined +} + +// CHANGE: treat only custom DOCKER_GIT_API_URL values as strict explicit controller endpoints. +// WHY: custom remote backends should fail loudly when unreachable, while default localhost should bootstrap locally. +// QUOTE(ТЗ): "кастомные URL остаются строгими" +// REF: user-request-2026-05-29-default-local-api-url-bootstrap +// SOURCE: n/a +// FORMAT THEOREM: nonempty(env) and not default_local(env) -> env; otherwise -> undefined +// PURITY: SHELL +// EFFECT: reads process.env +// INVARIANT: default-local URLs do not block local bootstrap. +// COMPLEXITY: O(n) where n = |DOCKER_GIT_API_URL|. export const resolveExplicitApiBaseUrl = (): string | undefined => { - const explicit = process.env["DOCKER_GIT_API_URL"]?.trim() - return explicit !== undefined && explicit.length > 0 ? trimTrailingSlashes(explicit) : undefined + const explicit = normalizeApiBaseUrl(process.env["DOCKER_GIT_API_URL"]) + return explicit !== undefined && !isDefaultLocalApiBaseUrl(explicit) ? explicit : undefined } export const resolveConfiguredApiBaseUrl = (): string => { @@ -126,6 +185,7 @@ export const buildApiBaseUrlCandidates = ({ controllerNetworks, currentContainerNetworks, defaultApiBaseUrl, + defaultLocalApiBaseUrl, explicitApiBaseUrl, port }: ApiBaseUrlCandidatesInput): ReadonlyArray => { @@ -150,6 +210,7 @@ export const buildApiBaseUrlCandidates = ({ return uniqueStrings( [ + defaultLocalApiBaseUrl ?? "", cachedApiBaseUrl ?? "", defaultApiBaseUrl, resolveControllerDnsApiBaseUrl(), diff --git a/packages/app/src/docker-git/controller.ts b/packages/app/src/docker-git/controller.ts index 06d0e8ff..9eba5957 100644 --- a/packages/app/src/docker-git/controller.ts +++ b/packages/app/src/docker-git/controller.ts @@ -1,18 +1,7 @@ import { Duration, Effect, pipe, Schedule } from "effect" import { resolveControllerComposeUpArgs, shouldBuildControllerImage } from "./controller-bootstrap-plan.js" -import { - controllerContainerName, - controllerExists, - type ControllerRuntime, - ensureControllerReachabilityNetworks, - inspectContainerNetworks, - inspectControllerPublishedPorts, - inspectControllerRevision, - prepareLocalControllerRevision, - resolveCurrentContainerNetworks, - runCompose -} from "./controller-docker.js" +import * as ControllerDocker from "./controller-docker.js" import { findReachableApiBaseUrl, findReachableDirectHealthProbe } from "./controller-health.js" import { inspectControllerImageRevision } from "./controller-image-revision.js" import { @@ -21,6 +10,7 @@ import { formatNetworkIps, resolveApiPort, resolveConfiguredApiBaseUrl, + resolveDefaultLocalApiBaseUrl, resolveExplicitApiBaseUrl, shouldRequireExplicitApiUrlForRemoteDocker, trimTrailingSlashes @@ -43,6 +33,8 @@ const controllerBootstrapError = (message: string): ControllerBootstrapError => message }) +type ControllerEffect = Effect.Effect + const rememberSelectedApiBaseUrl = (value: string): void => { selectedApiBaseUrl = trimTrailingSlashes(value) } @@ -54,9 +46,9 @@ const collectReachabilityDiagnostics = ( candidateUrls: ReadonlyArray, currentContainerNetworks: DockerNetworkIps, controllerNetworks: DockerNetworkIps -): Effect.Effect => +): Effect.Effect => Effect.gen(function*(_) { - const publishedPorts = yield* _(inspectControllerPublishedPorts()) + const publishedPorts = yield* _(ControllerDocker.inspectControllerPublishedPorts()) return [ "Tried endpoints:", @@ -71,7 +63,7 @@ const waitForReachableApiBaseUrl = ( candidateUrls: ReadonlyArray, currentContainerNetworks: DockerNetworkIps, controllerNetworks: DockerNetworkIps -): Effect.Effect => +): ControllerEffect => pipe( findReachableApiBaseUrl(candidateUrls), Effect.retry( @@ -160,21 +152,19 @@ type ControllerBootstrapContext = { readonly initialControllerNetworks: DockerNetworkIps } -const loadControllerBootstrapContext = (): Effect.Effect< - ControllerBootstrapContext, - ControllerBootstrapError, - ControllerRuntime -> => +const loadControllerBootstrapContext = (): ControllerEffect => Effect.gen(function*(_) { yield* _(prepareControllerRuntimeEnv()) yield* _(prepareControllerResourceLimitEnv()) const explicitApiBaseUrl = resolveExplicitApiBaseUrl() - const localControllerRevision = yield* _(prepareLocalControllerRevision()) - const currentControllerExists = yield* _(controllerExists()) - const currentControllerRevision = yield* _(inspectControllerRevision()) + const localControllerRevision = yield* _(ControllerDocker.prepareLocalControllerRevision()) + const currentControllerExists = yield* _(ControllerDocker.controllerExists()) + const currentControllerRevision = yield* _(ControllerDocker.inspectControllerRevision()) const currentImageRevision = yield* _(inspectControllerImageRevision()) - const currentContainerNetworks = yield* _(resolveCurrentContainerNetworks()) - const initialControllerNetworks = yield* _(inspectContainerNetworks(controllerContainerName)) + const currentContainerNetworks = yield* _(ControllerDocker.resolveCurrentContainerNetworks()) + const initialControllerNetworks = yield* _( + ControllerDocker.inspectContainerNetworks(ControllerDocker.controllerContainerName) + ) const forceRecreateForResourceLimits = shouldForceRecreateForControllerResourceLimits() const forceRecreateController = forceRecreateForResourceLimits || shouldForceRecreateController(currentControllerExists, localControllerRevision, currentControllerRevision) @@ -203,6 +193,7 @@ const buildBootstrapCandidateUrls = ( ): ReadonlyArray => buildApiBaseUrlCandidates({ explicitApiBaseUrl, + defaultLocalApiBaseUrl: resolveDefaultLocalApiBaseUrl(), cachedApiBaseUrl: selectedApiBaseUrl, defaultApiBaseUrl: resolveConfiguredApiBaseUrl(), currentContainerNetworks, @@ -243,16 +234,18 @@ const logControllerStart = ( const startAndRememberController = ( context: ControllerBootstrapContext -): Effect.Effect => +): ControllerEffect => Effect.gen(function*(_) { if (context.forceRecreateController || context.buildController) { yield* _(logControllerStart(context)) } - yield* _(runCompose(resolveControllerComposeUpArgs(context))) - yield* _(ensureControllerReachabilityNetworks(context.currentContainerNetworks)) + yield* _(ControllerDocker.runCompose(resolveControllerComposeUpArgs(context))) + yield* _(ControllerDocker.ensureControllerReachabilityNetworks(context.currentContainerNetworks)) - const controllerNetworks = yield* _(inspectContainerNetworks(controllerContainerName)) + const controllerNetworks = yield* _( + ControllerDocker.inspectContainerNetworks(ControllerDocker.controllerContainerName) + ) const candidateUrls = buildBootstrapCandidateUrls( context.explicitApiBaseUrl, context.currentContainerNetworks, @@ -274,12 +267,17 @@ const startAndRememberController = ( // EFFECT: Effect // INVARIANT: controller is reachable from the current runtime before any host API dispatch // COMPLEXITY: O(1) compose + O(k) health checks -export const ensureControllerReady = (): Effect.Effect => +export const ensureControllerReady = (): ControllerEffect => Effect.gen(function*(_) { const explicitApiBaseUrl = resolveExplicitApiBaseUrl() + const defaultLocalApiBaseUrl = resolveDefaultLocalApiBaseUrl() if (explicitApiBaseUrl !== undefined) { const reachableBeforeDocker = yield* _( - findReachableDirectHealthProbe({ explicitApiBaseUrl, cachedApiBaseUrl: selectedApiBaseUrl }) + findReachableDirectHealthProbe({ + cachedApiBaseUrl: selectedApiBaseUrl, + defaultLocalApiBaseUrl, + explicitApiBaseUrl + }) ) if (reachableBeforeDocker !== null) { rememberSelectedApiBaseUrl(reachableBeforeDocker.apiBaseUrl) @@ -288,12 +286,13 @@ export const ensureControllerReady = (): Effect.Effect => +export const restartController = (): ControllerEffect => Effect.gen(function*(_) { const explicitApiBaseUrl = resolveExplicitApiBaseUrl() if (explicitApiBaseUrl !== undefined) { diff --git a/packages/app/tests/docker-git/browser-frontend.test.ts b/packages/app/tests/docker-git/browser-frontend.test.ts index 7abf99df..8948e83b 100644 --- a/packages/app/tests/docker-git/browser-frontend.test.ts +++ b/packages/app/tests/docker-git/browser-frontend.test.ts @@ -21,6 +21,7 @@ const findReachableApiBaseUrlMock = vi.hoisted( () => vi.fn<(candidateUrls: ReadonlyArray) => Effect.Effect>() ) const resolveConfiguredApiBaseUrlMock = vi.hoisted(() => vi.fn<() => string>()) +const resolveDefaultLocalApiBaseUrlMock = vi.hoisted(() => vi.fn<() => string | undefined>()) const resolveExplicitApiBaseUrlMock = vi.hoisted(() => vi.fn<() => string | undefined>()) const runCommandCaptureMock = vi.hoisted(() => vi.fn<(spec: CommandSpec) => Effect.Effect>()) const runCommandExitCodeMock = vi.hoisted(() => vi.fn<(spec: CommandSpec) => Effect.Effect>()) @@ -37,7 +38,9 @@ vi.mock("../../src/docker-git/controller-health.js", () => ({ vi.mock("../../src/docker-git/controller-reachability.js", () => ({ resolveConfiguredApiBaseUrl: resolveConfiguredApiBaseUrlMock, - resolveExplicitApiBaseUrl: resolveExplicitApiBaseUrlMock + resolveDefaultLocalApiBaseUrl: resolveDefaultLocalApiBaseUrlMock, + resolveExplicitApiBaseUrl: resolveExplicitApiBaseUrlMock, + uniqueStrings: (values: ReadonlyArray) => [...new Set(values)] })) vi.mock("../../src/docker-git/frontend-lib/shell/command-runner.js", () => ({ @@ -84,6 +87,15 @@ const makeHttpUrl = (host: string, port: string): string => `http://${host}:${po const dockerBridgeHost = ["172", "17", "0", "2"].join(".") +const useReachableHostApiProbe = (defaultLocalApiBaseUrl?: string): void => { + resolveApiBaseUrlMock.mockReturnValue(makeHttpUrl(dockerBridgeHost, "3334")) + if (defaultLocalApiBaseUrl !== undefined) { + resolveDefaultLocalApiBaseUrlMock.mockReturnValue(defaultLocalApiBaseUrl) + } + resolveConfiguredApiBaseUrlMock.mockReturnValue(makeHttpUrl("127.0.0.1", "3334")) + findReachableApiBaseUrlMock.mockImplementation((candidateUrls) => Effect.succeed(candidateUrls[0] ?? "")) +} + const writeWebStateFile = ( statePath: string, state: Readonly> @@ -127,6 +139,8 @@ describe("browser frontend command", () => { findReachableApiBaseUrlMock.mockImplementation((candidateUrls) => Effect.succeed(candidateUrls[0] ?? "")) resolveConfiguredApiBaseUrlMock.mockReset() resolveConfiguredApiBaseUrlMock.mockReturnValue("http://127.0.0.1:3334") + resolveDefaultLocalApiBaseUrlMock.mockReset() + resolveDefaultLocalApiBaseUrlMock.mockImplementation(() => {}) resolveExplicitApiBaseUrlMock.mockReset() resolveExplicitApiBaseUrlMock.mockImplementation(() => {}) runCommandCaptureMock.mockReset() @@ -173,9 +187,7 @@ describe("browser frontend command", () => { it.effect("prefers the reachable host API URL over a selected Docker bridge URL for the web proxy", () => Effect.gen(function*(_) { - resolveApiBaseUrlMock.mockReturnValue(makeHttpUrl(dockerBridgeHost, "3334")) - resolveConfiguredApiBaseUrlMock.mockReturnValue("http://127.0.0.1:3334") - findReachableApiBaseUrlMock.mockImplementation((candidateUrls) => Effect.succeed(candidateUrls[0] ?? "")) + useReachableHostApiProbe() yield* _(runBrowserCommandUnderTest) @@ -215,6 +227,22 @@ describe("browser frontend command", () => { ]) })) + it.effect("treats the default local API URL as a reachable host candidate instead of a strict override", () => + Effect.gen(function*(_) { + useReachableHostApiProbe("http://localhost:3334") + + yield* _(runBrowserCommandUnderTest) + + expect(findReachableApiBaseUrlMock).toHaveBeenCalledWith([ + "http://localhost:3334", + "http://127.0.0.1:3334" + ]) + expect(streamingEnvs()).toEqual([ + expect.objectContaining({ DOCKER_GIT_API_URL: "http://localhost:3334" }), + expect.objectContaining({ DOCKER_GIT_API_URL: "http://localhost:3334" }) + ]) + })) + it.effect("binds browser web to all host interfaces by default", () => Effect.gen(function*(_) { yield* _(runBrowserCommandUnderTest) diff --git a/packages/app/tests/docker-git/controller-ready.test.ts b/packages/app/tests/docker-git/controller-ready.test.ts index 21faf102..13040002 100644 --- a/packages/app/tests/docker-git/controller-ready.test.ts +++ b/packages/app/tests/docker-git/controller-ready.test.ts @@ -10,6 +10,7 @@ const findReachableDirectHealthProbeMock = vi.hoisted( vi.fn< (options: { readonly cachedApiBaseUrl: string | undefined + readonly defaultLocalApiBaseUrl: string | undefined readonly explicitApiBaseUrl: string | undefined }) => Effect.Effect<{ readonly apiBaseUrl: string; readonly revision: string | null } | null> >() @@ -49,7 +50,16 @@ vi.mock("../../src/docker-git/controller-reachability.js", () => ({ isRemoteDockerHost: () => false, resolveApiPort: () => "3334", resolveConfiguredApiBaseUrl: () => "http://127.0.0.1:3334", - resolveExplicitApiBaseUrl: () => process.env["DOCKER_GIT_API_URL"]?.trim() || undefined, + resolveDefaultLocalApiBaseUrl: () => { + const value = process.env["DOCKER_GIT_API_URL"]?.trim() + return value === "http://127.0.0.1:3334" ? value : undefined + }, + resolveExplicitApiBaseUrl: () => { + const value = process.env["DOCKER_GIT_API_URL"]?.trim() + return value !== undefined && value.length > 0 && value !== "http://127.0.0.1:3334" + ? value + : undefined + }, shouldRequireExplicitApiUrlForRemoteDocker: () => false, trimTrailingSlashes: (value: string) => { let end = value.length @@ -75,6 +85,7 @@ vi.mock("../../src/docker-git/controller-runtime-shell.js", () => ({ describe("controller readiness bootstrap", () => { beforeEach(() => { + vi.resetModules() Reflect.deleteProperty(process.env, "DOCKER_GIT_API_URL") prepareLocalControllerRevisionMock.mockReset() findReachableDirectHealthProbeMock.mockReset() @@ -107,5 +118,44 @@ describe("controller readiness bootstrap", () => { expect(prepareControllerRuntimeEnvMock).not.toHaveBeenCalled() expect(resolveApiBaseUrl()).toBe("http://api.example.test") })) + + it.effect("falls back to local bootstrap when the default local API URL is not reachable", () => + Effect.gen(function*(_) { + process.env["DOCKER_GIT_API_URL"] = "http://127.0.0.1:3334" + findReachableDirectHealthProbeMock.mockReturnValue(Effect.succeed(null)) + + const { ensureControllerReady, resolveApiBaseUrl } = yield* _( + Effect.promise(() => import("../../src/docker-git/controller.js")) + ) + + yield* _(ensureControllerReady().pipe(Effect.provide(NodeContext.layer))) + + expect(findReachableDirectHealthProbeMock).toHaveBeenCalledWith({ + cachedApiBaseUrl: undefined, + defaultLocalApiBaseUrl: "http://127.0.0.1:3334", + explicitApiBaseUrl: undefined + }) + expect(prepareLocalControllerRevisionMock).toHaveBeenCalled() + expect(prepareControllerResourceLimitEnvMock).toHaveBeenCalledTimes(1) + expect(prepareControllerRuntimeEnvMock).toHaveBeenCalledTimes(1) + expect(resolveApiBaseUrl()).toBe("http://127.0.0.1:3334") + })) + + it.effect("rejects unreachable custom explicit API URLs before local bootstrap", () => + Effect.gen(function*(_) { + process.env["DOCKER_GIT_API_URL"] = "https://api.example.test" + findReachableDirectHealthProbeMock.mockReturnValue(Effect.succeed(null)) + + const { ensureControllerReady } = yield* _( + Effect.promise(() => import("../../src/docker-git/controller.js")) + ) + const error = yield* _(ensureControllerReady().pipe(Effect.provide(NodeContext.layer), Effect.flip)) + + expect(error._tag).toBe("ControllerBootstrapError") + expect(error.message).toContain("https://api.example.test") + expect(prepareLocalControllerRevisionMock).not.toHaveBeenCalled() + expect(prepareControllerResourceLimitEnvMock).not.toHaveBeenCalled() + expect(prepareControllerRuntimeEnvMock).not.toHaveBeenCalled() + })) }) /* jscpd:ignore-end */ diff --git a/packages/app/tests/docker-git/controller.test.ts b/packages/app/tests/docker-git/controller.test.ts index 4b400eff..e8cfc128 100644 --- a/packages/app/tests/docker-git/controller.test.ts +++ b/packages/app/tests/docker-git/controller.test.ts @@ -13,7 +13,10 @@ import { parseControllerGpuMode } from "../../src/docker-git/controller-docker.js" import { resolveCurrentContainerName } from "../../src/docker-git/controller-hostname.js" -import { shouldRequireExplicitApiUrlForRemoteDocker } from "../../src/docker-git/controller-reachability.js" +import { + isDefaultLocalApiBaseUrl, + shouldRequireExplicitApiUrlForRemoteDocker +} from "../../src/docker-git/controller-reachability.js" import { parseControllerRevisionEnvOutput, parseControllerRevisionLabelOutput, @@ -58,6 +61,8 @@ const controllerSourceRevisionArbitrary = fc const controllerGpuModeArbitrary = fc.constantFrom<"none" | "all">("none", "all") const controllerBuildSkillerModeArbitrary = fc.constantFrom<"0" | "1">("0", "1") const controllerDockerRuntimeArbitrary = fc.constantFrom<"host" | "isolated">("host", "isolated") +const defaultLocalApiHostArbitrary = fc.constantFrom("127.0.0.1", "localhost", "[::1]") +const apiBaseUrlTrailingSlashesArbitrary = fc.constantFrom("", "/", "///") const dockerHostArbitrary = fc.constantFrom( undefined, "", @@ -146,6 +151,20 @@ describe("controller reachability", () => { expect(isRemoteDockerHost("ssh://docker@example.test")).toBe(true) })) + it.effect("classifies only default localhost API URLs as non-strict overrides", () => + Effect.sync(() => { + fc.assert( + fc.property(defaultLocalApiHostArbitrary, apiBaseUrlTrailingSlashesArbitrary, (host, trailingSlashes) => { + expect(isDefaultLocalApiBaseUrl(`http://${host}:3334${trailingSlashes}`, "3334")).toBe(true) + }) + ) + + expect(isDefaultLocalApiBaseUrl(makeHttpUrl("127.0.0.1", "4444"), "3334")).toBe(false) + expect(isDefaultLocalApiBaseUrl(makeHttpUrl("0.0.0.0", "3334"), "3334")).toBe(false) + expect(isDefaultLocalApiBaseUrl("https://localhost:3334", "3334")).toBe(false) + expect(isDefaultLocalApiBaseUrl(`${makeHttpUrl("localhost", "3334")}/api`, "3334")).toBe(false) + })) + it.effect("requires an explicit API URL only for non-inspectable remote Docker hosts", () => Effect.sync(() => { fc.assert( diff --git a/scripts/e2e/browser-command.sh b/scripts/e2e/browser-command.sh index 8138ddbb..ece6fe16 100755 --- a/scripts/e2e/browser-command.sh +++ b/scripts/e2e/browser-command.sh @@ -24,6 +24,7 @@ export DOCKER_GIT_PROJECTS_ROOT_VOLUME="docker-git-e2e-browser-$RUN_ID-projects" export DOCKER_GIT_API_CONTAINER_NAME="docker-git-e2e-browser-$RUN_ID-api" DOCKER_GIT_API_PORT="$(dg_require_free_port 34000 34999 "browser API")" export DOCKER_GIT_API_PORT +export DOCKER_GIT_API_URL="http://127.0.0.1:${DOCKER_GIT_API_PORT}" DOCKER_GIT_WEB_PORT="$(dg_require_free_port 41000 41999 "browser web")" export DOCKER_GIT_WEB_PORT export COMPOSE_PROJECT_NAME="docker-git-e2e-browser-$RUN_ID"