Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/spec-node/dockerCompose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,22 @@ export function getBuildInfoForService(composeService: any, cliHostPath: typeof
context: (composeBuild.context as string | undefined) ?? cliHostPath.dirname(localComposeFiles[0]),
target: composeBuild.target as string | undefined,
args: composeBuild.args as Record<string, string> | undefined,
additionalContexts: toAdditionalContextsMap(composeBuild.additional_contexts),
}
};
}

// Compose accepts both a mapping and a list of 'name=value' entries.
function toAdditionalContextsMap(additionalContexts: Record<string, string> | string[] | undefined) {
if (!Array.isArray(additionalContexts)) {
return additionalContexts;
}
return Object.fromEntries(additionalContexts.map(entry => {
const i = entry.indexOf('=');
return i === -1 ? [entry, ''] : [entry.slice(0, i), entry.slice(i + 1)];
}));
}

export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConfig<DevContainerFromDockerComposeConfig>, projectName: string, params: DockerResolverParameters, localComposeFiles: string[], envFile: string | undefined, composeGlobalArgs: string[], runServices: string[], noCache: boolean, overrideFilePath: string, overrideFilePrefix: string, versionPrefix: string, additionalFeatures: Record<string, string | boolean | Record<string, string | boolean>>, canAddLabelsToContainer: boolean, additionalCacheFroms?: string[], noBuild?: boolean) {

const { common, dockerCLI, dockerComposeCLI: dockerComposeCLIFunc } = params;
Expand Down Expand Up @@ -181,7 +193,7 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf
dockerfile = modifiedDockerfile;
}
}
imageBuildInfo = await getImageBuildInfoFromDockerfile(params, originalDockerfile, serviceInfo.build?.args || {}, serviceInfo.build?.target, configWithRaw.substitute);
imageBuildInfo = await getImageBuildInfoFromDockerfile(params, originalDockerfile, serviceInfo.build?.args || {}, serviceInfo.build?.target, configWithRaw.substitute, Object.keys(serviceInfo.build.additionalContexts || {}));
} else {
imageBuildInfo = await getImageBuildInfoFromImage(params, composeService.image, configWithRaw.substitute);
}
Expand Down
9 changes: 9 additions & 0 deletions src/spec-node/dockerfileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,15 @@ export function findBaseImage(dockerfile: Dockerfile, buildArgs: Record<string,
return undefined;
}

