Skip to content
Merged
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
44 changes: 23 additions & 21 deletions src/spec-node/containerFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { LogLevel, makeLog } from '../spec-utils/log';
import { FeaturesConfig, getContainerFeaturesBaseDockerFile, getFeatureInstallWrapperScript, getFeatureLayers, getFeatureMainValue, getFeatureValueObject, generateFeaturesConfig, Feature, generateContainerEnvs } from '../spec-configuration/containerFeaturesConfiguration';
import { readLocalFile } from '../spec-utils/pfs';
import { includeAllConfiguredFeatures } from '../spec-utils/product';
import { createFeaturesTempFolder, DockerResolverParameters, getCacheFolder, getFolderImageName, getEmptyContextFolder, SubstitutedConfig } from './utils';
import { createFeaturesTempFolder, DockerResolverParameters, getCacheFolder, getFolderImageName, getEmptyContextFolder, SubstitutedConfig, ensureDockerHubImageAccessible } from './utils';
import { isEarlierVersion, parseVersion, runCommandNoPty } from '../spec-common/commonUtils';
import { getDevcontainerMetadata, getDevcontainerMetadataLabel, getImageBuildInfoFromImage, ImageBuildInfo, ImageMetadataEntry, imageMetadataLabel, MergedDevContainerConfig } from './imageMetadata';
import { supportsBuildContexts } from './dockerfileUtils';
Expand Down Expand Up @@ -154,7 +154,7 @@ export async function getExtendImageBuildInfo(params: DockerResolverParameters,
}
};
}
return { featureBuildInfo: getImageBuildOptions(params, config, dstFolder, baseName, imageBuildInfo) };
return { featureBuildInfo: await getImageBuildOptions(params, config, dstFolder, baseName, imageBuildInfo) };
}

// Generates the end configuration.
Expand Down Expand Up @@ -193,24 +193,25 @@ export interface ImageBuildOptions {
securityOpts: string[];
}

