diff --git a/Tasks/DockerComposeV1/Tests/DockerComposeUtilsTests.ts b/Tasks/DockerComposeV1/Tests/DockerComposeUtilsTests.ts new file mode 100644 index 000000000000..ab344f85336d --- /dev/null +++ b/Tasks/DockerComposeV1/Tests/DockerComposeUtilsTests.ts @@ -0,0 +1,83 @@ +import * as assert from 'assert'; +import * as DockerComposeUtils from '../dockercomposeutils'; + +describe('DockerComposeUtils Tests', function() { + describe('parseComposeArguments', function() { + it('should parse --profile flag as global argument', function() { + const result = DockerComposeUtils.parseComposeArguments('--profile build'); + assert.deepEqual(result.globalArgs, ['--profile', 'build']); + assert.deepEqual(result.commandArgs, []); + }); + + it('should parse --profile=value flag as global argument', function() { + const result = DockerComposeUtils.parseComposeArguments('--profile=build'); + assert.deepEqual(result.globalArgs, ['--profile=build']); + assert.deepEqual(result.commandArgs, []); + }); + + it('should separate global and command flags correctly', function() { + const result = DockerComposeUtils.parseComposeArguments('--profile build --no-cache'); + assert.deepEqual(result.globalArgs, ['--profile', 'build']); + assert.deepEqual(result.commandArgs, ['--no-cache']); + }); + + it('should handle multiple global flags', function() { + const result = DockerComposeUtils.parseComposeArguments('--profile build --parallel 4 --no-cache'); + assert.deepEqual(result.globalArgs, ['--profile', 'build', '--parallel', '4']); + assert.deepEqual(result.commandArgs, ['--no-cache']); + }); + + it('should handle order independence', function() { + const result = DockerComposeUtils.parseComposeArguments('--no-cache --profile build --parallel 2'); + assert.deepEqual(result.globalArgs, ['--profile', 'build', '--parallel', '2']); + assert.deepEqual(result.commandArgs, ['--no-cache']); + }); + + it('should handle command-only arguments', function() { + const result = DockerComposeUtils.parseComposeArguments('--pull --compress'); + assert.deepEqual(result.globalArgs, []); + assert.deepEqual(result.commandArgs, ['--pull', '--compress']); + }); + + it('should handle empty input', function() { + const result = DockerComposeUtils.parseComposeArguments(''); + assert.deepEqual(result.globalArgs, []); + assert.deepEqual(result.commandArgs, []); + }); + + it('should handle null/undefined input', function() { + const result1 = DockerComposeUtils.parseComposeArguments(null as any); + assert.deepEqual(result1.globalArgs, []); + assert.deepEqual(result1.commandArgs, []); + + const result2 = DockerComposeUtils.parseComposeArguments(undefined as any); + assert.deepEqual(result2.globalArgs, []); + assert.deepEqual(result2.commandArgs, []); + }); + + it('should handle quoted arguments', function() { + const result = DockerComposeUtils.parseComposeArguments('--profile "my profile" --no-cache'); + assert.deepEqual(result.globalArgs, ['--profile', 'my profile']); + assert.deepEqual(result.commandArgs, ['--no-cache']); + }); + + it('should handle all supported global flags', function() { + const globalFlags = [ + '--profile test', + '--ansi auto', + '--compatibility', + '--dry-run', + '--env-file .env', + '--parallel 2', + '--progress plain', + '--project-directory /path' + ]; + + globalFlags.forEach(flag => { + const result = DockerComposeUtils.parseComposeArguments(flag + ' --build'); + assert(result.globalArgs.length > 0, `${flag} should be parsed as global flag`); + assert.deepEqual(result.commandArgs, ['--build']); + }); + }); + }); +}); \ No newline at end of file diff --git a/Tasks/DockerComposeV1/Tests/L0.ts b/Tasks/DockerComposeV1/Tests/L0.ts index 16fe8e790de8..fb26e6e0487c 100644 --- a/Tasks/DockerComposeV1/Tests/L0.ts +++ b/Tasks/DockerComposeV1/Tests/L0.ts @@ -172,6 +172,20 @@ describe('Docker Compose Suite', function() { assert(tr.succeeded, 'task should have succeeded'); assert(tr.stdout.indexOf("[command]" + composeCommand + " -f F:\\dir2\\docker-compose.yml build --pull --parallel") != -1, "docker compose build should run with argumentss"); }); + + it('Runs successfully for windows docker compose service build with --profile global flag', async () => { + let tp = path.join(__dirname, 'L0Windows.js'); + let tr : ttm.MockTestRunner = new ttm.MockTestRunner(tp); + process.env["__command__"] = "Build services"; + process.env["__arguments__"] = "--profile build --no-cache"; + + await tr.runAsync(); + + assert(tr.invokedToolCount == 1, 'should have invoked tool one times. actual: ' + tr.invokedToolCount); + assert(tr.stderr.length == 0 || tr.errorIssues.length, 'should not have written to stderr'); + assert(tr.succeeded, 'task should have succeeded'); + assert(tr.stdout.indexOf("[command]" + composeCommand + " --profile build -f F:\\dir2\\docker-compose.yml build --no-cache") != -1, "docker compose build should run with --profile as global flag"); + }); } else { it('Runs successfully for linux docker compose service build', async () => { let tp = path.join(__dirname, 'L0Linux.js'); @@ -304,6 +318,20 @@ describe('Docker Compose Suite', function() { assert(tr.stdout.indexOf("[command]" + composeCommand + " -f /tmp/tempdir/100/docker-compose.yml build --pull --parallel") != -1, "docker compose build should run with argumentss"); }); + it('Runs successfully for linux docker compose service build with --profile global flag', async () => { + let tp = path.join(__dirname, 'L0Linux.js'); + let tr : ttm.MockTestRunner = new ttm.MockTestRunner(tp); + process.env["__command__"] = "Build services"; + process.env["__arguments__"] = "--profile build --no-cache"; + + await tr.runAsync(); + + assert(tr.invokedToolCount == 1, 'should have invoked tool one times. actual: ' + tr.invokedToolCount); + assert(tr.stderr.length == 0 || tr.errorIssues.length, 'should not have written to stderr'); + assert(tr.succeeded, 'task should have succeeded'); + assert(tr.stdout.indexOf("[command]" + composeCommand + " --profile build -f /tmp/tempdir/100/docker-compose.yml build --no-cache") != -1, "docker compose build should run with --profile as global flag"); + }); + it('Runs successfully for linux docker compose command with arguments', async () => { let tp = path.join(__dirname, 'L0Linux.js'); let tr : ttm.MockTestRunner = new ttm.MockTestRunner(tp); diff --git a/Tasks/DockerComposeV1/dockercomposebuild.ts b/Tasks/DockerComposeV1/dockercomposebuild.ts index c72856e6b396..c593a5b9e55f 100644 --- a/Tasks/DockerComposeV1/dockercomposebuild.ts +++ b/Tasks/DockerComposeV1/dockercomposebuild.ts @@ -6,6 +6,7 @@ import * as sourceUtils from "azure-pipelines-tasks-docker-common/sourceutils"; import * as imageUtils from "azure-pipelines-tasks-docker-common/containerimageutils"; import * as dockerCommandUtils from "azure-pipelines-tasks-docker-common/dockercommandutils"; import * as utils from "./utils"; +import * as DockerComposeUtils from "./dockercomposeutils"; function dockerTag(connection: DockerComposeConnection, source: string, target: string, outputUpdate: (output: any) => any) { var command = connection.createCommand(); @@ -63,11 +64,18 @@ function addOtherTags(connection: DockerComposeConnection, imageName: string, ou } export function run(connection: DockerComposeConnection, outputUpdate: (data: string) => any): any { - var command = connection.createComposeCommand(); - command.arg("build"); var arg = tl.getInput("arguments", false); - var commandArgs = dockerCommandUtils.getCommandArguments(arg || ""); - command.line(commandArgs || ""); + var parsedArgs = DockerComposeUtils.parseComposeArguments(arg || ""); + + var command = connection.createComposeCommand(parsedArgs.globalArgs); + command.arg("build"); + + // Add command-specific arguments + if (parsedArgs.commandArgs.length > 0) { + parsedArgs.commandArgs.forEach(cmdArg => { + command.arg(cmdArg); + }); + } return connection.execCommandWithLogging(command) .then((output) => outputUpdate(utils.writeTaskOutput("build", output))) diff --git a/Tasks/DockerComposeV1/dockercomposecommand.ts b/Tasks/DockerComposeV1/dockercomposecommand.ts index e8bcc0b8fcea..e3f76f655451 100644 --- a/Tasks/DockerComposeV1/dockercomposecommand.ts +++ b/Tasks/DockerComposeV1/dockercomposecommand.ts @@ -4,14 +4,21 @@ import * as tl from "azure-pipelines-task-lib/task"; import DockerComposeConnection from "./dockercomposeconnection"; import * as utils from "./utils"; import * as dockerCommandUtils from "azure-pipelines-tasks-docker-common/dockercommandutils"; +import * as DockerComposeUtils from "./dockercomposeutils"; export function run(connection: DockerComposeConnection, outputUpdate: (data: string) => any): any { - var command = connection.createComposeCommand(); + var args = tl.getInput("arguments", false); + var parsedArgs = DockerComposeUtils.parseComposeArguments(args || ""); + + var command = connection.createComposeCommand(parsedArgs.globalArgs); command.line(tl.getInput("dockerComposeCommand", true)); - var args = tl.getInput("arguments", false); - var commandArgs = dockerCommandUtils.getCommandArguments(args || ""); - command.line(commandArgs || ""); + // Add command-specific arguments + if (parsedArgs.commandArgs.length > 0) { + parsedArgs.commandArgs.forEach(cmdArg => { + command.arg(cmdArg); + }); + } return connection.execCommandWithLogging(command) .then((output) => outputUpdate(utils.writeTaskOutput("command", output))); diff --git a/Tasks/DockerComposeV1/dockercomposeconnection.ts b/Tasks/DockerComposeV1/dockercomposeconnection.ts index a7e0739cecb4..07ffefb7459c 100644 --- a/Tasks/DockerComposeV1/dockercomposeconnection.ts +++ b/Tasks/DockerComposeV1/dockercomposeconnection.ts @@ -89,7 +89,7 @@ export default class DockerComposeConnection extends ContainerConnection { return output || '\n'; } - public createComposeCommand(): tr.ToolRunner { + public createComposeCommand(globalArgs?: string[]): tr.ToolRunner { var command = tl.tool(this.dockerComposePath); if (!tl.getInput('dockerComposePath')) { @@ -97,6 +97,13 @@ export default class DockerComposeConnection extends ContainerConnection { process.env["COMPOSE_COMPATIBILITY"] = "true"; } + // Add global arguments before -f flags and project name + if (globalArgs && globalArgs.length > 0) { + globalArgs.forEach(arg => { + command.arg(arg); + }); + } + command.arg(["-f", this.dockerComposeFile]); var basePath = path.dirname(this.dockerComposeFile); this.additionalDockerComposeFiles.forEach(file => { @@ -115,8 +122,8 @@ export default class DockerComposeConnection extends ContainerConnection { return command; } - public getCombinedConfig(imageDigestComposeFile?: string): any { - var command = this.createComposeCommand(); + public getCombinedConfig(imageDigestComposeFile?: string, globalArgs?: string[]): any { + var command = this.createComposeCommand(globalArgs); if (imageDigestComposeFile) { command.arg(["-f", imageDigestComposeFile]); } diff --git a/Tasks/DockerComposeV1/dockercomposerun.ts b/Tasks/DockerComposeV1/dockercomposerun.ts index 05e5b36bd71d..af0b80c61dd4 100644 --- a/Tasks/DockerComposeV1/dockercomposerun.ts +++ b/Tasks/DockerComposeV1/dockercomposerun.ts @@ -4,9 +4,13 @@ import * as tl from "azure-pipelines-task-lib/task"; import DockerComposeConnection from "./dockercomposeconnection"; import * as dockerCommandUtils from "azure-pipelines-tasks-docker-common/dockercommandutils"; import * as utils from "./utils"; +import * as DockerComposeUtils from "./dockercomposeutils"; export async function run(connection: DockerComposeConnection, outputUpdate: (data: string) => any): Promise { - var command = connection.createComposeCommand(); + var arg = tl.getInput("arguments", false); + var parsedArgs = DockerComposeUtils.parseComposeArguments(arg || ""); + + var command = connection.createComposeCommand(parsedArgs.globalArgs); command.arg("run"); var detached = tl.getBoolInput("detached"); @@ -42,9 +46,12 @@ export async function run(connection: DockerComposeConnection, outputUpdate: (da var serviceName = tl.getInput("serviceName", true); command.arg(serviceName); - var arg = tl.getInput("arguments", false); - var commandArgs = dockerCommandUtils.getCommandArguments(arg || ""); - command.line(commandArgs || ""); + // Add command-specific arguments + if (parsedArgs.commandArgs.length > 0) { + parsedArgs.commandArgs.forEach(cmdArg => { + command.arg(cmdArg); + }); + } var containerCommand = tl.getInput("containerCommand"); if (containerCommand) { @@ -57,7 +64,7 @@ export async function run(connection: DockerComposeConnection, outputUpdate: (da } finally { if (!detached) { - var downCommand = connection.createComposeCommand(); + var downCommand = connection.createComposeCommand(parsedArgs.globalArgs); downCommand.arg("down"); await connection.execCommandWithLogging(downCommand) diff --git a/Tasks/DockerComposeV1/dockercomposeup.ts b/Tasks/DockerComposeV1/dockercomposeup.ts index 0c9016106ed2..47c060997891 100644 --- a/Tasks/DockerComposeV1/dockercomposeup.ts +++ b/Tasks/DockerComposeV1/dockercomposeup.ts @@ -4,9 +4,13 @@ import * as tl from "azure-pipelines-task-lib/task"; import DockerComposeConnection from "./dockercomposeconnection"; import * as dockerCommandUtils from "azure-pipelines-tasks-docker-common/dockercommandutils"; import * as utils from "./utils"; +import * as DockerComposeUtils from "./dockercomposeutils"; export function run(connection: DockerComposeConnection, outputUpdate: (data: string) => any): any { - var command = connection.createComposeCommand(); + var arg = tl.getInput("arguments", false); + var parsedArgs = DockerComposeUtils.parseComposeArguments(arg || ""); + + var command = connection.createComposeCommand(parsedArgs.globalArgs); command.arg("up"); var detached = tl.getBoolInput("detached"); @@ -24,9 +28,12 @@ export function run(connection: DockerComposeConnection, outputUpdate: (data: st command.arg("--abort-on-container-exit"); } - var arg = tl.getInput("arguments", false); - var commandArgs = dockerCommandUtils.getCommandArguments(arg || ""); - command.line(commandArgs || ""); + // Add command-specific arguments + if (parsedArgs.commandArgs.length > 0) { + parsedArgs.commandArgs.forEach(cmdArg => { + command.arg(cmdArg); + }); + } return connection.execCommandWithLogging(command) .then((output) => outputUpdate(utils.writeTaskOutput("up", output))) diff --git a/Tasks/DockerComposeV1/dockercomposeutils.ts b/Tasks/DockerComposeV1/dockercomposeutils.ts index 5f9d7653d4ee..785842f6c4a4 100644 --- a/Tasks/DockerComposeV1/dockercomposeutils.ts +++ b/Tasks/DockerComposeV1/dockercomposeutils.ts @@ -20,3 +20,89 @@ export function findDockerFile(dockerfilepath: string, currentWorkingDirectory: return dockerfilepath; } } + +export interface ParsedComposeArgs { + globalArgs: string[]; + commandArgs: string[]; +} + +export function parseComposeArguments(argsString: string): ParsedComposeArgs { + if (!argsString || argsString.trim() === "") { + return { globalArgs: [], commandArgs: [] }; + } + + // List of global Docker Compose flags that should be placed before the action/command + const globalFlags = [ + '--profile', + '--ansi', + '--compatibility', + '--dry-run', + '--env-file', + '--parallel', + '--progress', + '--project-directory' + // Note: -f/--file and -p/--project-name are already handled separately in createComposeCommand() + ]; + + const globalArgs: string[] = []; + const commandArgs: string[] = []; + + // Simple argument parsing - split by spaces but handle quoted strings + const args = parseArgumentString(argsString); + + let i = 0; + while (i < args.length) { + const arg = args[i]; + + // Check if this is a global flag + const isGlobalFlag = globalFlags.some(flag => arg === flag || arg.startsWith(flag + '=')); + + if (isGlobalFlag) { + globalArgs.push(arg); + + // If it's a flag that takes a separate value (not using = syntax), include the next argument too + if (!arg.includes('=') && i + 1 < args.length && !args[i + 1].startsWith('-')) { + globalArgs.push(args[i + 1]); + i++; // Skip the next argument as we've already processed it + } + } else { + commandArgs.push(arg); + } + + i++; + } + + return { globalArgs, commandArgs }; +} + +function parseArgumentString(argsString: string): string[] { + const args: string[] = []; + let currentArg = ''; + let inQuotes = false; + let quoteChar = ''; + + for (let i = 0; i < argsString.length; i++) { + const char = argsString[i]; + + if (!inQuotes && (char === '"' || char === "'")) { + inQuotes = true; + quoteChar = char; + } else if (inQuotes && char === quoteChar) { + inQuotes = false; + quoteChar = ''; + } else if (!inQuotes && char === ' ') { + if (currentArg.trim()) { + args.push(currentArg.trim()); + currentArg = ''; + } + } else { + currentArg += char; + } + } + + if (currentArg.trim()) { + args.push(currentArg.trim()); + } + + return args; +}