diff --git a/.github/workflows/dev-containers.yml b/.github/workflows/dev-containers.yml index 7ff110f56..392701225 100644 --- a/.github/workflows/dev-containers.yml +++ b/.github/workflows/dev-containers.yml @@ -55,8 +55,9 @@ jobs: "src/test/cli.test.ts", "src/test/cli.up.test.ts", "src/test/container-features/containerFeaturesOCIPush.test.ts", + "src/test/container-features/registryCompatibilityOCI.test.ts", # Run all except the above: - "--exclude src/test/container-features/containerFeaturesOCIPush.test.ts --exclude src/test/container-features/e2e.test.ts --exclude src/test/container-features/featuresCLICommands.test.ts --exclude src/test/container-features/containerFeaturesOrder.test.ts --exclude src/test/cli.build.test.ts --exclude src/test/cli.exec.buildKit.1.test.ts --exclude src/test/cli.exec.buildKit.2.test.ts --exclude src/test/cli.exec.nonBuildKit.1.test.ts --exclude src/test/cli.exec.nonBuildKit.2.test.ts --exclude src/test/cli.test.ts --exclude src/test/cli.up.test.ts 'src/test/**/*.test.ts'", + "--exclude src/test/container-features/registryCompatibilityOCI.test.ts --exclude src/test/container-features/containerFeaturesOCIPush.test.ts --exclude src/test/container-features/e2e.test.ts --exclude src/test/container-features/featuresCLICommands.test.ts --exclude src/test/container-features/containerFeaturesOrder.test.ts --exclude src/test/cli.build.test.ts --exclude src/test/cli.exec.buildKit.1.test.ts --exclude src/test/cli.exec.buildKit.2.test.ts --exclude src/test/cli.exec.nonBuildKit.1.test.ts --exclude src/test/cli.exec.nonBuildKit.2.test.ts --exclude src/test/cli.test.ts --exclude src/test/cli.up.test.ts 'src/test/**/*.test.ts'", ] steps: - name: Checkout diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 4bccd0747..6add12433 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -40,6 +40,7 @@ jobs: # "src/test/dockerUtils.test.ts", # "src/test/imageMetadata.test.ts", # "src/test/variableSubstitution.test.ts", + # "src/test/container-features/registryCompatibilityOCI.test.ts", # # Run all except the above: # "--exclude .....", ] diff --git a/src/spec-configuration/containerCollectionsOCI.ts b/src/spec-configuration/containerCollectionsOCI.ts index 21c90f410..5fcb3a26a 100644 --- a/src/spec-configuration/containerCollectionsOCI.ts +++ b/src/spec-configuration/containerCollectionsOCI.ts @@ -3,19 +3,19 @@ import * as semver from 'semver'; import * as tar from 'tar'; import * as jsonc from 'jsonc-parser'; -import { request } from '../spec-utils/httpRequest'; import { Log, LogLevel } from '../spec-utils/log'; import { isLocalFile, mkdirpLocal, readLocalFile, writeLocalFile } from '../spec-utils/pfs'; +import { requestEnsureAuthenticated } from './httpOCIRegistry'; export const DEVCONTAINER_MANIFEST_MEDIATYPE = 'application/vnd.devcontainers'; export const DEVCONTAINER_TAR_LAYER_MEDIATYPE = 'application/vnd.devcontainers.layer.v1+tar'; export const DEVCONTAINER_COLLECTION_LAYER_MEDIATYPE = 'application/vnd.devcontainers.collection.layer.v1+json'; -export type HEADERS = { 'authorization'?: string; 'user-agent': string; 'content-type'?: string; 'accept'?: string; 'content-length'?: string }; export interface CommonParams { env: NodeJS.ProcessEnv; output: Log; + cachedAuthHeader?: Record; // } // Represents the unique OCI identifier for a Feature or Template. @@ -170,7 +170,7 @@ export function getCollectionRef(output: Log, registry: string, namespace: strin // Validate if a manifest exists and is reachable about the declared feature/template. // Specification: https://github.com/opencontainers/distribution-spec/blob/v1.0.1/spec.md#pulling-manifests -export async function fetchOCIManifestIfExists(params: CommonParams, ref: OCIRef | OCICollectionRef, manifestDigest?: string, authToken?: string): Promise { +export async function fetchOCIManifestIfExists(params: CommonParams, ref: OCIRef | OCICollectionRef, manifestDigest?: string): Promise { const { output } = params; // Simple mechanism to avoid making a DNS request for @@ -188,7 +188,7 @@ export async function fetchOCIManifestIfExists(params: CommonParams, ref: OCIRef } const manifestUrl = `https://${ref.registry}/v2/${ref.path}/manifests/${reference}`; output.write(`manifest url: ${manifestUrl}`, LogLevel.Trace); - const manifest = await getManifest(params, manifestUrl, ref, authToken); + const manifest = await getManifest(params, manifestUrl, ref); if (!manifest) { return; @@ -202,184 +202,79 @@ export async function fetchOCIManifestIfExists(params: CommonParams, ref: OCIRef return manifest; } -export async function getManifest(params: CommonParams, url: string, ref: OCIRef | OCICollectionRef, authToken?: string | false, mimeType?: string): Promise { +export async function getManifest(params: CommonParams, url: string, ref: OCIRef | OCICollectionRef, mimeType?: string): Promise { const { output } = params; + let body: string = ''; try { - const headers: HEADERS = { + const headers = { 'user-agent': 'devcontainer', 'accept': mimeType || 'application/vnd.oci.image.manifest.v1+json', }; - const authorization = authToken ?? await fetchAuthorizationHeader(params, ref.registry, ref.path, 'pull'); - if (authorization) { - headers['authorization'] = authorization; - } - - const options = { - type: 'GET', + const httpOptions = { + type: 'GET', url: url, headers: headers }; - const response = await request(options); - const manifest: OCIManifest = JSON.parse(response.toString()); - - return manifest; - } catch (e) { - // A 404 is expected here if the manifest does not exist on the remote. - output.write(`Did not fetch manifest: ${e}`, LogLevel.Trace); - return undefined; - } -} - -// Exported Function -// Will attempt to generate/fetch the correct authorization header for subsequent requests (Bearer or Basic) -export async function fetchAuthorizationHeader(params: CommonParams, registry: string, ociRepoPath: string, operationScopes: string): Promise { - const { output } = params; - - const basicAuthTokenBase64 = await getBasicAuthCredential(params, registry); - const scopeToken = await fetchRegistryBearerToken(params, registry, ociRepoPath, operationScopes, basicAuthTokenBase64); - - // Prefer returned a Bearer token retrieved from the /token endpoint. - if (scopeToken) { - output.write(`Using scope token for registry '${registry}'`, LogLevel.Trace); - return `Bearer ${scopeToken}`; - } - - // If all we have are Basic auth credentials, return those for the caller to use. - if (basicAuthTokenBase64) { - output.write(`Using basic auth token for registry '${registry}'`, LogLevel.Trace); - return `Basic ${basicAuthTokenBase64}`; - } - - // If we have no credentials, and we weren't able to get a scope token anonymously, return undefined. - return undefined; -} - - -// * Internal helper for 'fetchAuthorizationHeader(...)' -// Attempts to get the Basic auth credentials for the provided registry. -// These may be programatically crafted via environment variables (GITHUB_TOKEN), -// parsed out of a special DEVCONTAINERS_OCI_AUTH environment variable, -// TODO: or directly read out of the local docker config file/credential helper. -async function getBasicAuthCredential(params: CommonParams, registry: string): Promise { - const { output, env } = params; - - let userToken: string | undefined = undefined; - if (!!env['GITHUB_TOKEN'] && registry === 'ghcr.io') { - output.write('Using environment GITHUB_TOKEN for auth', LogLevel.Trace); - userToken = `USERNAME:${env['GITHUB_TOKEN']}`; - } else if (!!env['DEVCONTAINERS_OCI_AUTH']) { - // eg: DEVCONTAINERS_OCI_AUTH=realm1|user1|token1,realm2|user2|token2 - const authContexts = env['DEVCONTAINERS_OCI_AUTH'].split(','); - const authContext = authContexts.find(a => a.split('|')[0] === registry); - - if (authContext) { - output.write(`Using match from DEVCONTAINERS_OCI_AUTH for registry '${registry}'`, LogLevel.Trace); - const split = authContext.split('|'); - userToken = `${split[1]}:${split[2]}`; + const res = await requestEnsureAuthenticated(params, httpOptions, ref); + if (!res) { + output.write('Request failed', LogLevel.Error); + return; } - } - - if (userToken) { - return Buffer.from(userToken).toString('base64'); - } - - // Represents anonymous access. - output.write(`No authentication credentials found for registry '${registry}'.`, LogLevel.Warning); - return undefined; -} - -// * Internal helper for 'fetchAuthorizationHeader(...)' -// https://github.com/oras-project/oras-go/blob/97a9c43c52f9d89ecf5475bc59bd1f96c8cc61f6/registry/remote/auth/scope.go#L60-L74 -// Using the provided Basic auth credentials, (or if none, anonymously), to ask the registry's '/token' endpoint for a token. -// Some registries (eg: ghcr.io) expect a scoped token to target resources and will not operate with just Basic Auth. -// Other registries (eg: the OCI Reference Implementation) will not return a valid token from '/token' -async function fetchRegistryBearerToken(params: CommonParams, registry: string, ociRepoPath: string, operationScopes: string, basicAuthTokenBase64: string | undefined = undefined): Promise { - const { output } = params; - - if (registry === 'mcr.microsoft.com') { - return undefined; - } - - const headers: HEADERS = { - 'user-agent': 'devcontainer' - }; - - if (!basicAuthTokenBase64) { - basicAuthTokenBase64 = await getBasicAuthCredential(params, registry); - } - - if (basicAuthTokenBase64) { - headers['authorization'] = `Basic ${basicAuthTokenBase64}`; - } - const authServer = registry === 'docker.io' ? 'auth.docker.io' : registry; - const registryServer = registry === 'docker.io' ? 'registry.docker.io' : registry; - const url = `https://${authServer}/token?scope=repository:${ociRepoPath}:${operationScopes}&service=${registryServer}`; - output.write(`Fetching scope token from: ${url}`, LogLevel.Trace); + const { resBody, statusCode } = res; + body = resBody.toString(); - const options = { - type: 'GET', - url: url, - headers: headers - }; + // NOTE: A 404 is expected here if the manifest does not exist on the remote. + if (statusCode > 299) { + output.write(`Did not fetch manifest: ${body}`, LogLevel.Trace); + return; + } - let authReq: Buffer; - try { - authReq = await request(options, output); - } catch (e: any) { - // This is ok if the registry is trying to speak Basic Auth with us. - output.write(`Not used a scoped token for ${registry}: ${e}`, LogLevel.Trace); + return JSON.parse(body); + } catch (e) { + output.write(`Failed to parse manifest: ${body}`, LogLevel.Error); return; } - - if (!authReq) { - output.write('Failed to get registry auth token', LogLevel.Error); - return undefined; - } - - let scopeToken: string | undefined; - try { - scopeToken = JSON.parse(authReq.toString())?.token; - } catch { - // not JSON - } - if (!scopeToken) { - output.write('Failed to parse registry auth token response', LogLevel.Error); - return undefined; - } - return scopeToken; } // Lists published versions/tags of a feature/template // Specification: https://github.com/opencontainers/distribution-spec/blob/v1.0.1/spec.md#content-discovery -export async function getPublishedVersions(params: CommonParams, ref: OCIRef, sorted: boolean = false, collectionType: string = 'feature'): Promise { +export async function getPublishedVersions(params: CommonParams, ref: OCIRef, sorted: boolean = false): Promise { const { output } = params; try { const url = `https://${ref.registry}/v2/${ref.namespace}/${ref.id}/tags/list`; - let authorization = await fetchAuthorizationHeader(params, ref.registry, ref.path, 'pull'); - - if (!authorization) { - output.write(`(!) ERR: Failed to get published versions for ${collectionType}: ${ref.resource}`, LogLevel.Error); - return undefined; - } - - const headers: HEADERS = { - 'user-agent': 'devcontainer', + const headers = { 'accept': 'application/json', - 'authorization': authorization }; - const options = { + const httpOptions = { type: 'GET', url: url, headers: headers }; - const response = await request(options); - const publishedVersionsResponse: OCITagList = JSON.parse(response.toString()); + const res = await requestEnsureAuthenticated(params, httpOptions, ref); + if (!res) { + output.write('Request failed', LogLevel.Error); + return; + } + + const { statusCode, resBody } = res; + const body = resBody.toString(); + + // Expected when publishing for the first time + if (statusCode === 404) { + return []; + // Unexpected Error + } else if (statusCode > 299) { + output.write(`(!) ERR: Could not fetch published tags for '${ref.namespace}/${ref.id}' : ${resBody ?? ''} `, LogLevel.Error); + return; + } + + const publishedVersionsResponse: OCITagList = JSON.parse(body); if (!sorted) { return publishedVersionsResponse.tags; @@ -394,17 +289,12 @@ export async function getPublishedVersions(params: CommonParams, ref: OCIRef, so return hasLatest ? ['latest', ...sortedVersions] : sortedVersions; } catch (e) { - // Publishing for the first time - if (e?.message.includes('HTTP 404: Not Found')) { - return []; - } - - output.write(`(!) ERR: Could not fetch published tags for '${ref.namespace}/${ref.id}' : ${e?.message ?? ''} `, LogLevel.Error); - return undefined; + output.write(`Failed to parse published versions: ${e}`, LogLevel.Error); + return; } } -export async function getBlob(params: CommonParams, url: string, ociCacheDir: string, destCachePath: string, ociRef: OCIRef, authToken?: string, ignoredFilesDuringExtraction: string[] = [], metadataFile?: string): Promise<{ files: string[]; metadata: {} | undefined } | undefined> { +export async function getBlob(params: CommonParams, url: string, ociCacheDir: string, destCachePath: string, ociRef: OCIRef, ignoredFilesDuringExtraction: string[] = [], metadataFile?: string): Promise<{ files: string[]; metadata: {} | undefined } | undefined> { // TODO: Parallelize if multiple layers (not likely). // TODO: Seeking might be needed if the size is too large. @@ -413,26 +303,30 @@ export async function getBlob(params: CommonParams, url: string, ociCacheDir: st await mkdirpLocal(ociCacheDir); const tempTarballPath = path.join(ociCacheDir, 'blob.tar'); - const headers: HEADERS = { - 'user-agent': 'devcontainer', + const headers = { 'accept': 'application/vnd.oci.image.manifest.v1+json', }; - const authorization = authToken ?? await fetchAuthorizationHeader(params, ociRef.registry, ociRef.path, 'pull'); - if (authorization) { - headers['authorization'] = authorization; - } - - const options = { + const httpOptions = { type: 'GET', url: url, headers: headers }; - const blob = await request(options, output); + const res = await requestEnsureAuthenticated(params, httpOptions, ociRef); + if (!res) { + output.write('Request failed', LogLevel.Error); + return; + } + + const { statusCode, resBody } = res; + if (statusCode > 299) { + output.write(`Failed to fetch blob (${url}): ${resBody}`, LogLevel.Error); + return; + } await mkdirpLocal(destCachePath); - await writeLocalFile(tempTarballPath, blob); + await writeLocalFile(tempTarballPath, resBody); const files: string[] = []; await tar.x( @@ -482,7 +376,7 @@ export async function getBlob(params: CommonParams, url: string, ociCacheDir: st files, metadata }; } catch (e) { - output.write(`error: ${e}`, LogLevel.Error); - return undefined; + output.write(`Error getting blob: ${e}`, LogLevel.Error); + return; } } \ No newline at end of file diff --git a/src/spec-configuration/containerCollectionsOCIPush.ts b/src/spec-configuration/containerCollectionsOCIPush.ts index d33beab15..f8adfe373 100644 --- a/src/spec-configuration/containerCollectionsOCIPush.ts +++ b/src/spec-configuration/containerCollectionsOCIPush.ts @@ -2,10 +2,10 @@ import * as path from 'path'; import * as fs from 'fs'; import * as crypto from 'crypto'; import { delay } from '../spec-common/async'; -import { headRequest, requestResolveHeaders } from '../spec-utils/httpRequest'; import { Log, LogLevel } from '../spec-utils/log'; import { isLocalFile } from '../spec-utils/pfs'; -import { DEVCONTAINER_COLLECTION_LAYER_MEDIATYPE, DEVCONTAINER_TAR_LAYER_MEDIATYPE, fetchOCIManifestIfExists, fetchAuthorizationHeader, HEADERS, OCICollectionRef, OCILayer, OCIManifest, OCIRef, CommonParams } from './containerCollectionsOCI'; +import { DEVCONTAINER_COLLECTION_LAYER_MEDIATYPE, DEVCONTAINER_TAR_LAYER_MEDIATYPE, fetchOCIManifestIfExists, OCICollectionRef, OCILayer, OCIManifest, OCIRef, CommonParams } from './containerCollectionsOCI'; +import { requestEnsureAuthenticated } from './httpOCIRegistry'; interface ManifestContainer { manifestObj: OCIManifest; @@ -30,13 +30,6 @@ export async function pushOCIFeatureOrTemplate(params: CommonParams, ociRef: OCI const dataBytes = fs.readFileSync(pathToTgz); - // Generate registry auth token with `pull,push` scopes. - const authorization = await fetchAuthorizationHeader(params, ociRef.registry, ociRef.path, 'pull,push'); - if (!authorization) { - output.write(`Failed to get registry auth token`, LogLevel.Error); - return; - } - // Generate Manifest for given feature/template artifact. const manifest = await generateCompleteManifestForIndividualFeatureOrTemplate(output, dataBytes, pathToTgz, ociRef, collectionType); if (!manifest) { @@ -47,13 +40,12 @@ export async function pushOCIFeatureOrTemplate(params: CommonParams, ociRef: OCI output.write(`Generated manifest: \n${JSON.stringify(manifest?.manifestObj, undefined, 4)}`, LogLevel.Trace); // If the exact manifest digest already exists in the registry, we don't need to push individual blobs (it's already there!) - const existingManifest = await fetchOCIManifestIfExists(params, ociRef, manifest.contentDigest, authorization); + const existingManifest = await fetchOCIManifestIfExists(params, ociRef, manifest.contentDigest); if (manifest.contentDigest && existingManifest) { output.write(`Not reuploading blobs, digest already exists.`, LogLevel.Trace); - return await putManifestWithTags(output, manifest, ociRef, tags, authorization); + return await putManifestWithTags(params, manifest, ociRef, tags); } - const blobsToPush = [ { name: 'configLayer', @@ -72,20 +64,20 @@ export async function pushOCIFeatureOrTemplate(params: CommonParams, ociRef: OCI for await (const blob of blobsToPush) { const { name, digest } = blob; - const blobExistsConfigLayer = await checkIfBlobExists(output, ociRef, digest, authorization); + const blobExistsConfigLayer = await checkIfBlobExists(params, ociRef, digest); output.write(`blob: '${name}' ${blobExistsConfigLayer ? 'DOES exists' : 'DOES NOT exist'} in registry.`, LogLevel.Trace); // PUT blobs if (!blobExistsConfigLayer) { // Obtain session ID with `/v2//blobs/uploads/` - const blobPutLocationUriPath = await postUploadSessionId(output, ociRef, authorization); + const blobPutLocationUriPath = await postUploadSessionId(params, ociRef); if (!blobPutLocationUriPath) { output.write(`Failed to get upload session ID`, LogLevel.Error); return; } - if (!(await putBlob(output, blobPutLocationUriPath, ociRef, blob, authorization))) { + if (!(await putBlob(params, blobPutLocationUriPath, ociRef, blob))) { output.write(`Failed to PUT blob '${name}' with digest '${digest}'`, LogLevel.Error); return; } @@ -93,7 +85,7 @@ export async function pushOCIFeatureOrTemplate(params: CommonParams, ociRef: OCI } // Send a final PUT to combine blobs and tag manifest properly. - return await putManifestWithTags(output, manifest, ociRef, tags, authorization); + return await putManifestWithTags(params, manifest, ociRef, tags); } // (!) Entrypoint function to push a collection metadata/overview file for a set of features/templates to a registry. @@ -106,12 +98,6 @@ export async function pushCollectionMetadata(params: CommonParams, collectionRef output.write(`Starting push of latest ${collectionType} collection for namespace '${collectionRef.path}' to '${collectionRef.registry}'`); output.write(`${JSON.stringify(collectionRef, null, 2)}`, LogLevel.Trace); - const authorization = await fetchAuthorizationHeader(params, collectionRef.registry, collectionRef.path, 'pull,push'); - if (!authorization) { - output.write(`Failed to get registry auth token`, LogLevel.Error); - return; - } - if (!(await isLocalFile(pathToCollectionJson))) { output.write(`Collection Metadata was not found at expected location: ${pathToCollectionJson}`, LogLevel.Error); return; @@ -128,10 +114,10 @@ export async function pushCollectionMetadata(params: CommonParams, collectionRef output.write(`Generated manifest: \n${JSON.stringify(manifest?.manifestObj, undefined, 4)}`, LogLevel.Trace); // If the exact manifest digest already exists in the registry, we don't need to push individual blobs (it's already there!) - const existingManifest = await fetchOCIManifestIfExists(params, collectionRef, manifest.contentDigest, authorization); + const existingManifest = await fetchOCIManifestIfExists(params, collectionRef, manifest.contentDigest); if (manifest.contentDigest && existingManifest) { output.write(`Not reuploading blobs, digest already exists.`, LogLevel.Trace); - return await putManifestWithTags(output, manifest, collectionRef, ['latest'], authorization); + return await putManifestWithTags(params, manifest, collectionRef, ['latest']); } const blobsToPush = [ @@ -151,20 +137,20 @@ export async function pushCollectionMetadata(params: CommonParams, collectionRef for await (const blob of blobsToPush) { const { name, digest } = blob; - const blobExistsConfigLayer = await checkIfBlobExists(output, collectionRef, digest, authorization); + const blobExistsConfigLayer = await checkIfBlobExists(params, collectionRef, digest); output.write(`blob: '${name}' with digest '${digest}' ${blobExistsConfigLayer ? 'already exists' : 'does not exist'} in registry.`, LogLevel.Trace); // PUT blobs if (!blobExistsConfigLayer) { // Obtain session ID with `/v2//blobs/uploads/` - const blobPutLocationUriPath = await postUploadSessionId(output, collectionRef, authorization); + const blobPutLocationUriPath = await postUploadSessionId(params, collectionRef); if (!blobPutLocationUriPath) { output.write(`Failed to get upload session ID`, LogLevel.Error); return; } - if (!(await putBlob(output, blobPutLocationUriPath, collectionRef, blob, authorization))) { + if (!(await putBlob(params, blobPutLocationUriPath, collectionRef, blob))) { output.write(`Failed to PUT blob '${name}' with digest '${digest}'`, LogLevel.Error); return; } @@ -173,13 +159,15 @@ export async function pushCollectionMetadata(params: CommonParams, collectionRef // Send a final PUT to combine blobs and tag manifest properly. // Collections are always tagged 'latest' - return await putManifestWithTags(output, manifest, collectionRef, ['latest'], authorization); + return await putManifestWithTags(params, manifest, collectionRef, ['latest']); } // --- Helper Functions // Spec: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests (PUT /manifests/) -async function putManifestWithTags(output: Log, manifest: ManifestContainer, ociRef: OCIRef | OCICollectionRef, tags: string[], authorization: string): Promise { +async function putManifestWithTags(params: CommonParams, manifest: ManifestContainer, ociRef: OCIRef | OCICollectionRef, tags: string[]): Promise { + const { output } = params; + output.write(`Tagging manifest with tags: ${tags.join(', ')}`, LogLevel.Trace); const { manifestStr, contentDigest } = manifest; @@ -188,28 +176,36 @@ async function putManifestWithTags(output: Log, manifest: ManifestContainer, oci const url = `https://${ociRef.registry}/v2/${ociRef.path}/manifests/${tag}`; output.write(`PUT -> '${url}'`, LogLevel.Trace); - const options = { + const httpOptions = { type: 'PUT', url, headers: { - 'Authorization': authorization, // Eg: 'Bearer ' or 'Basic ' - 'Content-Type': 'application/vnd.oci.image.manifest.v1+json', + 'content-type': 'application/vnd.oci.image.manifest.v1+json', }, data: Buffer.from(manifestStr), }; - let { statusCode, resHeaders, resBody } = await requestResolveHeaders(options, output); + let res = await requestEnsureAuthenticated(params, httpOptions, ociRef); + if (!res) { + output.write('Request failed', LogLevel.Error); + return; + } // Retry logic: when request fails with HTTP 429: too many requests - if (statusCode === 429) { + // TODO: Wrap into `requestEnsureAuthenticated`? + if (res.statusCode === 429) { output.write(`Failed to PUT manifest for tag ${tag} due to too many requests. Retrying...`, LogLevel.Warning); await delay(2000); - let response = await requestResolveHeaders(options, output); - statusCode = response.statusCode; - resHeaders = response.resHeaders; + res = await requestEnsureAuthenticated(params, httpOptions, ociRef); + if (!res) { + output.write('Request failed', LogLevel.Error); + return; + } } + const { statusCode, resBody, resHeaders } = res; + if (statusCode !== 201) { const parsed = JSON.parse(resBody?.toString() || '{}'); output.write(`Failed to PUT manifest for tag ${tag}\n${JSON.stringify(parsed, undefined, 4)}`, LogLevel.Error); @@ -225,15 +221,14 @@ async function putManifestWithTags(output: Log, manifest: ManifestContainer, oci } // Spec: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#post-then-put (PUT ?digest=) -async function putBlob(output: Log, blobPutLocationUriPath: string, ociRef: OCIRef | OCICollectionRef, blob: { name: string; digest: string; size: number; contents: Buffer }, authorization: string): Promise { +async function putBlob(params: CommonParams, blobPutLocationUriPath: string, ociRef: OCIRef | OCICollectionRef, blob: { name: string; digest: string; size: number; contents: Buffer }): Promise { + const { output } = params; const { name, digest, size, contents } = blob; output.write(`Starting PUT of ${name} blob '${digest}' (size=${size})`, LogLevel.Info); - const headers: HEADERS = { - 'user-agent': 'devcontainer', - 'authorization': authorization, + const headers = { 'content-type': 'application/octet-stream', 'content-length': `${size}` }; @@ -259,7 +254,14 @@ async function putBlob(output: Log, blobPutLocationUriPath: string, ociRef: OCIR output.write(`PUT blob to -> ${url}`, LogLevel.Trace); - const { statusCode, resBody } = await requestResolveHeaders({ type: 'PUT', url, headers, data: contents }, output); + const res = await requestEnsureAuthenticated(params, { type: 'PUT', url, headers, data: contents }, ociRef); + if (!res) { + output.write('Request failed', LogLevel.Error); + return false; + } + + const { statusCode, resBody } = res; + if (statusCode !== 201) { const parsed = JSON.parse(resBody?.toString() || '{}'); output.write(`${statusCode}: Failed to upload blob '${digest}' to '${url}' \n${JSON.stringify(parsed, undefined, 4)}`, LogLevel.Error); @@ -330,30 +332,37 @@ export async function calculateDataLayer(output: Log, data: Buffer, basename: st // Spec: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#checking-if-content-exists-in-the-registry // Requires registry auth token. -export async function checkIfBlobExists(output: Log, ociRef: OCIRef | OCICollectionRef, digest: string, authorization: string): Promise { - const headers: HEADERS = { - 'user-agent': 'devcontainer', - 'authorization': authorization, - }; - +export async function checkIfBlobExists(params: CommonParams, ociRef: OCIRef | OCICollectionRef, digest: string): Promise { + const { output } = params; + const url = `https://${ociRef.registry}/v2/${ociRef.path}/blobs/${digest}`; - const statusCode = await headRequest({ url, headers }, output); + const res = await requestEnsureAuthenticated(params, { type: 'HEAD', url, headers: {} }, ociRef); + if (!res) { + output.write('Request failed', LogLevel.Error); + return false; + } + const statusCode = res.statusCode; output.write(`${url}: ${statusCode}`, LogLevel.Trace); return statusCode === 200; } // Spec: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#post-then-put // Requires registry auth token. -async function postUploadSessionId(output: Log, ociRef: OCIRef | OCICollectionRef, authorization: string): Promise { - const headers: HEADERS = { - 'user-agent': 'devcontainer', - 'authorization': authorization - }; +async function postUploadSessionId(params: CommonParams, ociRef: OCIRef | OCICollectionRef): Promise { + const { output } = params; const url = `https://${ociRef.registry}/v2/${ociRef.path}/blobs/uploads/`; output.write(`Generating Upload URL -> ${url}`, LogLevel.Trace); - const { statusCode, resHeaders, resBody } = await requestResolveHeaders({ type: 'POST', url, headers }, output); + const res = await requestEnsureAuthenticated(params, { type: 'POST', url, headers: {} }, ociRef); + + if (!res) { + output.write('Request failed', LogLevel.Error); + return; + } + + const { statusCode, resBody, resHeaders } = res; + output.write(`${url}: ${statusCode}`, LogLevel.Trace); if (statusCode === 202) { const locationHeader = resHeaders['location'] || resHeaders['Location']; diff --git a/src/spec-configuration/containerFeaturesOCI.ts b/src/spec-configuration/containerFeaturesOCI.ts index c1061907e..54ae6ddc5 100644 --- a/src/spec-configuration/containerFeaturesOCI.ts +++ b/src/spec-configuration/containerFeaturesOCI.ts @@ -31,14 +31,14 @@ export function tryGetOCIFeatureSet(output: Log, identifier: string, options: bo return featureSet; } -export async function fetchOCIFeatureManifestIfExistsFromUserIdentifier(params: CommonParams, identifier: string, manifestDigest?: string, authToken?: string): Promise { +export async function fetchOCIFeatureManifestIfExistsFromUserIdentifier(params: CommonParams, identifier: string, manifestDigest?: string): Promise { const { output } = params; const featureRef = getRef(output, identifier); if (!featureRef) { return undefined; } - return await fetchOCIManifestIfExists(params, featureRef, manifestDigest, authToken); + return await fetchOCIManifestIfExists(params, featureRef, manifestDigest); } // Download a feature from which a manifest was previously downloaded. diff --git a/src/spec-configuration/containerTemplatesOCI.ts b/src/spec-configuration/containerTemplatesOCI.ts index 2a633cc02..944ba030d 100644 --- a/src/spec-configuration/containerTemplatesOCI.ts +++ b/src/spec-configuration/containerTemplatesOCI.ts @@ -41,7 +41,7 @@ export async function fetchTemplate(params: CommonParams, selectedTemplate: Sele output.write(`blob url: ${blobUrl}`, LogLevel.Trace); const tmpDir = userProvidedTmpDir || path.join(os.tmpdir(), 'vsch-template-temp', `${Date.now()}`); - const blobResult = await getBlob(params, blobUrl, tmpDir, templateDestPath, templateRef, undefined, ['devcontainer-template.json', 'README.md', 'NOTES.md'], 'devcontainer-template.json'); + const blobResult = await getBlob(params, blobUrl, tmpDir, templateDestPath, templateRef, ['devcontainer-template.json', 'README.md', 'NOTES.md'], 'devcontainer-template.json'); if (!blobResult) { throw new Error(`Failed to download package for ${templateRef.resource}`); @@ -121,14 +121,14 @@ export async function fetchTemplate(params: CommonParams, selectedTemplate: Sele } -async function fetchOCITemplateManifestIfExistsFromUserIdentifier(params: CommonParams, identifier: string, manifestDigest?: string, authToken?: string): Promise { +async function fetchOCITemplateManifestIfExistsFromUserIdentifier(params: CommonParams, identifier: string, manifestDigest?: string): Promise { const { output } = params; const templateRef = getRef(output, identifier); if (!templateRef) { return undefined; } - return await fetchOCIManifestIfExists(params, templateRef, manifestDigest, authToken); + return await fetchOCIManifestIfExists(params, templateRef, manifestDigest); } function replaceTemplatedValues(output: Log, template: string, options: TemplateOptions) { diff --git a/src/spec-configuration/httpOCIRegistry.ts b/src/spec-configuration/httpOCIRegistry.ts new file mode 100644 index 000000000..003466851 --- /dev/null +++ b/src/spec-configuration/httpOCIRegistry.ts @@ -0,0 +1,238 @@ +import * as os from 'os'; +import * as path from 'path'; +import * as jsonc from 'jsonc-parser'; + +import { request, requestResolveHeaders } from '../spec-utils/httpRequest'; +import { LogLevel } from '../spec-utils/log'; +import { isLocalFile, readLocalFile } from '../spec-utils/pfs'; +import { CommonParams, OCICollectionRef, OCIRef } from './containerCollectionsOCI'; + +export type HEADERS = { 'authorization'?: string; 'user-agent'?: string; 'content-type'?: string; 'accept'?: string; 'content-length'?: string }; + +interface DockerConfigFile { + auths: { + [registry: string]: { + auth: string; + }; + }; +} + +// WWW-Authenticate Regex +// realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:samalba/my-app:pull,push" +// realm="https://ghcr.io/token",service="ghcr.io",scope="repository:devcontainers/features:pull" +const realmRegex = /realm="([^"]+)"/; +const serviceRegex = /service="([^"]+)"/; +const scopeRegex = /scope="([^"]+)"/; + +// https://docs.docker.com/registry/spec/auth/token/#how-to-authenticate +export async function requestEnsureAuthenticated(params: CommonParams, httpOptions: { type: string; url: string; headers: HEADERS; data?: Buffer }, ociRef: OCIRef | OCICollectionRef) { + // If needed, Initialize the Authorization header cache. + if (!params.cachedAuthHeader) { + params.cachedAuthHeader = {}; + } + const { output, cachedAuthHeader } = params; + + // -- Update headers + httpOptions.headers['user-agent'] = 'devcontainer'; + // If the user has a cached auth token, attempt to use that first. + if (cachedAuthHeader[ociRef.registry]) { + output.write(`[httpOci] Applying cachedAuthHeader for registry ${ociRef.registry}...`, LogLevel.Trace); + httpOptions.headers.authorization = cachedAuthHeader[ociRef.registry]; + } + + const initialAttemptRes = await requestResolveHeaders(httpOptions, output); + + // For anything except a 401 response + // Simply return the original response to the caller. + if (initialAttemptRes.statusCode !== 401) { + const authScenario = cachedAuthHeader ? 'Cached' : 'NoAuth'; + output.write(`[httpOci] ${initialAttemptRes.statusCode} (${authScenario}): ${httpOptions.url}`, LogLevel.Trace); + return initialAttemptRes; + } + + // -- 'responseAttempt' status code was 401 at this point. + + // Attempt to authenticate via WWW-Authenticate Header. + const wwwAuthenticate = initialAttemptRes.resHeaders['WWW-Authenticate'] || initialAttemptRes.resHeaders['www-authenticate']; + if (!wwwAuthenticate) { + output.write(`[httpOci] ERR: Server did not provide instructions to authentiate! (Required: A 'WWW-Authenticate' Header)`, LogLevel.Error); + return; + } + + switch (wwwAuthenticate.split(' ')[0]) { + // Basic realm="localhost" + case 'Basic': + + output.write(`[httpOci] Attempting to authenticate via 'Basic' auth.`, LogLevel.Trace); + + const basicAuthCredential = await getBasicAuthCredential(params, ociRef); + if (!basicAuthCredential) { + output.write(`[httpOci] ERR: No basic auth credentials to send for registry service '${ociRef.registry}'`, LogLevel.Error); + } + + httpOptions.headers.authorization = `Basic ${basicAuthCredential}`; + break; + + // Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:samalba/my-app:pull,push" + case 'Bearer': + + output.write(`[httpOci] Attempting to authenticate via 'Bearer' auth.`, LogLevel.Trace); + + const realmGroup = realmRegex.exec(wwwAuthenticate); + const serviceGroup = serviceRegex.exec(wwwAuthenticate); + const scopeGroup = scopeRegex.exec(wwwAuthenticate); + + if (!realmGroup || !serviceGroup || !scopeGroup) { + output.write(`[httpOci] WWW-Authenticate header is not in expected format. Got: ${wwwAuthenticate}`, LogLevel.Trace); + return; + } + + const wwwAuthenticateData = { + realm: realmGroup[1], + service: serviceGroup[1], + scope: scopeGroup[1], + }; + + const bearerToken = await fetchRegistryBearerToken(params, ociRef, wwwAuthenticateData); + if (!bearerToken) { + output.write(`[httpOci] ERR: Failed to fetch Bearer token from registry.`, LogLevel.Error); + return; + } + + httpOptions.headers.authorization = `Bearer ${bearerToken}`; + break; + + default: + output.write(`[httpOci] ERR: Unsupported authentication mode '${wwwAuthenticate.split(' ')[0]}'`, LogLevel.Error); + return; + } + + // Retry the request with the updated authorization header. + const reattemptRes = await requestResolveHeaders(httpOptions, output); + output.write(`[httpOci] ${reattemptRes.statusCode} on reattempt after auth: ${httpOptions.url}`, LogLevel.Trace); + + // Cache the auth header if the request did not result in an unauthorized response. + if (reattemptRes.statusCode !== 401) { + params.cachedAuthHeader[ociRef.registry] = httpOptions.headers.authorization; + } + + return reattemptRes; +} + +// Attempts to get the Basic auth credentials for the provided registry. +// These may be programatically crafted via environment variables (GITHUB_TOKEN), +// parsed out of a special DEVCONTAINERS_OCI_AUTH environment variable, +async function getBasicAuthCredential(params: CommonParams, ociRef: OCIRef | OCICollectionRef): Promise { + const { output, env } = params; + const { registry } = ociRef; + + // TODO: Ask docker credential helper for credentials. + + if (!!env['GITHUB_TOKEN'] && registry === 'ghcr.io') { + output.write('[httpOci] Using environment GITHUB_TOKEN for auth', LogLevel.Trace); + const userToken = `USERNAME:${env['GITHUB_TOKEN']}`; + return Buffer.from(userToken).toString('base64'); + } else if (!!env['DEVCONTAINERS_OCI_AUTH']) { + // eg: DEVCONTAINERS_OCI_AUTH=service1|user1|token1,service2|user2|token2 + const authContexts = env['DEVCONTAINERS_OCI_AUTH'].split(','); + const authContext = authContexts.find(a => a.split('|')[0] === registry); + + if (authContext) { + output.write(`[httpOci] Using match from DEVCONTAINERS_OCI_AUTH for registry '${registry}'`, LogLevel.Trace); + const split = authContext.split('|'); + const userToken = `${split[1]}:${split[2]}`; + return Buffer.from(userToken) + .toString('base64'); + } + } else { + try { + const homeDir = os.homedir(); + if (homeDir) { + const dockerConfigPath = path.join(homeDir, '.docker', 'config.json'); + if (await isLocalFile(dockerConfigPath)) { + const dockerConfig: DockerConfigFile = jsonc.parse((await readLocalFile(dockerConfigPath)).toString()); + + if (dockerConfig.auths && dockerConfig.auths[registry] && dockerConfig.auths[registry].auth) { + output.write(`[httpOci] Found auth for registry '${registry}' in docker config.json`, LogLevel.Trace); + return dockerConfig.auths[registry].auth; + } + } + } + } catch (err) { + output.write(`[httpOci] Failed to read docker config.json: ${err}`, LogLevel.Trace); + } + } + + // Represents anonymous access. + output.write(`[httpOci] No authentication credentials found for registry '${registry}'. Accessing anonymously.`, LogLevel.Trace); + return; +} + +// https://docs.docker.com/registry/spec/auth/token/#requesting-a-token +async function fetchRegistryBearerToken(params: CommonParams, ociRef: OCIRef | OCICollectionRef, wwwAuthenticateData: { realm: string; service: string; scope: string }): Promise { + const { output } = params; + const { realm, service, scope } = wwwAuthenticateData; + + // TODO: Remove this. + if (realm.includes('mcr.microsoft.com')) { + return undefined; + } + + const headers: HEADERS = { + 'user-agent': 'devcontainer' + }; + + // The token server should first attempt to authenticate the client using any authentication credentials provided with the request. + // From Docker 1.11 the Docker engine supports both Basic Authentication and OAuth2 for getting tokens. + // Docker 1.10 and before, the registry client in the Docker Engine only supports Basic Authentication. + // If an attempt to authenticate to the token server fails, the token server should return a 401 Unauthorized response + // indicating that the provided credentials are invalid. + // > https://docs.docker.com/registry/spec/auth/token/#requesting-a-token + const basicAuthTokenBase64 = await getBasicAuthCredential(params, ociRef); + if (basicAuthTokenBase64) { + headers['authorization'] = `Basic ${basicAuthTokenBase64}`; + } + + // realm="https://auth.docker.io/token" + // service="registry.docker.io" + // scope="repository:samalba/my-app:pull,push" + // Example: + // https://auth.docker.io/token?service=registry.docker.io&scope=repository:samalba/my-app:pull,push + const url = `${realm}?service=${service}&scope=${scope}`; + output.write(`[httpOci] Attempting to fetch bearer token from: ${url}`, LogLevel.Trace); + + const options = { + type: 'GET', + url: url, + headers: headers + }; + + let authReq: Buffer; + try { + authReq = await request(options, output); + } catch (e: any) { + // This is ok if the registry is trying to speak Basic Auth with us. + output.write(`[httpOci] Could not fetch bearer token for '${service}': ${e}`, LogLevel.Error); + return; + } + + if (!authReq) { + output.write(`[httpOci] Did not receive bearer token for '${service}'`, LogLevel.Error); + return; + } + + let scopeToken: string | undefined; + try { + const json = JSON.parse(authReq.toString()); + scopeToken = json.token || json.access_token; // ghcr uses 'token', acr uses 'access_token' + } catch { + // not JSON + } + if (!scopeToken) { + output.write(`[httpOci] Unexpected bearer token response format for '${service}'`, LogLevel.Error); + output.write(`httpOci] Response: ${authReq.toString()}`, LogLevel.Trace); + return; + } + + return scopeToken; +} \ No newline at end of file diff --git a/src/spec-node/featuresCLI/infoManifest.ts b/src/spec-node/featuresCLI/infoManifest.ts index 7128dc938..28a21544a 100644 --- a/src/spec-node/featuresCLI/infoManifest.ts +++ b/src/spec-node/featuresCLI/infoManifest.ts @@ -1,5 +1,5 @@ import { Argv } from 'yargs'; -import { fetchAuthorizationHeader, fetchOCIManifestIfExists, getRef } from '../../spec-configuration/containerCollectionsOCI'; +import { fetchOCIManifestIfExists, getRef } from '../../spec-configuration/containerCollectionsOCI'; import { mapLogLevel } from '../../spec-utils/log'; import { getPackageConfig } from '../../spec-utils/product'; import { createLog } from '../devContainers'; @@ -44,8 +44,8 @@ async function featuresInfoManifest({ if (!featureRef) { return undefined; } - const authorization = await fetchAuthorizationHeader(params, featureRef.registry, featureRef.path, 'pull'); - const manifest = await fetchOCIManifestIfExists(params, featureRef, undefined, authorization); + + const manifest = await fetchOCIManifestIfExists(params, featureRef, undefined); console.log(JSON.stringify(manifest, undefined, 4)); await dispose(); diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index 742e7c71a..59978e5f6 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -25,8 +25,8 @@ import { Event } from '../spec-utils/event'; import { Mount } from '../spec-configuration/containerFeaturesConfiguration'; import { PackageConfiguration } from '../spec-utils/product'; import { ImageMetadataEntry } from './imageMetadata'; -import { fetchAuthorizationHeader, getManifest, getRef, HEADERS } from '../spec-configuration/containerCollectionsOCI'; -import { request } from '../spec-utils/httpRequest'; +import { getManifest, getRef } from '../spec-configuration/containerCollectionsOCI'; +import { requestEnsureAuthenticated } from '../spec-configuration/httpOCIRegistry'; export { getConfigFilePath, getDockerfilePath, isDockerFileConfig, resolveConfigFilePath } from '../spec-configuration/configuration'; export { uriToFsPath, parentURI } from '../spec-configuration/configurationCommonUtils'; @@ -213,19 +213,18 @@ export async function inspectDockerImage(params: DockerResolverParameters | Dock } } -export async function inspectImageInRegistry(output: Log, name: string, authToken?: string): Promise { +export async function inspectImageInRegistry(output: Log, name: string): Promise { const resourceAndVersion = qualifyImageName(name); const params = { output, env: process.env }; const ref = getRef(output, resourceAndVersion); if (!ref) { throw new Error(`Could not parse image name '${name}'`); } - const auth = authToken ?? await fetchAuthorizationHeader(params, ref.registry, ref.path, 'pull'); const registryServer = ref.registry === 'docker.io' ? 'registry-1.docker.io' : ref.registry; const manifestUrl = `https://${registryServer}/v2/${ref.path}/manifests/${ref.version}`; output.write(`manifest url: ${manifestUrl}`, LogLevel.Trace); - const manifest = await getManifest(params, manifestUrl, ref, auth || false, 'application/vnd.docker.distribution.manifest.v2+json'); + const manifest = await getManifest(params, manifestUrl, ref, 'application/vnd.docker.distribution.manifest.v2+json'); if (!manifest) { throw new Error(`No manifest found for ${resourceAndVersion}.`); } @@ -233,22 +232,18 @@ export async function inspectImageInRegistry(output: Log, name: string, authToke const blobUrl = `https://${registryServer}/v2/${ref.path}/blobs/${manifest.config.digest}`; output.write(`blob url: ${blobUrl}`, LogLevel.Trace); - const headers: HEADERS = { - 'user-agent': 'devcontainer', - }; - - if (auth) { - headers['authorization'] = auth; - } - - const options = { + const httpOptions = { type: 'GET', url: blobUrl, - headers: headers + headers: {} }; - const blob = await request(options, output); - const obj = JSON.parse(blob.toString()); + const res = await requestEnsureAuthenticated(params, httpOptions, ref); + if (!res) { + throw new Error(`Failed to fetch blob for ${resourceAndVersion}.`); + } + const blob = res.resBody.toString(); + const obj = JSON.parse(blob); return { Id: manifest.config.digest, Config: obj.config, diff --git a/src/test/container-features/configs/azure-container-registry/.devcontainer.json b/src/test/container-features/configs/azure-container-registry/.devcontainer.json new file mode 100644 index 000000000..caec368c1 --- /dev/null +++ b/src/test/container-features/configs/azure-container-registry/.devcontainer.json @@ -0,0 +1,8 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "devcontainercli.azurecr.io/features/color:1": { + "favorite": "pink" + } + } +} \ No newline at end of file diff --git a/src/test/container-features/containerFeaturesOCIPush.test.ts b/src/test/container-features/containerFeaturesOCIPush.test.ts index 28d6bf937..48ebb1e84 100644 --- a/src/test/container-features/containerFeaturesOCIPush.test.ts +++ b/src/test/container-features/containerFeaturesOCIPush.test.ts @@ -1,5 +1,5 @@ import { assert } from 'chai'; -import { fetchAuthorizationHeader, DEVCONTAINER_TAR_LAYER_MEDIATYPE, getRef } from '../../spec-configuration/containerCollectionsOCI'; +import { DEVCONTAINER_TAR_LAYER_MEDIATYPE, getRef } from '../../spec-configuration/containerCollectionsOCI'; import { fetchOCIFeatureManifestIfExistsFromUserIdentifier } from '../../spec-configuration/containerFeaturesOCI'; import { calculateDataLayer, checkIfBlobExists, calculateManifestAndContentDigest } from '../../spec-configuration/containerCollectionsOCIPush'; import { createPlainLog, LogLevel, makeLog } from '../../spec-utils/log'; @@ -346,19 +346,15 @@ describe('Test OCI Push Helper Functions', () => { if (!ociFeatureRef) { assert.fail('getRef() for the Feature should not be undefined'); } - const { registry, resource } = ociFeatureRef; - const sessionAuth = await fetchAuthorizationHeader({ output, env: process.env }, registry, resource, 'pull'); - if (!sessionAuth) { - assert.fail('Could not get registry auth token'); - } - const tarLayerBlobExists = await checkIfBlobExists(output, ociFeatureRef, 'sha256:b2006e7647191f7b47222ae48df049c6e21a4c5a04acfad0c4ef614d819de4c5', sessionAuth); + + const tarLayerBlobExists = await checkIfBlobExists({ output, env: process.env }, ociFeatureRef, 'sha256:b2006e7647191f7b47222ae48df049c6e21a4c5a04acfad0c4ef614d819de4c5'); assert.isTrue(tarLayerBlobExists); - const configLayerBlobExists = await checkIfBlobExists(output, ociFeatureRef, 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', sessionAuth); + const configLayerBlobExists = await checkIfBlobExists({ output, env: process.env }, ociFeatureRef, 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'); assert.isTrue(configLayerBlobExists); - const randomStringDoesNotExist = await checkIfBlobExists(output, ociFeatureRef, 'sha256:41af286dc0b172ed2f1ca934fd2278de4a1192302ffa07087cea2682e7d372e3', sessionAuth); + const randomStringDoesNotExist = await checkIfBlobExists({ output, env: process.env }, ociFeatureRef, 'sha256:41af286dc0b172ed2f1ca934fd2278de4a1192302ffa07087cea2682e7d372e3'); assert.isFalse(randomStringDoesNotExist); }); }); \ No newline at end of file diff --git a/src/test/container-features/registryCompatibilityOCI.test.ts b/src/test/container-features/registryCompatibilityOCI.test.ts new file mode 100644 index 000000000..492fc4257 --- /dev/null +++ b/src/test/container-features/registryCompatibilityOCI.test.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { assert } from 'chai'; +import * as path from 'path'; +import { devContainerDown, devContainerUp, shellExec } from '../testUtils'; + +const pkg = require('../../../package.json'); + +describe('Registry Compatibility', function () { + this.timeout('120s'); + + const tmp = path.relative(process.cwd(), path.join(__dirname, 'tmp')); + const cli = `npx --prefix ${tmp} devcontainer`; + + before('Install', async () => { + await shellExec(`rm -rf ${tmp}/node_modules`); + await shellExec(`mkdir -p ${tmp}`); + await shellExec(`npm --prefix ${tmp} install devcontainers-cli-${pkg.version}.tgz`); + }); + + // TODO: Matrix this test against all tested registries + describe('Azure Container Registry', () => { + + describe(`'devcontainer up' with a Feature anonymously pulled from ACR`, () => { + let containerId: string | null = null; + const testFolder = `${__dirname}/configs/azure-container-registry`; + + before(async () => containerId = (await devContainerUp(cli, testFolder, { 'logLevel': 'trace' })).containerId); + after(async () => await devContainerDown({ containerId })); + + it('should exec the color command', async () => { + const res = await shellExec(`${cli} exec --workspace-folder ${testFolder} color`); + const response = JSON.parse(res.stdout); + console.log(res.stderr); + assert.equal(response.outcome, 'success'); + assert.match(res.stderr, /my favorite color is pink/); + }); + }); + + describe(`devcontainer features info manifest`, async () => { + + it('fetches manifest anonymously from ACR', async () => { + + let infoManifestResult: { stdout: string; stderr: string } | null = null; + let success = false; + try { + infoManifestResult = await shellExec(`${cli} features info manifest devcontainercli.azurecr.io/features/hello --log-level trace`); + success = true; + } catch (error) { + assert.fail('features info tags sub-command should not throw'); + } + + assert.isTrue(success); + assert.isDefined(infoManifestResult); + const manifest = infoManifestResult.stdout; + const regex = /application\/vnd\.devcontainers\.layer\.v1\+tar/; + assert.match(manifest, regex); + }); + }); + }); + +}); \ No newline at end of file