Skip to content

Commit cabdf6d

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

8 files changed

Lines changed: 292 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: 7 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,8 +62,9 @@ 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)
67+
- `DOCKER_GIT_CONTROLLER_PRIVILEGED` (default: `false`; set to `true` only when using `DOCKER_GIT_DOCKER_RUNTIME=isolated`)
6668
- `DOCKER_GIT_DOCKERD_TCP_HOST` (default: `tcp://0.0.0.0:2375`; reachable only inside Docker networks unless explicitly published)
6769
- `DOCKER_GIT_DOCKERD_DEFAULT_CGROUPNS_MODE` (default: `host`; keeps nested project containers compatible with cgroup v2 DinD)
6870
- `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: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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+
7+
import {
8+
controllerBuildSkillerEnvKey,
9+
controllerGpuModeEnvKey,
10+
ensureSkillerSubmoduleInitialized,
11+
prepareControllerRevision
12+
} from "../../src/docker-git/controller-compose.js"
13+
import { controllerRevisionEnvKey } from "../../src/docker-git/controller-revision.js"
14+
import { commandExecutorLayer } from "./fixtures/command-executor.js"
15+
16+
const expectedSkillerSubmoduleCommand =
17+
"git submodule update --init --checkout third_party/skiller-desktop-skills-manager"
18+
const skillerPackageRelativePath = "third_party/skiller-desktop-skills-manager/package.json"
19+
20+
const temporaryControllerRoot = Effect.gen(function*(_) {
21+
const fs = yield* _(FileSystem.FileSystem)
22+
return yield* _(fs.makeTempDirectoryScoped({ prefix: "docker-git-controller-compose-" }))
23+
})
24+
25+
const writeRootFile = (
26+
rootDir: string,
27+
relativePath: string,
28+
contents: string
29+
) =>
30+
Effect.all({
31+
fs: FileSystem.FileSystem,
32+
path: Path.Path
33+
}).pipe(
34+
Effect.flatMap(({ fs, path }) => {
35+
const absolutePath = path.join(rootDir, relativePath)
36+
return fs.makeDirectory(path.dirname(absolutePath), { recursive: true }).pipe(
37+
Effect.zipRight(fs.writeFileString(absolutePath, contents))
38+
)
39+
})
40+
)
41+
42+
const writeMinimalCompose = (rootDir: string) =>
43+
writeRootFile(rootDir, "docker-compose.yml", "services:\n api:\n image: docker-git-api\n")
44+
45+
const writeSkillerPackage = (rootDir: string) =>
46+
writeRootFile(rootDir, skillerPackageRelativePath, "{\"name\":\"skiller-desktop-skills-manager\"}\n")
47+
48+
const withWorkingDirectory = (nextCwd: string) =>
49+
Effect.acquireRelease(
50+
Effect.sync(() => {
51+
const previousCwd = process.cwd()
52+
process.chdir(nextCwd)
53+
return previousCwd
54+
}),
55+
(previousCwd) =>
56+
Effect.sync(() => {
57+
process.chdir(previousCwd)
58+
})
59+
)
60+
61+
const setOptionalEnv = (key: string, value: string | undefined): void => {
62+
if (value === undefined) {
63+
Reflect.deleteProperty(process.env, key)
64+
return
65+
}
66+
process.env[key] = value
67+
}
68+
69+
const withControllerEnv = (entries: ReadonlyArray<readonly [string, string | undefined]>) =>
70+
Effect.acquireRelease(
71+
Effect.sync(() => {
72+
const previousEntries: Array<readonly [string, string | undefined]> = entries.map(([
73+
key
74+
]) => [key, process.env[key]])
75+
for (const [key, value] of entries) {
76+
setOptionalEnv(key, value)
77+
}
78+
return previousEntries
79+
}),
80+
(previousEntries) =>
81+
Effect.sync(() => {
82+
for (const [key, value] of previousEntries) {
83+
setOptionalEnv(key, value)
84+
}
85+
})
86+
)
87+
88+
type PreparedRevision = {
89+
readonly persistedRevision: string | undefined
90+
readonly revision: string
91+
}
92+
93+
type PrepareRevisionFixture = {
94+
readonly buildSkillerMode: string | undefined
95+
readonly includeSkillerPackage: boolean
96+
}
97+
98+
const prepareRevisionInTemporaryRoot = ({
99+
buildSkillerMode,
100+
includeSkillerPackage
101+
}: PrepareRevisionFixture) =>
102+
Effect.scoped(
103+
Effect.gen(function*(_) {
104+
const rootDir = yield* _(temporaryControllerRoot)
105+
yield* _(writeMinimalCompose(rootDir))
106+
if (includeSkillerPackage) {
107+
yield* _(writeSkillerPackage(rootDir))
108+
}
109+
yield* _(withWorkingDirectory(rootDir))
110+
yield* _(
111+
withControllerEnv([
112+
[controllerBuildSkillerEnvKey, buildSkillerMode],
113+
[controllerGpuModeEnvKey, undefined],
114+
[controllerRevisionEnvKey, undefined]
115+
])
116+
)
117+
118+
const revision = yield* _(prepareControllerRevision())
119+
return { persistedRevision: process.env[controllerRevisionEnvKey], revision }
120+
})
121+
).pipe(Effect.provide(NodeContext.layer))
122+
123+
const expectPreparedRevision = (prepared: PreparedRevision, pattern: RegExp): void => {
124+
expect(prepared.revision).toMatch(pattern)
125+
expect(prepared.persistedRevision).toBe(prepared.revision)
126+
}
127+
128+
describe("controller compose preparation", () => {
129+
it.effect("does not initialize the Skiller submodule when package metadata already exists", () =>
130+
Effect.scoped(
131+
Effect.gen(function*(_) {
132+
const rootDir = yield* _(temporaryControllerRoot)
133+
yield* _(writeSkillerPackage(rootDir))
134+
135+
yield* _(ensureSkillerSubmoduleInitialized(rootDir))
136+
})
137+
).pipe(Effect.provide(NodeContext.layer)))
138+
139+
it.effect("reports a typed failure when submodule initialization cannot provide package metadata", () =>
140+
Effect.scoped(
141+
Effect.gen(function*(_) {
142+
const rootDir = yield* _(temporaryControllerRoot)
143+
const startedCommands: Array<string> = []
144+
145+
const error = yield* _(
146+
ensureSkillerSubmoduleInitialized(rootDir).pipe(
147+
Effect.provide(commandExecutorLayer((command) => {
148+
startedCommands.push([command.command, ...command.args].join(" "))
149+
return { exitCode: 128, stderr: "fatal: no submodule mapping found", stdout: "" }
150+
})),
151+
Effect.provide(NodeContext.layer),
152+
Effect.flip
153+
)
154+
)
155+
156+
expect(error._tag).toBe("ControllerBootstrapError")
157+
expect(error.message).toContain(expectedSkillerSubmoduleCommand)
158+
expect(startedCommands).toEqual([expectedSkillerSubmoduleCommand])
159+
})
160+
).pipe(Effect.provide(NodeContext.layer)))
161+
162+
it.effect("prepares and persists host controller revisions for Skiller build modes", () =>
163+
Effect.gen(function*(_) {
164+
const enabled = yield* _(prepareRevisionInTemporaryRoot({
165+
buildSkillerMode: undefined,
166+
includeSkillerPackage: true
167+
}))
168+
const disabled = yield* _(prepareRevisionInTemporaryRoot({
169+
buildSkillerMode: "0",
170+
includeSkillerPackage: false
171+
}))
172+
173+
expectPreparedRevision(enabled, /^[a-f0-9]{16}-none-skiller1$/u)
174+
expectPreparedRevision(disabled, /^[a-f0-9]{16}-none-skiller0$/u)
175+
}))
176+
})

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

Lines changed: 7 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,18 @@
1-
import * as Command from "@effect/platform/Command"
2-
import * as CommandExecutor from "@effect/platform/CommandExecutor"
31
import * as FileSystem from "@effect/platform/FileSystem"
42
import * as Path from "@effect/platform/Path"
53
import { describe, expect, it } from "@effect/vitest"
6-
import { Effect, Either, Layer } from "effect"
7-
import * as Inspectable from "effect/Inspectable"
8-
import * as Sink from "effect/Sink"
9-
import * as Stream from "effect/Stream"
4+
import { Effect, Either } from "effect"
105
import * as fc from "fast-check"
116

127
import { inspectControllerImageRevision } from "../../src/docker-git/controller-image-revision.js"
138
import type { ControllerBootstrapError } from "../../src/docker-git/host-errors.js"
9+
import {
10+
commandExecutorLayer,
11+
emptyCommandResult,
12+
type TestCommandHandler,
13+
type TestCommandResult
14+
} from "./fixtures/command-executor.js"
1415

15-
type TestCommandResult = {
16-
readonly exitCode: number
17-
readonly stderr: string
18-
readonly stdout: string
19-
}
20-
21-
const emptyCommandResult: TestCommandResult = {
22-
exitCode: 0,
23-
stderr: "",
24-
stdout: ""
25-
}
2616
const composeImageLineArbitrary = fc
2717
.string({ minLength: 1 })
2818
.filter((value) => value.trim().length > 0 && !value.includes("\n") && !value.includes("\r"))
@@ -33,81 +23,6 @@ const nonReusableComposeImagesOutputArbitrary = fc.oneof(
3323
)
3424
)
3525

36-
const encodeText = (value: string): Uint8Array => new TextEncoder().encode(value)
37-
38-
const textStream = (value: string) => value.length === 0 ? Stream.empty : Stream.succeed(encodeText(value))
39-
40-
/**
41-
* Builds a completed process for controller image revision shell tests.
42-
*
43-
* @param result - Command result emitted by the fake process.
44-
* @returns A completed Effect platform process.
45-
* @pure true
46-
* @effect none
47-
* @invariant The process is already stopped and its exit code is deterministic.
48-
* @precondition `result.stdout` and `result.stderr` are finite strings.
49-
* @postcondition Consumers observe exactly the provided stdout, stderr, and exit code.
50-
* @complexity O(n) time and O(n) space where n = |stdout| + |stderr|.
51-
* @throws Never
52-
*/
53-
// CHANGE: model Docker CLI process output without touching the host Docker daemon
54-
// WHY: image revision fallback invariants must be unit-testable without external services
55-
// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать"
56-
// REF: CodeRabbit PR #344 review 4349211730
57-
// SOURCE: n/a
58-
// FORMAT THEOREM: process(result).stdout = result.stdout and process(result).exit = result.exitCode
59-
// PURITY: CORE
60-
// EFFECT: none
61-
// INVARIANT: fake process is not running after construction
62-
// COMPLEXITY: O(n)
63-
const completedProcess = (result: TestCommandResult): CommandExecutor.Process => ({
64-
[CommandExecutor.ProcessTypeId]: CommandExecutor.ProcessTypeId,
65-
[Inspectable.NodeInspectSymbol]: () => ({ _tag: "TestProcess" }),
66-
exitCode: Effect.succeed(CommandExecutor.ExitCode(result.exitCode)),
67-
isRunning: Effect.succeed(false),
68-
kill: () => Effect.void,
69-
pid: CommandExecutor.ProcessId(0),
70-
stderr: textStream(result.stderr),
71-
stdin: Sink.drain,
72-
stdout: textStream(result.stdout),
73-
toJSON: () => ({ _tag: "TestProcess" }),
74-
toString: () => "TestProcess"
75-
})
76-
77-
type TestCommandHandler = (command: Command.StandardCommand) => TestCommandResult
78-
79-
/**
80-
* Creates a command-executor layer backed by a pure command handler.
81-
*
82-
* @param handler - Total handler for standard commands.
83-
* @returns Layer providing CommandExecutor.
84-
* @pure true
85-
* @effect none
86-
* @invariant Every started command maps to exactly one completed fake process.
87-
* @precondition The handler is total for all commands issued by the test subject.
88-
* @postcondition Command effects never reach the real operating system.
89-
* @complexity O(1) layer construction.
90-
* @throws Never
91-
*/
92-
// CHANGE: provide typed Effect dependency injection for Docker command tests
93-
// WHY: controller image revision inspection is a shell effect and must be tested through its service boundary
94-
// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать"
95-
// REF: CodeRabbit PR #344 review 4349211730
96-
// SOURCE: n/a
97-
// FORMAT THEOREM: start(command) = completedProcess(handler(command))
98-
// PURITY: SHELL
99-
// EFFECT: Layer<CommandExecutor>
100-
// INVARIANT: no command escapes the fake executor
101-
// COMPLEXITY: O(1)
102-
const commandExecutorLayer = (handler: TestCommandHandler) =>
103-
Layer.succeed(
104-
CommandExecutor.CommandExecutor,
105-
CommandExecutor.makeExecutor((command) => {
106-
const standardCommand = Command.flatten(command)[0]
107-
return Effect.succeed(completedProcess(handler(standardCommand)))
108-
})
109-
)
110-
11126
/**
11227
* Runs image revision inspection with a controlled command handler.
11328
*

0 commit comments

Comments
 (0)