Skip to content

Commit ac93a7b

Browse files
committed
fix(shell): address controller runtime review comments
1 parent f753238 commit ac93a7b

5 files changed

Lines changed: 173 additions & 51 deletions

File tree

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,19 @@ export const loadControllerDockerRuntime = (): Effect.Effect<ControllerDockerRun
2626
)
2727
}
2828

29-
const isolatedOverlayFileName = (composeFileName: string): string =>
30-
composeFileName.endsWith(".yaml")
31-
? `${composeFileName.slice(0, -".yaml".length)}.isolated.yaml`
32-
: `${composeFileName.slice(0, -".yml".length)}.isolated.yml`
29+
const isolatedOverlayFileName = (composeFileName: string): Effect.Effect<string, ControllerBootstrapError> => {
30+
if (composeFileName.endsWith(".yaml")) {
31+
return Effect.succeed(`${composeFileName.slice(0, -".yaml".length)}.isolated.yaml`)
32+
}
33+
if (composeFileName.endsWith(".yml")) {
34+
return Effect.succeed(`${composeFileName.slice(0, -".yml".length)}.isolated.yml`)
35+
}
36+
return Effect.fail(
37+
controllerBootstrapError(
38+
`${controllerDockerRuntimeEnvKey}=isolated requires a .yml or .yaml compose file. Received: ${composeFileName}`
39+
)
40+
)
41+
}
3342

