mirror of
https://github.com/hoverkraft-tech/compose-action.git
synced 2026-08-31 23:52:49 +08:00
Compare commits
4 Commits
610019824f
...
6f330cd1dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f330cd1dc | ||
|
|
75b024b03b | ||
|
|
26f805aa21 | ||
|
|
e56276d1d3 |
3
.github/linters/.codespellrc
vendored
Normal file
3
.github/linters/.codespellrc
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
[codespell]
|
||||
# Skip third-party/generated paths and French-locale content files.
|
||||
skip = .git,*.svg,**/package-lock.json,**/dist/**,**/node_modules/**,**/coverage/**,**/junit.xml
|
||||
2
.github/workflows/__check-dist.yml
vendored
2
.github/workflows/__check-dist.yml
vendored
@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
- name: Build dist/ Directory
|
||||
id: package
|
||||
run: ${{ steps.setup-node.outputs.run-script-command }} package
|
||||
run: npm run package
|
||||
|
||||
# This will fail the workflow if the PR wasn't created by Dependabot.
|
||||
- name: Compare Directories
|
||||
|
||||
107
dist/index.js
generated
vendored
107
dist/index.js
generated
vendored
@ -26549,7 +26549,7 @@ var require_dist2 = __commonJS({
|
||||
return (0, exports.restartMany)([service], options);
|
||||
};
|
||||
exports.restartOne = restartOne;
|
||||
var logs2 = function(services, options = {}) {
|
||||
var logs = function(services, options = {}) {
|
||||
const args = Array.isArray(services) ? services : [services];
|
||||
if (options.follow) {
|
||||
args.unshift("--follow");
|
||||
@ -26559,7 +26559,7 @@ var require_dist2 = __commonJS({
|
||||
}
|
||||
return (0, exports.execCompose)("logs", args, options);
|
||||
};
|
||||
exports.logs = logs2;
|
||||
exports.logs = logs;
|
||||
var port = async function(service, containerPort, options) {
|
||||
const args = [service, containerPort];
|
||||
try {
|
||||
@ -31374,6 +31374,7 @@ function info(message) {
|
||||
|
||||
// src/services/docker-compose.service.ts
|
||||
var import_docker_compose = __toESM(require_dist2(), 1);
|
||||
import { spawn as spawn2 } from "node:child_process";
|
||||
var DockerComposeService = class {
|
||||
async up({ upFlags, services, ...optionsInputs }) {
|
||||
const options = {
|
||||
@ -31402,15 +31403,48 @@ var DockerComposeService = class {
|
||||
}
|
||||
}
|
||||
async logs({ services, ...optionsInputs }) {
|
||||
const options = {
|
||||
...this.getCommonOptions(optionsInputs),
|
||||
follow: false
|
||||
};
|
||||
const { err, out } = await (0, import_docker_compose.logs)(services, options);
|
||||
return {
|
||||
error: err,
|
||||
output: out
|
||||
};
|
||||
const options = this.getCommonOptions(optionsInputs);
|
||||
const { executablePath, executableArgs } = this.getDockerComposeCommandExecution("logs", services, options);
|
||||
return new Promise((resolve2) => {
|
||||
let settled = false;
|
||||
const childProcess = spawn2(executablePath, executableArgs, {
|
||||
cwd: options.cwd
|
||||
});
|
||||
childProcess.on("error", (error2) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve2({
|
||||
error: `Unable to collect docker compose logs: ${error2.message}`,
|
||||
output: ""
|
||||
});
|
||||
});
|
||||
if (!childProcess.stdout || !childProcess.stderr) {
|
||||
settled = true;
|
||||
resolve2({
|
||||
error: "Unable to collect docker compose logs: stdout/stderr unavailable",
|
||||
output: ""
|
||||
});
|
||||
return;
|
||||
}
|
||||
childProcess.stdout.on("data", (chunk) => {
|
||||
options.callback?.(Buffer.from(chunk), "stdout");
|
||||
});
|
||||
childProcess.stderr.on("data", (chunk) => {
|
||||
options.callback?.(Buffer.from(chunk), "stderr");
|
||||
});
|
||||
childProcess.on("close", (exitCode, signal) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve2({
|
||||
error: signal ? `Docker Compose logs command failed with signal ${signal}` : exitCode !== null && exitCode !== 0 ? `Docker Compose logs command failed with exit code ${exitCode}` : "",
|
||||
output: ""
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
getCommonOptions({
|
||||
dockerFlags,
|
||||
@ -31434,8 +31468,51 @@ var DockerComposeService = class {
|
||||
* Formats docker-compose errors into proper Error objects with readable messages
|
||||
*/
|
||||
formatDockerComposeError(error2) {
|
||||
return new Error(this.getDockerComposeErrorMessage(error2));
|
||||
}
|
||||
getDockerComposeCommandExecution(command, commandArgs, options) {
|
||||
const composeArgs = [
|
||||
...this.getComposeOptionArgs(options.composeOptions),
|
||||
...this.getConfigArgs(options.config),
|
||||
command,
|
||||
...this.getComposeOptionArgs(options.commandOptions),
|
||||
...commandArgs
|
||||
];
|
||||
if (options.executable?.standalone) {
|
||||
return {
|
||||
executablePath: options.executable.executablePath ?? "docker-compose",
|
||||
executableArgs: composeArgs
|
||||
};
|
||||
}
|
||||
return {
|
||||
executablePath: options.executable?.executablePath ?? "docker",
|
||||
executableArgs: [
|
||||
...this.getComposeOptionArgs(options.executable?.options),
|
||||
"compose",
|
||||
...composeArgs
|
||||
]
|
||||
};
|
||||
}
|
||||
getConfigArgs(config) {
|
||||
if (typeof config === "undefined") {
|
||||
return [];
|
||||
}
|
||||
if (typeof config === "string") {
|
||||
return ["-f", config];
|
||||
}
|
||||
return config.flatMap((item) => ["-f", item]);
|
||||
}
|
||||
getComposeOptionArgs(composeOptions) {
|
||||
if (!composeOptions) {
|
||||
return [];
|
||||
}
|
||||
return composeOptions.flatMap(
|
||||
(option) => Array.isArray(option) ? option : [option]
|
||||
);
|
||||
}
|
||||
getDockerComposeErrorMessage(error2) {
|
||||
if (error2 instanceof Error) {
|
||||
return error2;
|
||||
return error2.message;
|
||||
}
|
||||
if (this.isDockerComposeResult(error2)) {
|
||||
const parts = [];
|
||||
@ -31454,12 +31531,12 @@ var DockerComposeService = class {
|
||||
parts.push("\nStandard output:");
|
||||
parts.push(error2.out.trim());
|
||||
}
|
||||
return new Error(parts.join("\n"));
|
||||
return parts.join("\n");
|
||||
}
|
||||
if (typeof error2 === "string") {
|
||||
return new Error(error2);
|
||||
return error2;
|
||||
}
|
||||
return new Error(JSON.stringify(error2));
|
||||
return JSON.stringify(error2);
|
||||
}
|
||||
/**
|
||||
* Type guard to check if an object is a docker-compose result
|
||||
|
||||
136
dist/post.js
generated
vendored
136
dist/post.js
generated
vendored
@ -26549,7 +26549,7 @@ var require_dist2 = __commonJS({
|
||||
return (0, exports.restartMany)([service], options);
|
||||
};
|
||||
exports.restartOne = restartOne;
|
||||
var logs2 = function(services, options = {}) {
|
||||
var logs = function(services, options = {}) {
|
||||
const args = Array.isArray(services) ? services : [services];
|
||||
if (options.follow) {
|
||||
args.unshift("--follow");
|
||||
@ -26559,7 +26559,7 @@ var require_dist2 = __commonJS({
|
||||
}
|
||||
return (0, exports.execCompose)("logs", args, options);
|
||||
};
|
||||
exports.logs = logs2;
|
||||
exports.logs = logs;
|
||||
var port = async function(service, containerPort, options) {
|
||||
const args = [service, containerPort];
|
||||
try {
|
||||
@ -27111,6 +27111,7 @@ function info(message) {
|
||||
|
||||
// src/services/docker-compose.service.ts
|
||||
var import_docker_compose = __toESM(require_dist2(), 1);
|
||||
import { spawn } from "node:child_process";
|
||||
var DockerComposeService = class {
|
||||
async up({ upFlags, services, ...optionsInputs }) {
|
||||
const options = {
|
||||
@ -27139,15 +27140,48 @@ var DockerComposeService = class {
|
||||
}
|
||||
}
|
||||
async logs({ services, ...optionsInputs }) {
|
||||
const options = {
|
||||
...this.getCommonOptions(optionsInputs),
|
||||
follow: false
|
||||
};
|
||||
const { err, out } = await (0, import_docker_compose.logs)(services, options);
|
||||
return {
|
||||
error: err,
|
||||
output: out
|
||||
};
|
||||
const options = this.getCommonOptions(optionsInputs);
|
||||
const { executablePath, executableArgs } = this.getDockerComposeCommandExecution("logs", services, options);
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const childProcess = spawn(executablePath, executableArgs, {
|
||||
cwd: options.cwd
|
||||
});
|
||||
childProcess.on("error", (error2) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve({
|
||||
error: `Unable to collect docker compose logs: ${error2.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) => {
|
||||
options.callback?.(Buffer.from(chunk), "stdout");
|
||||
});
|
||||
childProcess.stderr.on("data", (chunk) => {
|
||||
options.callback?.(Buffer.from(chunk), "stderr");
|
||||
});
|
||||
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: ""
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
getCommonOptions({
|
||||
dockerFlags,
|
||||
@ -27171,8 +27205,51 @@ var DockerComposeService = class {
|
||||
* Formats docker-compose errors into proper Error objects with readable messages
|
||||
*/
|
||||
formatDockerComposeError(error2) {
|
||||
return new Error(this.getDockerComposeErrorMessage(error2));
|
||||
}
|
||||
getDockerComposeCommandExecution(command, commandArgs, options) {
|
||||
const composeArgs = [
|
||||
...this.getComposeOptionArgs(options.composeOptions),
|
||||
...this.getConfigArgs(options.config),
|
||||
command,
|
||||
...this.getComposeOptionArgs(options.commandOptions),
|
||||
...commandArgs
|
||||
];
|
||||
if (options.executable?.standalone) {
|
||||
return {
|
||||
executablePath: options.executable.executablePath ?? "docker-compose",
|
||||
executableArgs: composeArgs
|
||||
};
|
||||
}
|
||||
return {
|
||||
executablePath: options.executable?.executablePath ?? "docker",
|
||||
executableArgs: [
|
||||
...this.getComposeOptionArgs(options.executable?.options),
|
||||
"compose",
|
||||
...composeArgs
|
||||
]
|
||||
};
|
||||
}
|
||||
getConfigArgs(config) {
|
||||
if (typeof config === "undefined") {
|
||||
return [];
|
||||
}
|
||||
if (typeof config === "string") {
|
||||
return ["-f", config];
|
||||
}
|
||||
return config.flatMap((item) => ["-f", item]);
|
||||
}
|
||||
getComposeOptionArgs(composeOptions) {
|
||||
if (!composeOptions) {
|
||||
return [];
|
||||
}
|
||||
return composeOptions.flatMap(
|
||||
(option) => Array.isArray(option) ? option : [option]
|
||||
);
|
||||
}
|
||||
getDockerComposeErrorMessage(error2) {
|
||||
if (error2 instanceof Error) {
|
||||
return error2;
|
||||
return error2.message;
|
||||
}
|
||||
if (this.isDockerComposeResult(error2)) {
|
||||
const parts = [];
|
||||
@ -27191,12 +27268,12 @@ var DockerComposeService = class {
|
||||
parts.push("\nStandard output:");
|
||||
parts.push(error2.out.trim());
|
||||
}
|
||||
return new Error(parts.join("\n"));
|
||||
return parts.join("\n");
|
||||
}
|
||||
if (typeof error2 === "string") {
|
||||
return new Error(error2);
|
||||
return error2;
|
||||
}
|
||||
return new Error(JSON.stringify(error2));
|
||||
return JSON.stringify(error2);
|
||||
}
|
||||
/**
|
||||
* Type guard to check if an object is a docker-compose result
|
||||
@ -27335,20 +27412,25 @@ async function run() {
|
||||
const inputService = new InputService();
|
||||
const dockerComposeService = new DockerComposeService();
|
||||
const inputs = inputService.getInputs();
|
||||
const { error: error2, output } = await dockerComposeService.logs({
|
||||
dockerFlags: inputs.dockerFlags,
|
||||
composeFiles: inputs.composeFiles,
|
||||
composeFlags: inputs.composeFlags,
|
||||
cwd: inputs.cwd,
|
||||
services: inputs.services,
|
||||
serviceLogger: loggerService.getServiceLogger(inputs.serviceLogLevel)
|
||||
});
|
||||
if (error2) {
|
||||
loggerService.debug(`docker compose error:
|
||||
try {
|
||||
loggerService.debug("docker compose logs:");
|
||||
const { error: error2 } = await dockerComposeService.logs({
|
||||
dockerFlags: inputs.dockerFlags,
|
||||
composeFiles: inputs.composeFiles,
|
||||
composeFlags: inputs.composeFlags,
|
||||
cwd: inputs.cwd,
|
||||
services: inputs.services,
|
||||
serviceLogger: loggerService.getServiceLogger(inputs.serviceLogLevel)
|
||||
});
|
||||
if (error2) {
|
||||
loggerService.debug(`docker compose error:
|
||||
${error2}`);
|
||||
}
|
||||
} catch (error2) {
|
||||
loggerService.warn(
|
||||
`Unable to collect docker compose logs before cleanup: ${error2 instanceof Error ? error2.message : JSON.stringify(error2)}`
|
||||
);
|
||||
}
|
||||
loggerService.debug(`docker compose logs:
|
||||
${output}`);
|
||||
await dockerComposeService.down({
|
||||
dockerFlags: inputs.dockerFlags,
|
||||
composeFiles: inputs.composeFiles,
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@ -3145,9 +3145,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.17",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
|
||||
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@ -42,6 +42,7 @@ const { DockerComposeService } = await import(
|
||||
describe("run", () => {
|
||||
let infoMock: ReturnType<typeof vi.spyOn>;
|
||||
let debugMock: ReturnType<typeof vi.spyOn>;
|
||||
let warnMock: ReturnType<typeof vi.spyOn>;
|
||||
let getInputsMock: ReturnType<typeof vi.spyOn>;
|
||||
let serviceDownMock: ReturnType<typeof vi.spyOn>;
|
||||
let serviceLogsMock: ReturnType<typeof vi.spyOn>;
|
||||
@ -55,12 +56,15 @@ describe("run", () => {
|
||||
debugMock = vi
|
||||
.spyOn(LoggerService.prototype, "debug")
|
||||
.mockImplementation(() => {});
|
||||
warnMock = vi
|
||||
.spyOn(LoggerService.prototype, "warn")
|
||||
.mockImplementation(() => {});
|
||||
getInputsMock = vi.spyOn(InputService.prototype, "getInputs");
|
||||
serviceDownMock = vi.spyOn(DockerComposeService.prototype, "down");
|
||||
serviceLogsMock = vi.spyOn(DockerComposeService.prototype, "logs");
|
||||
});
|
||||
|
||||
it("should bring down docker compose service(s) and log output", async () => {
|
||||
it("should bring down docker compose service(s)", async () => {
|
||||
// Arrange
|
||||
getInputsMock.mockImplementation(() => ({
|
||||
dockerFlags: [],
|
||||
@ -75,7 +79,7 @@ describe("run", () => {
|
||||
serviceLogLevel: LogLevel.Debug,
|
||||
}));
|
||||
|
||||
serviceLogsMock.mockResolvedValue({ error: "", output: "test logs" });
|
||||
serviceLogsMock.mockResolvedValue({ error: "", output: "" });
|
||||
serviceDownMock.mockResolvedValue();
|
||||
|
||||
// Act
|
||||
@ -100,12 +104,11 @@ describe("run", () => {
|
||||
serviceLogger: debugMock,
|
||||
});
|
||||
|
||||
expect(debugMock).toHaveBeenCalledWith("docker compose logs:\ntest logs");
|
||||
expect(infoMock).toHaveBeenCalledWith("docker compose is down");
|
||||
expect(setFailedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should log docker composer errors if any", async () => {
|
||||
it("should log docker compose command errors if any", async () => {
|
||||
// Arrange
|
||||
getInputsMock.mockImplementation(() => ({
|
||||
dockerFlags: [],
|
||||
@ -131,15 +134,40 @@ describe("run", () => {
|
||||
await run();
|
||||
|
||||
// Assert
|
||||
expect(debugMock).toHaveBeenCalledWith("docker compose logs:");
|
||||
expect(debugMock).toHaveBeenCalledWith(
|
||||
"docker compose error:\ntest logs error",
|
||||
);
|
||||
expect(debugMock).toHaveBeenCalledWith(
|
||||
"docker compose logs:\ntest logs output",
|
||||
);
|
||||
expect(infoMock).toHaveBeenCalledWith("docker compose is down");
|
||||
});
|
||||
|
||||
it("should continue cleanup when collecting logs fails", async () => {
|
||||
getInputsMock.mockImplementation(() => ({
|
||||
dockerFlags: [],
|
||||
composeFiles: ["docker-compose.yml"],
|
||||
services: [],
|
||||
composeFlags: [],
|
||||
upFlags: [],
|
||||
downFlags: [],
|
||||
cwd: "/current/working/dir",
|
||||
composeVersion: null,
|
||||
githubToken: null,
|
||||
serviceLogLevel: LogLevel.Debug,
|
||||
}));
|
||||
|
||||
serviceLogsMock.mockRejectedValue(new Error("Test logs error"));
|
||||
serviceDownMock.mockResolvedValue();
|
||||
|
||||
await run();
|
||||
|
||||
expect(warnMock).toHaveBeenCalledWith(
|
||||
"Unable to collect docker compose logs before cleanup: Test logs error",
|
||||
);
|
||||
expect(serviceDownMock).toHaveBeenCalled();
|
||||
expect(infoMock).toHaveBeenCalledWith("docker compose is down");
|
||||
expect(setFailedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should set failed when an error occurs", async () => {
|
||||
// Arrange
|
||||
getInputsMock.mockImplementation(() => {
|
||||
|
||||
@ -15,21 +15,27 @@ export async function run(): Promise<void> {
|
||||
|
||||
const inputs = inputService.getInputs();
|
||||
|
||||
const { error, output } = await dockerComposeService.logs({
|
||||
dockerFlags: inputs.dockerFlags,
|
||||
composeFiles: inputs.composeFiles,
|
||||
composeFlags: inputs.composeFlags,
|
||||
cwd: inputs.cwd,
|
||||
services: inputs.services,
|
||||
serviceLogger: loggerService.getServiceLogger(inputs.serviceLogLevel),
|
||||
});
|
||||
try {
|
||||
loggerService.debug("docker compose logs:");
|
||||
|
||||
if (error) {
|
||||
loggerService.debug(`docker compose error:\n${error}`);
|
||||
const { error } = await dockerComposeService.logs({
|
||||
dockerFlags: inputs.dockerFlags,
|
||||
composeFiles: inputs.composeFiles,
|
||||
composeFlags: inputs.composeFlags,
|
||||
cwd: inputs.cwd,
|
||||
services: inputs.services,
|
||||
serviceLogger: loggerService.getServiceLogger(inputs.serviceLogLevel),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
loggerService.debug(`docker compose error:\n${error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
loggerService.warn(
|
||||
`Unable to collect docker compose logs before cleanup: ${error instanceof Error ? error.message : JSON.stringify(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
loggerService.debug(`docker compose logs:\n${output}`);
|
||||
|
||||
await dockerComposeService.down({
|
||||
dockerFlags: inputs.dockerFlags,
|
||||
composeFiles: inputs.composeFiles,
|
||||
|
||||
@ -73,7 +73,7 @@ describe("post", () => {
|
||||
serviceLogLevel: LogLevel.Debug,
|
||||
}));
|
||||
|
||||
serviceLogsMock.mockResolvedValue({ error: "", output: "test logs" });
|
||||
serviceLogsMock.mockResolvedValue({ error: "", output: "" });
|
||||
serviceDownMock.mockResolvedValueOnce();
|
||||
|
||||
await import("./post.js");
|
||||
@ -97,10 +97,6 @@ describe("post", () => {
|
||||
serviceLogger: debugMock,
|
||||
});
|
||||
|
||||
expect(debugMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"docker compose logs:\ntest logs",
|
||||
);
|
||||
expect(infoMock).toHaveBeenNthCalledWith(1, "docker compose is down");
|
||||
|
||||
expect(setFailedMock).not.toHaveBeenCalled();
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type {
|
||||
IDockerComposeLogOptions,
|
||||
IDockerComposeOptions,
|
||||
IDockerComposeResult,
|
||||
} from "docker-compose";
|
||||
@ -17,19 +17,16 @@ const upManyMock =
|
||||
>();
|
||||
const downMock =
|
||||
vi.fn<(options: IDockerComposeOptions) => Promise<IDockerComposeResult>>();
|
||||
const logsMock =
|
||||
vi.fn<
|
||||
(
|
||||
services: string[],
|
||||
options: IDockerComposeLogOptions,
|
||||
) => Promise<IDockerComposeResult>
|
||||
>();
|
||||
const spawnMock = vi.fn();
|
||||
|
||||
vi.doMock("docker-compose", () => ({
|
||||
upAll: upAllMock,
|
||||
upMany: upManyMock,
|
||||
down: downMock,
|
||||
logs: logsMock,
|
||||
}));
|
||||
|
||||
vi.doMock("node:child_process", () => ({
|
||||
spawn: spawnMock,
|
||||
}));
|
||||
|
||||
// Dynamic import after mock setup
|
||||
@ -360,31 +357,164 @@ describe("DockerComposeService", () => {
|
||||
});
|
||||
|
||||
describe("logs", () => {
|
||||
it("should call logs with correct options", async () => {
|
||||
it("should stream logs with wrapper-compatible command arguments", async () => {
|
||||
const debugMock = vi.fn();
|
||||
const logsInputs = {
|
||||
dockerFlags: [] as string[],
|
||||
dockerFlags: ["--context", "dev"] as string[],
|
||||
composeFiles: ["docker-compose.yml"],
|
||||
services: ["helloworld2", "helloworld3"],
|
||||
composeFlags: [] as string[],
|
||||
composeFlags: ["--profile", "ci"] as string[],
|
||||
cwd: "/current/working/dir",
|
||||
serviceLogger: debugMock,
|
||||
};
|
||||
|
||||
logsMock.mockResolvedValue({ exitCode: 0, err: "", out: "logs" });
|
||||
const stdout = new EventEmitter();
|
||||
const stderr = new EventEmitter();
|
||||
const childProcess = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
};
|
||||
childProcess.stdout = stdout;
|
||||
childProcess.stderr = stderr;
|
||||
spawnMock.mockReturnValue(childProcess);
|
||||
|
||||
await service.logs(logsInputs);
|
||||
const logsPromise = service.logs(logsInputs);
|
||||
|
||||
expect(logsMock).toHaveBeenCalledWith(["helloworld2", "helloworld3"], {
|
||||
composeOptions: [],
|
||||
config: ["docker-compose.yml"],
|
||||
cwd: "/current/working/dir",
|
||||
executable: {
|
||||
executablePath: "docker",
|
||||
options: [],
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
"docker",
|
||||
[
|
||||
"--context",
|
||||
"dev",
|
||||
"compose",
|
||||
"--profile",
|
||||
"ci",
|
||||
"-f",
|
||||
"docker-compose.yml",
|
||||
"logs",
|
||||
"helloworld2",
|
||||
"helloworld3",
|
||||
],
|
||||
{
|
||||
cwd: "/current/working/dir",
|
||||
},
|
||||
follow: false,
|
||||
callback: expect.any(Function),
|
||||
);
|
||||
|
||||
stdout.emit("data", Buffer.from("logs"));
|
||||
stderr.emit("data", Buffer.from("error logs"));
|
||||
childProcess.emit("close", 0);
|
||||
|
||||
await expect(logsPromise).resolves.toEqual({ error: "", output: "" });
|
||||
|
||||
expect(debugMock).toHaveBeenNthCalledWith(1, "logs");
|
||||
expect(debugMock).toHaveBeenNthCalledWith(2, "error logs");
|
||||
});
|
||||
|
||||
it("should return a non-fatal error message when logs command fails", async () => {
|
||||
const logsInputs = {
|
||||
dockerFlags: ["--context", "dev"] as string[],
|
||||
composeFiles: ["docker-compose.yml"] as string[],
|
||||
services: [] as string[],
|
||||
composeFlags: ["--profile", "ci"] as string[],
|
||||
cwd: "/current/working/dir",
|
||||
serviceLogger: vi.fn(),
|
||||
};
|
||||
|
||||
const childProcess = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
};
|
||||
childProcess.stdout = new EventEmitter();
|
||||
childProcess.stderr = new EventEmitter();
|
||||
spawnMock.mockReturnValue(childProcess);
|
||||
|
||||
const logsPromise = service.logs(logsInputs);
|
||||
|
||||
childProcess.emit("close", 1);
|
||||
|
||||
await expect(logsPromise).resolves.toEqual({
|
||||
error: "Docker Compose logs command failed with exit code 1",
|
||||
output: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return a non-fatal error message when logs command is terminated by a signal", async () => {
|
||||
const logsInputs = {
|
||||
dockerFlags: [] as string[],
|
||||
composeFiles: ["docker-compose.yml"] as string[],
|
||||
services: [] as string[],
|
||||
composeFlags: [] as string[],
|
||||
cwd: "/current/working/dir",
|
||||
serviceLogger: vi.fn(),
|
||||
};
|
||||
|
||||
const childProcess = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
};
|
||||
childProcess.stdout = new EventEmitter();
|
||||
childProcess.stderr = new EventEmitter();
|
||||
spawnMock.mockReturnValue(childProcess);
|
||||
|
||||
const logsPromise = service.logs(logsInputs);
|
||||
|
||||
childProcess.emit("close", null, "SIGTERM");
|
||||
|
||||
await expect(logsPromise).resolves.toEqual({
|
||||
error: "Docker Compose logs command failed with signal SIGTERM",
|
||||
output: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return a non-fatal error message when spawning logs fails", async () => {
|
||||
const logsInputs = {
|
||||
dockerFlags: [] as string[],
|
||||
composeFiles: ["docker-compose.yml"] as string[],
|
||||
services: [] as string[],
|
||||
composeFlags: [] as string[],
|
||||
cwd: "/current/working/dir",
|
||||
serviceLogger: vi.fn(),
|
||||
};
|
||||
|
||||
const childProcess = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
};
|
||||
childProcess.stdout = new EventEmitter();
|
||||
childProcess.stderr = new EventEmitter();
|
||||
spawnMock.mockReturnValue(childProcess);
|
||||
|
||||
const logsPromise = service.logs(logsInputs);
|
||||
|
||||
childProcess.emit("error", new Error("spawn ENOENT"));
|
||||
|
||||
await expect(logsPromise).resolves.toEqual({
|
||||
error: "Unable to collect docker compose logs: spawn ENOENT",
|
||||
output: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return a non-fatal error message when output streams are unavailable", async () => {
|
||||
const logsInputs = {
|
||||
dockerFlags: [] as string[],
|
||||
composeFiles: ["docker-compose.yml"] as string[],
|
||||
services: [] as string[],
|
||||
composeFlags: [] as string[],
|
||||
cwd: "/current/working/dir",
|
||||
serviceLogger: vi.fn(),
|
||||
};
|
||||
|
||||
const childProcess = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter | null;
|
||||
stderr: EventEmitter | null;
|
||||
};
|
||||
childProcess.stdout = null;
|
||||
childProcess.stderr = null;
|
||||
spawnMock.mockReturnValue(childProcess);
|
||||
|
||||
await expect(service.logs(logsInputs)).resolves.toEqual({
|
||||
error:
|
||||
"Unable to collect docker compose logs: stdout/stderr unavailable",
|
||||
output: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
down,
|
||||
type IDockerComposeLogOptions,
|
||||
type IDockerComposeOptions,
|
||||
type IDockerComposeResult,
|
||||
logs,
|
||||
upAll,
|
||||
upMany,
|
||||
} from "docker-compose";
|
||||
@ -60,17 +59,62 @@ export class DockerComposeService {
|
||||
error: string;
|
||||
output: string;
|
||||
}> {
|
||||
const options: IDockerComposeLogOptions = {
|
||||
...this.getCommonOptions(optionsInputs),
|
||||
follow: false,
|
||||
};
|
||||
const options = this.getCommonOptions(optionsInputs);
|
||||
const { executablePath, executableArgs } =
|
||||
this.getDockerComposeCommandExecution("logs", services, options);
|
||||
|
||||
const { err, out } = await logs(services, options);
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const childProcess = spawn(executablePath, executableArgs, {
|
||||
cwd: options.cwd,
|
||||
});
|
||||
|
||||
return {
|
||||
error: err,
|
||||
output: out,
|
||||
};
|
||||
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) => {
|
||||
options.callback?.(Buffer.from(chunk), "stdout");
|
||||
});
|
||||
|
||||
childProcess.stderr.on("data", (chunk: Buffer | string) => {
|
||||
options.callback?.(Buffer.from(chunk), "stderr");
|
||||
});
|
||||
|
||||
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({
|
||||
@ -96,16 +140,77 @@ export class DockerComposeService {
|
||||
* 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;
|
||||
return new Error(this.getDockerComposeErrorMessage(error));
|
||||
}
|
||||
|
||||
private getDockerComposeCommandExecution(
|
||||
command: string,
|
||||
commandArgs: string[],
|
||||
options: IDockerComposeOptions,
|
||||
): {
|
||||
executablePath: string;
|
||||
executableArgs: string[];
|
||||
} {
|
||||
const composeArgs = [
|
||||
...this.getComposeOptionArgs(options.composeOptions),
|
||||
...this.getConfigArgs(options.config),
|
||||
command,
|
||||
...this.getComposeOptionArgs(options.commandOptions),
|
||||
...commandArgs,
|
||||
];
|
||||
|
||||
if (options.executable?.standalone) {
|
||||
return {
|
||||
executablePath: options.executable.executablePath ?? "docker-compose",
|
||||
executableArgs: composeArgs,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
executablePath: options.executable?.executablePath ?? "docker",
|
||||
executableArgs: [
|
||||
...this.getComposeOptionArgs(options.executable?.options),
|
||||
"compose",
|
||||
...composeArgs,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private getConfigArgs(config: IDockerComposeOptions["config"]): string[] {
|
||||
if (typeof config === "undefined") {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof config === "string") {
|
||||
return ["-f", config];
|
||||
}
|
||||
|
||||
return config.flatMap((item) => ["-f", item]);
|
||||
}
|
||||
|
||||
private getComposeOptionArgs(
|
||||
composeOptions:
|
||||
| IDockerComposeOptions["composeOptions"]
|
||||
| IDockerComposeOptions["commandOptions"]
|
||||
| NonNullable<IDockerComposeOptions["executable"]>["options"],
|
||||
): string[] {
|
||||
if (!composeOptions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return composeOptions.flatMap((option) =>
|
||||
Array.isArray(option) ? option : [option],
|
||||
);
|
||||
}
|
||||
|
||||
private getDockerComposeErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
// 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}`,
|
||||
@ -114,30 +219,25 @@ export class DockerComposeService {
|
||||
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"));
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
// Handle string errors
|
||||
if (typeof error === "string") {
|
||||
return new Error(error);
|
||||
return error;
|
||||
}
|
||||
|
||||
// Fallback for unknown error types
|
||||
return new Error(JSON.stringify(error));
|
||||
return JSON.stringify(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an object is a docker-compose result
|
||||
*/
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user