Skip to content

Commit a2eacf4

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

8 files changed

Lines changed: 328 additions & 100 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: 14 additions & 1 deletion
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"
@@ -97,6 +97,7 @@ const skillerSubmoduleCommand = [
9797
"--checkout",
9898
skillerSubmodulePath
9999
]
100+
const skillerSubmoduleInitTimeout = Duration.seconds(60)
100101

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

0 commit comments

Comments
 (0)