Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docker-compose.api.gpu.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
services:
api:
gpus: all
1 change: 0 additions & 1 deletion docker-compose.api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ services:
volumes:
- docker_git_projects:${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git}
- docker_git_docker_data:/var/lib/docker
gpus: all
privileged: true
cgroup: host
init: true
Expand Down
3 changes: 3 additions & 0 deletions docker-compose.gpu.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
services:
api:
gpus: all
1 change: 0 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ services:
volumes:
- docker_git_projects:${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git}
- docker_git_docker_data:/var/lib/docker
gpus: all
privileged: true
cgroup: host
init: true
Expand Down
147 changes: 147 additions & 0 deletions packages/app/src/docker-git/controller-compose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
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 { computeLocalControllerRevision, controllerRevisionEnvKey } from "./controller-revision.js"
import { findExistingUpwards } from "./frontend-lib/usecases/path-helpers.js"
import type { ControllerBootstrapError } from "./host-errors.js"

export const controllerGpuModeEnvKey = "DOCKER_GIT_CONTROLLER_GPU"

export type ControllerGpuMode = "none" | "all"

export type ControllerComposeFiles = {
readonly composePath: string
readonly gpuOverlayPath: string | null
}

const controllerBootstrapError = (message: string): ControllerBootstrapError => ({
_tag: "ControllerBootstrapError",
message
})

export const parseControllerGpuMode = (raw?: string): ControllerGpuMode | null => {
const trimmed = raw?.trim() ?? ""
if (trimmed.length === 0 || trimmed === "none") {
return "none"
}
return trimmed === "all" ? "all" : null
}

export const controllerRevisionForMode = (
sourceRevision: string,
gpuMode: ControllerGpuMode
): string => `${sourceRevision}-${gpuMode}`

const loadControllerGpuMode = (): Effect.Effect<ControllerGpuMode, ControllerBootstrapError> => {
const raw = process.env[controllerGpuModeEnvKey]
const parsed = parseControllerGpuMode(raw)
if (parsed !== null) {
return Effect.succeed(parsed)
}
return Effect.fail(
controllerBootstrapError(
`${controllerGpuModeEnvKey} must be unset or one of: none, all. Received: ${raw ?? ""}`
)
)
}

const composeFilePath = (): Effect.Effect<string, PlatformError, FileSystem.FileSystem | Path.Path> =>
Effect.gen(function*(_) {
const fs = yield* _(FileSystem.FileSystem)
const path = yield* _(Path.Path)
const found = yield* _(findExistingUpwards(fs, path, process.cwd(), "docker-compose.yml", 20))
return found ?? path.resolve(process.cwd(), "docker-compose.yml")
})

const mapComposePathError = (error: PlatformError): ControllerBootstrapError =>
controllerBootstrapError(`Failed to resolve docker-compose.yml path.\nDetails: ${String(error)}`)

const mapControllerRevisionError = (error: PlatformError): ControllerBootstrapError =>
controllerBootstrapError(`Failed to compute docker-git controller revision.\nDetails: ${String(error)}`)

export const composeFilesForMode = (
composePath: string,
gpuOverlayPath: string | null
): ReadonlyArray<string> =>
gpuOverlayPath === null
? ["-f", composePath]
: ["-f", composePath, "-f", gpuOverlayPath]

const requireGpuOverlayPath = (
composePath: string
): Effect.Effect<string, ControllerBootstrapError, FileSystem.FileSystem | Path.Path> =>
Effect.gen(function*(_) {
const fs = yield* _(FileSystem.FileSystem)
const path = yield* _(Path.Path)
const gpuOverlayPath = path.join(path.dirname(composePath), "docker-compose.gpu.yml")
const exists = yield* _(fs.exists(gpuOverlayPath).pipe(Effect.mapError(mapComposePathError)))
return exists
? gpuOverlayPath
: yield* _(
Effect.fail(
controllerBootstrapError(`${controllerGpuModeEnvKey}=all requires ${gpuOverlayPath}, but it was not found.`)
)
)
})

const composeFilesForGpuMode = (
composePath: string,
gpuMode: ControllerGpuMode
): Effect.Effect<ControllerComposeFiles, ControllerBootstrapError, FileSystem.FileSystem | Path.Path> =>
gpuMode === "none"
? Effect.succeed({ composePath, gpuOverlayPath: null })
: requireGpuOverlayPath(composePath).pipe(
Effect.map((gpuOverlayPath) => ({ composePath, gpuOverlayPath }))
)

