Skip to content

Commit faf0058

Browse files
committed
test(shell): cover controller compose preparation
1 parent 09ca1dd commit faf0058

8 files changed

Lines changed: 355 additions & 102 deletions

File tree

docker-compose.api.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ services:
3737
- docker_git_projects:${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git}
3838
- docker_git_docker_data:/var/lib/docker
3939
- /var/run/docker.sock:/var/run/docker.sock
40-
privileged: true
40+
privileged: ${DOCKER_GIT_CONTROLLER_PRIVILEGED:-false}
4141
cgroup: host
4242
init: true
4343
restart: unless-stopped

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ services:
3737
- docker_git_projects:${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git}
3838
- docker_git_docker_data:/var/lib/docker
3939
- /var/run/docker.sock:/var/run/docker.sock
40-
privileged: true
40+
privileged: ${DOCKER_GIT_CONTROLLER_PRIVILEGED:-false}
4141
cgroup: host
4242
init: true
4343
restart: unless-stopped

packages/api/README.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,18 @@ This is now the intended controller plane:
1111

1212
## Runtime contract: host-Docker-backed
1313

14-
`docker-git` is host-Docker-backed, not isolated. The controller container
15-
created from this package binds the host socket
14+
`docker-git` is host-Docker-backed by default. The primary controller
15+
container created from this package binds the host socket
1616
(`/var/run/docker.sock:/var/run/docker.sock`, see `docker-compose.yml`) and
17-
uses it to spawn per-project containers. There is no Docker-in-Docker
18-
runtime; the daemon is always the host's daemon.
17+
uses it to spawn per-project containers. `DOCKER_GIT_DOCKER_RUNTIME=isolated`
18+
is an opt-in fallback for environments that explicitly require an embedded
19+
controller daemon.
20+
21+
Security note: binding `/var/run/docker.sock` gives the controller container
22+
root-equivalent control over the host Docker daemon, including the ability to
23+
create containers and mount host paths. This is an intended trade-off for the
24+
host-backed architecture; run the controller only in trusted environments and
25+
review the threat model before exposing the API.
1926

2027
The host CLI (`packages/app`) also talks to that same daemon directly when
2128
it bootstraps the controller. Three failure modes look identical at first
@@ -61,8 +68,9 @@ Optional env:
6168

6269
- `DOCKER_GIT_API_BIND_HOST` (default: `127.0.0.1`)
6370
- `DOCKER_GIT_API_PORT` (default: `3334`)
64-
- `DOCKER_GIT_DOCKER_RUNTIME` (default: `host`; set to `isolated` to use an embedded controller daemon)
71+
- `DOCKER_GIT_DOCKER_RUNTIME` (default: `host`; set to `isolated` as an optional fallback to use an embedded controller daemon)
6572
- `DOCKER_GIT_CONTROLLER_DOCKER_HOST` (default: `unix:///var/run/docker.sock`; socket path inside the controller)
73+
- `DOCKER_GIT_CONTROLLER_PRIVILEGED` (default: `false`; set to `true` only when using `DOCKER_GIT_DOCKER_RUNTIME=isolated`)
6674
- `DOCKER_GIT_DOCKERD_TCP_HOST` (default: `tcp://0.0.0.0:2375`; reachable only inside Docker networks unless explicitly published)
6775
- `DOCKER_GIT_DOCKERD_DEFAULT_CGROUPNS_MODE` (default: `host`; keeps nested project containers compatible with cgroup v2 DinD)
6876
- `DOCKER_GIT_PROJECT_DOCKER_HOST` (default: empty; unset uses host socket in project containers when mounted)

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type * as CommandExecutor from "@effect/platform/CommandExecutor"
22
import type { PlatformError } from "@effect/platform/Error"
33
import * as FileSystem from "@effect/platform/FileSystem"
44
import * as Path from "@effect/platform/Path"
5-
import { Effect } from "effect"
5+
import { Duration, Effect } from "effect"
66

77
import { computeLocalControllerRevision, controllerRevisionEnvKey } from "./controller-revision.js"
88
import { runCommandWithCapturedOutput } from "./frontend-lib/shell/command-runner.js"
@@ -87,6 +87,9 @@ const composeFilePath = (): Effect.Effect<string, PlatformError, FileSystem.File
8787
const mapComposePathError = (error: PlatformError): ControllerBootstrapError =>
8888
controllerBootstrapError(`Failed to resolve docker-compose.yml path.\nDetails: ${String(error)}`)
8989