// BuildKit looks up named build contexts by the familiar image reference without ':latest'.
export function isNamedBuildContext(image: string, buildContextNames: string[]) {
const familiar = image
.replace(/^(?:index\.)?docker\.io\//, '')
.replace(/^library\/(?=[^/]+$)/, '')
.replace(/:latest$/, '');
return buildContextNames.includes(familiar);
}

function extractDirectives(preambleStr: string) {
const map: Record<string, string> = {};
for (const line of preambleStr.split(/\r?\n/)) {
Expand Down
17 changes: 11 additions & 6 deletions src/spec-node/imageMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { Feature, FeaturesConfig, Mount, parseMount, SchemaFeatureLifecycleHooks
import { ContainerDetails, DockerCLIParameters, ImageDetails } from '../spec-shutdown/dockerUtils';
import { Log, LogLevel } from '../spec-utils/log';
import { getBuildInfoForService, readDockerComposeConfig } from './dockerCompose';
import { Dockerfile, extractDockerfile, findBaseImage, findUserStatement } from './dockerfileUtils';
import { Dockerfile, extractDockerfile, findBaseImage, findUserStatement, isNamedBuildContext } from './dockerfileUtils';
import { SubstituteConfig, SubstitutedConfig, DockerResolverParameters, inspectDockerImage, uriToWSLFsPath, envListToObj } from './utils';

const pickConfigProperties: (keyof DevContainerConfig & keyof ImageMetadataEntry)[] = [
Expand Down Expand Up @@ -365,7 +365,7 @@ export async function getImageBuildInfo(params: DockerResolverParameters | Docke
const { context, dockerfilePath } = serviceInfo.build;
const resolvedDockerfilePath = cliHost.path.isAbsolute(dockerfilePath) ? dockerfilePath : cliHost.path.resolve(context, dockerfilePath);
const dockerfile = (await cliHost.readFile(resolvedDockerfilePath)).toString();
return getImageBuildInfoFromDockerfile(params, dockerfile, serviceInfo.build.args || {}, serviceInfo.build.target, configWithRaw.substitute);
return getImageBuildInfoFromDockerfile(params, dockerfile, serviceInfo.build.args || {}, serviceInfo.build.target, configWithRaw.substitute, Object.keys(serviceInfo.build.additionalContexts || {}));
} else {
return getImageBuildInfoFromImage(params, composeService.image, configWithRaw.substitute);
}
Expand Down Expand Up @@ -393,13 +393,13 @@ export async function getImageBuildInfoFromImage(params: DockerResolverParameter
};
}

export async function getImageBuildInfoFromDockerfile(params: DockerResolverParameters | DockerCLIParameters, dockerfile: string, dockerBuildArgs: Record<string, string>, targetStage: string | undefined, substitute: SubstituteConfig) {
export async function getImageBuildInfoFromDockerfile(params: DockerResolverParameters | DockerCLIParameters, dockerfile: string, dockerBuildArgs: Record<string, string>, targetStage: string | undefined, substitute: SubstituteConfig, buildContextNames: string[] = []) {
const { output } = 'output' in params ? params : params.common;
const omitSyntaxDirective = 'common' in params ? !!params.common.omitSyntaxDirective : false;
return internalGetImageBuildInfoFromDockerfile(imageName => inspectDockerImage(params, imageName, true), dockerfile, dockerBuildArgs, targetStage, substitute, output, omitSyntaxDirective, params.buildPlatformInfo, params.targetPlatformInfo);
return internalGetImageBuildInfoFromDockerfile(imageName => inspectDockerImage(params, imageName, true), dockerfile, dockerBuildArgs, targetStage, substitute, output, omitSyntaxDirective, params.buildPlatformInfo, params.targetPlatformInfo, buildContextNames);
}

export async function internalGetImageBuildInfoFromDockerfile(inspectDockerImage: (imageName: string) => Promise<ImageDetails>, dockerfileText: string, dockerBuildArgs: Record<string, string>, targetStage: string | undefined, substitute: SubstituteConfig, output: Log, omitSyntaxDirective: boolean, buildPlatform: PlatformInfo, targetPlatform: PlatformInfo): Promise<ImageBuildInfo> {
export async function internalGetImageBuildInfoFromDockerfile(inspectDockerImage: (imageName: string) => Promise<ImageDetails>, dockerfileText: string, dockerBuildArgs: Record<string, string>, targetStage: string | undefined, substitute: SubstituteConfig, output: Log, omitSyntaxDirective: boolean, buildPlatform: PlatformInfo, targetPlatform: PlatformInfo, buildContextNames: string[] = []): Promise<ImageBuildInfo> {
const dockerfile = extractDockerfile(dockerfileText);
if (dockerfile.preamble.directives.syntax && omitSyntaxDirective) {
output.write(`Omitting syntax directive '${dockerfile.preamble.directives.syntax}' from Dockerfile.`, LogLevel.Trace);
Expand All @@ -425,7 +425,12 @@ export async function internalGetImageBuildInfoFromDockerfile(inspectDockerImage
TARGETVARIANT: targetPlatform.variant ?? "",
};
const baseImage = findBaseImage(dockerfile, dockerBuildArgs, targetStage, globalBuildxPlatformArgs);
const imageDetails = baseImage && await inspectDockerImage(baseImage) || undefined;
// A named build context replaces the image at build time, so the reference may not exist locally or in any registry.
const fromBuildContext = !!baseImage && isNamedBuildContext(baseImage, buildContextNames);
if (fromBuildContext) {
output.write(`Not inspecting base image '${baseImage}' because a named build context provides it.`);
}
const imageDetails = baseImage && !fromBuildContext && await inspectDockerImage(baseImage) || undefined;
const dockerfileUser = findUserStatement(dockerfile, dockerBuildArgs, envListToObj(imageDetails?.Config.Env), globalBuildxPlatformArgs, targetStage);
const user = dockerfileUser || imageDetails?.Config.User || 'root';
const metadata = imageDetails ? getImageMetadata(imageDetails, substitute, output) : { config: [], raw: [], substitute };
Expand Down
20 changes: 20 additions & 0 deletions src/test/dockerComposeUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ build:
target: a-target
args:
arg1: value1
additional_contexts:
base: service:base
`;
const info = loadYamlAndGetBuildInfoForService(input);
assert.deepEqual(info, {
Expand All @@ -32,10 +34,26 @@ build:
args: {
arg1: 'value1',
},
additionalContexts: {
base: 'service:base',
},
}
});
});

it('Parses additional_contexts in list form', () => {
const input = `
build:
context: ./a-path
additional_contexts:
- base=docker-image://alpine:3.20
`;
const info = loadYamlAndGetBuildInfoForService(input);
assert.deepEqual(info.build?.additionalContexts, {
base: 'docker-image://alpine:3.20',
});
});

it('Parses image-only info', () => {
const input = `
image: my-image
Expand Down Expand Up @@ -74,6 +92,7 @@ build:
dockerfilePath: 'Dockerfile',
target: undefined,
args: undefined,
additionalContexts: undefined,
}
});
});
Expand All @@ -91,6 +110,7 @@ build:
dockerfilePath: 'my-dockerfile',
target: undefined,
args: undefined,
additionalContexts: undefined,
}
});
});
Expand Down
55 changes: 54 additions & 1 deletion src/test/dockerfileUtils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { assert, expect } from 'chai';
import { imageMetadataLabel, internalGetImageBuildInfoFromDockerfile } from '../spec-node/imageMetadata';
import { ensureDockerfileHasFinalStageName, extractDockerfile, findBaseImage, findUserStatement, supportsBuildContexts } from '../spec-node/dockerfileUtils';
import { ensureDockerfileHasFinalStageName, extractDockerfile, findBaseImage, findUserStatement, isNamedBuildContext, supportsBuildContexts } from '../spec-node/dockerfileUtils';
import { ImageDetails } from '../spec-shutdown/dockerUtils';
import { nullLog } from '../spec-utils/log';
import { testSubstitute } from './testUtils';
Expand Down Expand Up @@ -242,6 +242,59 @@ FROM base-\${TARGETARCH}
assert.strictEqual(info.metadata.config.length, 0);
assert.strictEqual(info.metadata.raw.length, 0);
});

