Skip to content

Commit fefcc47

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

2 files changed

Lines changed: 169 additions & 5 deletions

File tree

packages/api/README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@ 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.
1920

2021
The host CLI (`packages/app`) also talks to that same daemon directly when
2122
it bootstraps the controller. Three failure modes look identical at first
@@ -61,7 +62,7 @@ Optional env:
6162

6263
- `DOCKER_GIT_API_BIND_HOST` (default: `127.0.0.1`)
6364
- `DOCKER_GIT_API_PORT` (default: `3334`)
64-
- `DOCKER_GIT_DOCKER_RUNTIME` (default: `host`; set to `isolated` to use an embedded controller daemon)
65+
- `DOCKER_GIT_DOCKER_RUNTIME` (default: `host`; set to `isolated` as an optional fallback to use an embedded controller daemon)
6566
- `DOCKER_GIT_CONTROLLER_DOCKER_HOST` (default: `unix:///var/run/docker.sock`; socket path inside the controller)
6667
- `DOCKER_GIT_DOCKERD_TCP_HOST` (default: `tcp://0.0.0.0:2375`; reachable only inside Docker networks unless explicitly published)
6768
- `DOCKER_GIT_DOCKERD_DEFAULT_CGROUPNS_MODE` (default: `host`; keeps nested project containers compatible with cgroup v2 DinD)
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { NodeContext } from "@effect/platform-node"
2+
import { describe, expect, it } from "@effect/vitest"
3+
import { Effect } from "effect"
4+
import * as fs from "node:fs"
5+
import * as os from "node:os"
6+
import path from "node:path"
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 { ControllerBootstrapError } from "../../src/docker-git/host-errors.js"
16+
17+
const expectedSkillerSubmoduleCommand =
18+
"git submodule update --init --checkout third_party/skiller-desktop-skills-manager"
19+
const skillerPackageRelativePath = path.join("third_party", "skiller-desktop-skills-manager", "package.json")
20+
21+
const temporaryControllerRoot = Effect.acquireRelease(
22+
Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "docker-git-controller-compose-"))),
23+
(rootDir) =>
24+
Effect.sync(() => {
25+
fs.rmSync(rootDir, { force: true, recursive: true })
26+
})
27+
)
28+
29+
const writeRootFile = (rootDir: string, relativePath: string, contents: string): Effect.Effect<void> =>
30+
Effect.sync(() => {
31+
const absolutePath = path.join(rootDir, relativePath)
32+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true })
33+
fs.writeFileSync(absolutePath, contents, "utf8")
34+
})
35+
36+
const writeMinimalCompose = (rootDir: string): Effect.Effect<void> =>
37+
writeRootFile(rootDir, "docker-compose.yml", "services:\n api:\n image: docker-git-api\n")
38+
39+
const writeSkillerPackage = (rootDir: string): Effect.Effect<void> =>
40+
writeRootFile(rootDir, skillerPackageRelativePath, "{\"name\":\"skiller-desktop-skills-manager\"}\n")
41+
42+
const withWorkingDirectory = (nextCwd: string) =>
43+
Effect.acquireRelease(
44+
Effect.sync(() => {
45+
const previousCwd = process.cwd()
46+
process.chdir(nextCwd)
47+
return previousCwd
48+
}),
49+
(previousCwd) =>
50+
Effect.sync(() => {
51+
process.chdir(previousCwd)
52+
})
53+
)
54+
55+
const setOptionalEnv = (key: string, value: string | undefined): void => {
56+
if (value === undefined) {
57+
Reflect.deleteProperty(process.env, key)
58+
return
59+
}
60+
process.env[key] = value
61+
}
62+
63+
const withControllerEnv = (entries: ReadonlyArray<readonly [string, string | undefined]>) =>
64+
Effect.acquireRelease(
65+
Effect.sync(() => {
66+
const previousEntries: Array<readonly [string, string | undefined]> = entries.map(([
67+
key
68+
]) => [key, process.env[key]])
69+
for (const [key, value] of entries) {
70+
setOptionalEnv(key, value)
71+
}
72+
return previousEntries
73+
}),
74+
(previousEntries) =>
75+
Effect.sync(() => {
76+
for (const [key, value] of previousEntries) {
77+
setOptionalEnv(key, value)
78+
}
79+
})
80+
)
81+
82+
type PreparedRevision = {
83+
readonly persistedRevision: string | undefined
84+
readonly revision: string
85+
}
86+
87+
type PrepareRevisionFixture = {
88+
readonly buildSkillerMode: string | undefined
89+
readonly includeSkillerPackage: boolean
90+
}
91+
92+
const prepareRevisionInTemporaryRoot = ({
93+
buildSkillerMode,
94+
includeSkillerPackage
95+
}: PrepareRevisionFixture): Effect.Effect<PreparedRevision, ControllerBootstrapError> =>
96+
Effect.scoped(
97+
Effect.gen(function*(_) {
98+
const rootDir = yield* _(temporaryControllerRoot)
99+
yield* _(writeMinimalCompose(rootDir))
100+
if (includeSkillerPackage) {
101+
yield* _(writeSkillerPackage(rootDir))
102+
}
103+
yield* _(withWorkingDirectory(rootDir))
104+
yield* _(
105+
withControllerEnv([
106+
[controllerBuildSkillerEnvKey, buildSkillerMode],
107+
[controllerGpuModeEnvKey, undefined],
108+
[controllerRevisionEnvKey, undefined]
109+
])
110+
)
111+
112+
const revision = yield* _(prepareControllerRevision().pipe(Effect.provide(NodeContext.layer)))
113+
return { persistedRevision: process.env[controllerRevisionEnvKey], revision }
114+
})
115+
)
116+
117+
const expectPreparedRevision = (prepared: PreparedRevision, pattern: RegExp): void => {
118+
expect(prepared.revision).toMatch(pattern)
119+
expect(prepared.persistedRevision).toBe(prepared.revision)
120+
}
121+
122+
describe("controller compose preparation", () => {
123+
it.effect("does not initialize the Skiller submodule when package metadata already exists", () =>
124+
Effect.scoped(
125+
Effect.gen(function*(_) {
126+
const rootDir = yield* _(temporaryControllerRoot)
127+
yield* _(writeSkillerPackage(rootDir))
128+
129+
yield* _(ensureSkillerSubmoduleInitialized(rootDir).pipe(Effect.provide(NodeContext.layer)))
130+
})
131+
))
132+
133+
it.effect("reports a typed failure when submodule initialization cannot provide package metadata", () =>
134+
Effect.scoped(
135+
Effect.gen(function*(_) {
136+
const rootDir = yield* _(temporaryControllerRoot)
137+
138+
const error = yield* _(
139+
ensureSkillerSubmoduleInitialized(rootDir).pipe(
140+
Effect.provide(NodeContext.layer),
141+
Effect.flip
142+
)
143+
)
144+
145+
expect(error.message).toContain(expectedSkillerSubmoduleCommand)
146+
})
147+
))
148+
149+
it.effect("prepares and persists host controller revisions for Skiller build modes", () =>
150+
Effect.gen(function*(_) {
151+
const enabled = yield* _(prepareRevisionInTemporaryRoot({
152+
buildSkillerMode: undefined,
153+
includeSkillerPackage: true
154+
}))
155+
const disabled = yield* _(prepareRevisionInTemporaryRoot({
156+
buildSkillerMode: "0",
157+
includeSkillerPackage: false
158+
}))
159+
160+
expectPreparedRevision(enabled, /^[a-f0-9]{16}-none-skiller1$/u)
161+
expectPreparedRevision(disabled, /^[a-f0-9]{16}-none-skiller0$/u)
162+
}))
163+
})

0 commit comments

Comments
 (0)