diff --git a/src/spec-node/containerFeatures.ts b/src/spec-node/containerFeatures.ts index c7e42a56a..f8912254a 100644 --- a/src/spec-node/containerFeatures.ts +++ b/src/spec-node/containerFeatures.ts @@ -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'; @@ -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. @@ -193,24 +193,25 @@ export interface ImageBuildOptions { securityOpts: string[]; } -function getImageBuildOptions(params: DockerResolverParameters, config: SubstitutedConfig, dstFolder: string, baseName: string, imageBuildInfo: ImageBuildInfo): ImageBuildOptions { - const syntax = imageBuildInfo.dockerfile?.preamble.directives.syntax; - return { - dstFolder, - dockerfileContent: ` +async function getImageBuildOptions(params: DockerResolverParameters, config: SubstitutedConfig, dstFolder: string, baseName: string, imageBuildInfo: ImageBuildInfo): Promise { + 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, - buildKitContexts: {} as Record, - securityOpts: [], - }; + buildArgs: { + _DEV_CONTAINERS_BASE_IMAGE: baseName, + } as Record, + buildKitContexts: {} as Record, + securityOpts: [], + }; } function getOmitDevcontainerPropertyOverride(resolverParams: { omitConfigRemotEnvFromMetadata?: boolean }): (keyof DevContainerConfig & keyof ImageMetadataEntry)[] { @@ -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 `; diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index 81f6b6b17..0dbed5171 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -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'; @@ -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(fn: () => Promise, options: { retryIntervalMilliseconds: number; maxRetries: number; output: Log }): Promise { const { retryIntervalMilliseconds, maxRetries, output } = options; @@ -46,7 +52,11 @@ export async function retry(fn: () => Promise, 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)); } } @@ -599,3 +609,71 @@ export function runAsyncHandler(handler: () => Promise) { } })(); } + +// 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 { + 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 { + 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; + } +}