it('for a base image provided by a named build context', async () => {
const dockerfile = `FROM local-base
USER dockerfileUser
`;
const info = await internalGetImageBuildInfoFromDockerfile(async (imageName) => {
assert.fail(`Unexpected inspection of ${imageName}`);
}, dockerfile, {}, undefined, testSubstitute, nullLog, false, { os: 'linux', arch: 'amd64' }, { os: 'linux', arch: 'amd64' }, ['local-base']);
assert.strictEqual(info.user, 'dockerfileUser');
assert.strictEqual(info.metadata.config.length, 0);
assert.strictEqual(info.metadata.raw.length, 0);
});

it('for a base image not provided by the named build contexts', async () => {
const dockerfile = `FROM ubuntu:latest
`;
const details: ImageDetails = {
Id: '123',
Config: {
User: 'imageUser',
Env: null,
Labels: null,
Entrypoint: null,
Cmd: null
},
Os: 'linux',
Architecture: 'amd64'
};
const info = await internalGetImageBuildInfoFromDockerfile(async (imageName) => {
assert.strictEqual(imageName, 'ubuntu:latest');
return details;
}, dockerfile, {}, undefined, testSubstitute, nullLog, false, { os: 'linux', arch: 'amd64' }, { os: 'linux', arch: 'amd64' }, ['local-base']);
assert.strictEqual(info.user, 'imageUser');
});
});

describe('isNamedBuildContext', () => {

it('matches the familiar reference form BuildKit uses', () => {
assert.isTrue(isNamedBuildContext('local-base', ['local-base']));
assert.isTrue(isNamedBuildContext('local-base:latest', ['local-base']));
assert.isTrue(isNamedBuildContext('docker.io/library/alpine', ['alpine']));
assert.isTrue(isNamedBuildContext('library/alpine:latest', ['alpine']));
assert.isTrue(isNamedBuildContext('docker.io/org/image:1.0', ['org/image:1.0']));
assert.isTrue(isNamedBuildContext('ghcr.io/org/image', ['ghcr.io/org/image']));
});

it('does not match other references', () => {
assert.isFalse(isNamedBuildContext('local-base:1.0', ['local-base']));
assert.isFalse(isNamedBuildContext('ghcr.io/org/image', ['org/image']));
assert.isFalse(isNamedBuildContext('library/org/image', ['org/image']));
assert.isFalse(isNamedBuildContext('local-base', []));
});
});

describe('findBaseImage', () => {
Expand Down