3443
export const resolveControllerRuntimeOverlayPath = (
3544
composePath: string,
@@ -40,9 +49,10 @@ export const resolveControllerRuntimeOverlayPath = (
4049
: Effect.gen(function*(_) {
4150
const fs = yield* _(FileSystem.FileSystem)
4251
const path = yield* _(Path.Path)
52+
const overlayFileName = yield* _(isolatedOverlayFileName(path.basename(composePath)))
4353
const runtimeOverlayPath = path.join(
4454
path.dirname(composePath),
45-
isolatedOverlayFileName(path.basename(composePath))
55+
overlayFileName
4656
)
4757
const exists = yield* _(fs.exists(runtimeOverlayPath).pipe(Effect.mapError(mapComposePathError)))
4858
return exists

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,30 @@ export const defaultIsolatedProjectDockerHost = "tcp://host.docker.internal:2375
44

55
export type ControllerDockerRuntime = "host" | "isolated"
66

7+
// CHANGE: parse and normalize the controller Docker runtime mode.
8+
// WHY: controller startup must distinguish host-backed Docker from the isolated embedded daemon fallback.
9+
// QUOTE(ТЗ): "Host-Docker-backed runtime is the intended default; isolated is opt-in fallback"
10+
// REF: packages/api/README.md
11+
// SOURCE: n/a
12+
// FORMAT THEOREM: forall raw: empty(trim(raw)) or trim(raw)=host -> host; trim(raw)=isolated -> isolated; otherwise -> null
13+
// PURITY: CORE
14+
// EFFECT: n/a
15+
// INVARIANT: parseControllerDockerRuntime is deterministic over trimmed input.
16+
// COMPLEXITY: O(1) time, O(1) space.
17+
/**
18+
* Parses the controller Docker runtime mode from an environment value.
19+
*
20+
* @param raw - Raw `DOCKER_GIT_DOCKER_RUNTIME` value.
21+
* @returns `"host"` for empty or host mode, `"isolated"` for isolated mode, or `null` for invalid input.
22+
*
23+
* @pure true
24+
* @effect n/a
25+
* @invariant Empty input and `"host"` normalize to `"host"`; `"isolated"` normalizes to `"isolated"`; all other values return `null`.
26+
* @precondition `raw` is a finite string or `undefined`.
27+
* @postcondition The result is exactly `"host"`, `"isolated"`, or `null`.
28+
* @complexity O(1) time and O(1) space.
29+
* @throws Never
30+
*/
731
export const parseControllerDockerRuntime = (raw?: string): ControllerDockerRuntime | null => {
832
const trimmed = raw?.trim() ?? ""
933
if (trimmed.length === 0 || trimmed === "host") {
@@ -12,6 +36,31 @@ export const parseControllerDockerRuntime = (raw?: string): ControllerDockerRunt
1236
return trimmed === "isolated" ? "isolated" : null
1337
}
1438

39+
// CHANGE: resolve the project-container Docker endpoint from controller runtime mode.
40+
// WHY: isolated controllers must inject a reachable embedded daemon endpoint, while host-backed mode keeps the host-socket default.
41+
// QUOTE(ТЗ): "when isolated, project containers default to tcp://host.docker.internal:2375"
42+
// REF: packages/api/README.md
43+
// SOURCE: n/a
44+
// FORMAT THEOREM: forall runtime, raw: runtime=isolated -> nonempty(trim(raw) or defaultIsolatedProjectDockerHost); runtime=host -> trim(raw) or empty
45+
// PURITY: CORE
46+
// EFFECT: n/a
47+
// INVARIANT: isolated runtime always returns a non-empty Docker host string.
48+
// COMPLEXITY: O(1) time, O(1) space.
49+
/**
50+
* Resolves the Docker host URL passed to project containers.
51+
*
52+
* @param runtime - Normalized controller runtime mode.
53+
* @param rawProjectDockerHost - Raw `DOCKER_GIT_PROJECT_DOCKER_HOST` override.
54+
* @returns The trimmed project Docker host, the isolated default endpoint, or an empty host-mode value.
55+
*
56+
* @pure true
57+
* @effect n/a
58+
* @invariant Isolated runtime returns a non-empty endpoint; host runtime returns the explicit endpoint or an empty string.
59+
* @precondition `runtime` is a valid `ControllerDockerRuntime`; `rawProjectDockerHost` is a finite string or `undefined`.
60+
* @postcondition Result is non-empty in isolated mode and preserves host-mode emptiness when no override exists.
61+
* @complexity O(1) time and O(1) space.
62+
* @throws Never
63+
*/
1564
export const resolveProjectDockerHostForRuntime = (
1665
runtime: ControllerDockerRuntime,
1766
rawProjectDockerHost?: string

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

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { describe, expect, it } from "@effect/vitest"
55
import { Effect } from "effect"
66
import * as fc from "fast-check"
77

8+
import { resolveControllerRuntimeOverlayPath } from "../../src/docker-git/controller-compose-runtime.js"
89
import {
910
controllerBuildSkillerEnvKey,
1011
controllerGpuModeEnvKey,
@@ -107,6 +108,7 @@ type PreparedRevision = {
107108
}
108109

109110
type ControllerBuildSkillerFixtureMode = "0" | "1" | undefined
111+
type ControllerDockerRuntimeEnvFixtureMode = "host" | "isolated" | undefined
110112

111113
type PrepareRevisionFixture = {
112114
readonly buildSkillerMode: ControllerBuildSkillerFixtureMode
@@ -118,6 +120,11 @@ const controllerBuildSkillerFixtureModeArbitrary = fc.constantFrom<ControllerBui
118120
"0",
119121
"1"
120122
)
123+
const controllerDockerRuntimeEnvFixtureModeArbitrary = fc.constantFrom<ControllerDockerRuntimeEnvFixtureMode>(
124+
undefined,
125+
"host",
126+
"isolated"
127+
)
121128
const prepareRevisionFixtureArbitrary: fc.Arbitrary<PrepareRevisionFixture> = fc
122129
.record({
123130
buildSkillerMode: controllerBuildSkillerFixtureModeArbitrary,
@@ -126,18 +133,27 @@ const prepareRevisionFixtureArbitrary: fc.Arbitrary<PrepareRevisionFixture> = fc
126133
.filter(({ buildSkillerMode, includeSkillerPackage }) => buildSkillerMode === "0" || includeSkillerPackage)
127134
const controllerRevisionPattern = /^[a-f0-9]{16}-host-none-skiller[01]$/u
128135

136+
const withMinimalControllerRoot = <A, E, R>(
137+
effect: (rootDir: string) => Effect.Effect<A, E, R>
138+
) =>
139+
Effect.scoped(
140+
Effect.gen(function*(_) {
141+
const rootDir = yield* _(temporaryControllerRoot)
142+
yield* _(writeMinimalCompose(rootDir))
143+
yield* _(withWorkingDirectory(rootDir))
144+
return yield* _(effect(rootDir))
145+
})
146+
)
147+
129148
const prepareRevisionInTemporaryRoot = ({
130149
buildSkillerMode,
131150
includeSkillerPackage
132151
}: PrepareRevisionFixture) =>
133-
Effect.scoped(
152+
withMinimalControllerRoot((rootDir) =>
134153
Effect.gen(function*(_) {
135-
const rootDir = yield* _(temporaryControllerRoot)
136-
yield* _(writeMinimalCompose(rootDir))
137154
if (includeSkillerPackage) {
138155
yield* _(writeSkillerPackage(rootDir))
139156
}
140-
yield* _(withWorkingDirectory(rootDir))
141157
yield* _(
142158
withControllerEnv([
143159
[controllerBuildSkillerEnvKey, buildSkillerMode],
@@ -165,6 +181,23 @@ const expectPreparedRevisionInvariants = (fixture: PrepareRevisionFixture, prepa
165181
expect(prepared.revision.endsWith(expectedSkillerSuffixForMode(fixture.buildSkillerMode))).toBe(true)
166182
}
167183

184+
const resolveComposeFilesInTemporaryRoot = (
185+
dockerRuntimeMode: ControllerDockerRuntimeEnvFixtureMode
186+
) =>
187+
withMinimalControllerRoot((rootDir) =>
188+
Effect.gen(function*(_) {
189+
yield* _(writeMinimalIsolatedCompose(rootDir))
190+
yield* _(
191+
withControllerEnv([
192+
[controllerBuildSkillerEnvKey, "0"],
193+
[controllerDockerRuntimeEnvKey, dockerRuntimeMode],
194+
[controllerGpuModeEnvKey, undefined]
195+
])
196+
)
197+
return yield* _(resolveControllerComposeFiles())
198+
})
199+
).pipe(Effect.provide(NodeContext.layer))
200+
168201
const assertControllerComposeProperty = <PropertyArgs>(property: fc.IAsyncProperty<PropertyArgs>) =>
169202
Effect.tryPromise({
170203
catch: (cause) => cause,
@@ -217,32 +250,38 @@ describe("controller compose preparation", () => {
217250
})
218251
).pipe(Effect.provide(NodeContext.layer)))
219252

220-
/* jscpd:ignore-start */
221253
it.effect("adds the isolated runtime overlay only for isolated controller mode", () =>
254+
assertControllerComposeProperty(
255+
fc.asyncProperty(controllerDockerRuntimeEnvFixtureModeArbitrary, (dockerRuntimeMode) =>
256+
Effect.runPromise(
257+
resolveComposeFilesInTemporaryRoot(dockerRuntimeMode).pipe(
258+
Effect.tap((files) =>
259+
Effect.sync(() => {
260+
if (dockerRuntimeMode === "isolated") {
261+
expect(files.runtimeOverlayPath).toBeDefined()
262+
expect(files.runtimeOverlayPath?.endsWith("docker-compose.isolated.yml")).toBe(true)
263+
return
264+
}
265+
expect(files.runtimeOverlayPath).toBeNull()
266+
})
267+
),
268+
Effect.asVoid
269+
)
270+
))
271+
))
272+
273+
it.effect("rejects unsupported compose filename extensions for isolated controller mode", () =>
222274
Effect.scoped(
223275
Effect.gen(function*(_) {
276+
const path = yield* _(Path.Path)
224277
const rootDir = yield* _(temporaryControllerRoot)
225-
yield* _(writeMinimalCompose(rootDir))
226-
yield* _(writeMinimalIsolatedCompose(rootDir))
227-
yield* _(withWorkingDirectory(rootDir))
228-
229-
yield* _(
230-
withControllerEnv([
231-
[controllerBuildSkillerEnvKey, "0"],
232-
[controllerDockerRuntimeEnvKey, "host"],
233-
[controllerGpuModeEnvKey, undefined]
234-
])
278+
const error = yield* _(
279+
resolveControllerRuntimeOverlayPath(path.join(rootDir, "docker-compose.json"), "isolated").pipe(Effect.flip)
235280
)
236-
const hostFiles = yield* _(resolveControllerComposeFiles())
237-
expect(hostFiles.runtimeOverlayPath).toBeNull()
238-
239-
yield* _(withControllerEnv([[controllerDockerRuntimeEnvKey, "isolated"]]))
240-
const isolatedFiles = yield* _(resolveControllerComposeFiles())
241-
expect(isolatedFiles.runtimeOverlayPath).toBeDefined()
242-
expect(isolatedFiles.runtimeOverlayPath?.endsWith("docker-compose.isolated.yml")).toBe(true)
281+
expect(error._tag).toBe("ControllerBootstrapError")
282+
expect(error.message).toContain(".yml or .yaml")
243283
})
244284
).pipe(Effect.provide(NodeContext.layer)))
245-
/* jscpd:ignore-end */
246285

247286
it.effect("prepares and persists host controller revisions for Skiller build modes", () =>
248287
assertControllerComposeProperty(

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

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,26 @@ const controllerSourceRevisionArbitrary = fc
5858
const controllerGpuModeArbitrary = fc.constantFrom<"none" | "all">("none", "all")
5959
const controllerBuildSkillerModeArbitrary = fc.constantFrom<"0" | "1">("0", "1")
6060
const controllerDockerRuntimeArbitrary = fc.constantFrom<"host" | "isolated">("host", "isolated")
61+
const dockerHostArbitrary = fc.constantFrom<string | undefined>(
62+
undefined,
63+
"",
64+
" unix:///var/run/docker.sock ",
65+
"tcp://docker.example.test:2376",
66+
" ssh://docker@example.test "
67+
)
68+
const explicitApiBaseUrlArbitrary = fc.option(fc.webUrl(), { nil: undefined })
69+
const dockerNetworkNameArbitrary = fc
70+
.string({ maxLength: 16, minLength: 1 })
71+
.filter((value) => value.trim().length > 0)
72+
const dockerNetworkIpArbitrary = fc
73+
.tuple(
74+
fc.integer({ max: 255, min: 1 }),
75+
fc.integer({ max: 255, min: 0 }),
76+
fc.integer({ max: 255, min: 0 }),
77+
fc.integer({ max: 254, min: 1 })
78+
)
79+
.map((octets) => joinIp(...octets.map(String)))
80+
const dockerNetworkIpsArbitrary = fc.dictionary(dockerNetworkNameArbitrary, dockerNetworkIpArbitrary)
6181

6282
describe("controller reachability", () => {
6383
it.effect("builds direct API candidates without Docker inspection", () =>
@@ -128,33 +148,37 @@ describe("controller reachability", () => {
128148

129149
it.effect("requires an explicit API URL only for non-inspectable remote Docker hosts", () =>
130150
Effect.sync(() => {
131-
expect(
132-
shouldRequireExplicitApiUrlForRemoteDocker("tcp://docker.example.test:2376", undefined, {})
133-
).toBe(true)
134-
expect(
135-
shouldRequireExplicitApiUrlForRemoteDocker(
136-
"tcp://docker.example.test:2376",
137-
makeHttpUrl("api.example.test", "3334"),
138-
{}
139-
)
140-
).toBe(false)
141-
expect(
142-
shouldRequireExplicitApiUrlForRemoteDocker(
143-
"tcp://host.docker.internal:2375",
144-
undefined,
145-
{ bridge: joinIp("172", "17", "0", "2") }
151+
fc.assert(
152+
fc.property(
153+
dockerHostArbitrary,
154+
explicitApiBaseUrlArbitrary,
155+
dockerNetworkIpsArbitrary,
156+
(dockerHost, explicitApiBaseUrl, currentContainerNetworks) => {
157+
const expected = isRemoteDockerHost(dockerHost) &&
158+
explicitApiBaseUrl === undefined &&
159+
Object.keys(currentContainerNetworks).length === 0
160+
161+
expect(
162+
shouldRequireExplicitApiUrlForRemoteDocker(dockerHost, explicitApiBaseUrl, currentContainerNetworks)
163+
).toBe(expected)
164+
}
146165
)
147-
).toBe(false)
148-
expect(
149-
shouldRequireExplicitApiUrlForRemoteDocker("unix:///var/run/docker.sock", undefined, {})
150-
).toBe(false)
166+
)
151167
}))
152168

153169
it.effect("resolves the current container name from HOSTNAME or OS hostname", () =>
154170
Effect.sync(() => {
155-
expect(resolveCurrentContainerName(" env-container ", "os-container")).toBe("env-container")
156-
expect(resolveCurrentContainerName("", " os-container ")).toBe("os-container")
157-
expect(resolveCurrentContainerName(undefined, " os-container ")).toBe("os-container")
171+
fc.assert(
172+
fc.property(
173+
fc.option(fc.string(), { nil: undefined }),
174+
fc.string(),
175+
(envHostname, systemHostname) => {
176+
expect(resolveCurrentContainerName(envHostname, systemHostname)).toBe(
177+
envHostname?.trim() || systemHostname.trim()
178+
)
179+
}
180+
)
181+
)
158182
}))
159183

160184
it.effect("parses controller revision from container env output", () =>

scripts/e2e/browser-command.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ wait_for_controller_health() {
160160

161161
for _ in $(seq 1 "$attempts"); do
162162
logged_url="$(read_logged_api_base_url || true)"
163-
for candidate in "$local_url" "$logged_url"; do
163+
for candidate in "$logged_url" "$local_url"; do
164164
if [[ -z "$candidate" ]]; then
165165
continue
166166
fi

0 commit comments

Comments
 (0)