diff --git a/ctl b/ctl index 475e3d05..fb84972e 100755 --- a/ctl +++ b/ctl @@ -13,6 +13,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" COMPOSE_FILE="$ROOT/docker-compose.yml" +COMPOSE_ISOLATED_FILE="$ROOT/docker-compose.isolated.yml" CONTAINER_NAME="docker-git-api" API_PORT="${DOCKER_GIT_API_PORT:-3334}" API_HOST="${DOCKER_GIT_API_BIND_HOST:-127.0.0.1}" @@ -54,7 +55,11 @@ USAGE } compose() { - "${DOCKER_CMD[@]}" compose -f "$COMPOSE_FILE" "$@" + local compose_args=(-f "$COMPOSE_FILE") + if [[ "${DOCKER_GIT_DOCKER_RUNTIME:-host}" == "isolated" ]]; then + compose_args+=(-f "$COMPOSE_ISOLATED_FILE") + fi + "${DOCKER_CMD[@]}" compose "${compose_args[@]}" "$@" } compute_controller_revision() { diff --git a/docker-compose.api.isolated.yml b/docker-compose.api.isolated.yml new file mode 100644 index 00000000..05826d3c --- /dev/null +++ b/docker-compose.api.isolated.yml @@ -0,0 +1,8 @@ +services: + api: + privileged: ${DOCKER_GIT_CONTROLLER_PRIVILEGED:-true} + environment: + DOCKER_GIT_PROJECT_DOCKER_HOST: ${DOCKER_GIT_PROJECT_DOCKER_HOST:-tcp://host.docker.internal:2375} + volumes: !override + - docker_git_projects:${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git} + - docker_git_docker_data:/var/lib/docker 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.isolated.yml b/docker-compose.isolated.yml new file mode 100644 index 00000000..05826d3c --- /dev/null +++ b/docker-compose.isolated.yml @@ -0,0 +1,8 @@ +services: + api: + privileged: ${DOCKER_GIT_CONTROLLER_PRIVILEGED:-true} + environment: + DOCKER_GIT_PROJECT_DOCKER_HOST: ${DOCKER_GIT_PROJECT_DOCKER_HOST:-tcp://host.docker.internal:2375} + volumes: !override + - docker_git_projects:${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git} + - docker_git_docker_data:/var/lib/docker 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..c70823c6 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -5,17 +5,27 @@ 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. In isolated mode, start the controller through the host CLI +or include `docker-compose.isolated.yml`; that overlay removes the host socket +bind and defaults project containers to the embedded daemon endpoint +`tcp://host.docker.internal:2375`. + +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 @@ -52,6 +62,14 @@ docker compose up -d --build ./ctl health ``` +Isolated fallback: + +```bash +DOCKER_GIT_DOCKER_RUNTIME=isolated \ + docker compose -f docker-compose.yml -f docker-compose.isolated.yml up -d --build +./ctl health +``` + Default port mapping: - host: `127.0.0.1:3334` @@ -61,12 +79,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` in host mode; isolated overlays default it to `true` for the embedded Docker daemon) - `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 in host mode; isolated mode defaults to `tcp://host.docker.internal:2375`) +- `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..93d62ec3 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="" @@ -15,6 +15,10 @@ cleanup() { trap cleanup EXIT INT TERM if [[ "$runtime" == "isolated" ]]; then + if [[ -z "${DOCKER_GIT_PROJECT_DOCKER_HOST:-}" ]]; then + export DOCKER_GIT_PROJECT_DOCKER_HOST="tcp://host.docker.internal:2375" + fi + if [[ "$docker_host" != unix://* ]]; then echo "DOCKER_GIT_DOCKER_RUNTIME=isolated requires a unix:// DOCKER_HOST for the managed controller daemon." >&2 exit 1 diff --git a/packages/app/scripts/print-controller-revision.ts b/packages/app/scripts/print-controller-revision.ts index b814e36e..613c8ead 100644 --- a/packages/app/scripts/print-controller-revision.ts +++ b/packages/app/scripts/print-controller-revision.ts @@ -3,7 +3,13 @@ import { NodeContext, NodeRuntime } from "@effect/platform-node" import { Effect, pipe } from "effect" +import { + controllerRevisionForMode, + parseControllerBuildSkillerMode, + parseControllerGpuMode +} from "../src/docker-git/controller-compose.ts" import { computeLocalControllerRevision } from "../src/docker-git/controller-revision.ts" +import { parseControllerDockerRuntime } from "../src/docker-git/controller-runtime.ts" // CHANGE: expose controller revision computation as a reusable Bun script for shell tooling // WHY: ctl must inject the same deterministic controller revision into docker compose as the host CLI bootstrap path @@ -25,9 +31,32 @@ const readComposePath = (): Effect.Effect => { : Effect.fail(new Error(usage)) } +const readControllerRevisionModes = (): Effect.Effect<{ + readonly buildSkillerMode: "0" | "1" + readonly dockerRuntime: "host" | "isolated" + readonly gpuMode: "none" | "all" +}, Error> => { + const gpuMode = parseControllerGpuMode(process.env["DOCKER_GIT_CONTROLLER_GPU"]) + const buildSkillerMode = parseControllerBuildSkillerMode(process.env["DOCKER_GIT_CONTROLLER_BUILD_SKILLER"]) + const dockerRuntime = parseControllerDockerRuntime(process.env["DOCKER_GIT_DOCKER_RUNTIME"]) + if (gpuMode === null || buildSkillerMode === null || dockerRuntime === null) { + return Effect.fail(new Error("Invalid controller revision mode environment.")) + } + return Effect.succeed({ buildSkillerMode, dockerRuntime, gpuMode }) +} + const main = pipe( - readComposePath(), - Effect.flatMap((composePath) => computeLocalControllerRevision(composePath)), + Effect.all({ + composePath: readComposePath(), + modes: readControllerRevisionModes() + }), + Effect.flatMap(({ composePath, modes }) => + computeLocalControllerRevision(composePath).pipe( + Effect.map((revision) => + controllerRevisionForMode(revision, modes.gpuMode, modes.buildSkillerMode, modes.dockerRuntime) + ) + ) + ), Effect.tap((revision) => Effect.sync(() => process.stdout.write(`${revision}\n`))), Effect.asVoid, Effect.provide(NodeContext.layer) diff --git a/packages/app/src/docker-git/controller-compose-runtime.ts b/packages/app/src/docker-git/controller-compose-runtime.ts new file mode 100644 index 00000000..dfb29704 --- /dev/null +++ b/packages/app/src/docker-git/controller-compose-runtime.ts @@ -0,0 +1,67 @@ +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 { + type ControllerDockerRuntime, + controllerDockerRuntimeEnvKey, + parseControllerDockerRuntime +} from "./controller-runtime.js" +import { type ControllerBootstrapError, controllerBootstrapError } from "./host-errors.js" + +const mapComposePathError = (error: PlatformError): ControllerBootstrapError => + controllerBootstrapError(`Failed to resolve docker-compose.yml path.\nDetails: ${String(error)}`) + +export const loadControllerDockerRuntime = (): Effect.Effect => { + const raw = process.env[controllerDockerRuntimeEnvKey] + const parsed = parseControllerDockerRuntime(raw) + if (parsed !== null) { + return Effect.succeed(parsed) + } + return Effect.fail( + controllerBootstrapError( + `${controllerDockerRuntimeEnvKey} must be unset or one of: host, isolated. Received: ${raw ?? ""}` + ) + ) +} + +const isolatedOverlayFileName = (composeFileName: string): Effect.Effect => { + if (composeFileName.endsWith(".yaml")) { + return Effect.succeed(`${composeFileName.slice(0, -".yaml".length)}.isolated.yaml`) + } + if (composeFileName.endsWith(".yml")) { + return Effect.succeed(`${composeFileName.slice(0, -".yml".length)}.isolated.yml`) + } + return Effect.fail( + controllerBootstrapError( + `${controllerDockerRuntimeEnvKey}=isolated requires a .yml or .yaml compose file. Received: ${composeFileName}` + ) + ) +} + +export const resolveControllerRuntimeOverlayPath = ( + composePath: string, + dockerRuntime: ControllerDockerRuntime +): Effect.Effect => + dockerRuntime === "host" + ? Effect.succeed(null) + : Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const overlayFileName = yield* _(isolatedOverlayFileName(path.basename(composePath))) + const runtimeOverlayPath = path.join( + path.dirname(composePath), + overlayFileName + ) + const exists = yield* _(fs.exists(runtimeOverlayPath).pipe(Effect.mapError(mapComposePathError))) + return exists + ? runtimeOverlayPath + : yield* _( + Effect.fail( + controllerBootstrapError( + `${controllerDockerRuntimeEnvKey}=isolated requires ${runtimeOverlayPath}, but it was not found.` + ) + ) + ) + }) diff --git a/packages/app/src/docker-git/controller-compose.ts b/packages/app/src/docker-git/controller-compose.ts index 44fdd23a..35807f39 100644 --- a/packages/app/src/docker-git/controller-compose.ts +++ b/packages/app/src/docker-git/controller-compose.ts @@ -1,9 +1,13 @@ +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 { loadControllerDockerRuntime, resolveControllerRuntimeOverlayPath } from "./controller-compose-runtime.js" import { computeLocalControllerRevision, controllerRevisionEnvKey } from "./controller-revision.js" +import type { ControllerDockerRuntime } from "./controller-runtime.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" @@ -16,8 +20,12 @@ export type ControllerBuildSkillerMode = "0" | "1" export type ControllerComposeFiles = { readonly composePath: string readonly gpuOverlayPath: string | null + readonly runtimeOverlayPath: string | null } +const skillerSubmodulePath = "third_party/skiller-desktop-skills-manager" +const skillerPackagePath = `${skillerSubmodulePath}/package.json` + const controllerBootstrapError = (message: string): ControllerBootstrapError => ({ _tag: "ControllerBootstrapError", message @@ -42,8 +50,9 @@ export const parseControllerBuildSkillerMode = (raw?: string): ControllerBuildSk export const controllerRevisionForMode = ( sourceRevision: string, gpuMode: ControllerGpuMode, - buildSkillerMode: ControllerBuildSkillerMode = "1" -): string => `${sourceRevision}-${gpuMode}-skiller${buildSkillerMode}` + buildSkillerMode: ControllerBuildSkillerMode = "1", + dockerRuntime: ControllerDockerRuntime = "host" +): string => `${sourceRevision}-${dockerRuntime}-${gpuMode}-skiller${buildSkillerMode}` const loadControllerGpuMode = (): Effect.Effect => { const raw = process.env[controllerGpuModeEnvKey] @@ -82,16 +91,121 @@ 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 -): ReadonlyArray => - gpuOverlayPath === null - ? ["-f", composePath] - : ["-f", composePath, "-f", gpuOverlayPath] + gpuOverlayPath: string | null, + runtimeOverlayPath: string | null = null +): ReadonlyArray => [ + "-f", + composePath, + ...(runtimeOverlayPath === null ? [] : ["-f", runtimeOverlayPath]), + ...(gpuOverlayPath === null ? [] : ["-f", gpuOverlayPath]) +] + +export const composeFilesToArgs = (composeFiles: ControllerComposeFiles): ReadonlyArray => + composeFilesForMode( + composeFiles.composePath, + composeFiles.gpuOverlayPath, + composeFiles.runtimeOverlayPath + ) const requireGpuOverlayPath = ( composePath: string @@ -115,33 +229,34 @@ const composeFilesForGpuMode = ( gpuMode: ControllerGpuMode ): Effect.Effect => gpuMode === "none" - ? Effect.succeed({ composePath, gpuOverlayPath: null }) + ? Effect.succeed({ composePath, gpuOverlayPath: null, runtimeOverlayPath: null }) : requireGpuOverlayPath(composePath).pipe( - Effect.map((gpuOverlayPath) => ({ composePath, gpuOverlayPath })) + Effect.map((gpuOverlayPath) => ({ composePath, gpuOverlayPath, runtimeOverlayPath: null })) ) type ComposePathAndGpuMode = { readonly composePath: string + readonly dockerRuntime: ControllerDockerRuntime readonly gpuMode: ControllerGpuMode 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) => - loadControllerGpuMode().pipe( - Effect.flatMap((gpuMode) => - loadControllerBuildSkillerMode().pipe( - Effect.flatMap((buildSkillerMode) => effect({ buildSkillerMode, composePath, gpuMode })) - ) - ) + Effect.all({ + buildSkillerMode: loadControllerBuildSkillerMode(), + dockerRuntime: loadControllerDockerRuntime(), + gpuMode: loadControllerGpuMode() + }).pipe( + Effect.flatMap((modes) => effect({ composePath, ...modes })) ) ) ) @@ -150,16 +265,24 @@ export const resolveControllerComposeFiles = (): Effect.Effect< ControllerComposeFiles, ControllerBootstrapError, FileSystem.FileSystem | Path.Path -> => withComposePathAndGpuMode(({ composePath, gpuMode }) => composeFilesForGpuMode(composePath, gpuMode)) +> => + withComposePathAndGpuMode(({ composePath, dockerRuntime, gpuMode }) => + Effect.gen(function*(_) { + const composeFiles = yield* _(composeFilesForGpuMode(composePath, gpuMode)) + const runtimeOverlayPath = yield* _(resolveControllerRuntimeOverlayPath(composePath, dockerRuntime)) + return { ...composeFiles, runtimeOverlayPath } + }) + ) const computeControllerRevision = ( composePath: string, gpuMode: ControllerGpuMode, - buildSkillerMode: ControllerBuildSkillerMode + buildSkillerMode: ControllerBuildSkillerMode, + dockerRuntime: ControllerDockerRuntime ): Effect.Effect => computeLocalControllerRevision(composePath).pipe( Effect.mapError(mapControllerRevisionError), - Effect.map((revision) => controllerRevisionForMode(revision, gpuMode, buildSkillerMode)) + Effect.map((revision) => controllerRevisionForMode(revision, gpuMode, buildSkillerMode, dockerRuntime)) ) const persistControllerRevision = (revision: string): Effect.Effect => @@ -170,8 +293,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))) + withComposePathAndGpuMode(({ buildSkillerMode, composePath, dockerRuntime, gpuMode }) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + if (buildSkillerMode === "1") { + yield* _(ensureSkillerSubmoduleInitialized(path.dirname(composePath))) + } + return yield* _(computeControllerRevision(composePath, gpuMode, buildSkillerMode, dockerRuntime)) + }) + ).pipe( + Effect.tap((revision) => persistControllerRevision(revision)) + ) diff --git a/packages/app/src/docker-git/controller-docker.ts b/packages/app/src/docker-git/controller-docker.ts index f209de7b..29bd8b5e 100644 --- a/packages/app/src/docker-git/controller-docker.ts +++ b/packages/app/src/docker-git/controller-docker.ts @@ -4,7 +4,8 @@ import type * as FileSystem from "@effect/platform/FileSystem" import type * as Path from "@effect/platform/Path" import { Effect } from "effect" -import { composeFilesForMode, prepareControllerRevision, resolveControllerComposeFiles } from "./controller-compose.js" +import { composeFilesToArgs, prepareControllerRevision, resolveControllerComposeFiles } from "./controller-compose.js" +import { readCurrentContainerName } from "./controller-hostname.js" import { runCommandCaptureWithFailureOutput, runCommandExitCode, @@ -29,6 +30,7 @@ export { parseControllerBuildSkillerMode, parseControllerGpuMode } from "./controller-compose.js" +export { parseControllerDockerRuntime } from "./controller-runtime.js" export type ControllerRuntime = | CommandExecutor.CommandExecutor @@ -384,7 +386,7 @@ export const runCompose = ( const composeFiles = yield* _(resolveControllerComposeFiles()) const invocation = buildDockerInvocation(dockerCommand, [ "compose", - ...composeFilesForMode(composeFiles.composePath, composeFiles.gpuOverlayPath), + ...composeFilesToArgs(composeFiles), ...args ]) const exitCode = yield* _( @@ -461,7 +463,9 @@ export const inspectControllerPublishedPorts = (): Effect.Effect => - inspectContainerNetworks(process.env["HOSTNAME"]?.trim() ?? "") + readCurrentContainerName().pipe( + Effect.flatMap((containerName) => inspectContainerNetworks(containerName)) + ) const connectControllerToNetworkBestEffort = ( networkName: string diff --git a/packages/app/src/docker-git/controller-hostname.ts b/packages/app/src/docker-git/controller-hostname.ts new file mode 100644 index 00000000..fd8d37be --- /dev/null +++ b/packages/app/src/docker-git/controller-hostname.ts @@ -0,0 +1,29 @@ +import * as FileSystem from "@effect/platform/FileSystem" +import { Effect } from "effect" + +const readSystemHostname = (): Effect.Effect => + FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => fs.readFileString("/etc/hostname")), + Effect.map((value) => value.trim()), + Effect.orElseSucceed(() => "") + ) + +// CHANGE: fall back to the system hostname when HOSTNAME is not exported +// WHY: containerized runtimes can have an inspectable Docker hostname without a HOSTNAME env variable +// QUOTE(ТЗ): "Полностью запусти локально и проверь что всё работает" +// REF: user-request-2026-05-27-pr-351-browser-e2e +// SOURCE: n/a +// FORMAT THEOREM: trim(envHostname) != "" -> envHostname; otherwise trim(systemHostname) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: Docker inspection never receives an empty name when the system hostname is non-empty +// COMPLEXITY: O(|envHostname| + |systemHostname|) +export const resolveCurrentContainerName = ( + envHostname: string | undefined, + systemHostname: string +): string => envHostname?.trim() || systemHostname.trim() + +export const readCurrentContainerName = (): Effect.Effect => + readSystemHostname().pipe( + Effect.map((systemHostname) => resolveCurrentContainerName(process.env["HOSTNAME"], systemHostname)) + ) diff --git a/packages/app/src/docker-git/controller-image-revision.ts b/packages/app/src/docker-git/controller-image-revision.ts index ea2c1414..56508a26 100644 --- a/packages/app/src/docker-git/controller-image-revision.ts +++ b/packages/app/src/docker-git/controller-image-revision.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" -import { composeFilesForMode, resolveControllerComposeFiles } from "./controller-compose.js" +import { composeFilesToArgs, resolveControllerComposeFiles } from "./controller-compose.js" import { type ControllerRuntime, runDockerCapture, runDockerCaptureWithFailureOutput } from "./controller-docker.js" import { parseControllerRevisionLabelOutput } from "./controller-revision.js" import type { ControllerBootstrapError } from "./host-errors.js" @@ -131,7 +131,7 @@ const inspectControllerComposeImageName = (): Effect.Effect< runDockerCapture( [ "compose", - ...composeFilesForMode(composeFiles.composePath, composeFiles.gpuOverlayPath), + ...composeFilesToArgs(composeFiles), "config", "--images" ], diff --git a/packages/app/src/docker-git/controller-reachability.ts b/packages/app/src/docker-git/controller-reachability.ts index 3194d116..262b8dc9 100644 --- a/packages/app/src/docker-git/controller-reachability.ts +++ b/packages/app/src/docker-git/controller-reachability.ts @@ -102,6 +102,25 @@ export const isRemoteDockerHost = (dockerHost = process.env["DOCKER_HOST"]): boo return trimmed.startsWith("tcp://") || trimmed.startsWith("ssh://") } +// CHANGE: allow remote Docker bootstrap when the current runtime is inspectable on that daemon +// WHY: containerized hosts often reach Docker through tcp://host.docker.internal while sharing daemon networks +// QUOTE(ТЗ): "Надо проверить запускается ли сервер теперь" +// REF: user-request-2026-05-27-pr-351-browser-e2e +// SOURCE: n/a +// FORMAT THEOREM: remote(dockerHost) ∧ noExplicitApi ∧ empty(networks) -> require_explicit_api +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: remote Docker is allowed only when network-derived controller candidates can be constructed +// COMPLEXITY: O(k) where k = |currentContainerNetworks| +export const shouldRequireExplicitApiUrlForRemoteDocker = ( + dockerHost: string | undefined, + explicitApiBaseUrl: string | undefined, + currentContainerNetworks: DockerNetworkIps +): boolean => + isRemoteDockerHost(dockerHost) && + explicitApiBaseUrl === undefined && + Object.keys(currentContainerNetworks).length === 0 + export const buildApiBaseUrlCandidates = ({ cachedApiBaseUrl, controllerNetworks, diff --git a/packages/app/src/docker-git/controller-revision.ts b/packages/app/src/docker-git/controller-revision.ts index 0a566965..58b92a87 100644 --- a/packages/app/src/docker-git/controller-revision.ts +++ b/packages/app/src/docker-git/controller-revision.ts @@ -9,6 +9,9 @@ const controllerRevisionInputs: ReadonlyArray = [ "docker-compose.yml", "docker-compose.api.yml", "docker-compose.gpu.yml", + "docker-compose.isolated.yml", + "docker-compose.api.isolated.yml", + ".gitmodules", "package.json", "bun.lock", "bunfig.toml", diff --git a/packages/app/src/docker-git/controller-runtime-shell.ts b/packages/app/src/docker-git/controller-runtime-shell.ts new file mode 100644 index 00000000..073af66d --- /dev/null +++ b/packages/app/src/docker-git/controller-runtime-shell.ts @@ -0,0 +1,40 @@ +import { Effect } from "effect" + +import { + controllerDockerRuntimeEnvKey, + parseControllerDockerRuntime, + projectDockerHostEnvKey, + resolveProjectDockerHostForRuntime +} from "./controller-runtime.js" +import { type ControllerBootstrapError, controllerBootstrapError } from "./host-errors.js" + +// CHANGE: prepare controller runtime environment before docker compose bootstrap +// WHY: isolated controllers must pass a non-empty embedded daemon endpoint to project containers +// QUOTE(ТЗ): "исправить все уязвимости которые нашёл" +// REF: pr-351-review-isolated-runtime +// SOURCE: n/a +// FORMAT THEOREM: runtime=isolated -> projectDockerHost != "" +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: explicit DOCKER_GIT_PROJECT_DOCKER_HOST is preserved after trimming +// COMPLEXITY: O(1) +export const prepareControllerRuntimeEnv = (): Effect.Effect => { + const rawRuntime = process.env[controllerDockerRuntimeEnvKey] + const runtime = parseControllerDockerRuntime(rawRuntime) + if (runtime === null) { + return Effect.fail( + controllerBootstrapError( + `${controllerDockerRuntimeEnvKey} must be unset or one of: host, isolated. Received: ${rawRuntime ?? ""}` + ) + ) + } + + return Effect.sync(() => { + const projectDockerHost = resolveProjectDockerHostForRuntime(runtime, process.env[projectDockerHostEnvKey]) + if (projectDockerHost.length === 0) { + Reflect.deleteProperty(process.env, projectDockerHostEnvKey) + return + } + process.env[projectDockerHostEnvKey] = projectDockerHost + }) +} diff --git a/packages/app/src/docker-git/controller-runtime.ts b/packages/app/src/docker-git/controller-runtime.ts new file mode 100644 index 00000000..31fde59c --- /dev/null +++ b/packages/app/src/docker-git/controller-runtime.ts @@ -0,0 +1,70 @@ +export const controllerDockerRuntimeEnvKey = "DOCKER_GIT_DOCKER_RUNTIME" +export const projectDockerHostEnvKey = "DOCKER_GIT_PROJECT_DOCKER_HOST" +export const defaultIsolatedProjectDockerHost = "tcp://host.docker.internal:2375" + +export type ControllerDockerRuntime = "host" | "isolated" + +// CHANGE: parse and normalize the controller Docker runtime mode. +// WHY: controller startup must distinguish host-backed Docker from the isolated embedded daemon fallback. +// QUOTE(ТЗ): "Host-Docker-backed runtime is the intended default; isolated is opt-in fallback" +// REF: packages/api/README.md +// SOURCE: n/a +// FORMAT THEOREM: forall raw: empty(trim(raw)) or trim(raw)=host -> host; trim(raw)=isolated -> isolated; otherwise -> null +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: parseControllerDockerRuntime is deterministic over trimmed input. +// COMPLEXITY: O(1) time, O(1) space. +/** + * Parses the controller Docker runtime mode from an environment value. + * + * @param raw - Raw `DOCKER_GIT_DOCKER_RUNTIME` value. + * @returns `"host"` for empty or host mode, `"isolated"` for isolated mode, or `null` for invalid input. + * + * @pure true + * @effect n/a + * @invariant Empty input and `"host"` normalize to `"host"`; `"isolated"` normalizes to `"isolated"`; all other values return `null`. + * @precondition `raw` is a finite string or `undefined`. + * @postcondition The result is exactly `"host"`, `"isolated"`, or `null`. + * @complexity O(1) time and O(1) space. + * @throws Never + */ +export const parseControllerDockerRuntime = (raw?: string): ControllerDockerRuntime | null => { + const trimmed = raw?.trim() ?? "" + if (trimmed.length === 0 || trimmed === "host") { + return "host" + } + return trimmed === "isolated" ? "isolated" : null +} + +// CHANGE: resolve the project-container Docker endpoint from controller runtime mode. +// WHY: isolated controllers must inject a reachable embedded daemon endpoint, while host-backed mode keeps the host-socket default. +// QUOTE(ТЗ): "when isolated, project containers default to tcp://host.docker.internal:2375" +// REF: packages/api/README.md +// SOURCE: n/a +// FORMAT THEOREM: forall runtime, raw: runtime=isolated -> nonempty(trim(raw) or defaultIsolatedProjectDockerHost); runtime=host -> trim(raw) or empty +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: isolated runtime always returns a non-empty Docker host string. +// COMPLEXITY: O(1) time, O(1) space. +/** + * Resolves the Docker host URL passed to project containers. + * + * @param runtime - Normalized controller runtime mode. + * @param rawProjectDockerHost - Raw `DOCKER_GIT_PROJECT_DOCKER_HOST` override. + * @returns The trimmed project Docker host, the isolated default endpoint, or an empty host-mode value. + * + * @pure true + * @effect n/a + * @invariant Isolated runtime returns a non-empty endpoint; host runtime returns the explicit endpoint or an empty string. + * @precondition `runtime` is a valid `ControllerDockerRuntime`; `rawProjectDockerHost` is a finite string or `undefined`. + * @postcondition Result is non-empty in isolated mode and preserves host-mode emptiness when no override exists. + * @complexity O(1) time and O(1) space. + * @throws Never + */ +export const resolveProjectDockerHostForRuntime = ( + runtime: ControllerDockerRuntime, + rawProjectDockerHost?: string +): string => + runtime === "isolated" + ? (rawProjectDockerHost?.trim() || defaultIsolatedProjectDockerHost) + : (rawProjectDockerHost?.trim() ?? "") diff --git a/packages/app/src/docker-git/controller.ts b/packages/app/src/docker-git/controller.ts index 95ffb2fb..06d0e8ff 100644 --- a/packages/app/src/docker-git/controller.ts +++ b/packages/app/src/docker-git/controller.ts @@ -19,10 +19,10 @@ import { buildApiBaseUrlCandidates, type DockerNetworkIps, formatNetworkIps, - isRemoteDockerHost, resolveApiPort, resolveConfiguredApiBaseUrl, resolveExplicitApiBaseUrl, + shouldRequireExplicitApiUrlForRemoteDocker, trimTrailingSlashes } from "./controller-reachability.js" import { @@ -30,6 +30,7 @@ import { shouldForceRecreateForControllerResourceLimits } from "./controller-resource-limits-shell.js" import { shouldForceRecreateController } from "./controller-revision.js" +import { prepareControllerRuntimeEnv } from "./controller-runtime-shell.js" import type { ControllerBootstrapError } from "./host-errors.js" export type { ControllerRuntime } from "./controller-docker.js" @@ -98,9 +99,17 @@ const waitForReachableApiBaseUrl = ( }) ) -const failIfRemoteDockerWithoutApiUrl = (): Effect.Effect => { +const failIfRemoteDockerWithoutApiUrl = ( + currentContainerNetworks: DockerNetworkIps +): Effect.Effect => { const explicitApiBaseUrl = resolveExplicitApiBaseUrl() - if (!isRemoteDockerHost() || explicitApiBaseUrl !== undefined) { + if ( + !shouldRequireExplicitApiUrlForRemoteDocker( + process.env["DOCKER_HOST"], + explicitApiBaseUrl, + currentContainerNetworks + ) + ) { return Effect.void } @@ -157,6 +166,7 @@ const loadControllerBootstrapContext = (): Effect.Effect< ControllerRuntime > => Effect.gen(function*(_) { + yield* _(prepareControllerRuntimeEnv()) yield* _(prepareControllerResourceLimitEnv()) const explicitApiBaseUrl = resolveExplicitApiBaseUrl() const localControllerRevision = yield* _(prepareLocalControllerRevision()) @@ -266,31 +276,10 @@ const startAndRememberController = ( // COMPLEXITY: O(1) compose + O(k) health checks export const ensureControllerReady = (): Effect.Effect => Effect.gen(function*(_) { - yield* _(failIfRemoteDockerWithoutApiUrl()) const explicitApiBaseUrl = resolveExplicitApiBaseUrl() - const localControllerRevision = yield* _(prepareLocalControllerRevision()) - const forceRecreateForResourceLimits = shouldForceRecreateForControllerResourceLimits() - if (explicitApiBaseUrl === undefined) { - const reachableBeforeDocker = yield* _( - findReachableDirectHealthProbe({ - explicitApiBaseUrl, - cachedApiBaseUrl: selectedApiBaseUrl - }) - ) - if ( - reachableBeforeDocker !== null && - reachableBeforeDocker.revision === localControllerRevision && - !forceRecreateForResourceLimits - ) { - rememberSelectedApiBaseUrl(reachableBeforeDocker.apiBaseUrl) - return - } - } else { + if (explicitApiBaseUrl !== undefined) { const reachableBeforeDocker = yield* _( - findReachableDirectHealthProbe({ - explicitApiBaseUrl, - cachedApiBaseUrl: selectedApiBaseUrl - }) + findReachableDirectHealthProbe({ explicitApiBaseUrl, cachedApiBaseUrl: selectedApiBaseUrl }) ) if (reachableBeforeDocker !== null) { rememberSelectedApiBaseUrl(reachableBeforeDocker.apiBaseUrl) @@ -299,7 +288,25 @@ export const ensureControllerReady = (): Effect.Effect => Effect.gen(function*(_) { - yield* _(failIfRemoteDockerWithoutApiUrl()) const explicitApiBaseUrl = resolveExplicitApiBaseUrl() if (explicitApiBaseUrl !== undefined) { yield* _(ensureControllerReady()) @@ -317,6 +323,7 @@ export const restartController = (): Effect.Effect ({ + _tag: "ControllerBootstrapError", + message +}) + export type ApiRequestError = { readonly _tag: "ApiRequestError" readonly method: string 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..04bd40cb --- /dev/null +++ b/packages/app/tests/docker-git/controller-compose.test.ts @@ -0,0 +1,300 @@ +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 { resolveControllerRuntimeOverlayPath } from "../../src/docker-git/controller-compose-runtime.js" +import { + controllerBuildSkillerEnvKey, + controllerGpuModeEnvKey, + ensureSkillerSubmoduleInitialized, + prepareControllerRevision, + resolveControllerComposeFiles +} from "../../src/docker-git/controller-compose.js" +import { controllerRevisionEnvKey } from "../../src/docker-git/controller-revision.js" +import { controllerDockerRuntimeEnvKey } from "../../src/docker-git/controller-runtime.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 writeMinimalIsolatedCompose = (rootDir: string) => + writeRootFile(rootDir, "docker-compose.isolated.yml", "services:\n api:\n volumes: !override []\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 ControllerDockerRuntimeEnvFixtureMode = "host" | "isolated" | undefined + +type PrepareRevisionFixture = { + readonly buildSkillerMode: ControllerBuildSkillerFixtureMode + readonly includeSkillerPackage: boolean +} + +const controllerBuildSkillerFixtureModeArbitrary = fc.constantFrom( + undefined, + "0", + "1" +) +const controllerDockerRuntimeEnvFixtureModeArbitrary = fc.constantFrom( + undefined, + "host", + "isolated" +) +const prepareRevisionFixtureArbitrary: fc.Arbitrary = fc + .record({ + buildSkillerMode: controllerBuildSkillerFixtureModeArbitrary, + includeSkillerPackage: fc.boolean() + }) + .filter(({ buildSkillerMode, includeSkillerPackage }) => buildSkillerMode === "0" || includeSkillerPackage) +const controllerRevisionPattern = /^[a-f0-9]{16}-host-none-skiller[01]$/u + +const withMinimalControllerRoot = ( + effect: (rootDir: string) => Effect.Effect +) => + Effect.scoped( + Effect.gen(function*(_) { + const rootDir = yield* _(temporaryControllerRoot) + yield* _(writeMinimalCompose(rootDir)) + yield* _(withWorkingDirectory(rootDir)) + return yield* _(effect(rootDir)) + }) + ) + +const prepareRevisionInTemporaryRoot = ({ + buildSkillerMode, + includeSkillerPackage +}: PrepareRevisionFixture) => + withMinimalControllerRoot((rootDir) => + Effect.gen(function*(_) { + if (includeSkillerPackage) { + yield* _(writeSkillerPackage(rootDir)) + } + yield* _( + withControllerEnv([ + [controllerBuildSkillerEnvKey, buildSkillerMode], + [controllerDockerRuntimeEnvKey, undefined], + [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 resolveComposeFilesInTemporaryRoot = ( + dockerRuntimeMode: ControllerDockerRuntimeEnvFixtureMode +) => + withMinimalControllerRoot((rootDir) => + Effect.gen(function*(_) { + yield* _(writeMinimalIsolatedCompose(rootDir)) + yield* _( + withControllerEnv([ + [controllerBuildSkillerEnvKey, "0"], + [controllerDockerRuntimeEnvKey, dockerRuntimeMode], + [controllerGpuModeEnvKey, undefined] + ]) + ) + return yield* _(resolveControllerComposeFiles()) + }) + ).pipe(Effect.provide(NodeContext.layer)) + +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("adds the isolated runtime overlay only for isolated controller mode", () => + assertControllerComposeProperty( + fc.asyncProperty(controllerDockerRuntimeEnvFixtureModeArbitrary, (dockerRuntimeMode) => + Effect.runPromise( + resolveComposeFilesInTemporaryRoot(dockerRuntimeMode).pipe( + Effect.tap((files) => + Effect.sync(() => { + if (dockerRuntimeMode === "isolated") { + expect(files.runtimeOverlayPath).toBeDefined() + expect(files.runtimeOverlayPath?.endsWith("docker-compose.isolated.yml")).toBe(true) + return + } + expect(files.runtimeOverlayPath).toBeNull() + }) + ), + Effect.asVoid + ) + )) + )) + + it.effect("rejects unsupported compose filename extensions for isolated controller mode", () => + Effect.scoped( + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const rootDir = yield* _(temporaryControllerRoot) + const error = yield* _( + resolveControllerRuntimeOverlayPath(path.join(rootDir, "docker-compose.json"), "isolated").pipe(Effect.flip) + ) + expect(error._tag).toBe("ControllerBootstrapError") + expect(error.message).toContain(".yml or .yaml") + }) + ).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-ready.test.ts b/packages/app/tests/docker-git/controller-ready.test.ts new file mode 100644 index 00000000..21faf102 --- /dev/null +++ b/packages/app/tests/docker-git/controller-ready.test.ts @@ -0,0 +1,111 @@ +/* jscpd:ignore-start */ +import { NodeContext } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import { beforeEach, vi } from "vitest" + +const prepareLocalControllerRevisionMock = vi.hoisted(() => vi.fn<() => Effect.Effect>()) +const findReachableDirectHealthProbeMock = vi.hoisted( + () => + vi.fn< + (options: { + readonly cachedApiBaseUrl: string | undefined + readonly explicitApiBaseUrl: string | undefined + }) => Effect.Effect<{ readonly apiBaseUrl: string; readonly revision: string | null } | null> + >() +) +const prepareControllerResourceLimitEnvMock = vi.hoisted(() => vi.fn<() => Effect.Effect>()) +const prepareControllerRuntimeEnvMock = vi.hoisted(() => vi.fn<() => Effect.Effect>()) + +vi.mock("../../src/docker-git/controller-bootstrap-plan.js", () => ({ + resolveControllerComposeUpArgs: () => ["up", "-d"], + shouldBuildControllerImage: () => false +})) + +vi.mock("../../src/docker-git/controller-docker.js", () => ({ + controllerContainerName: "docker-git-api", + controllerExists: () => Effect.succeed(false), + ensureControllerReachabilityNetworks: () => Effect.void, + inspectContainerNetworks: () => Effect.succeed({}), + inspectControllerPublishedPorts: () => Effect.succeed("unavailable"), + inspectControllerRevision: () => Effect.succeed(null), + prepareLocalControllerRevision: prepareLocalControllerRevisionMock, + resolveCurrentContainerNetworks: () => Effect.succeed({}), + runCompose: () => Effect.void +})) + +vi.mock("../../src/docker-git/controller-health.js", () => ({ + findReachableApiBaseUrl: () => Effect.succeed("http://127.0.0.1:3334"), + findReachableDirectHealthProbe: findReachableDirectHealthProbeMock +})) + +vi.mock("../../src/docker-git/controller-image-revision.js", () => ({ + inspectControllerImageRevision: () => Effect.succeed(null) +})) + +vi.mock("../../src/docker-git/controller-reachability.js", () => ({ + buildApiBaseUrlCandidates: () => [], + formatNetworkIps: () => "unavailable", + isRemoteDockerHost: () => false, + resolveApiPort: () => "3334", + resolveConfiguredApiBaseUrl: () => "http://127.0.0.1:3334", + resolveExplicitApiBaseUrl: () => process.env["DOCKER_GIT_API_URL"]?.trim() || undefined, + shouldRequireExplicitApiUrlForRemoteDocker: () => false, + trimTrailingSlashes: (value: string) => { + let end = value.length + while (end > 0 && value[end - 1] === "/") { + end -= 1 + } + return value.slice(0, end) + } +})) + +vi.mock("../../src/docker-git/controller-resource-limits-shell.js", () => ({ + prepareControllerResourceLimitEnv: prepareControllerResourceLimitEnvMock, + shouldForceRecreateForControllerResourceLimits: () => false +})) + +vi.mock("../../src/docker-git/controller-revision.js", () => ({ + shouldForceRecreateController: () => false +})) + +vi.mock("../../src/docker-git/controller-runtime-shell.js", () => ({ + prepareControllerRuntimeEnv: prepareControllerRuntimeEnvMock +})) + +describe("controller readiness bootstrap", () => { + beforeEach(() => { + Reflect.deleteProperty(process.env, "DOCKER_GIT_API_URL") + prepareLocalControllerRevisionMock.mockReset() + findReachableDirectHealthProbeMock.mockReset() + prepareControllerResourceLimitEnvMock.mockReset() + prepareControllerRuntimeEnvMock.mockReset() + prepareLocalControllerRevisionMock.mockImplementation(() => Effect.succeed("local-revision")) + prepareControllerResourceLimitEnvMock.mockImplementation(() => Effect.void) + prepareControllerRuntimeEnvMock.mockImplementation(() => Effect.void) + }) + + it.effect("probes explicit API URL before preparing a local controller revision", () => + Effect.gen(function*(_) { + process.env["DOCKER_GIT_API_URL"] = "http://api.example.test" + findReachableDirectHealthProbeMock.mockImplementation(({ explicitApiBaseUrl }) => + Effect.succeed({ + apiBaseUrl: explicitApiBaseUrl ?? "http://api.example.test", + revision: "remote-revision" + }) + ) + + const { ensureControllerReady, resolveApiBaseUrl } = yield* _( + Effect.promise(() => import("../../src/docker-git/controller.js")) + ) + + yield* _(ensureControllerReady().pipe(Effect.provide(NodeContext.layer))) + + expect(findReachableDirectHealthProbeMock).toHaveBeenCalledTimes(1) + expect(prepareLocalControllerRevisionMock).not.toHaveBeenCalled() + expect(prepareControllerResourceLimitEnvMock).not.toHaveBeenCalled() + expect(prepareControllerRuntimeEnvMock).not.toHaveBeenCalled() + expect(resolveApiBaseUrl()).toBe("http://api.example.test") + })) +}) +/* jscpd:ignore-end */ diff --git a/packages/app/tests/docker-git/controller-resource-limits.test.ts b/packages/app/tests/docker-git/controller-resource-limits.test.ts index 6c7693c8..c5e7bdae 100644 --- a/packages/app/tests/docker-git/controller-resource-limits.test.ts +++ b/packages/app/tests/docker-git/controller-resource-limits.test.ts @@ -17,6 +17,7 @@ import { } from "../../src/docker-git/controller-resource-limits.js" const composeFiles: ReadonlyArray = ["docker-compose.yml", "docker-compose.api.yml"] +const isolatedComposeFiles: ReadonlyArray = ["docker-compose.isolated.yml", "docker-compose.api.isolated.yml"] const readComposeFile = (relativePath: string): Effect.Effect => Effect.gen(function*(_) { @@ -51,6 +52,31 @@ describe("controller compose resource limits", () => { })) }) } + + for (const composeFile of isolatedComposeFiles) { + describe(composeFile, () => { + it.effect("removes the host Docker socket bind in isolated runtime overlays", () => + Effect.gen(function*(_) { + const contents = yield* _(readComposeFile(composeFile)) + expect(contents).toContain("volumes: !override") + expect(contents).not.toContain("/var/run/docker.sock:/var/run/docker.sock") + })) + + it.effect("defaults project containers to the embedded controller daemon", () => + Effect.gen(function*(_) { + const contents = yield* _(readComposeFile(composeFile)) + expect(contents).toContain( + "DOCKER_GIT_PROJECT_DOCKER_HOST: ${DOCKER_GIT_PROJECT_DOCKER_HOST:-tcp://host.docker.internal:2375}" + ) + })) + + it.effect("enables privileged controller mode for the embedded Docker daemon", () => + Effect.gen(function*(_) { + const contents = yield* _(readComposeFile(composeFile)) + expect(contents).toContain("privileged: ${DOCKER_GIT_CONTROLLER_PRIVILEGED:-true}") + })) + }) + } }) describe("controller resource limit resolution", () => { diff --git a/packages/app/tests/docker-git/controller.test.ts b/packages/app/tests/docker-git/controller.test.ts index 1af7e393..4b400eff 100644 --- a/packages/app/tests/docker-git/controller.test.ts +++ b/packages/app/tests/docker-git/controller.test.ts @@ -9,13 +9,17 @@ import { import { controllerRevisionForMode, parseControllerBuildSkillerMode, + parseControllerDockerRuntime, parseControllerGpuMode } from "../../src/docker-git/controller-docker.js" +import { resolveCurrentContainerName } from "../../src/docker-git/controller-hostname.js" +import { shouldRequireExplicitApiUrlForRemoteDocker } from "../../src/docker-git/controller-reachability.js" import { parseControllerRevisionEnvOutput, parseControllerRevisionLabelOutput, shouldForceRecreateController } from "../../src/docker-git/controller-revision.js" +import { resolveProjectDockerHostForRuntime } from "../../src/docker-git/controller-runtime.js" import { buildApiBaseUrlCandidates, isRemoteDockerHost } from "../../src/docker-git/controller.js" /** @@ -48,6 +52,33 @@ 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") +const controllerDockerRuntimeArbitrary = fc.constantFrom<"host" | "isolated">("host", "isolated") +const dockerHostArbitrary = fc.constantFrom( + undefined, + "", + " unix:///var/run/docker.sock ", + "tcp://docker.example.test:2376", + " ssh://docker@example.test " +) +const explicitApiBaseUrlArbitrary = fc.option(fc.webUrl(), { nil: undefined }) +const dockerNetworkNameArbitrary = fc + .string({ maxLength: 16, minLength: 1 }) + .filter((value) => value.trim().length > 0) +const dockerNetworkIpArbitrary = fc + .tuple( + fc.integer({ max: 255, min: 1 }), + fc.integer({ max: 255, min: 0 }), + fc.integer({ max: 255, min: 0 }), + fc.integer({ max: 254, min: 1 }) + ) + .map((octets) => joinIp(...octets.map(String))) +const dockerNetworkIpsArbitrary = fc.dictionary(dockerNetworkNameArbitrary, dockerNetworkIpArbitrary) + describe("controller reachability", () => { it.effect("builds direct API candidates without Docker inspection", () => Effect.sync(() => { @@ -115,6 +146,41 @@ describe("controller reachability", () => { expect(isRemoteDockerHost("ssh://docker@example.test")).toBe(true) })) + it.effect("requires an explicit API URL only for non-inspectable remote Docker hosts", () => + Effect.sync(() => { + fc.assert( + fc.property( + dockerHostArbitrary, + explicitApiBaseUrlArbitrary, + dockerNetworkIpsArbitrary, + (dockerHost, explicitApiBaseUrl, currentContainerNetworks) => { + const expected = isRemoteDockerHost(dockerHost) && + explicitApiBaseUrl === undefined && + Object.keys(currentContainerNetworks).length === 0 + + expect( + shouldRequireExplicitApiUrlForRemoteDocker(dockerHost, explicitApiBaseUrl, currentContainerNetworks) + ).toBe(expected) + } + ) + ) + })) + + it.effect("resolves the current container name from HOSTNAME or OS hostname", () => + Effect.sync(() => { + fc.assert( + fc.property( + fc.option(fc.string(), { nil: undefined }), + fc.string(), + (envHostname, systemHostname) => { + expect(resolveCurrentContainerName(envHostname, systemHostname)).toBe( + envHostname?.trim() || systemHostname.trim() + ) + } + ) + ) + })) + it.effect("parses controller revision from container env output", () => Effect.sync(() => { const parsed = parseControllerRevisionEnvOutput( @@ -213,10 +279,43 @@ describe("controller reachability", () => { expect(parseControllerBuildSkillerMode("skip")).toBeNull() })) - it.effect("includes controller GPU and Skiller build modes in the revision", () => + it.effect("parses controller Docker runtime from environment values", () => + Effect.sync(() => { + expect(parseControllerDockerRuntime()).toBe("host") + expect(parseControllerDockerRuntime("")).toBe("host") + expect(parseControllerDockerRuntime("host")).toBe("host") + expect(parseControllerDockerRuntime("isolated")).toBe("isolated") + expect(parseControllerDockerRuntime("remote")).toBeNull() + })) + + it.effect("defaults isolated project containers to the embedded controller daemon", () => Effect.sync(() => { - 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") + expect(resolveProjectDockerHostForRuntime("host")).toBe("") + expect(resolveProjectDockerHostForRuntime("host", " tcp://custom:2375 ")).toBe("tcp://custom:2375") + expect(resolveProjectDockerHostForRuntime("isolated")).toBe("tcp://host.docker.internal:2375") + expect(resolveProjectDockerHostForRuntime("isolated", " tcp://custom:2375 ")).toBe("tcp://custom:2375") + })) + + it.effect("includes controller runtime, GPU and Skiller build modes in the revision", () => + Effect.sync(() => { + expect(controllerRevisionForMode("abc123def4567890", "none")).toBe("abc123def4567890-host-none-skiller1") + expect(controllerRevisionForMode("abc123def4567890", "all")).toBe("abc123def4567890-host-all-skiller1") + expect(controllerRevisionForMode("abc123def4567890", "none", "0", "isolated")).toBe( + "abc123def4567890-isolated-none-skiller0" + ) + + fc.assert( + fc.property( + controllerSourceRevisionArbitrary, + controllerGpuModeArbitrary, + controllerBuildSkillerModeArbitrary, + controllerDockerRuntimeArbitrary, + (sourceRevision, gpuMode, buildSkillerMode, dockerRuntime) => { + expect(controllerRevisionForMode(sourceRevision, gpuMode, buildSkillerMode, dockerRuntime)).toBe( + `${sourceRevision}-${dockerRuntime}-${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))) + }) + ) diff --git a/scripts/e2e/_lib.sh b/scripts/e2e/_lib.sh index b294514f..4cfa6316 100644 --- a/scripts/e2e/_lib.sh +++ b/scripts/e2e/_lib.sh @@ -417,7 +417,31 @@ dg_project_ssh_to_container() { shift 2 if ! dg_has_project_docker_access; then - "$local_ssh_command" "$@" + local local_ssh_exit=0 + if "$local_ssh_command" "$@"; then + return 0 + else + local_ssh_exit=$? + fi + + if [[ "$local_ssh_exit" != "255" ]]; then + return "$local_ssh_exit" + fi + + local local_container_ip + local_container_ip="$(dg_project_container_ip "$container")" + if [[ -z "$local_container_ip" ]]; then + return "$local_ssh_exit" + fi + + local local_identity_arg="" + local_identity_arg="$(dg_find_ssh_identity_arg "$@" || true)" + local local_rewritten=() + while IFS= read -r -d '' arg; do + local_rewritten+=("$arg") + done < <(dg_rewrite_ssh_args_for_project_container "$local_container_ip" "$local_identity_arg" "$@") + + "$local_ssh_command" "${local_rewritten[@]}" return fi diff --git a/scripts/e2e/browser-command.sh b/scripts/e2e/browser-command.sh index 5670f8b7..8138ddbb 100755 --- a/scripts/e2e/browser-command.sh +++ b/scripts/e2e/browser-command.sh @@ -17,6 +17,7 @@ STATE_PATH="$ROOT/.orch/state/browser-frontend.json" FAILURE_DUMPED=0 BROWSER_PID="" BROWSER_STARTUP_ATTEMPTS="${DOCKER_GIT_E2E_BROWSER_STARTUP_ATTEMPTS:-240}" +RESOLVED_API_BASE_URL="" export DOCKER_GIT_PROJECTS_ROOT="$ROOT" export DOCKER_GIT_PROJECTS_ROOT_VOLUME="docker-git-e2e-browser-$RUN_ID-projects" @@ -129,6 +130,55 @@ wait_for_http_contains() { fail "timed out waiting for endpoint: $url" } +read_logged_api_base_url() { + if [[ ! -f "$BROWSER_LOG" ]]; then + return 1 + fi + + local line="" + local url="" + line="$(grep -F " for API http" "$BROWSER_LOG" | tail -n 1 || true)" + if [[ -z "$line" ]]; then + return 1 + fi + + url="${line##* for API }" + url="${url%% *}" + url="${url%.}" + if [[ -z "$url" ]]; then + return 1 + fi + + printf '%s\n' "$url" +} + +wait_for_controller_health() { + local attempts="${1:-90}" + local local_url="http://127.0.0.1:${DOCKER_GIT_API_PORT}" + local logged_url="" + local body="" + + for _ in $(seq 1 "$attempts"); do + logged_url="$(read_logged_api_base_url || true)" + for candidate in "$logged_url" "$local_url"; do + if [[ -z "$candidate" ]]; then + continue + fi + if body="$(curl -fsS --connect-timeout 2 --max-time 5 "${candidate}/health" 2>/dev/null)" \ + && grep -Fq -- '"ok":true' <<<"$body"; then + RESOLVED_API_BASE_URL="$candidate" + return 0 + fi + done + if ! browser_alive; then + fail "browser command exited before controller became ready: ${logged_url:-$local_url}" + fi + sleep 2 + done + + fail "timed out waiting for controller endpoint: ${logged_url:-$local_url}" +} + trap 'on_error $LINENO' ERR trap cleanup EXIT @@ -148,7 +198,7 @@ fi BROWSER_PID="$!" wait_for_log_line "Ensuring docker-git API controller is current." -wait_for_http_contains "http://127.0.0.1:${DOCKER_GIT_API_PORT}/health" '"ok":true' "$BROWSER_STARTUP_ATTEMPTS" +wait_for_controller_health "$BROWSER_STARTUP_ATTEMPTS" wait_for_http_contains "http://127.0.0.1:${DOCKER_GIT_WEB_PORT}/" "docker-git browser" "$BROWSER_STARTUP_ATTEMPTS" wait_for_http_contains "http://127.0.0.1:${DOCKER_GIT_WEB_PORT}/api/health" '"ok":true' "$BROWSER_STARTUP_ATTEMPTS" wait_for_log_line "docker-git web runtime listening on http://" @@ -160,7 +210,7 @@ docker ps --format '{{.Names}}' | grep -qx "$DOCKER_GIT_API_CONTAINER_NAME" \ grep -Fq -- "\"port\": \"$DOCKER_GIT_WEB_PORT\"" "$STATE_PATH" \ || fail "expected runtime state to record web port $DOCKER_GIT_WEB_PORT" -grep -Fq -- "\"apiBaseUrl\": \"http://127.0.0.1:$DOCKER_GIT_API_PORT\"" "$STATE_PATH" \ - || fail "expected runtime state to record API base URL http://127.0.0.1:$DOCKER_GIT_API_PORT" +grep -Fq -- "\"apiBaseUrl\": \"$RESOLVED_API_BASE_URL\"" "$STATE_PATH" \ + || fail "expected runtime state to record API base URL $RESOLVED_API_BASE_URL" echo "e2e/browser-command: bun run docker-git -- browser startup verified" >&2