diff --git a/src/spec-node/dockerCompose.ts b/src/spec-node/dockerCompose.ts index 4c20ebd68..6f21d54c9 100644 --- a/src/spec-node/dockerCompose.ts +++ b/src/spec-node/dockerCompose.ts @@ -243,6 +243,28 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf buildOverrideContent += ` - ${buildKitContext}=${featureBuildInfo.buildKitContexts[buildKitContext]}\n`; } } + + // Handle labels + let labels: string[] = []; + // Add labels from params + if (params.additionalLabels && params.additionalLabels.length > 0) { + labels.push(...params.additionalLabels); + } + // Add labels from compose (dictionary) + if (composeService.labels && Object.keys(composeService.labels).length > 0) { + labels.push(...Object.entries(composeService.labels).map(([key, value]) => `${key}=${value}`)); + } + // Add labels from compose (array) + if (composeService.labels && composeService.labels.length > 0) { + labels.push(...composeService.labels); + } + // Finally add all labels + if (labels.length > 0) { + buildOverrideContent += ` labels:\n`; + labels.forEach(label => { + buildOverrideContent += ` - ${label}\n`; + }); + } } // Generate the docker-compose override and build diff --git a/src/spec-node/singleContainer.ts b/src/spec-node/singleContainer.ts index 07d111cc2..0f580281d 100644 --- a/src/spec-node/singleContainer.ts +++ b/src/spec-node/singleContainer.ts @@ -244,6 +244,10 @@ async function buildAndExtendImage(buildParams: DockerResolverParameters, config } } } + + // Add labels + args.push(...buildParams.additionalLabels.length > 0 ? buildParams.additionalLabels.map(label => ['--label', label]).flat() : []); + const buildArgs = config.build?.args; if (buildArgs) { for (const key in buildArgs) { diff --git a/src/test/cli.build.test.ts b/src/test/cli.build.test.ts index 0dfae0427..7e9d99e81 100644 --- a/src/test/cli.build.test.ts +++ b/src/test/cli.build.test.ts @@ -27,13 +27,68 @@ describe('Dev Containers CLI', function () { describe('Command build', () => { - it('should build successfully with valid image metadata --label property', async () => { + it('should build successfully with valid image metadata --label property (image)', async () => { const testFolder = `${__dirname}/configs/example`; const response = await shellExec(`${cli} build --workspace-folder ${testFolder} --label 'name=label-test' --label 'type=multiple-labels'`); const res = JSON.parse(response.stdout); assert.equal(res.outcome, 'success'); - const labels = await shellExec(`docker inspect --format '{{json .Config.Labels}}' ${res.imageName} | jq`); - assert.match(labels.stdout.toString(), /\"name\": \"label-test\"/); + const labelsResponse = await shellExec(`docker inspect --format '{{json .Config.Labels}}' ${res.imageName}`); + const labels = JSON.parse(labelsResponse.stdout); + assert.equal(labels.name, 'label-test'); + assert.equal(labels.type, 'multiple-labels'); + }); + + it('should build successfully with valid image metadata --label property (dockerfile)', async () => { + const testFolder = `${__dirname}/configs/example-dockerfile`; + const response = await shellExec(`${cli} build --workspace-folder ${testFolder} --label 'name=label-test' --label 'type=multiple-labels'`); + const res = JSON.parse(response.stdout); + assert.equal(res.outcome, 'success'); + const labelsResponse = await shellExec(`docker inspect --format '{{json .Config.Labels}}' ${res.imageName}`); + const labels = JSON.parse(labelsResponse.stdout); + assert.equal(labels.name, 'label-test'); + assert.equal(labels.type, 'multiple-labels'); + }); + + it('should build successfully with valid image metadata --label property (compose)', async () => { + const testFolder = `${__dirname}/configs/compose-without-name`; + const image1 = 'image-1'; + await shellExec(`docker rmi -f ${image1}`); + const res = await shellExec(`${cli} build --workspace-folder ${testFolder} --image-name ${image1} --label 'name=label-test' --label 'type=multiple-labels'`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + assert.equal(response.imageName[0], image1); + const labelsResponse = await shellExec(`docker inspect --format '{{json .Config.Labels}}' ${response.imageName[0]}`); + const labels = JSON.parse(labelsResponse.stdout); + assert.equal(labels.name, 'label-test'); + assert.equal(labels.type, 'multiple-labels'); + }); + + it('should build successfully with valid image metadata --label (inside compose as dictionary)', async () => { + const testFolder = `${__dirname}/configs/compose-with-labels`; + const image1 = 'image-1'; + await shellExec(`docker rmi -f ${image1}`); + const res = await shellExec(`${cli} build --workspace-folder ${testFolder} --image-name ${image1}`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + assert.equal(response.imageName[0], image1); + const labelsResponse = await shellExec(`docker inspect --format '{{json .Config.Labels}}' ${response.imageName[0]}`); + const labels = JSON.parse(labelsResponse.stdout); + assert.equal(labels.name, 'label-test'); + assert.equal(labels.type, 'multiple-labels'); + }); + + it('should build successfully with valid image metadata --label (inside compose as array)', async () => { + const testFolder = `${__dirname}/configs/compose-with-labels-array`; + const image1 = 'image-1'; + await shellExec(`docker rmi -f ${image1}`); + const res = await shellExec(`${cli} build --workspace-folder ${testFolder} --image-name ${image1}`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + assert.equal(response.imageName[0], image1); + const labelsResponse = await shellExec(`docker inspect --format '{{json .Config.Labels}}' ${response.imageName[0]}`); + const labels = JSON.parse(labelsResponse.stdout); + assert.equal(labels.name, 'label-test'); + assert.equal(labels.type, 'multiple-labels'); }); it('should fail to build with correct error message for local feature', async () => { diff --git a/src/test/configs/compose-with-labels-array/.devcontainer/devcontainer.json b/src/test/configs/compose-with-labels-array/.devcontainer/devcontainer.json new file mode 100644 index 000000000..7b42997d3 --- /dev/null +++ b/src/test/configs/compose-with-labels-array/.devcontainer/devcontainer.json @@ -0,0 +1,5 @@ +{ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspace" +} \ No newline at end of file diff --git a/src/test/configs/compose-with-labels-array/.devcontainer/docker-compose.yml b/src/test/configs/compose-with-labels-array/.devcontainer/docker-compose.yml new file mode 100644 index 000000000..0afcf22ce --- /dev/null +++ b/src/test/configs/compose-with-labels-array/.devcontainer/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3.8' + +name: custom-project-name + +services: + app: + image: ubuntu:latest + volumes: + - ..:/workspace:cached + command: sleep infinity + labels: + - "name=label-test" + - "type=multiple-labels" diff --git a/src/test/configs/compose-with-labels/.devcontainer/devcontainer.json b/src/test/configs/compose-with-labels/.devcontainer/devcontainer.json new file mode 100644 index 000000000..7b42997d3 --- /dev/null +++ b/src/test/configs/compose-with-labels/.devcontainer/devcontainer.json @@ -0,0 +1,5 @@ +{ + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspace" +} \ No newline at end of file diff --git a/src/test/configs/compose-with-labels/.devcontainer/docker-compose.yml b/src/test/configs/compose-with-labels/.devcontainer/docker-compose.yml new file mode 100644 index 000000000..d4181d4c0 --- /dev/null +++ b/src/test/configs/compose-with-labels/.devcontainer/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3.8' + +name: custom-project-name + +services: + app: + image: ubuntu:latest + volumes: + - ..:/workspace:cached + command: sleep infinity + labels: + name: "label-test" + type: "multiple-labels" diff --git a/src/test/configs/example-dockerfile/.devcontainer.json b/src/test/configs/example-dockerfile/.devcontainer.json new file mode 100644 index 000000000..c972df2d4 --- /dev/null +++ b/src/test/configs/example-dockerfile/.devcontainer.json @@ -0,0 +1,11 @@ +// Example devcontainer.json configuration with a Dockerfile +{ + "build": { + "dockerfile": "Dockerfile" + }, + "features": { + "ghcr.io/devcontainers/features/go:1": { + "version": "latest" + } + } +} \ No newline at end of file diff --git a/src/test/configs/example-dockerfile/Dockerfile b/src/test/configs/example-dockerfile/Dockerfile new file mode 100644 index 000000000..87589e56e --- /dev/null +++ b/src/test/configs/example-dockerfile/Dockerfile @@ -0,0 +1 @@ +FROM mcr.microsoft.com/devcontainers/base:bookworm