Skip to content

Commit 3dee2bd

Browse files
committed
fix(shell): support containerized remote Docker controller startup
1 parent 6eea5b0 commit 3dee2bd

6 files changed

Lines changed: 151 additions & 9 deletions

File tree

packages/app/src/docker-git/controller-docker.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type * as Path from "@effect/platform/Path"
55
import { Effect } from "effect"
66

77
import { composeFilesForMode, prepareControllerRevision, resolveControllerComposeFiles } from "./controller-compose.js"
8+
import { readCurrentContainerName } from "./controller-hostname.js"
89
import {
910
runCommandCaptureWithFailureOutput,
1011
runCommandExitCode,
@@ -461,7 +462,9 @@ export const inspectControllerPublishedPorts = (): Effect.Effect<string, never,
461462
)
462463

463464
export const resolveCurrentContainerNetworks = (): Effect.Effect<DockerNetworkIps, never, ControllerRuntime> =>
464-
inspectContainerNetworks(process.env["HOSTNAME"]?.trim() ?? "")
465+
readCurrentContainerName().pipe(
466+
Effect.flatMap((containerName) => inspectContainerNetworks(containerName))
467+
)
465468

466469
const connectControllerToNetworkBestEffort = (
467470
networkName: string
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import * as FileSystem from "@effect/platform/FileSystem"
2+
import { Effect } from "effect"
3+
4+
const readSystemHostname = (): Effect.Effect<string, never, FileSystem.FileSystem> =>
5+
FileSystem.FileSystem.pipe(
6+
Effect.flatMap((fs) => fs.readFileString("/etc/hostname")),
7+
Effect.map((value) => value.trim()),
8+
Effect.orElseSucceed(() => "")
9+
)
10+
11+
// CHANGE: fall back to the system hostname when HOSTNAME is not exported
12+
// WHY: containerized runtimes can have an inspectable Docker hostname without a HOSTNAME env variable
13+
// QUOTE(ТЗ): "Полностью запусти локально и проверь что всё работает"
14+
// REF: user-request-2026-05-27-pr-351-browser-e2e
15+
// SOURCE: n/a
16+
// FORMAT THEOREM: trim(envHostname) != "" -> envHostname; otherwise trim(systemHostname)
17+
// PURITY: CORE
18+
// EFFECT: n/a
19+
// INVARIANT: Docker inspection never receives an empty name when the system hostname is non-empty
20+
// COMPLEXITY: O(|envHostname| + |systemHostname|)
21+
export const resolveCurrentContainerName = (
22+
envHostname: string | undefined,
23+
systemHostname: string
24+
): string => envHostname?.trim() || systemHostname.trim()
25+
26+
export const readCurrentContainerName = (): Effect.Effect<string, never, FileSystem.FileSystem> =>
27+
readSystemHostname().pipe(
28+
Effect.map((systemHostname) => resolveCurrentContainerName(process.env["HOSTNAME"], systemHostname))
29+
)

packages/app/src/docker-git/controller-reachability.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,25 @@ export const isRemoteDockerHost = (dockerHost = process.env["DOCKER_HOST"]): boo
102102
return trimmed.startsWith("tcp://") || trimmed.startsWith("ssh://")
103103
}
104104

