Files
ccs/src/docker/docker-executor.ts
T
Tam Nhu Tran 60167d3f2b fix(docker): use export for remote env vars and increase build timeout
Remote compose commands placed env vars directly before a compound
if/then statement which is invalid bash syntax. Changed to export
statements chained with &&.

Added REMOTE_DOCKER_BUILD_TIMEOUT_MS (5min) for up and update
operations that involve npm install inside the container. Quick
queries (status, config, down) keep the default 30s timeout.

Closes #832
2026-03-28 16:22:17 -04:00

378 lines
11 KiB
TypeScript

import { spawn, spawnSync } from 'child_process';
import * as path from 'path';
import {
DOCKER_CONTAINER_NAME,
DOCKER_LOG_FILES,
DOCKER_REMOTE_DIR,
type DockerAssetPaths,
createDockerConfigSummary,
getInstalledCcsVersion,
getDockerAssetPaths,
} from './docker-assets';
import type {
DockerCommandResult,
DockerCommandTarget,
DockerConfigSummary,
DockerLogsOptions,
DockerStatusResult,
DockerUpOptions,
} from './docker-types';
const LOCAL_DOCKER_SYNC_TIMEOUT_MS = 10_000;
const REMOTE_DOCKER_SYNC_TIMEOUT_MS = 30_000;
const REMOTE_DOCKER_BUILD_TIMEOUT_MS = 300_000;
function quotePosix(value: string): string {
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
}
function renderCommand(command: string, args: string[]): string {
return [command, ...args.map((arg) => quotePosix(arg))].join(' ');
}
function normalizeOutput(value: string | Buffer | null | undefined): string {
if (typeof value === 'string') return value;
return value ? value.toString('utf8') : '';
}
interface DockerSyncRunOptions {
cwd?: string;
env?: NodeJS.ProcessEnv;
remote?: boolean;
timeoutMs?: number;
}
function formatTimeout(timeoutMs: number): string {
return timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}s` : `${timeoutMs}ms`;
}
function buildTimeoutMessage(
command: string,
args: string[],
timeoutMs: number,
remote: boolean
): string {
return [
`Command timed out after ${formatTimeout(timeoutMs)} while running a ${
remote ? 'remote' : 'local'
} Docker command.`,
`Command: ${renderCommand(command, args)}`,
remote
? 'Check SSH reachability and the remote Docker host, then try again.'
: 'Check Docker availability on this machine, then try again.',
].join('\n');
}
function applyDefaultTimeouts(options: DockerSyncRunOptions): DockerSyncRunOptions {
return {
...options,
timeoutMs:
options.timeoutMs ??
(options.remote ? REMOTE_DOCKER_SYNC_TIMEOUT_MS : LOCAL_DOCKER_SYNC_TIMEOUT_MS),
};
}
function runSync(
command: string,
args: string[],
options: DockerSyncRunOptions = {}
): DockerCommandResult {
const normalizedOptions = applyDefaultTimeouts(options);
const result = spawnSync(command, args, {
cwd: normalizedOptions.cwd,
env: normalizedOptions.env,
encoding: 'utf8',
stdio: 'pipe',
timeout: normalizedOptions.timeoutMs,
windowsHide: true,
});
const errorMessage =
result.error && 'code' in result.error && result.error.code === 'ETIMEDOUT'
? buildTimeoutMessage(
command,
args,
normalizedOptions.timeoutMs ?? LOCAL_DOCKER_SYNC_TIMEOUT_MS,
normalizedOptions.remote ?? false
)
: result.error?.message;
const stderr = [normalizeOutput(result.stderr), errorMessage].filter(Boolean).join('\n');
return {
command: renderCommand(command, args),
exitCode: result.status ?? 1,
stdout: normalizeOutput(result.stdout),
stderr,
remote: normalizedOptions.remote ?? false,
};
}
function runStreaming(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: 'inherit',
windowsHide: true,
});
child.on('error', reject);
child.on('close', (code) => {
if ((code ?? 1) === 0) {
resolve();
return;
}
reject(new Error(`Command failed (${code ?? 1}): ${renderCommand(command, args)}`));
});
});
}
let cachedLocalComposePrefix: string[] | undefined;
function resolveLocalComposePrefix(): string[] {
if (cachedLocalComposePrefix) {
return [...cachedLocalComposePrefix];
}
if (
runSync('docker', ['compose', 'version'], {
timeoutMs: LOCAL_DOCKER_SYNC_TIMEOUT_MS,
}).exitCode === 0
) {
cachedLocalComposePrefix = ['docker', 'compose'];
return [...cachedLocalComposePrefix];
}
if (
runSync('docker-compose', ['version'], {
timeoutMs: LOCAL_DOCKER_SYNC_TIMEOUT_MS,
}).exitCode === 0
) {
cachedLocalComposePrefix = ['docker-compose'];
return [...cachedLocalComposePrefix];
}
throw new Error('Docker Compose is not available. Install Docker Desktop or docker-compose.');
}
function buildRemoteComposeCommand(composeArgs: string[]): string {
const suffix = composeArgs.map((arg) => quotePosix(arg)).join(' ');
return [
'if docker compose version >/dev/null 2>&1; then',
`docker compose ${suffix};`,
'elif docker-compose version >/dev/null 2>&1; then',
`docker-compose ${suffix};`,
'else',
"echo 'Docker Compose is not available on the remote host.' >&2;",
'exit 127;',
'fi',
].join(' ');
}
function buildRemoteDockerCommand(args: string[]): string {
return ['docker', ...args.map((arg) => quotePosix(arg))].join(' ');
}
interface DockerExecutorDeps {
assets?: DockerAssetPaths;
getInstalledCcsVersion?: () => string;
resolveLocalComposePrefix?: () => string[];
runSync?: (
command: string,
args: string[],
options?: DockerSyncRunOptions
) => DockerCommandResult;
runStreaming?: (command: string, args: string[]) => Promise<void>;
}
export class DockerExecutor {
private readonly assets: DockerAssetPaths;
constructor(private readonly deps: DockerExecutorDeps = {}) {
this.assets = deps.assets ?? getDockerAssetPaths();
}
getConfig(options: { host?: string; port?: number; proxyPort?: number }): DockerConfigSummary {
return createDockerConfigSummary(options);
}
up(options: DockerUpOptions): void {
if (options.host) {
this.stageRemoteAssets(options.host);
}
this.ensureSuccess(
this.runCompose(
['up', '-d', '--build'],
options,
{
CCS_NPM_VERSION: this.getInstalledCcsVersion(),
CCS_DASHBOARD_PORT: String(options.port),
CCS_CLIPROXY_PORT: String(options.proxyPort),
},
REMOTE_DOCKER_BUILD_TIMEOUT_MS
),
'Docker stack startup',
options
);
}
down(options: DockerCommandTarget): void {
this.ensureSuccess(this.runCompose(['down'], options), 'Docker stack shutdown', options);
}
status(options: DockerCommandTarget): DockerStatusResult {
const compose = this.runCompose(['ps'], options);
this.ensureSuccess(compose, 'Docker status', options);
let supervisor: DockerCommandResult | undefined;
if (compose.exitCode === 0) {
supervisor = this.runDocker(
['exec', DOCKER_CONTAINER_NAME, 'supervisorctl', '-c', '/etc/supervisord.conf', 'status'],
options
);
}
return { compose, supervisor };
}
update(options: DockerCommandTarget): void {
const script =
'npm install -g @kaitranntt/ccs@latest --force && ccs cliproxy --latest && supervisorctl -c /etc/supervisord.conf restart ccs-dashboard cliproxy';
this.ensureSuccess(
this.runDocker(
['exec', DOCKER_CONTAINER_NAME, 'sh', '-lc', script],
options,
REMOTE_DOCKER_BUILD_TIMEOUT_MS
),
'Docker stack update',
options
);
}
async logs(options: DockerLogsOptions): Promise<string | void> {
const files = options.service
? [DOCKER_LOG_FILES[options.service]]
: [DOCKER_LOG_FILES.ccs, DOCKER_LOG_FILES.cliproxy];
const touch = `mkdir -p /var/log/ccs && touch ${files.map((file) => quotePosix(file)).join(' ')}`;
const command = options.follow
? `${touch} && tail -n 100 -F ${files.map((file) => quotePosix(file)).join(' ')}`
: options.service
? `${touch} && tail -n 100 ${quotePosix(files[0])}`
: `${touch} && printf '== ccs ==\\n' && tail -n 100 ${quotePosix(
DOCKER_LOG_FILES.ccs
)} && printf '\\n== cliproxy ==\\n' && tail -n 100 ${quotePosix(DOCKER_LOG_FILES.cliproxy)}`;
if (options.follow) {
await this.runDockerStreaming(['exec', DOCKER_CONTAINER_NAME, 'sh', '-lc', command], options);
return;
}
const result = this.runDocker(['exec', DOCKER_CONTAINER_NAME, 'sh', '-lc', command], options);
this.ensureSuccess(result, 'Docker log retrieval', options);
return result.stdout;
}
private stageRemoteAssets(host: string): void {
this.ensureSuccess(
this.runSync('ssh', [host, `mkdir -p ${DOCKER_REMOTE_DIR}`], { remote: true }),
'Remote Docker asset staging',
{ host }
);
const files = [
this.assets.composeFile,
this.assets.dockerfile,
this.assets.supervisordConfig,
this.assets.entrypoint,
];
const target = `${host}:${DOCKER_REMOTE_DIR}/`;
this.ensureSuccess(
this.runSync('scp', [...files, target], { remote: true }),
'Remote Docker asset copy',
{
host,
}
);
}
private runCompose(
args: string[],
options: DockerCommandTarget,
env: Record<string, string> = {},
timeoutMs?: number
): DockerCommandResult {
if (!options.host) {
const prefix = this.resolveLocalComposePrefix();
const command = prefix[0];
const composeArgs = [...prefix.slice(1), '-f', this.assets.composeFile, ...args];
return this.runSync(command, composeArgs, {
cwd: path.dirname(this.assets.composeFile),
env: { ...process.env, ...env },
remote: false,
timeoutMs,
});
}
const envExports = Object.entries(env)
.map(([key, value]) => `export ${key}=${quotePosix(value)}`)
.join(' && ');
const composeArgs = ['-f', path.basename(this.assets.composeFile), ...args];
const remoteCommand = `cd ${DOCKER_REMOTE_DIR}${envExports ? ` && ${envExports}` : ''} && ${buildRemoteComposeCommand(composeArgs)}`;
return this.runSync('ssh', [options.host, remoteCommand], { remote: true, timeoutMs });
}
private runDocker(
args: string[],
options: DockerCommandTarget,
timeoutMs?: number
): DockerCommandResult {
if (!options.host) {
return this.runSync('docker', args, { timeoutMs });
}
return this.runSync('ssh', [options.host, buildRemoteDockerCommand(args)], {
remote: true,
timeoutMs,
});
}
private async runDockerStreaming(args: string[], options: DockerCommandTarget): Promise<void> {
if (!options.host) {
await this.runStreaming('docker', args);
return;
}
await this.runStreaming('ssh', [options.host, buildRemoteDockerCommand(args)]);
}
private getInstalledCcsVersion(): string {
return this.deps.getInstalledCcsVersion?.() ?? getInstalledCcsVersion();
}
private resolveLocalComposePrefix(): string[] {
return this.deps.resolveLocalComposePrefix?.() ?? resolveLocalComposePrefix();
}
private runSync(
command: string,
args: string[],
options: DockerSyncRunOptions = {}
): DockerCommandResult {
const normalizedOptions = applyDefaultTimeouts(options);
return (
this.deps.runSync?.(command, args, normalizedOptions) ??
runSync(command, args, normalizedOptions)
);
}
private async runStreaming(command: string, args: string[]): Promise<void> {
await (this.deps.runStreaming?.(command, args) ?? runStreaming(command, args));
}
private ensureSuccess(
result: DockerCommandResult,
label: string,
options: DockerCommandTarget
): void {
if (result.exitCode === 0) {
return;
}
const detail = (result.stderr || result.stdout).trim();
const hint =
options.host && /No such file|no configuration file|can't cd|not found/i.test(detail)
? `\nRun \`ccs docker up --host ${options.host}\` first.`
: '';
throw new Error(`${label} failed.${detail ? `\n${detail}` : ''}${hint}`);
}
}