90+
const mapSkillerPathError = (error: PlatformError): ControllerBootstrapError =>
91+
controllerBootstrapError(`Failed to check Skiller submodule path.\nDetails: ${String(error)}`)
92+
9093
const mapControllerRevisionError = (error: PlatformError): ControllerBootstrapError =>
9194
controllerBootstrapError(`Failed to compute docker-git controller revision.\nDetails: ${String(error)}`)
9295

@@ -97,6 +100,7 @@ const skillerSubmoduleCommand = [
97100
"--checkout",
98101
skillerSubmodulePath
99102
]
103+
const skillerSubmoduleInitTimeout = Duration.seconds(60)
100104

101105
const formatSkillerSubmoduleFailure = (rootDir: string, exitCode: number, output: string): ControllerBootstrapError =>
102106
controllerBootstrapError(
@@ -121,6 +125,18 @@ const runSkillerSubmoduleInit = (
121125
[0],
122126
(exitCode, output) => formatSkillerSubmoduleFailure(rootDir, exitCode, output)
123127
).pipe(
128+
Effect.timeoutFail({
129+
duration: skillerSubmoduleInitTimeout,
130+
onTimeout: () =>
131+
controllerBootstrapError(
132+
[
133+
"Timed out while initializing Skiller submodule before building docker-git controller.",
134+
`Command: git ${skillerSubmoduleCommand.join(" ")}`,
135+
`Working directory: ${rootDir}`,
136+
`Timeout: ${Duration.toSeconds(skillerSubmoduleInitTimeout)} seconds`
137+
].join("\n")
138+
)
139+
}),
124140
Effect.mapError((error): ControllerBootstrapError =>
125141
error._tag === "ControllerBootstrapError"
126142
? error
@@ -147,15 +163,15 @@ export const ensureSkillerSubmoduleInitialized = (
147163
const fs = yield* _(FileSystem.FileSystem)
148164
const path = yield* _(Path.Path)
149165
const packagePath = path.join(rootDir, skillerPackagePath)
150-
const existsBeforeInit = yield* _(fs.exists(packagePath).pipe(Effect.mapError(mapComposePathError)))
166+
const existsBeforeInit = yield* _(fs.exists(packagePath).pipe(Effect.mapError(mapSkillerPathError)))
151167
if (existsBeforeInit) {
152168
return
153169
}
154170

155171
yield* _(Effect.log("Initializing Skiller submodule for docker-git controller build."))
156172
yield* _(runSkillerSubmoduleInit(rootDir))
157173

158-
const existsAfterInit = yield* _(fs.exists(packagePath).pipe(Effect.mapError(mapComposePathError)))
174+
const existsAfterInit = yield* _(fs.exists(packagePath).pipe(Effect.mapError(mapSkillerPathError)))
159175
if (existsAfterInit) {
160176
return
161177
}
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import { NodeContext } from "@effect/platform-node"
2+
import * as FileSystem from "@effect/platform/FileSystem"
3+
import * as Path from "@effect/platform/Path"
4+
import { describe, expect, it } from "@effect/vitest"
5+
import { Effect } from "effect"
6+
import * as fc from "fast-check"
7+
8+
import {
9+
controllerBuildSkillerEnvKey,
10+
controllerGpuModeEnvKey,
11+
ensureSkillerSubmoduleInitialized,
12+
prepareControllerRevision
13+
} from "../../src/docker-git/controller-compose.js"
14+
import { controllerRevisionEnvKey } from "../../src/docker-git/controller-revision.js"
15+
import type { TestCommandResult } from "./fixtures/command-executor.js"
16+
import { commandExecutorLayer, emptyCommandResult } from "./fixtures/command-executor.js"
17+
18+
const expectedSkillerSubmoduleCommand =
19+
"git submodule update --init --checkout third_party/skiller-desktop-skills-manager"
20+
const skillerPackageRelativePath = "third_party/skiller-desktop-skills-manager/package.json"
21+
22+
const recordedCommandExecutorLayer = (
23+
startedCommands: Array<string>,
24+
result: TestCommandResult
25+
) =>
26+
commandExecutorLayer((command) => {
27+
startedCommands.push([command.command, ...command.args].join(" "))
28+
return result
29+
})
30+
31+
const temporaryControllerRoot = Effect.gen(function*(_) {
32+
const fs = yield* _(FileSystem.FileSystem)
33+
return yield* _(fs.makeTempDirectoryScoped({ prefix: "docker-git-controller-compose-" }))
34+
})
35+
36+
const writeRootFile = (
37+
rootDir: string,
38+
relativePath: string,
39+
contents: string
40+
) =>
41+
Effect.all({
42+
fs: FileSystem.FileSystem,
43+
path: Path.Path
44+
}).pipe(
45+
Effect.flatMap(({ fs, path }) => {
46+
const absolutePath = path.join(rootDir, relativePath)
47+
return fs.makeDirectory(path.dirname(absolutePath), { recursive: true }).pipe(
48+
Effect.zipRight(fs.writeFileString(absolutePath, contents))
49+
)
50+
})
51+
)
52+
53+
const writeMinimalCompose = (rootDir: string) =>
54+
writeRootFile(rootDir, "docker-compose.yml", "services:\n api:\n image: docker-git-api\n")
55+
56+
const writeSkillerPackage = (rootDir: string) =>
57+
writeRootFile(rootDir, skillerPackageRelativePath, "{\"name\":\"skiller-desktop-skills-manager\"}\n")
58+
59+
const withWorkingDirectory = (nextCwd: string) =>
60+
Effect.acquireRelease(
61+
Effect.sync(() => {
62+
const previousCwd = process.cwd()
63+
process.chdir(nextCwd)
64+
return previousCwd
65+
}),
66+
(previousCwd) =>
67+
Effect.sync(() => {
68+
process.chdir(previousCwd)
69+
})
70+
)
71+
72+
const setOptionalEnv = (key: string, value: string | undefined): void => {
73+
if (value === undefined) {
74+
Reflect.deleteProperty(process.env, key)
75+
return
76+
}
77+
process.env[key] = value
78+
}
79+
80+
const withControllerEnv = (entries: ReadonlyArray<readonly [string, string | undefined]>) =>
81+
Effect.acquireRelease(
82+
Effect.sync(() => {
83+
const previousEntries: Array<readonly [string, string | undefined]> = entries.map(([
84+
key
85+
]) => [key, process.env[key]])
86+
for (const [key, value] of entries) {
87+
setOptionalEnv(key, value)
88+
}
89+
return previousEntries
90+
}),
91+
(previousEntries) =>
92+
Effect.sync(() => {
93+
for (const [key, value] of previousEntries) {
94+
setOptionalEnv(key, value)
95+
}
96+
})
97+
)
98+
99+
type PreparedRevision = {
100+
readonly persistedRevision: string | undefined
101+
readonly revision: string
102+
}
103+
104+
type ControllerBuildSkillerFixtureMode = "0" | "1" | undefined
105+
106+
type PrepareRevisionFixture = {
107+
readonly buildSkillerMode: ControllerBuildSkillerFixtureMode
108+
readonly includeSkillerPackage: boolean
109+
}
110+
111+
const controllerBuildSkillerFixtureModeArbitrary = fc.constantFrom<ControllerBuildSkillerFixtureMode>(
112+
undefined,
113+
"0",
114+
"1"
115+
)
116+
const prepareRevisionFixtureArbitrary: fc.Arbitrary<PrepareRevisionFixture> = fc
117+
.record({
118+
buildSkillerMode: controllerBuildSkillerFixtureModeArbitrary,
119+
includeSkillerPackage: fc.boolean()
120+
})
121+
.filter(({ buildSkillerMode, includeSkillerPackage }) => buildSkillerMode === "0" || includeSkillerPackage)
122+
const controllerRevisionPattern = /^[a-f0-9]{16}-none-skiller[01]$/u
123+
124+
const prepareRevisionInTemporaryRoot = ({
125+
buildSkillerMode,
126+
includeSkillerPackage
127+
}: PrepareRevisionFixture) =>
128+
Effect.scoped(
129+
Effect.gen(function*(_) {
130+
const rootDir = yield* _(temporaryControllerRoot)
131+
yield* _(writeMinimalCompose(rootDir))
132+
if (includeSkillerPackage) {
133+
yield* _(writeSkillerPackage(rootDir))
134+
}
135+
yield* _(withWorkingDirectory(rootDir))
136+
yield* _(
137+
withControllerEnv([
138+
[controllerBuildSkillerEnvKey, buildSkillerMode],
139+
[controllerGpuModeEnvKey, undefined],
140+
[controllerRevisionEnvKey, undefined]
141+
])
142+
)
143+
144+
const revision = yield* _(prepareControllerRevision())
145+
return { persistedRevision: process.env[controllerRevisionEnvKey], revision }
146+
})
147+
).pipe(Effect.provide(NodeContext.layer))
148+
149+
const expectPreparedRevision = (prepared: PreparedRevision, pattern: RegExp): void => {
150+
expect(prepared.revision).toMatch(pattern)
151+
expect(prepared.persistedRevision).toBe(prepared.revision)
152+
}
153+
154+
const expectedSkillerSuffixForMode = (buildSkillerMode: ControllerBuildSkillerFixtureMode): string =>
155+
buildSkillerMode === "0" ? "skiller0" : "skiller1"
156+
157+
const expectPreparedRevisionInvariants = (fixture: PrepareRevisionFixture, prepared: PreparedRevision): void => {
158+
expectPreparedRevision(prepared, controllerRevisionPattern)
159+
expect(prepared.revision.endsWith(expectedSkillerSuffixForMode(fixture.buildSkillerMode))).toBe(true)
160+
}
161+
162+
const assertControllerComposeProperty = <PropertyArgs>(property: fc.IAsyncProperty<PropertyArgs>) =>
163+
Effect.tryPromise({
164+
catch: (cause) => cause,
165+
try: () => fc.assert(property, { numRuns: 25 })
166+
})
167+
168+
describe("controller compose preparation", () => {
169+
it.effect("does not initialize the Skiller submodule when package metadata already exists", () => {
170+
const startedCommands: Array<string> = []
171+
172+
return Effect.scoped(
173+
temporaryControllerRoot.pipe(
174+
Effect.tap(writeSkillerPackage),
175+
Effect.flatMap((rootDir) =>
176+
ensureSkillerSubmoduleInitialized(rootDir).pipe(
177+
Effect.provide(recordedCommandExecutorLayer(startedCommands, emptyCommandResult))
178+
)
179+
),
180+
Effect.tap(() =>
181+
Effect.sync(() => {
182+
expect(startedCommands).toEqual([])
183+
})
184+
)
185+
)
186+
).pipe(Effect.provide(NodeContext.layer))
187+
})
188+
189+
it.effect("reports a typed failure when submodule initialization cannot provide package metadata", () =>
190+
Effect.scoped(
191+
Effect.gen(function*(_) {
192+
const rootDir = yield* _(temporaryControllerRoot)
193+
const startedCommands: Array<string> = []
194+
195+
const error = yield* _(
196+
ensureSkillerSubmoduleInitialized(rootDir).pipe(
197+
Effect.provide(
198+
recordedCommandExecutorLayer(
199+
startedCommands,
200+
{ exitCode: 128, stderr: "fatal: no submodule mapping found", stdout: "" }
201+
)
202+
),
203+
Effect.provide(NodeContext.layer),
204+
Effect.flip
205+
)
206+
)
207+
208+
expect(error._tag).toBe("ControllerBootstrapError")
209+
expect(error.message).toContain(expectedSkillerSubmoduleCommand)
210+
expect(startedCommands).toEqual([expectedSkillerSubmoduleCommand])
211+
})
212+
).pipe(Effect.provide(NodeContext.layer)))
213+
214+
it.effect("prepares and persists host controller revisions for Skiller build modes", () =>
215+
assertControllerComposeProperty(
216+
fc.asyncProperty(prepareRevisionFixtureArbitrary, (fixture) =>
217+
Effect.runPromise(
218+
prepareRevisionInTemporaryRoot(fixture).pipe(
219+
Effect.tap((prepared) =>
220+
Effect.sync(() => {
221+
expectPreparedRevisionInvariants(fixture, prepared)
222+
})
223+
),
224+
Effect.asVoid
225+
)
226+
))
227+
))
228+
})

0 commit comments

Comments
 (0)