mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
feat(docker): add Compose deployment support
This commit is contained in:
+28
-5
@@ -23,7 +23,7 @@
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
@@ -276,6 +276,24 @@ export interface DockerMount {
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a bind source into the Docker daemon's filesystem namespace.
|
||||
*
|
||||
* A bare-host Codeman process and its Docker daemon see the same HOME, so the
|
||||
* source is returned unchanged. In Docker-outside-of-Docker deployments,
|
||||
* `runtimeHome` is the path inside Codeman while `daemonHome` is the host path
|
||||
* bind-mounted there. Sources beneath HOME must therefore be translated before
|
||||
* they are sent through the Docker socket.
|
||||
*/
|
||||
export function resolveDockerDaemonMountSource(source: string, runtimeHome: string, daemonHome?: string): string {
|
||||
const configuredDaemonHome = daemonHome?.trim();
|
||||
if (!configuredDaemonHome) return source;
|
||||
|
||||
const relativeSource = relative(resolve(runtimeHome), resolve(source));
|
||||
if (relativeSource.startsWith('..') || isAbsolute(relativeSource)) return source;
|
||||
return resolve(configuredDaemonHome, relativeSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved, IO-free context for buildDockerCreateArgs. The caller (tmux-manager)
|
||||
* resolves the environment-dependent bits (host uid, existing cred mounts, the
|
||||
@@ -299,6 +317,8 @@ export interface DockerCreateContext {
|
||||
addHostGateway: boolean;
|
||||
/** Engine host-gateway alias (host.docker.internal / host.containers.internal). */
|
||||
gatewayAlias: string;
|
||||
/** Omit --memory-swap when the host kernel cannot enforce swap limits. */
|
||||
disableSwapLimit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -316,12 +336,15 @@ function mountSpec(m: DockerMount): string {
|
||||
return `type=bind,src=${m.src},dst=${m.dst}${m.readonly ? ',readonly' : ''}`;
|
||||
}
|
||||
|
||||
function resourceFlags(resources?: DockerResourceLimits): string[] {
|
||||
function resourceFlags(resources?: DockerResourceLimits, disableSwapLimit = false): string[] {
|
||||
if (!resources) return [];
|
||||
const flags: string[] = [];
|
||||
if (resources.memory) {
|
||||
// memory-swap == memory disables swap, making --memory a REAL OOM cap.
|
||||
flags.push('--memory', resources.memory, '--memory-swap', resources.memory);
|
||||
flags.push('--memory', resources.memory);
|
||||
// memory-swap == memory disables swap where the daemon supports swap
|
||||
// accounting. Some kernels, including the deployed Unraid host, do not;
|
||||
// requesting it there emits a warning and Docker ignores the value.
|
||||
if (!disableSwapLimit) flags.push('--memory-swap', resources.memory);
|
||||
}
|
||||
if (resources.cpus) flags.push('--cpus', resources.cpus);
|
||||
if (resources.pidsLimit) flags.push('--pids-limit', String(resources.pidsLimit));
|
||||
@@ -386,7 +409,7 @@ export function buildDockerCreateArgs(ctx: DockerCreateContext): string[] {
|
||||
if (addHostGateway) args.push('--add-host', `${gatewayAlias}:host-gateway`);
|
||||
|
||||
args.push(
|
||||
...resourceFlags(docker.resources),
|
||||
...resourceFlags(docker.resources, ctx.disableSwapLimit),
|
||||
// GPU passthrough (needs the NVIDIA container toolkit on the host). No storage
|
||||
// cap is set, so the container's writable layer + volumes grow elastically as
|
||||
// data flows in (bounded only by host disk).
|
||||
|
||||
+27
-4
@@ -74,6 +74,7 @@ import {
|
||||
hostGatewayAlias,
|
||||
resolveDockerClaudeArtifacts,
|
||||
resolveDockerCredentialArtifacts,
|
||||
resolveDockerDaemonMountSource,
|
||||
type DockerCreateContext,
|
||||
type DockerMount,
|
||||
type DockerSeedCopy,
|
||||
@@ -1319,8 +1320,23 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
|
||||
const startFailMsg = shellescape(`Codeman: container ${docker.containerName} failed to start (docker daemon down?)`);
|
||||
|
||||
const imageCheck = `${base} image inspect ${image} >/dev/null 2>&1 || { echo ${imageMissingMsg}; exit 1; }`;
|
||||
// create-if-missing (idempotent): reconnect / boot recovery re-runs this exact chain.
|
||||
const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${base} ${createArgs}`;
|
||||
// create-if-missing (idempotent): reconnect / boot recovery re-runs this exact
|
||||
// chain. A daemon without swap accounting warns whenever --memory is present,
|
||||
// even when --memory-swap is omitted. In compatibility mode, retain the memory
|
||||
// cap and filter ONLY that exact warning; all other stdout/stderr and the real
|
||||
// create exit status are preserved so mount/config failures remain visible.
|
||||
// A session-unique file avoids shell variables and command substitution, both
|
||||
// of which would be expanded too early by the nested bash/tmux launch layers.
|
||||
const createOutputPath = shellescape(`/tmp/codeman-create-${sessionId}.log`);
|
||||
const filteredCreateOutput = `sed '/^WARNING: Your kernel does not support swap limit capabilities or the cgroup is not mounted\\. Memory limited without swap\\.$/d' ${createOutputPath}`;
|
||||
const removeCreateOutput = `rm -f ${createOutputPath}`;
|
||||
const createCommand = createContext.disableSwapLimit
|
||||
? `{ if ${base} ${createArgs} >${createOutputPath} 2>&1; ` +
|
||||
`then ${filteredCreateOutput}; ${removeCreateOutput}; ` +
|
||||
`elif ${base} inspect ${name} >/dev/null 2>&1; then ${removeCreateOutput}; ` +
|
||||
`else ${filteredCreateOutput} >&2; ${removeCreateOutput}; false; fi; }`
|
||||
: `${base} ${createArgs}`;
|
||||
const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${createCommand}`;
|
||||
const start = `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`;
|
||||
// Seed writable credential config from read-only host mounts ONCE per container
|
||||
// (guarded by [ -e ] so reconnects never clobber in-container config; `cp -a` for
|
||||
@@ -1431,11 +1447,18 @@ export function resolveDockerLaunchOptions(
|
||||
sessionId,
|
||||
instance: CODEMAN_INSTANCE,
|
||||
userArgs,
|
||||
credentialMounts,
|
||||
extraMounts,
|
||||
credentialMounts: credentialMounts.map((mount) => ({
|
||||
...mount,
|
||||
src: resolveDockerDaemonMountSource(mount.src, home, process.env.CODEMAN_DOCKER_HOST_HOME),
|
||||
})),
|
||||
extraMounts: extraMounts.map((mount) => ({
|
||||
...mount,
|
||||
src: resolveDockerDaemonMountSource(mount.src, home, process.env.CODEMAN_DOCKER_HOST_HOME),
|
||||
})),
|
||||
envCreate,
|
||||
addHostGateway: !isDesktop,
|
||||
gatewayAlias,
|
||||
disableSwapLimit: process.env.CODEMAN_DOCKER_DISABLE_SWAP_LIMIT === '1',
|
||||
};
|
||||
|
||||
const execEnv: Record<string, string> = {
|
||||
|
||||
@@ -26,7 +26,9 @@ import { SYNTHETIC_ADMIN, findUser } from '../user-store.js';
|
||||
|
||||
// Shared path constants used across route modules. CASES_DIR (project folders)
|
||||
// stays shared across instances; SETTINGS_PATH is per-instance runtime state.
|
||||
export const CASES_DIR = join(homedir(), 'codeman-cases');
|
||||
// Docker Compose deployments set CODEMAN_CASES_PATH to a host-absolute bind
|
||||
// mount so Docker cases can share the same workspace path with the host daemon.
|
||||
export const CASES_DIR = process.env.CODEMAN_CASES_PATH || join(homedir(), 'codeman-cases');
|
||||
export const SETTINGS_PATH = dataPath('settings.json');
|
||||
|
||||
/**
|
||||
|
||||
@@ -2994,9 +2994,9 @@ export function registerSessionRoutes(
|
||||
|
||||
casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
|
||||
docker = sessionDocker;
|
||||
// Seed resume so a relaunch resumes the case's last conversation from the
|
||||
// bind-mounted transcript (decision: resume-on-start default ON).
|
||||
if (sessionDocker.resumeOnStart && dockerCase.lastClaudeSessionId) {
|
||||
// Seed only Claude's resume id. Codex, Gemini, and the other CLIs have
|
||||
// separate conversation stores and must never receive a Claude UUID.
|
||||
if (mode === 'claude' && sessionDocker.resumeOnStart && dockerCase.lastClaudeSessionId) {
|
||||
dockerResumeId = dockerCase.lastClaudeSessionId;
|
||||
}
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user