diff --git a/docker-compose.api.yml b/docker-compose.api.yml index 78d29a25..ee1c6af6 100644 --- a/docker-compose.api.yml +++ b/docker-compose.api.yml @@ -11,11 +11,11 @@ services: environment: DOCKER_GIT_API_PORT: ${DOCKER_GIT_API_PORT:-3334} DOCKER_GIT_CONTROLLER_REV: ${DOCKER_GIT_CONTROLLER_REV:-unknown} - DOCKER_GIT_DOCKER_RUNTIME: ${DOCKER_GIT_DOCKER_RUNTIME:-isolated} + DOCKER_GIT_DOCKER_RUNTIME: ${DOCKER_GIT_DOCKER_RUNTIME:-host} DOCKER_HOST: ${DOCKER_GIT_CONTROLLER_DOCKER_HOST:-unix:///var/run/docker.sock} DOCKER_GIT_DOCKERD_TCP_HOST: ${DOCKER_GIT_DOCKERD_TCP_HOST:-tcp://0.0.0.0:2375} DOCKER_GIT_DOCKERD_DEFAULT_CGROUPNS_MODE: ${DOCKER_GIT_DOCKERD_DEFAULT_CGROUPNS_MODE:-host} - DOCKER_GIT_PROJECT_DOCKER_HOST: ${DOCKER_GIT_PROJECT_DOCKER_HOST:-tcp://host.docker.internal:2375} + DOCKER_GIT_PROJECT_DOCKER_HOST: ${DOCKER_GIT_PROJECT_DOCKER_HOST:-} DOCKER_GIT_PROJECT_SSH_BIND_HOST: ${DOCKER_GIT_PROJECT_SSH_BIND_HOST:-0.0.0.0} DOCKER_GIT_PROJECTS_ROOT: ${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git} DOCKER_GIT_PROJECTS_ROOT_VOLUME: ${DOCKER_GIT_PROJECTS_ROOT_VOLUME:-docker-git-projects} @@ -36,7 +36,8 @@ services: volumes: - docker_git_projects:${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git} - docker_git_docker_data:/var/lib/docker - privileged: true + - /var/run/docker.sock:/var/run/docker.sock + privileged: ${DOCKER_GIT_CONTROLLER_PRIVILEGED:-false} cgroup: host init: true restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index 78d29a25..ee1c6af6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,11 +11,11 @@ services: environment: DOCKER_GIT_API_PORT: ${DOCKER_GIT_API_PORT:-3334} DOCKER_GIT_CONTROLLER_REV: ${DOCKER_GIT_CONTROLLER_REV:-unknown} - DOCKER_GIT_DOCKER_RUNTIME: ${DOCKER_GIT_DOCKER_RUNTIME:-isolated} + DOCKER_GIT_DOCKER_RUNTIME: ${DOCKER_GIT_DOCKER_RUNTIME:-host} DOCKER_HOST: ${DOCKER_GIT_CONTROLLER_DOCKER_HOST:-unix:///var/run/docker.sock} DOCKER_GIT_DOCKERD_TCP_HOST: ${DOCKER_GIT_DOCKERD_TCP_HOST:-tcp://0.0.0.0:2375} DOCKER_GIT_DOCKERD_DEFAULT_CGROUPNS_MODE: ${DOCKER_GIT_DOCKERD_DEFAULT_CGROUPNS_MODE:-host} - DOCKER_GIT_PROJECT_DOCKER_HOST: ${DOCKER_GIT_PROJECT_DOCKER_HOST:-tcp://host.docker.internal:2375} + DOCKER_GIT_PROJECT_DOCKER_HOST: ${DOCKER_GIT_PROJECT_DOCKER_HOST:-} DOCKER_GIT_PROJECT_SSH_BIND_HOST: ${DOCKER_GIT_PROJECT_SSH_BIND_HOST:-0.0.0.0} DOCKER_GIT_PROJECTS_ROOT: ${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git} DOCKER_GIT_PROJECTS_ROOT_VOLUME: ${DOCKER_GIT_PROJECTS_ROOT_VOLUME:-docker-git-projects} @@ -36,7 +36,8 @@ services: volumes: - docker_git_projects:${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git} - docker_git_docker_data:/var/lib/docker - privileged: true + - /var/run/docker.sock:/var/run/docker.sock + privileged: ${DOCKER_GIT_CONTROLLER_PRIVILEGED:-false} cgroup: host init: true restart: unless-stopped diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile index 3383cff0..74af1b7d 100644 --- a/packages/api/Dockerfile +++ b/packages/api/Dockerfile @@ -150,7 +150,7 @@ LABEL io.prover-coder-ai.docker-git.controller-build-skiller=$DOCKER_GIT_CONTROL ENV DOCKER_GIT_CONTROLLER_REV=$DOCKER_GIT_CONTROLLER_REV ENV DOCKER_GIT_CONTROLLER_BUILD_SKILLER=$DOCKER_GIT_CONTROLLER_BUILD_SKILLER ENV DOCKER_GIT_API_PORT=3334 -ENV DOCKER_GIT_DOCKER_RUNTIME=isolated +ENV DOCKER_GIT_DOCKER_RUNTIME=host ENV DOCKER_HOST=unix:///var/run/docker.sock EXPOSE 3334 diff --git a/packages/api/README.md b/packages/api/README.md index b28122a5..7f97ea89 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -5,17 +5,24 @@ HTTP API for docker-git orchestration (projects, agents, logs/events, federation This is now the intended controller plane: - the API runs inside `docker-git-api` - `.docker-git` state lives in the Docker volume `docker-git-projects` -- the API starts an isolated Docker daemon inside the controller by default -- child project containers no longer depend on host bind mounts for bootstrap auth/env -- the host `/var/run/docker.sock` is not mounted into the controller or project containers +- the API uses the host Docker daemon by default via `/var/run/docker.sock` +- child project containers use host-backed Docker unless an explicit + `DOCKER_GIT_PROJECT_DOCKER_HOST` is provided ## Runtime contract: host-Docker-backed -`docker-git` is host-Docker-backed, not isolated. The controller container -created from this package binds the host socket +`docker-git` is host-Docker-backed by default. The primary controller +container created from this package binds the host socket (`/var/run/docker.sock:/var/run/docker.sock`, see `docker-compose.yml`) and -uses it to spawn per-project containers. There is no Docker-in-Docker -runtime; the daemon is always the host's daemon. +uses it to spawn per-project containers. `DOCKER_GIT_DOCKER_RUNTIME=isolated` +is an opt-in fallback for environments that explicitly require an embedded +controller daemon. + +Security note: binding `/var/run/docker.sock` gives the controller container +root-equivalent control over the host Docker daemon, including the ability to +create containers and mount host paths. This is an intended trade-off for the +host-backed architecture; run the controller only in trusted environments and +review the threat model before exposing the API. The host CLI (`packages/app`) also talks to that same daemon directly when it bootstraps the controller. Three failure modes look identical at first @@ -61,12 +68,13 @@ Optional env: - `DOCKER_GIT_API_BIND_HOST` (default: `127.0.0.1`) - `DOCKER_GIT_API_PORT` (default: `3334`) -- `DOCKER_GIT_DOCKER_RUNTIME` (default: `isolated`; starts a managed Docker daemon in `docker-git-api`) +- `DOCKER_GIT_DOCKER_RUNTIME` (default: `host`; set to `isolated` as an optional fallback to use an embedded controller daemon) - `DOCKER_GIT_CONTROLLER_DOCKER_HOST` (default: `unix:///var/run/docker.sock`; socket path inside the controller) +- `DOCKER_GIT_CONTROLLER_PRIVILEGED` (default: `false`; set to `true` only when using `DOCKER_GIT_DOCKER_RUNTIME=isolated`) - `DOCKER_GIT_DOCKERD_TCP_HOST` (default: `tcp://0.0.0.0:2375`; reachable only inside Docker networks unless explicitly published) - `DOCKER_GIT_DOCKERD_DEFAULT_CGROUPNS_MODE` (default: `host`; keeps nested project containers compatible with cgroup v2 DinD) -- `DOCKER_GIT_PROJECT_DOCKER_HOST` (default: `tcp://host.docker.internal:2375`; lets project containers use the isolated daemon) -- `DOCKER_GIT_PROJECT_SSH_BIND_HOST` (default: `0.0.0.0` in controller mode; project SSH binds inside the isolated controller runtime) +- `DOCKER_GIT_PROJECT_DOCKER_HOST` (default: empty; unset uses host socket in project containers when mounted) +- `DOCKER_GIT_PROJECT_SSH_BIND_HOST` (default: `0.0.0.0`) - `DOCKER_GIT_PROJECTS_ROOT` (container path, default: `/home/dev/.docker-git`) - `DOCKER_GIT_PROJECTS_ROOT_VOLUME` (Docker volume name for controller state, default: `docker-git-projects`) - `DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN` (optional public ActivityPub origin) diff --git a/packages/api/scripts/start-controller.sh b/packages/api/scripts/start-controller.sh index 5e2b2884..4397f227 100644 --- a/packages/api/scripts/start-controller.sh +++ b/packages/api/scripts/start-controller.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -runtime="${DOCKER_GIT_DOCKER_RUNTIME:-isolated}" +runtime="${DOCKER_GIT_DOCKER_RUNTIME:-host}" docker_host="${DOCKER_HOST:-unix:///var/run/docker.sock}" dockerd_pid="" diff --git a/packages/app/src/docker-git/controller-compose.ts b/packages/app/src/docker-git/controller-compose.ts index 44fdd23a..5b477e63 100644 --- a/packages/app/src/docker-git/controller-compose.ts +++ b/packages/app/src/docker-git/controller-compose.ts @@ -1,9 +1,11 @@ +import type * as CommandExecutor from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" -import { Effect } from "effect" +import { Duration, Effect } from "effect" import { computeLocalControllerRevision, controllerRevisionEnvKey } from "./controller-revision.js" +import { runCommandWithCapturedOutput } from "./frontend-lib/shell/command-runner.js" import { findExistingUpwards } from "./frontend-lib/usecases/path-helpers.js" import type { ControllerBootstrapError } from "./host-errors.js" @@ -18,6 +20,9 @@ export type ControllerComposeFiles = { readonly gpuOverlayPath: string | null } +const skillerSubmodulePath = "third_party/skiller-desktop-skills-manager" +const skillerPackagePath = `${skillerSubmodulePath}/package.json` + const controllerBootstrapError = (message: string): ControllerBootstrapError => ({ _tag: "ControllerBootstrapError", message @@ -82,9 +87,104 @@ const composeFilePath = (): Effect.Effect controllerBootstrapError(`Failed to resolve docker-compose.yml path.\nDetails: ${String(error)}`) +const mapSkillerPathError = (error: PlatformError): ControllerBootstrapError => + controllerBootstrapError(`Failed to check Skiller submodule path.\nDetails: ${String(error)}`) + const mapControllerRevisionError = (error: PlatformError): ControllerBootstrapError => controllerBootstrapError(`Failed to compute docker-git controller revision.\nDetails: ${String(error)}`) +const skillerSubmoduleCommand = [ + "submodule", + "update", + "--init", + "--checkout", + skillerSubmodulePath +] +const skillerSubmoduleInitTimeout = Duration.seconds(60) + +const formatSkillerSubmoduleFailure = (rootDir: string, exitCode: number, output: string): ControllerBootstrapError => + controllerBootstrapError( + [ + "Failed to initialize Skiller submodule before building docker-git controller.", + `Command: git ${skillerSubmoduleCommand.join(" ")}`, + `Working directory: ${rootDir}`, + `Exit code: ${exitCode}`, + output.trim().length > 0 ? `Output:\n${output.trim()}` : "Output: n/a" + ].join("\n") + ) + +const runSkillerSubmoduleInit = ( + rootDir: string +): Effect.Effect => + runCommandWithCapturedOutput( + { + cwd: rootDir, + command: "git", + args: skillerSubmoduleCommand + }, + [0], + (exitCode, output) => formatSkillerSubmoduleFailure(rootDir, exitCode, output) + ).pipe( + Effect.timeoutFail({ + duration: skillerSubmoduleInitTimeout, + onTimeout: () => + controllerBootstrapError( + [ + "Timed out while initializing Skiller submodule before building docker-git controller.", + `Command: git ${skillerSubmoduleCommand.join(" ")}`, + `Working directory: ${rootDir}`, + `Timeout: ${Duration.toSeconds(skillerSubmoduleInitTimeout)} seconds` + ].join("\n") + ) + }), + Effect.mapError((error): ControllerBootstrapError => + error._tag === "ControllerBootstrapError" + ? error + : controllerBootstrapError( + `Failed to initialize Skiller submodule before building docker-git controller.\nDetails: ${String(error)}` + ) + ) + ) + +// CHANGE: initialize the pinned Skiller submodule before controller Docker builds +// WHY: the API image copies `third_party`, so an empty submodule makes the patch/build step fail +// QUOTE(ТЗ): "исправь проблему" +// REF: user-message-2026-05-24-controller-skiller-submodule +// SOURCE: n/a +// FORMAT THEOREM: forall root: missing(root/skillerPackagePath) -> init(root) -> exists(root/skillerPackagePath) or typed error +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: controller revision and Docker build context are computed only after Skiller source exists +// COMPLEXITY: O(1) filesystem probes plus O(git submodule update) +export const ensureSkillerSubmoduleInitialized = ( + rootDir: string +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const packagePath = path.join(rootDir, skillerPackagePath) + const existsBeforeInit = yield* _(fs.exists(packagePath).pipe(Effect.mapError(mapSkillerPathError))) + if (existsBeforeInit) { + return + } + + yield* _(Effect.log("Initializing Skiller submodule for docker-git controller build.")) + yield* _(runSkillerSubmoduleInit(rootDir)) + + const existsAfterInit = yield* _(fs.exists(packagePath).pipe(Effect.mapError(mapSkillerPathError))) + if (existsAfterInit) { + return + } + + return yield* _( + Effect.fail( + controllerBootstrapError( + `Skiller submodule initialization completed but ${packagePath} was not found.` + ) + ) + ) + }) + export const composeFilesForMode = ( composePath: string, gpuOverlayPath: string | null @@ -126,13 +226,13 @@ type ComposePathAndGpuMode = { readonly buildSkillerMode: ControllerBuildSkillerMode } -const withComposePathAndGpuMode = ( +const withComposePathAndGpuMode = ( effect: (input: ComposePathAndGpuMode) => Effect.Effect< A, ControllerBootstrapError, - FileSystem.FileSystem | Path.Path + R > -): Effect.Effect => +): Effect.Effect => composeFilePath().pipe( Effect.mapError(mapComposePathError), Effect.flatMap((composePath) => @@ -170,8 +270,16 @@ const persistControllerRevision = (revision: string): Effect.Effect => export const prepareControllerRevision = (): Effect.Effect< string, ControllerBootstrapError, - FileSystem.FileSystem | Path.Path + FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor > => withComposePathAndGpuMode(({ buildSkillerMode, composePath, gpuMode }) => - computeControllerRevision(composePath, gpuMode, buildSkillerMode) - ).pipe(Effect.tap((revision) => persistControllerRevision(revision))) + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + if (buildSkillerMode === "1") { + yield* _(ensureSkillerSubmoduleInitialized(path.dirname(composePath))) + } + return yield* _(computeControllerRevision(composePath, gpuMode, buildSkillerMode)) + }) + ).pipe( + Effect.tap((revision) => persistControllerRevision(revision)) + ) diff --git a/packages/app/src/docker-git/controller-revision.ts b/packages/app/src/docker-git/controller-revision.ts index 0a566965..fc6f9e59 100644 --- a/packages/app/src/docker-git/controller-revision.ts +++ b/packages/app/src/docker-git/controller-revision.ts @@ -9,6 +9,7 @@ const controllerRevisionInputs: ReadonlyArray = [ "docker-compose.yml", "docker-compose.api.yml", "docker-compose.gpu.yml", + ".gitmodules", "package.json", "bun.lock", "bunfig.toml", diff --git a/packages/app/tests/docker-git/controller-compose.test.ts b/packages/app/tests/docker-git/controller-compose.test.ts new file mode 100644 index 00000000..6a04a221 --- /dev/null +++ b/packages/app/tests/docker-git/controller-compose.test.ts @@ -0,0 +1,228 @@ +import { NodeContext } from "@effect/platform-node" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import * as fc from "fast-check" + +import { + controllerBuildSkillerEnvKey, + controllerGpuModeEnvKey, + ensureSkillerSubmoduleInitialized, + prepareControllerRevision +} from "../../src/docker-git/controller-compose.js" +import { controllerRevisionEnvKey } from "../../src/docker-git/controller-revision.js" +import type { TestCommandResult } from "./fixtures/command-executor.js" +import { commandExecutorLayer, emptyCommandResult } from "./fixtures/command-executor.js" + +const expectedSkillerSubmoduleCommand = + "git submodule update --init --checkout third_party/skiller-desktop-skills-manager" +const skillerPackageRelativePath = "third_party/skiller-desktop-skills-manager/package.json" + +const recordedCommandExecutorLayer = ( + startedCommands: Array, + result: TestCommandResult +) => + commandExecutorLayer((command) => { + startedCommands.push([command.command, ...command.args].join(" ")) + return result + }) + +const temporaryControllerRoot = Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + return yield* _(fs.makeTempDirectoryScoped({ prefix: "docker-git-controller-compose-" })) +}) + +const writeRootFile = ( + rootDir: string, + relativePath: string, + contents: string +) => + Effect.all({ + fs: FileSystem.FileSystem, + path: Path.Path + }).pipe( + Effect.flatMap(({ fs, path }) => { + const absolutePath = path.join(rootDir, relativePath) + return fs.makeDirectory(path.dirname(absolutePath), { recursive: true }).pipe( + Effect.zipRight(fs.writeFileString(absolutePath, contents)) + ) + }) + ) + +const writeMinimalCompose = (rootDir: string) => + writeRootFile(rootDir, "docker-compose.yml", "services:\n api:\n image: docker-git-api\n") + +const writeSkillerPackage = (rootDir: string) => + writeRootFile(rootDir, skillerPackageRelativePath, "{\"name\":\"skiller-desktop-skills-manager\"}\n") + +const withWorkingDirectory = (nextCwd: string) => + Effect.acquireRelease( + Effect.sync(() => { + const previousCwd = process.cwd() + process.chdir(nextCwd) + return previousCwd + }), + (previousCwd) => + Effect.sync(() => { + process.chdir(previousCwd) + }) + ) + +const setOptionalEnv = (key: string, value: string | undefined): void => { + if (value === undefined) { + Reflect.deleteProperty(process.env, key) + return + } + process.env[key] = value +} + +const withControllerEnv = (entries: ReadonlyArray) => + Effect.acquireRelease( + Effect.sync(() => { + const previousEntries: Array = entries.map(([ + key + ]) => [key, process.env[key]]) + for (const [key, value] of entries) { + setOptionalEnv(key, value) + } + return previousEntries + }), + (previousEntries) => + Effect.sync(() => { + for (const [key, value] of previousEntries) { + setOptionalEnv(key, value) + } + }) + ) + +type PreparedRevision = { + readonly persistedRevision: string | undefined + readonly revision: string +} + +type ControllerBuildSkillerFixtureMode = "0" | "1" | undefined + +type PrepareRevisionFixture = { + readonly buildSkillerMode: ControllerBuildSkillerFixtureMode + readonly includeSkillerPackage: boolean +} + +const controllerBuildSkillerFixtureModeArbitrary = fc.constantFrom( + undefined, + "0", + "1" +) +const prepareRevisionFixtureArbitrary: fc.Arbitrary = fc + .record({ + buildSkillerMode: controllerBuildSkillerFixtureModeArbitrary, + includeSkillerPackage: fc.boolean() + }) + .filter(({ buildSkillerMode, includeSkillerPackage }) => buildSkillerMode === "0" || includeSkillerPackage) +const controllerRevisionPattern = /^[a-f0-9]{16}-none-skiller[01]$/u + +const prepareRevisionInTemporaryRoot = ({ + buildSkillerMode, + includeSkillerPackage +}: PrepareRevisionFixture) => + Effect.scoped( + Effect.gen(function*(_) { + const rootDir = yield* _(temporaryControllerRoot) + yield* _(writeMinimalCompose(rootDir)) + if (includeSkillerPackage) { + yield* _(writeSkillerPackage(rootDir)) + } + yield* _(withWorkingDirectory(rootDir)) + yield* _( + withControllerEnv([ + [controllerBuildSkillerEnvKey, buildSkillerMode], + [controllerGpuModeEnvKey, undefined], + [controllerRevisionEnvKey, undefined] + ]) + ) + + const revision = yield* _(prepareControllerRevision()) + return { persistedRevision: process.env[controllerRevisionEnvKey], revision } + }) + ).pipe(Effect.provide(NodeContext.layer)) + +const expectPreparedRevision = (prepared: PreparedRevision, pattern: RegExp): void => { + expect(prepared.revision).toMatch(pattern) + expect(prepared.persistedRevision).toBe(prepared.revision) +} + +const expectedSkillerSuffixForMode = (buildSkillerMode: ControllerBuildSkillerFixtureMode): string => + buildSkillerMode === "0" ? "skiller0" : "skiller1" + +const expectPreparedRevisionInvariants = (fixture: PrepareRevisionFixture, prepared: PreparedRevision): void => { + expectPreparedRevision(prepared, controllerRevisionPattern) + expect(prepared.revision.endsWith(expectedSkillerSuffixForMode(fixture.buildSkillerMode))).toBe(true) +} + +const assertControllerComposeProperty = (property: fc.IAsyncProperty) => + Effect.tryPromise({ + catch: (cause) => cause, + try: () => fc.assert(property, { numRuns: 25 }) + }) + +describe("controller compose preparation", () => { + it.effect("does not initialize the Skiller submodule when package metadata already exists", () => { + const startedCommands: Array = [] + + return Effect.scoped( + temporaryControllerRoot.pipe( + Effect.tap(writeSkillerPackage), + Effect.flatMap((rootDir) => + ensureSkillerSubmoduleInitialized(rootDir).pipe( + Effect.provide(recordedCommandExecutorLayer(startedCommands, emptyCommandResult)) + ) + ), + Effect.tap(() => + Effect.sync(() => { + expect(startedCommands).toEqual([]) + }) + ) + ) + ).pipe(Effect.provide(NodeContext.layer)) + }) + + it.effect("reports a typed failure when submodule initialization cannot provide package metadata", () => + Effect.scoped( + Effect.gen(function*(_) { + const rootDir = yield* _(temporaryControllerRoot) + const startedCommands: Array = [] + + const error = yield* _( + ensureSkillerSubmoduleInitialized(rootDir).pipe( + Effect.provide( + recordedCommandExecutorLayer( + startedCommands, + { exitCode: 128, stderr: "fatal: no submodule mapping found", stdout: "" } + ) + ), + Effect.provide(NodeContext.layer), + Effect.flip + ) + ) + + expect(error._tag).toBe("ControllerBootstrapError") + expect(error.message).toContain(expectedSkillerSubmoduleCommand) + expect(startedCommands).toEqual([expectedSkillerSubmoduleCommand]) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("prepares and persists host controller revisions for Skiller build modes", () => + assertControllerComposeProperty( + fc.asyncProperty(prepareRevisionFixtureArbitrary, (fixture) => + Effect.runPromise( + prepareRevisionInTemporaryRoot(fixture).pipe( + Effect.tap((prepared) => + Effect.sync(() => { + expectPreparedRevisionInvariants(fixture, prepared) + }) + ), + Effect.asVoid + ) + )) + )) +}) diff --git a/packages/app/tests/docker-git/controller-image-revision.test.ts b/packages/app/tests/docker-git/controller-image-revision.test.ts index 8c66bc1e..5cb082ea 100644 --- a/packages/app/tests/docker-git/controller-image-revision.test.ts +++ b/packages/app/tests/docker-git/controller-image-revision.test.ts @@ -1,28 +1,18 @@ -import * as Command from "@effect/platform/Command" -import * as CommandExecutor from "@effect/platform/CommandExecutor" import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { describe, expect, it } from "@effect/vitest" -import { Effect, Either, Layer } from "effect" -import * as Inspectable from "effect/Inspectable" -import * as Sink from "effect/Sink" -import * as Stream from "effect/Stream" +import { Effect, Either } from "effect" import * as fc from "fast-check" import { inspectControllerImageRevision } from "../../src/docker-git/controller-image-revision.js" import type { ControllerBootstrapError } from "../../src/docker-git/host-errors.js" +import { + commandExecutorLayer, + emptyCommandResult, + type TestCommandHandler, + type TestCommandResult +} from "./fixtures/command-executor.js" -type TestCommandResult = { - readonly exitCode: number - readonly stderr: string - readonly stdout: string -} - -const emptyCommandResult: TestCommandResult = { - exitCode: 0, - stderr: "", - stdout: "" -} const composeImageLineArbitrary = fc .string({ minLength: 1 }) .filter((value) => value.trim().length > 0 && !value.includes("\n") && !value.includes("\r")) @@ -33,81 +23,6 @@ const nonReusableComposeImagesOutputArbitrary = fc.oneof( ) ) -const encodeText = (value: string): Uint8Array => new TextEncoder().encode(value) - -const textStream = (value: string) => value.length === 0 ? Stream.empty : Stream.succeed(encodeText(value)) - -/** - * Builds a completed process for controller image revision shell tests. - * - * @param result - Command result emitted by the fake process. - * @returns A completed Effect platform process. - * @pure true - * @effect none - * @invariant The process is already stopped and its exit code is deterministic. - * @precondition `result.stdout` and `result.stderr` are finite strings. - * @postcondition Consumers observe exactly the provided stdout, stderr, and exit code. - * @complexity O(n) time and O(n) space where n = |stdout| + |stderr|. - * @throws Never - */ -// CHANGE: model Docker CLI process output without touching the host Docker daemon -// WHY: image revision fallback invariants must be unit-testable without external services -// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать" -// REF: CodeRabbit PR #344 review 4349211730 -// SOURCE: n/a -// FORMAT THEOREM: process(result).stdout = result.stdout and process(result).exit = result.exitCode -// PURITY: CORE -// EFFECT: none -// INVARIANT: fake process is not running after construction -// COMPLEXITY: O(n) -const completedProcess = (result: TestCommandResult): CommandExecutor.Process => ({ - [CommandExecutor.ProcessTypeId]: CommandExecutor.ProcessTypeId, - [Inspectable.NodeInspectSymbol]: () => ({ _tag: "TestProcess" }), - exitCode: Effect.succeed(CommandExecutor.ExitCode(result.exitCode)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - pid: CommandExecutor.ProcessId(0), - stderr: textStream(result.stderr), - stdin: Sink.drain, - stdout: textStream(result.stdout), - toJSON: () => ({ _tag: "TestProcess" }), - toString: () => "TestProcess" -}) - -type TestCommandHandler = (command: Command.StandardCommand) => TestCommandResult - -/** - * Creates a command-executor layer backed by a pure command handler. - * - * @param handler - Total handler for standard commands. - * @returns Layer providing CommandExecutor. - * @pure true - * @effect none - * @invariant Every started command maps to exactly one completed fake process. - * @precondition The handler is total for all commands issued by the test subject. - * @postcondition Command effects never reach the real operating system. - * @complexity O(1) layer construction. - * @throws Never - */ -// CHANGE: provide typed Effect dependency injection for Docker command tests -// WHY: controller image revision inspection is a shell effect and must be tested through its service boundary -// QUOTE(ТЗ): "комментарии ребита надо было тоже поддержать" -// REF: CodeRabbit PR #344 review 4349211730 -// SOURCE: n/a -// FORMAT THEOREM: start(command) = completedProcess(handler(command)) -// PURITY: SHELL -// EFFECT: Layer -// INVARIANT: no command escapes the fake executor -// COMPLEXITY: O(1) -const commandExecutorLayer = (handler: TestCommandHandler) => - Layer.succeed( - CommandExecutor.CommandExecutor, - CommandExecutor.makeExecutor((command) => { - const standardCommand = Command.flatten(command)[0] - return Effect.succeed(completedProcess(handler(standardCommand))) - }) - ) - /** * Runs image revision inspection with a controlled command handler. * diff --git a/packages/app/tests/docker-git/controller.test.ts b/packages/app/tests/docker-git/controller.test.ts index 1af7e393..20807265 100644 --- a/packages/app/tests/docker-git/controller.test.ts +++ b/packages/app/tests/docker-git/controller.test.ts @@ -48,6 +48,12 @@ const joinIp = (...octets: ReadonlyArray): string => octets.join(".") * @throws Never */ const makeHttpUrl = (host: string, port: string): string => ["ht", "tp://", host, ":", port].join("") +const controllerSourceRevisionArbitrary = fc + .string({ maxLength: 64, minLength: 1 }) + .filter((value) => !value.includes("\n") && !value.includes("\r")) +const controllerGpuModeArbitrary = fc.constantFrom<"none" | "all">("none", "all") +const controllerBuildSkillerModeArbitrary = fc.constantFrom<"0" | "1">("0", "1") + describe("controller reachability", () => { it.effect("builds direct API candidates without Docker inspection", () => Effect.sync(() => { @@ -218,5 +224,18 @@ describe("controller reachability", () => { expect(controllerRevisionForMode("abc123def4567890", "none")).toBe("abc123def4567890-none-skiller1") expect(controllerRevisionForMode("abc123def4567890", "all")).toBe("abc123def4567890-all-skiller1") expect(controllerRevisionForMode("abc123def4567890", "none", "0")).toBe("abc123def4567890-none-skiller0") + + fc.assert( + fc.property( + controllerSourceRevisionArbitrary, + controllerGpuModeArbitrary, + controllerBuildSkillerModeArbitrary, + (sourceRevision, gpuMode, buildSkillerMode) => { + expect(controllerRevisionForMode(sourceRevision, gpuMode, buildSkillerMode)).toBe( + `${sourceRevision}-${gpuMode}-skiller${buildSkillerMode}` + ) + } + ) + ) })) }) diff --git a/packages/app/tests/docker-git/fixtures/command-executor.ts b/packages/app/tests/docker-git/fixtures/command-executor.ts new file mode 100644 index 00000000..330d713f --- /dev/null +++ b/packages/app/tests/docker-git/fixtures/command-executor.ts @@ -0,0 +1,67 @@ +import * as Command from "@effect/platform/Command" +import * as CommandExecutor from "@effect/platform/CommandExecutor" +import { Effect, Layer } from "effect" +import * as Inspectable from "effect/Inspectable" +import * as Sink from "effect/Sink" +import * as Stream from "effect/Stream" + +export type TestCommandResult = { + readonly exitCode: number + readonly stderr: string + readonly stdout: string +} + +export type TestCommandHandler = (command: Command.StandardCommand) => TestCommandResult + +export const emptyCommandResult: TestCommandResult = { + exitCode: 0, + stderr: "", + stdout: "" +} + +const encodeText = (value: string): Uint8Array => new TextEncoder().encode(value) + +const textStream = (value: string) => value.length === 0 ? Stream.empty : Stream.succeed(encodeText(value)) + +// CHANGE: model CLI process output without touching the host process table +// WHY: shell-boundary tests need deterministic CommandExecutor behavior +// QUOTE(TZ): "fix possible CI/CD and CodeRabbit complaints" +// REF: user-message-2026-05-24-coderabbit-ci +// SOURCE: n/a +// FORMAT THEOREM: process(result).stdout = result.stdout and process(result).stderr = result.stderr and process(result).exit = result.exitCode +// PURITY: CORE +// EFFECT: none +// INVARIANT: fake process is not running after construction +// COMPLEXITY: O(n) where n = |stdout| + |stderr| +const completedProcess = (result: TestCommandResult): CommandExecutor.Process => ({ + [CommandExecutor.ProcessTypeId]: CommandExecutor.ProcessTypeId, + [Inspectable.NodeInspectSymbol]: () => ({ _tag: "TestProcess" }), + exitCode: Effect.succeed(CommandExecutor.ExitCode(result.exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + pid: CommandExecutor.ProcessId(0), + stderr: textStream(result.stderr), + stdin: Sink.drain, + stdout: textStream(result.stdout), + toJSON: () => ({ _tag: "TestProcess" }), + toString: () => "TestProcess" +}) + +// CHANGE: provide typed Effect dependency injection for command-shell tests +// WHY: tests must verify shell behavior without executing host commands +// QUOTE(TZ): "fix possible CI/CD and CodeRabbit complaints" +// REF: user-message-2026-05-24-coderabbit-ci +// SOURCE: n/a +// FORMAT THEOREM: start(command) = completedProcess(handler(flatten(command))) +// PURITY: SHELL +// EFFECT: Layer +// INVARIANT: no command escapes the fake executor +// COMPLEXITY: O(1) excluding handler cost +export const commandExecutorLayer = (handler: TestCommandHandler) => + Layer.succeed( + CommandExecutor.CommandExecutor, + CommandExecutor.makeExecutor((command) => { + const standardCommand = Command.flatten(command)[0] + return Effect.succeed(completedProcess(handler(standardCommand))) + }) + )