type ComposePathAndGpuMode = {
readonly composePath: string
readonly gpuMode: ControllerGpuMode
}

const withComposePathAndGpuMode = <A>(
effect: (input: ComposePathAndGpuMode) => Effect.Effect<
A,
ControllerBootstrapError,
FileSystem.FileSystem | Path.Path
>
): Effect.Effect<A, ControllerBootstrapError, FileSystem.FileSystem | Path.Path> =>
composeFilePath().pipe(
Effect.mapError(mapComposePathError),
Effect.flatMap((composePath) =>
loadControllerGpuMode().pipe(
Effect.flatMap((gpuMode) => effect({ composePath, gpuMode }))
)
)
)

export const resolveControllerComposeFiles = (): Effect.Effect<
ControllerComposeFiles,
ControllerBootstrapError,
FileSystem.FileSystem | Path.Path
> => withComposePathAndGpuMode(({ composePath, gpuMode }) => composeFilesForGpuMode(composePath, gpuMode))

const computeControllerRevision = (
composePath: string,
gpuMode: ControllerGpuMode
): Effect.Effect<string, ControllerBootstrapError, FileSystem.FileSystem | Path.Path> =>
computeLocalControllerRevision(composePath).pipe(
Effect.mapError(mapControllerRevisionError),
Effect.map((revision) => controllerRevisionForMode(revision, gpuMode))
)

const persistControllerRevision = (revision: string): Effect.Effect<void> =>
Effect.sync(() => {
process.env[controllerRevisionEnvKey] = revision
})

export const prepareControllerRevision = (): Effect.Effect<
string,
ControllerBootstrapError,
FileSystem.FileSystem | Path.Path
> =>
withComposePathAndGpuMode(({ composePath, gpuMode }) => computeControllerRevision(composePath, gpuMode)).pipe(
Effect.tap((revision) => persistControllerRevision(revision))
)
59 changes: 9 additions & 50 deletions packages/app/src/docker-git/controller-docker.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
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 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 {
runCommandCapture,
runCommandExitCode,
Expand All @@ -18,13 +18,11 @@ import {
resolveConfiguredApiBaseUrl,
uniqueStrings
} from "./controller-reachability.js"
import {
computeLocalControllerRevision,
controllerRevisionEnvKey,
parseControllerRevisionEnvOutput
} from "./controller-revision.js"
import { parseControllerRevisionEnvOutput } from "./controller-revision.js"
import type { ControllerBootstrapError } from "./host-errors.js"

export { controllerGpuModeEnvKey, controllerRevisionForMode, parseControllerGpuMode } from "./controller-compose.js"

export type ControllerRuntime =
| CommandExecutor.CommandExecutor
| FileSystem.FileSystem
Expand All @@ -41,33 +39,6 @@ const controllerBootstrapError = (message: string): ControllerBootstrapError =>
message
})

const composeFilePath = (): Effect.Effect<string, PlatformError, FileSystem.FileSystem | Path.Path> =>
Effect.gen(function*(_) {
const fs = yield* _(FileSystem.FileSystem)
const path = yield* _(Path.Path)
let current = process.cwd()

for (;;) {
const candidate = path.join(current, "docker-compose.yml")
const exists = yield* _(fs.exists(candidate))
if (exists) {
return candidate
}

const parent = path.dirname(current)
if (parent === current) {
return path.resolve(process.cwd(), "docker-compose.yml")
}
current = parent
}
})

const mapComposePathError = (error: PlatformError): ControllerBootstrapError =>
controllerBootstrapError(`Failed to resolve docker-compose.yml path.\nDetails: ${String(error)}`)

const mapControllerRevisionError = (error: PlatformError): ControllerBootstrapError =>
controllerBootstrapError(`Failed to compute docker-git controller revision.\nDetails: ${String(error)}`)