105+
// CHANGE: allow remote Docker bootstrap when the current runtime is inspectable on that daemon
106+
// WHY: containerized hosts often reach Docker through tcp://host.docker.internal while sharing daemon networks
107+
// QUOTE(ТЗ): "Надо проверить запускается ли сервер теперь"
108+
// REF: user-request-2026-05-27-pr-351-browser-e2e
109+
// SOURCE: n/a
110+
// FORMAT THEOREM: remote(dockerHost) ∧ noExplicitApi ∧ empty(networks) -> require_explicit_api
111+
// PURITY: CORE
112+
// EFFECT: n/a
113+
// INVARIANT: remote Docker is allowed only when network-derived controller candidates can be constructed
114+
// COMPLEXITY: O(k) where k = |currentContainerNetworks|
115+
export const shouldRequireExplicitApiUrlForRemoteDocker = (
116+
dockerHost: string | undefined,
117+
explicitApiBaseUrl: string | undefined,
118+
currentContainerNetworks: DockerNetworkIps
119+
): boolean =>
120+
isRemoteDockerHost(dockerHost) &&
121+
explicitApiBaseUrl === undefined &&
122+
Object.keys(currentContainerNetworks).length === 0
123+
105124
export const buildApiBaseUrlCandidates = ({
106125
cachedApiBaseUrl,
107126
controllerNetworks,

packages/app/src/docker-git/controller.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ import {
1919
buildApiBaseUrlCandidates,
2020
type DockerNetworkIps,
2121
formatNetworkIps,
22-
isRemoteDockerHost,
2322
resolveApiPort,
2423
resolveConfiguredApiBaseUrl,
2524
resolveExplicitApiBaseUrl,
25+
shouldRequireExplicitApiUrlForRemoteDocker,
2626
trimTrailingSlashes
2727
} from "./controller-reachability.js"
2828
import {
@@ -98,9 +98,17 @@ const waitForReachableApiBaseUrl = (
9898
})
9999
)
100100

101-
const failIfRemoteDockerWithoutApiUrl = (): Effect.Effect<void, ControllerBootstrapError> => {
101+
const failIfRemoteDockerWithoutApiUrl = (
102+
currentContainerNetworks: DockerNetworkIps
103+
): Effect.Effect<void, ControllerBootstrapError> => {
102104
const explicitApiBaseUrl = resolveExplicitApiBaseUrl()
103-
if (!isRemoteDockerHost() || explicitApiBaseUrl !== undefined) {
105+
if (
106+
!shouldRequireExplicitApiUrlForRemoteDocker(
107+
process.env["DOCKER_HOST"],
108+
explicitApiBaseUrl,
109+
currentContainerNetworks
110+
)
111+
) {
104112
return Effect.void
105113
}
106114

@@ -266,7 +274,6 @@ const startAndRememberController = (
266274
// COMPLEXITY: O(1) compose + O(k) health checks
267275
export const ensureControllerReady = (): Effect.Effect<void, ControllerBootstrapError, ControllerRuntime> =>
268276
Effect.gen(function*(_) {
269-
yield* _(failIfRemoteDockerWithoutApiUrl())
270277
const explicitApiBaseUrl = resolveExplicitApiBaseUrl()
271278
const localControllerRevision = yield* _(prepareLocalControllerRevision())
272279
const forceRecreateForResourceLimits = shouldForceRecreateForControllerResourceLimits()
@@ -300,6 +307,7 @@ export const ensureControllerReady = (): Effect.Effect<void, ControllerBootstrap
300307
}
301308

302309
const bootstrapContext = yield* _(loadControllerBootstrapContext())
310+
yield* _(failIfRemoteDockerWithoutApiUrl(bootstrapContext.currentContainerNetworks))
303311
const reusedExistingController = yield* _(reuseReachableControllerIfPossible(bootstrapContext))
304312
if (reusedExistingController) {
305313
return
@@ -309,14 +317,14 @@ export const ensureControllerReady = (): Effect.Effect<void, ControllerBootstrap
309317

310318
export const restartController = (): Effect.Effect<void, ControllerBootstrapError, ControllerRuntime> =>
311319
Effect.gen(function*(_) {
312-
yield* _(failIfRemoteDockerWithoutApiUrl())
313320
const explicitApiBaseUrl = resolveExplicitApiBaseUrl()
314321
if (explicitApiBaseUrl !== undefined) {
315322
yield* _(ensureControllerReady())
316323
return
317324
}
318325

319326
const bootstrapContext = yield* _(loadControllerBootstrapContext())
327+
yield* _(failIfRemoteDockerWithoutApiUrl(bootstrapContext.currentContainerNetworks))
320328
const forceRecreateController = true
321329
const buildController = shouldBuildControllerImage({
322330
currentControllerRevision: bootstrapContext.currentControllerRevision,

packages/app/tests/docker-git/controller.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
parseControllerBuildSkillerMode,
1212
parseControllerGpuMode
1313
} from "../../src/docker-git/controller-docker.js"
14+
import { resolveCurrentContainerName } from "../../src/docker-git/controller-hostname.js"
15+
import { shouldRequireExplicitApiUrlForRemoteDocker } from "../../src/docker-git/controller-reachability.js"
1416
import {
1517
parseControllerRevisionEnvOutput,
1618
parseControllerRevisionLabelOutput,
@@ -121,6 +123,37 @@ describe("controller reachability", () => {
121123
expect(isRemoteDockerHost("ssh://docker@example.test")).toBe(true)
122124
}))
123125

126+
it.effect("requires an explicit API URL only for non-inspectable remote Docker hosts", () =>
127+
Effect.sync(() => {
128+
expect(
129+
shouldRequireExplicitApiUrlForRemoteDocker("tcp://docker.example.test:2376", undefined, {})
130+
).toBe(true)
131+
expect(
132+
shouldRequireExplicitApiUrlForRemoteDocker(
133+
"tcp://docker.example.test:2376",
134+
makeHttpUrl("api.example.test", "3334"),
135+
{}
136+
)
137+
).toBe(false)
138+
expect(
139+
shouldRequireExplicitApiUrlForRemoteDocker(
140+
"tcp://host.docker.internal:2375",
141+
undefined,
142+
{ bridge: joinIp("172", "17", "0", "2") }
143+
)
144+
).toBe(false)
145+
expect(
146+
shouldRequireExplicitApiUrlForRemoteDocker("unix:///var/run/docker.sock", undefined, {})
147+
).toBe(false)
148+
}))
149+
150+
it.effect("resolves the current container name from HOSTNAME or OS hostname", () =>
151+
Effect.sync(() => {
152+
expect(resolveCurrentContainerName(" env-container ", "os-container")).toBe("env-container")
153+
expect(resolveCurrentContainerName("", " os-container ")).toBe("os-container")
154+
expect(resolveCurrentContainerName(undefined, " os-container ")).toBe("os-container")
155+
}))
156+
124157
it.effect("parses controller revision from container env output", () =>
125158
Effect.sync(() => {
126159
const parsed = parseControllerRevisionEnvOutput(

scripts/e2e/browser-command.sh

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ STATE_PATH="$ROOT/.orch/state/browser-frontend.json"
1717
FAILURE_DUMPED=0
1818
BROWSER_PID=""
1919
BROWSER_STARTUP_ATTEMPTS="${DOCKER_GIT_E2E_BROWSER_STARTUP_ATTEMPTS:-240}"
20+
RESOLVED_API_BASE_URL=""
2021

2122
export DOCKER_GIT_PROJECTS_ROOT="$ROOT"
2223
export DOCKER_GIT_PROJECTS_ROOT_VOLUME="docker-git-e2e-browser-$RUN_ID-projects"
@@ -129,6 +130,55 @@ wait_for_http_contains() {
129130
fail "timed out waiting for endpoint: $url"
130131
}
131132

133+
read_logged_api_base_url() {
134+
if [[ ! -f "$BROWSER_LOG" ]]; then
135+
return 1
136+
fi
137+
138+
local line=""
139+
local url=""
140+
line="$(grep -F " for API http" "$BROWSER_LOG" | tail -n 1 || true)"
141+
if [[ -z "$line" ]]; then
142+
return 1
143+
fi
144+
145+
url="${line##* for API }"
146+
url="${url%% *}"
147+
url="${url%.}"
148+
if [[ -z "$url" ]]; then
149+
return 1
150+
fi
151+
152+
printf '%s\n' "$url"
153+
}
154+
155+
wait_for_controller_health() {
156+
local attempts="${1:-90}"
157+
local local_url="http://127.0.0.1:${DOCKER_GIT_API_PORT}"
158+
local logged_url=""
159+
local body=""
160+
161+
for _ in $(seq 1 "$attempts"); do
162+
logged_url="$(read_logged_api_base_url || true)"
163+
for candidate in "$local_url" "$logged_url"; do
164+
if [[ -z "$candidate" ]]; then
165+
continue
166+
fi
167+
if body="$(curl -fsS --connect-timeout 2 --max-time 5 "${candidate}/health" 2>/dev/null)" \
168+
&& grep -Fq -- '"ok":true' <<<"$body"; then
169+
RESOLVED_API_BASE_URL="$candidate"
170+
return 0
171+
fi
172+
done
173+
if ! browser_alive; then
174+
fail "browser command exited before controller became ready: ${logged_url:-$local_url}"
175+
fi
176+
sleep 2
177+
done
178+
179+
fail "timed out waiting for controller endpoint: ${logged_url:-$local_url}"
180+
}
181+
132182
trap 'on_error $LINENO' ERR
133183
trap cleanup EXIT
134184

@@ -148,7 +198,7 @@ fi
148198
BROWSER_PID="$!"
149199

150200
wait_for_log_line "Ensuring docker-git API controller is current."
151-
wait_for_http_contains "http://127.0.0.1:${DOCKER_GIT_API_PORT}/health" '"ok":true' "$BROWSER_STARTUP_ATTEMPTS"
201+
wait_for_controller_health "$BROWSER_STARTUP_ATTEMPTS"
152202
wait_for_http_contains "http://127.0.0.1:${DOCKER_GIT_WEB_PORT}/" "<title>docker-git browser</title>" "$BROWSER_STARTUP_ATTEMPTS"
153203
wait_for_http_contains "http://127.0.0.1:${DOCKER_GIT_WEB_PORT}/api/health" '"ok":true' "$BROWSER_STARTUP_ATTEMPTS"
154204
wait_for_log_line "docker-git web runtime listening on http://"
@@ -160,7 +210,7 @@ docker ps --format '{{.Names}}' | grep -qx "$DOCKER_GIT_API_CONTAINER_NAME" \
160210

161211
grep -Fq -- "\"port\": \"$DOCKER_GIT_WEB_PORT\"" "$STATE_PATH" \
162212
|| fail "expected runtime state to record web port $DOCKER_GIT_WEB_PORT"
163-
grep -Fq -- "\"apiBaseUrl\": \"http://127.0.0.1:$DOCKER_GIT_API_PORT\"" "$STATE_PATH" \
164-
|| fail "expected runtime state to record API base URL http://127.0.0.1:$DOCKER_GIT_API_PORT"
213+
grep -Fq -- "\"apiBaseUrl\": \"$RESOLVED_API_BASE_URL\"" "$STATE_PATH" \
214+
|| fail "expected runtime state to record API base URL $RESOLVED_API_BASE_URL"
165215

166216
echo "e2e/browser-command: bun run docker-git -- browser startup verified" >&2

0 commit comments

Comments
 (0)