function getImageBuildOptions(params: DockerResolverParameters, config: SubstitutedConfig<DevContainerConfig>, dstFolder: string, baseName: string, imageBuildInfo: ImageBuildInfo): ImageBuildOptions {
const syntax = imageBuildInfo.dockerfile?.preamble.directives.syntax;
return {
dstFolder,
dockerfileContent: `
async function getImageBuildOptions(params: DockerResolverParameters, config: SubstitutedConfig<DevContainerConfig>, dstFolder: string, baseName: string, imageBuildInfo: ImageBuildInfo): Promise<ImageBuildOptions> {
const syntax = imageBuildInfo.dockerfile?.preamble.directives.syntax;
const dockerHubAccessible = syntax ? await ensureDockerHubImageAccessible(params, 'docker/dockerfile', '1.4') : false;
return {
dstFolder,
dockerfileContent: `
FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage
${getDevcontainerMetadataLabel(getDevcontainerMetadata(imageBuildInfo.metadata, config, { featureSets: [] }, [], getOmitDevcontainerPropertyOverride(params.common)))}
`,
overrideTarget: 'dev_containers_target_stage',
dockerfilePrefixContent: `${syntax ? `# syntax=${syntax}` : ''}
ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder
overrideTarget: 'dev_containers_target_stage',
dockerfilePrefixContent: `${dockerHubAccessible && syntax ? `# syntax=${syntax}` : ''}
ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder
`,
buildArgs: {
_DEV_CONTAINERS_BASE_IMAGE: baseName,
} as Record<string, string>,
buildKitContexts: {} as Record<string, string>,
securityOpts: [],
};
buildArgs: {
_DEV_CONTAINERS_BASE_IMAGE: baseName,
} as Record<string, string>,
buildKitContexts: {} as Record<string, string>,
securityOpts: [],
};
}

function getOmitDevcontainerPropertyOverride(resolverParams: { omitConfigRemotEnvFromMetadata?: boolean }): (keyof DevContainerConfig & keyof ImageMetadataEntry)[] {
Expand Down Expand Up @@ -262,11 +263,12 @@ async function getFeaturesBuildOptions(params: DockerResolverParameters, devCont
.replace('#{devcontainerMetadata}', getDevcontainerMetadataLabel(imageMetadata))
.replace('#{containerEnvMetadata}', generateContainerEnvs(devContainerConfig.config.containerEnv, true))
;
const syntax = imageBuildInfo.dockerfile?.preamble.directives.syntax;
const omitSyntaxDirective = common.omitSyntaxDirective; // Can be removed when https://github.com/moby/buildkit/issues/4556 is fixed
const dockerfilePrefixContent = `${omitSyntaxDirective ? '' :
useBuildKitBuildContexts && !(imageBuildInfo.dockerfile && supportsBuildContexts(imageBuildInfo.dockerfile)) ? '# syntax=docker/dockerfile:1.4' :
syntax ? `# syntax=${syntax}` : ''}
const syntax = imageBuildInfo.dockerfile?.preamble.directives.syntax;
const omitSyntaxDirective = common.omitSyntaxDirective; // Can be removed when https://github.com/moby/buildkit/issues/4556 is fixed
const dockerHubAccessible = !omitSyntaxDirective ? await ensureDockerHubImageAccessible(params, 'docker/dockerfile', '1.4') : false;
const dockerfilePrefixContent = `${omitSyntaxDirective ? '' :
useBuildKitBuildContexts && dockerHubAccessible && !(imageBuildInfo.dockerfile && supportsBuildContexts(imageBuildInfo.dockerfile)) ? '# syntax=docker/dockerfile:1.4' :
syntax ? `# syntax=${syntax}` : ''}
ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder
`;

Expand Down
82 changes: 80 additions & 2 deletions src/spec-node/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { ImageMetadataEntry, MergedDevContainerConfig } from './imageMetadata';
import { getImageIndexEntryForPlatform, getManifest, getRef } from '../spec-configuration/containerCollectionsOCI';
import { requestEnsureAuthenticated } from '../spec-configuration/httpOCIRegistry';
import { configFileLabel, findDevContainer, hostFolderLabel } from './singleContainer';

import { requestResolveHeaders } from '../spec-utils/httpRequest';
export { getConfigFilePath, getDockerfilePath, isDockerFileConfig } from '../spec-configuration/configuration';
export { uriToFsPath, parentURI } from '../spec-configuration/configurationCommonUtils';

Expand All @@ -37,6 +37,12 @@ export type BindMountConsistency = 'consistent' | 'cached' | 'delegated' | undef

export type GPUAvailability = 'all' | 'detect' | 'none';

// Constants for DockerHub registry + image access check
const DEVCONTAINER_USER_AGENT = 'devcontainer';
const DOCKER_MANIFEST_ACCEPT_HEADER = 'application/vnd.docker.distribution.manifest.v2+json';
const DOCKERFILE_FRONTEND_CHECK_MAX_RETRIES = 5;
const DOCKERFILE_FRONTEND_CHECK_RETRY_INTERVAL_MS = 2000;

// Generic retry function
export async function retry<T>(fn: () => Promise<T>, options: { retryIntervalMilliseconds: number; maxRetries: number; output: Log }): Promise<T> {
const { retryIntervalMilliseconds, maxRetries, output } = options;
Expand All @@ -46,7 +52,11 @@ export async function retry<T>(fn: () => Promise<T>, options: { retryIntervalMil
return await fn();
} catch (err) {
lastError = err;
output.write(`Retrying (Attempt ${i}) with error '${toErrorText(err)}'`, LogLevel.Warning);
output.write(
`Retrying (Attempt ${i}) with error
'${toErrorText(String(err && (err.stack || err.message) || err))}'`,
LogLevel.Warning
);
await new Promise(resolve => setTimeout(resolve, retryIntervalMilliseconds));
}
}
Expand Down Expand Up @@ -599,3 +609,71 @@ export function runAsyncHandler(handler: () => Promise<void>) {
}
})();
}

// Helper functions to construct DockerHub URLs
function getDockerHubAuthUrl(imageName: string, version: string): string {
return `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${imageName}:pull&tag=${version}`;
}

function getDockerHubRegistryUrl(imageName: string, version: string): string {
return `https://registry-1.docker.io/v2/${imageName}/manifests/${version}`;
}

async function checkDockerHubImageAccessible(params: DockerResolverParameters, imageName: string, version: string): Promise<void> {
const { output } = params.common;

const authUrl = getDockerHubAuthUrl(imageName, version);
const registryUrl = getDockerHubRegistryUrl(imageName, version);

const tokenRes = await requestResolveHeaders({
type: 'GET',
url: authUrl,
headers: { 'user-agent': DEVCONTAINER_USER_AGENT }
}, output);
if (!tokenRes || tokenRes.statusCode !== 200) {
throw new Error('Token fetch failed: status ' + (tokenRes?.statusCode ?? 'unknown'));
}

let body: any;
try {
body = JSON.parse(tokenRes.resBody.toString());
} catch (e) {
throw new Error('Token parse failed: ' + (e instanceof Error ? e.message : String(e)));
}
const token: string | undefined = body?.token || body?.access_token;
if (!token) {
throw new Error('Token missing in auth response');
}

const manifestRes = await requestResolveHeaders({
type: 'GET',
url: registryUrl,
headers: {
'user-agent': DEVCONTAINER_USER_AGENT,
'authorization': `Bearer ${token}`,
'accept': DOCKER_MANIFEST_ACCEPT_HEADER
}
}, output);
if (!manifestRes || manifestRes.statusCode !== 200) {
throw new Error('Manifest fetch failed: status ' + (manifestRes?.statusCode ?? 'unknown'));
}
}

export async function ensureDockerHubImageAccessible(params: DockerResolverParameters, imageName: string, version: string): Promise<boolean> {
const { output } = params.common;
try {
await retry(
async () => { await checkDockerHubImageAccessible(params, imageName, version); },
{ maxRetries: DOCKERFILE_FRONTEND_CHECK_MAX_RETRIES, retryIntervalMilliseconds: DOCKERFILE_FRONTEND_CHECK_RETRY_INTERVAL_MS, output }
);
output.write('Dockerfile frontend is accessible in DockerHub registry.', LogLevel.Info);
return true;
} catch (err) {
output.write(
'Dockerfile frontend check failed after retries: ' +
(err instanceof Error ? err.message : String(err)),
LogLevel.Warning
);
return false;
}
}