compose-action/src/services/docker-compose.service.ts
copilot-swe-agent[bot] e7fba772e2
fix: avoid buffering compose logs during cleanup
Co-authored-by: neilime <314088+neilime@users.noreply.github.com>
2026-08-07 09:27:55 +00:00

223 lines
5.7 KiB
TypeScript

import { spawn } from "node:child_process";
import {
down,
type IDockerComposeOptions,
type IDockerComposeResult,
upAll,
upMany,
} from "docker-compose";
import type { Inputs } from "./input.service.js";
type OptionsInputs = {
dockerFlags: Inputs["dockerFlags"];
composeFiles: Inputs["composeFiles"];
composeFlags: Inputs["composeFlags"];
cwd: Inputs["cwd"];
serviceLogger: (message: string) => void;
};
export type UpInputs = OptionsInputs & {
upFlags: Inputs["upFlags"];
services: Inputs["services"];
};
export type DownInputs = OptionsInputs & { downFlags: Inputs["downFlags"] };
export type LogsInputs = OptionsInputs & { services: Inputs["services"] };
export class DockerComposeService {
async up({ upFlags, services, ...optionsInputs }: UpInputs): Promise<void> {
const options: IDockerComposeOptions = {
...this.getCommonOptions(optionsInputs),
commandOptions: upFlags,
};
try {
if (services.length > 0) {
await upMany(services, options);
return;
}
await upAll(options);
} catch (error) {
throw this.formatDockerComposeError(error);
}
}
async down({ downFlags, ...optionsInputs }: DownInputs): Promise<void> {
const options: IDockerComposeOptions = {
...this.getCommonOptions(optionsInputs),
commandOptions: downFlags,
};
try {
await down(options);
} catch (error) {
throw this.formatDockerComposeError(error);
}
}
async logs({ services, ...optionsInputs }: LogsInputs): Promise<{
error: string;
output: string;
}> {
const commandArgs = this.getDockerComposeCommandArgs("logs", {
dockerFlags: optionsInputs.dockerFlags,
composeFlags: optionsInputs.composeFlags,
composeFiles: optionsInputs.composeFiles,
commandArgs: services,
});
return new Promise((resolve) => {
let settled = false;
const childProcess = spawn("docker", commandArgs, {
cwd: optionsInputs.cwd,
});
childProcess.on("error", (error) => {
if (settled) {
return;
}
settled = true;
resolve({
error: `Unable to collect docker compose logs: ${error.message}`,
output: "",
});
});
if (!childProcess.stdout || !childProcess.stderr) {
settled = true;
resolve({
error: "Unable to collect docker compose logs: stdout/stderr unavailable",
output: "",
});
return;
}
childProcess.stdout.on("data", (chunk: Buffer | string) => {
optionsInputs.serviceLogger(chunk.toString());
});
childProcess.stderr.on("data", (chunk: Buffer | string) => {
optionsInputs.serviceLogger(chunk.toString());
});
childProcess.on("close", (exitCode, signal) => {
if (settled) {
return;
}
settled = true;
resolve({
error:
signal
? `Docker Compose logs command failed with signal ${signal}`
: exitCode !== null && exitCode !== 0
? `Docker Compose logs command failed with exit code ${exitCode}`
: "",
output: "",
});
});
});
}
private getCommonOptions({
dockerFlags,
composeFiles,
composeFlags,
cwd,
serviceLogger,
}: OptionsInputs): IDockerComposeOptions {
return {
config: composeFiles,
composeOptions: composeFlags,
cwd: cwd,
callback: (chunk) => serviceLogger(chunk.toString()),
executable: {
executablePath: "docker",
options: dockerFlags,
},
};
}
/**
* Builds docker CLI arguments in the order expected by `docker compose`.
*/
private getDockerComposeCommandArgs(
command: "logs",
{
dockerFlags,
composeFlags,
composeFiles,
commandArgs,
}: {
dockerFlags: string[];
composeFlags: string[];
composeFiles: string[];
commandArgs: string[];
},
): string[] {
return [
...dockerFlags,
"compose",
...composeFlags,
...composeFiles.flatMap((composeFile) => ["-f", composeFile]),
command,
...commandArgs,
];
}
/**
* Formats docker-compose errors into proper Error objects with readable messages
*/
private formatDockerComposeError(error: unknown): Error {
// If it's already an Error, return it
if (error instanceof Error) {
return error;
}
// Handle docker-compose result objects
if (this.isDockerComposeResult(error)) {
const parts: string[] = [];
// Add exit code information
if (error.exitCode !== null) {
parts.push(
`Docker Compose command failed with exit code ${error.exitCode}`,
);
} else {
parts.push("Docker Compose command failed");
}
// Add error stream output if available
if (error.err?.trim()) {
parts.push("\nError output:");
parts.push(error.err.trim());
}
// Add standard output if available and different from error output
if (error.out?.trim() && error.out !== error.err) {
parts.push("\nStandard output:");
parts.push(error.out.trim());
}
return new Error(parts.join("\n"));
}
// Handle string errors
if (typeof error === "string") {
return new Error(error);
}
// Fallback for unknown error types
return new Error(JSON.stringify(error));
}
/**
* Type guard to check if an object is a docker-compose result
*/
private isDockerComposeResult(error: unknown): error is IDockerComposeResult {
return (
typeof error === "object" &&
error !== null &&
"exitCode" in error &&
"err" in error &&
"out" in error
);
}
}