const currentProcessEnv = (): Readonly<Record<string, string>> =>
Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined)
Expand Down Expand Up @@ -211,11 +182,10 @@ export const runCompose = (
): Effect.Effect<void, ControllerBootstrapError, ControllerRuntime> =>
Effect.gen(function*(_) {
const dockerCommand = yield* _(resolveDockerCommand())
const composePath = yield* _(composeFilePath().pipe(Effect.mapError(mapComposePathError)))
const composeFiles = yield* _(resolveControllerComposeFiles())
const invocation = buildDockerInvocation(dockerCommand, [
"compose",
"-f",
composePath,
...composeFilesForMode(composeFiles.composePath, composeFiles.gpuOverlayPath),
...args
])
const exitCode = yield* _(
Expand Down Expand Up @@ -269,18 +239,7 @@ export const inspectControllerRevision = (): Effect.Effect<
)

export const prepareLocalControllerRevision = (): Effect.Effect<string, ControllerBootstrapError, ControllerRuntime> =>
Effect.gen(function*(_) {
const composePath = yield* _(composeFilePath().pipe(Effect.mapError(mapComposePathError)))
const revision = yield* _(
computeLocalControllerRevision(composePath).pipe(Effect.mapError(mapControllerRevisionError))
)
yield* _(
Effect.sync(() => {
process.env[controllerRevisionEnvKey] = revision
})
)
return revision
})
prepareControllerRevision()

export const inspectContainerNetworks = (
containerName: string
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/docker-git/controller-revision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const controllerRevisionEnvKey = "DOCKER_GIT_CONTROLLER_REV"

const controllerRevisionInputs: ReadonlyArray<string> = [
"docker-compose.yml",
"docker-compose.gpu.yml",
"package.json",
"bun.lock",
"bunfig.toml",
Expand Down
3 changes: 1 addition & 2 deletions packages/app/src/docker-git/frontend-lib/core/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,7 @@ export type Command =
export const isDockerNetworkMode = (value: string): value is DockerNetworkMode =>
value === "shared" || value === "project"

export const isGpuMode = (value: string): value is GpuMode =>
value === "none" || value === "all"
export const isGpuMode = (value: string): value is GpuMode => value === "none" || value === "all"

// CHANGE: derive compose network name from typed template config
// WHY: keep network naming deterministic across template generation and runtime checks
Expand Down
3 changes: 1 addition & 2 deletions packages/app/src/lib/core/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,8 +242,7 @@ export type Command =
export const isDockerNetworkMode = (value: string): value is DockerNetworkMode =>
value === "shared" || value === "project"

export const isGpuMode = (value: string): value is GpuMode =>
value === "none" || value === "all"
export const isGpuMode = (value: string): value is GpuMode => value === "none" || value === "all"

// CHANGE: derive compose network name from typed template config
// WHY: keep network naming deterministic across template generation and runtime checks
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/lib/core/templates/docker-compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,9 @@ const renderComposeServices = (
build: .
container_name: ${config.containerName}
restart: unless-stopped
${renderGpu(config.gpu)}${renderEnvFiles(config)} # runtime auth/env must be loaded into the container process, not only bootstrap scripts
${renderGpu(config.gpu)}${
renderEnvFiles(config)
} # runtime auth/env must be loaded into the container process, not only bootstrap scripts
environment:
REPO_URL: "${config.repoUrl}"
REPO_REF: "${config.repoRef}"
Expand Down
22 changes: 13 additions & 9 deletions packages/app/src/lib/usecases/apply-overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,20 @@
import type { ApplyCommand, TemplateConfig } from "../core/domain.js"
import { normalizeAuthLabel, normalizeGitTokenLabel } from "../core/token-labels.js"

const applyOverrideKeys = [
"gitTokenLabel",
"codexTokenLabel",
"claudeTokenLabel",
"cpuLimit",
"ramLimit",
"playwrightCpuLimit",
"playwrightRamLimit",
"gpu",
"enableMcpPlaywright"
] satisfies ReadonlyArray<keyof ApplyCommand>

export const hasApplyOverrides = (command: ApplyCommand): boolean =>
command.gitTokenLabel !== undefined ||
command.codexTokenLabel !== undefined ||
command.claudeTokenLabel !== undefined ||
command.cpuLimit !== undefined ||
command.ramLimit !== undefined ||
command.playwrightCpuLimit !== undefined ||
command.playwrightRamLimit !== undefined ||
command.gpu !== undefined ||
command.enableMcpPlaywright !== undefined
applyOverrideKeys.some((key) => command[key] !== undefined)

const applyTokenOverrides = (template: TemplateConfig, command: ApplyCommand): TemplateConfig => {
let next = template
Expand Down
Loading