diff --git a/src/spec-configuration/containerCollectionsOCI.ts b/src/spec-configuration/containerCollectionsOCI.ts index 8a683870c..457bc2cf8 100644 --- a/src/spec-configuration/containerCollectionsOCI.ts +++ b/src/spec-configuration/containerCollectionsOCI.ts @@ -20,6 +20,7 @@ export interface CommonParams { // Represents the unique OCI identifier for a Feature or Template. // eg: ghcr.io/devcontainers/features/go:1.0.0 +// eg: ghcr.io/devcontainers/features/go@sha256:fe73f123927bd9ed1abda190d3009c4d51d0e17499154423c5913cf344af15a3 // Constructed by 'getRef()' export interface OCIRef { registry: string; // 'ghcr.io' @@ -28,7 +29,10 @@ export interface OCIRef { path: string; // 'devcontainers/features/go' resource: string; // 'ghcr.io/devcontainers/features/go' id: string; // 'go' - version?: string; // '1.0.0' + + version: string; // (Either the contents of 'tag' or 'digest') + tag?: string; // '1.0.0' + digest?: string; // 'sha256:fe73f123927bd9ed1abda190d3009c4d51d0e17499154423c5913cf344af15a3' } // Represents the unique OCI identifier for a Collection's Metadata artifact. @@ -38,6 +42,7 @@ export interface OCICollectionRef { registry: string; // 'ghcr.io' path: string; // 'devcontainers/features' resource: string; // 'ghcr.io/devcontainers/features' + tag: 'latest'; // 'latest' (always) version: 'latest'; // 'latest' (always) } @@ -88,12 +93,14 @@ interface OCIImageIndex { // Following Spec: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-manifests // Alternative Spec: https://docs.docker.com/registry/spec/api/#overview // -// Entire path ('namespace' in spec terminology) for the given repository +// The path: +// 'namespace' in spec terminology for the given repository // (eg: devcontainers/features/go) const regexForPath = /^[a-z0-9]+([._-][a-z0-9]+)*(\/[a-z0-9]+([._-][a-z0-9]+)*)*$/; +// The reference: // MUST be either (a) the digest of the manifest or (b) a tag // MUST be at most 128 characters in length and MUST match the following regular expression: -const regexForReference = /^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$/; +const regexForVersionOrDigest = /^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$/; // https://go.dev/doc/install/source#environment // Expected by OCI Spec as seen here: https://github.com/opencontainers/image-spec/blob/main/image-index.md#image-index-property-descriptions @@ -124,20 +131,54 @@ export function getRef(output: Log, input: string): OCIRef | undefined { input = input.toLowerCase(); const indexOfLastColon = input.lastIndexOf(':'); + const indexOfLastAtCharacter = input.lastIndexOf('@'); let resource = ''; - let version = ''; // TODO: Support parsing out manifest digest (...@sha256:...) - - // 'If' condition is true in the following cases: - // 1. The final colon is before the first slash (a port) : eg: ghcr.io:8081/codspace/features/ruby - // 2. There is no version : eg: ghcr.io/codspace/features/ruby - // In both cases, assume 'latest' tag. - if (indexOfLastColon === -1 || indexOfLastColon < input.indexOf('/')) { - resource = input; - version = 'latest'; + let tag: string | undefined = undefined; + let digest: string | undefined = undefined; + + if (indexOfLastAtCharacter !== -1) { + // The version is specified by digest + // eg: ghcr.io/codspace/features/ruby@sha256:abcdefgh + resource = input.substring(0, indexOfLastAtCharacter); + const digestWithHashingAlgorithm = input.substring(indexOfLastAtCharacter + 1); + const splitOnColon = digestWithHashingAlgorithm.split(':'); + if (splitOnColon.length !== 2) { + output.write(`Failed to parse digest '${digestWithHashingAlgorithm}'. Expected format: 'sha256:abcdefghijk'`, LogLevel.Error); + return; + } + + if (splitOnColon[0] !== 'sha256') { + output.write(`Digest algorithm for input '${input}' failed validation. Expected hashing algorithm to be 'sha256'.`, LogLevel.Error); + return; + } + + if (!regexForVersionOrDigest.test(splitOnColon[1])) { + output.write(`Digest for input '${input}' failed validation. Expected digest to match regex '${regexForVersionOrDigest}'.`, LogLevel.Error); + } + + digest = digestWithHashingAlgorithm; } else { - resource = input.substring(0, indexOfLastColon); - version = input.substring(indexOfLastColon + 1); + // In both cases, assume 'latest' tag. + if (indexOfLastColon === -1 || indexOfLastColon < input.lastIndexOf('/')) { + // 1. The final colon is before the first slash (a port) + // eg: ghcr.io:8081/codspace/features/ruby + // 2. There is no tag at all + // eg: ghcr.io/codspace/features/ruby + // In both cases, assume the 'latest' tag + resource = input; + tag = 'latest'; + } else { + // The version is specified by tag + // eg: ghcr.io/codspace/features/ruby:1.0.0 + resource = input.substring(0, indexOfLastColon); + tag = input.substring(indexOfLastColon + 1); + } + } + + if (tag && !regexForVersionOrDigest.test(tag)) { + output.write(`Tag '${tag}' for input '${input}' failed validation. Expected digest to match regex '${regexForVersionOrDigest}'.`, LogLevel.Error); + return; } const splitOnSlash = resource.split('/'); @@ -149,36 +190,36 @@ export function getRef(output: Log, input: string): OCIRef | undefined { const path = `${namespace}/${id}`; + if (!regexForPath.exec(path)) { + output.write(`Path '${path}' for input '${input}' failed validation. Expected path to match regex '${regexForPath}'.`, LogLevel.Error); + return; + } + + const version = digest || tag || 'latest'; // The most specific version. + output.write(`> input: ${input}`, LogLevel.Trace); output.write(`>`, LogLevel.Trace); output.write(`> resource: ${resource}`, LogLevel.Trace); output.write(`> id: ${id}`, LogLevel.Trace); - output.write(`> version: ${version}`, LogLevel.Trace); output.write(`> owner: ${owner}`, LogLevel.Trace); output.write(`> namespace: ${namespace}`, LogLevel.Trace); // TODO: We assume 'namespace' includes at least one slash (eg: 'devcontainers/features') output.write(`> registry: ${registry}`, LogLevel.Trace); output.write(`> path: ${path}`, LogLevel.Trace); - - // Validate results of parse. - - if (!regexForPath.exec(path)) { - output.write(`Parsed path '${path}' for input '${input}' failed validation.`, LogLevel.Error); - return undefined; - } - - if (!regexForReference.test(version)) { - output.write(`Parsed version '${version}' for input '${input}' failed validation.`, LogLevel.Error); - return undefined; - } + output.write(`>`, LogLevel.Trace); + output.write(`> version: ${version}`, LogLevel.Trace); + output.write(`> tag?: ${tag}`, LogLevel.Trace); + output.write(`> digest?: ${digest}`, LogLevel.Trace); return { id, - version, owner, namespace, registry, resource, path, + version, + tag, + digest, }; } @@ -203,7 +244,8 @@ export function getCollectionRef(output: Log, registry: string, namespace: strin registry, path, resource, - version: 'latest' + version: 'latest', + tag: 'latest', }; } diff --git a/src/spec-configuration/containerFeaturesConfiguration.ts b/src/spec-configuration/containerFeaturesConfiguration.ts index 363700fac..5d91404aa 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -724,18 +724,14 @@ export async function getFeatureIdType(params: CommonParams, userFeatureId: stri return { type: 'file-path', manifest: undefined }; } - // version identifier for a github release feature. - // DEPRECATED: This is a legacy feature-set ID - if (userFeatureId.includes('@')) { - return { type: 'github-repo', manifest: undefined }; - } - const manifest = await fetchOCIFeatureManifestIfExistsFromUserIdentifier(params, userFeatureId); if (manifest) { return { type: 'oci', manifest: manifest }; } else { + output.write(`Could not resolve Feature manifest for '${userFeatureId}'. If necessary, provide registry credentials with 'docker login '.`, LogLevel.Warning); + output.write(`Falling back to legacy GitHub Releases mode to acquire Feature.`, LogLevel.Trace); + // DEPRECATED: This is a legacy feature-set ID - output.write('(!) WARNING: Falling back to deprecated GitHub Release syntax. See https://github.com/devcontainers/spec/blob/main/proposals/devcontainer-features.md#referencing-a-feature for updated specification.', LogLevel.Warning); return { type: 'github-repo', manifest: undefined }; } } diff --git a/src/test/container-features/configs/dockerfile-with-v2-oci-features/.devcontainer.json b/src/test/container-features/configs/dockerfile-with-v2-oci-features/.devcontainer.json index bbd591b5b..0e246b0cb 100644 --- a/src/test/container-features/configs/dockerfile-with-v2-oci-features/.devcontainer.json +++ b/src/test/container-features/configs/dockerfile-with-v2-oci-features/.devcontainer.json @@ -7,7 +7,7 @@ }, "features": { "terraform": "latest", - "ghcr.io/devcontainers/features/docker-in-docker:1": {}, + "ghcr.io/devcontainers/features/docker-in-docker@sha256:e32e8937c87345ff7a937d22cacb7f395d41deffde9943291ef3cc0ac91a8ac6": {}, "node": "16" } } \ No newline at end of file diff --git a/src/test/container-features/containerFeaturesOCI.test.ts b/src/test/container-features/containerFeaturesOCI.test.ts index b2767efda..e8d685525 100644 --- a/src/test/container-features/containerFeaturesOCI.test.ts +++ b/src/test/container-features/containerFeaturesOCI.test.ts @@ -18,6 +18,7 @@ describe('getCollectionRef()', async function () { assert.equal(collectionRef.path, 'devcontainers/templates'); assert.equal(collectionRef.resource, 'ghcr.io/devcontainers/templates'); assert.equal(collectionRef.version, 'latest'); + assert.equal(collectionRef.tag, collectionRef.version); }); it('valid getCollectionRef() that was originally uppercase', async () => { @@ -30,6 +31,7 @@ describe('getCollectionRef()', async function () { assert.equal(collectionRef.path, 'devcontainers/templates'); assert.equal(collectionRef.resource, 'ghcr.io/devcontainers/templates'); assert.equal(collectionRef.version, 'latest'); + assert.equal(collectionRef.tag, collectionRef.version); }); it('valid getCollectionRef() with port in registry', async () => { @@ -42,6 +44,7 @@ describe('getCollectionRef()', async function () { assert.equal(collectionRef.path, 'devcontainers/templates'); assert.equal(collectionRef.resource, 'ghcr.io:8001/devcontainers/templates'); assert.equal(collectionRef.version, 'latest'); + assert.equal(collectionRef.tag, collectionRef.version); }); it('invalid getCollectionRef() with an invalid character in path', async () => { @@ -70,10 +73,28 @@ describe('getRef()', async function () { assert.equal(feat.owner, 'devcontainers'); assert.equal(feat.registry, 'ghcr.io'); assert.equal(feat.resource, 'ghcr.io/devcontainers/templates/docker-from-docker'); - assert.equal(feat.version, 'latest'); + assert.equal(feat.tag, 'latest'); + assert.equal(feat.tag, feat.version); assert.equal(feat.path, 'devcontainers/templates/docker-from-docker'); }); + it('valid getRef() with a digest', async () => { + const feat = getRef(output, 'ghcr.io/my-org/my-features/my-feat@sha256:1234567890123456789012345678901234567890123456789012345678901234'); + if (!feat) { + assert.fail('featureRef should not be undefined'); + } + assert.ok(feat); + assert.equal(feat.id, 'my-feat'); + assert.equal(feat.namespace, 'my-org/my-features'); + assert.equal(feat.owner, 'my-org'); + assert.equal(feat.registry, 'ghcr.io'); + assert.equal(feat.resource, 'ghcr.io/my-org/my-features/my-feat'); + assert.equal(feat.path, 'my-org/my-features/my-feat'); + assert.isUndefined(feat.tag); + assert.equal(feat.digest, 'sha256:1234567890123456789012345678901234567890123456789012345678901234'); + assert.equal(feat.digest, feat.version); + }); + it('valid getRef() without a version tag', async () => { const feat = getRef(output, 'ghcr.io/devcontainers/templates/docker-from-docker'); if (!feat) { @@ -86,7 +107,9 @@ describe('getRef()', async function () { assert.equal(feat.registry, 'ghcr.io'); assert.equal(feat.resource, 'ghcr.io/devcontainers/templates/docker-from-docker'); assert.equal(feat.path, 'devcontainers/templates/docker-from-docker'); - assert.equal(feat.version, 'latest'); // Defaults to 'latest' if not version supplied. + assert.equal(feat.tag, 'latest'); // Defaults to 'latest' if not version supplied. + assert.isUndefined(feat.digest); + assert.equal(feat.tag, feat.version); }); it('valid getRef() automatically downcases', async () => { @@ -101,7 +124,8 @@ describe('getRef()', async function () { assert.equal(feat.registry, 'ghcr.io'); assert.equal(feat.resource, 'ghcr.io/devcontainers/templates/docker-from-docker'); assert.equal(feat.path, 'devcontainers/templates/docker-from-docker'); - assert.equal(feat.version, 'latest'); // Defaults to 'latest' if not version supplied. + assert.equal(feat.tag, 'latest'); // Defaults to 'latest' if not version supplied. + assert.equal(feat.tag, feat.version); }); it('valid getRef() with a registry that contains a port.', async () => { @@ -116,7 +140,8 @@ describe('getRef()', async function () { assert.equal(feat.registry, 'docker.io:8001'); assert.equal(feat.resource, 'docker.io:8001/devcontainers/templates/docker-from-docker'); assert.equal(feat.path, 'devcontainers/templates/docker-from-docker'); - assert.equal(feat.version, 'latest'); // Defaults to 'latest' if not version supplied. + assert.equal(feat.tag, 'latest'); // Defaults to 'latest' if not version supplied. + assert.equal(feat.tag, feat.version); }); it('valid getRef() really short path and no version', async () => { @@ -131,7 +156,8 @@ describe('getRef()', async function () { assert.equal(feat.registry, 'docker.io:8001'); assert.equal(feat.resource, 'docker.io:8001/a/b/c'); assert.equal(feat.path, 'a/b/c'); - assert.equal(feat.version, 'latest'); // Defaults to 'latest' if not version supplied. + assert.equal(feat.tag, 'latest'); // Defaults to 'latest' if not version supplied. + assert.equal(feat.tag, feat.version); }); it('invalid getRef() with duplicate version tags', async () => { @@ -164,6 +190,16 @@ describe('getRef()', async function () { assert.isUndefined(feat); }); + it('invalid getRef() with unsupported digest hashing algorithm', async () => { + const feat = getRef(output, 'ghcr.io/devcontainers//templates/docker-from-docker@sha100:1234567890123456789012345678901234567890123456789012345678901234'); + assert.isUndefined(feat); + }); + + it('invalid getRef() with mis-shaped digest', async () => { + const feat = getRef(output, 'ghcr.io/devcontainers//templates/docker-from-docker@1234567890123456789012345678901234567890123456789012345678901234'); + assert.isUndefined(feat); + }); + }); describe('Test OCI Pull', () => { @@ -179,7 +215,7 @@ describe('Test OCI Pull', () => { assert.equal(feat.owner, 'codspace'); assert.equal(feat.registry, 'ghcr.io'); assert.equal(feat.resource, 'ghcr.io/codspace/features/ruby'); - assert.equal(feat.version, '1'); + assert.equal(feat.tag, '1'); assert.equal(feat.path, 'codspace/features/ruby'); }); diff --git a/src/test/container-features/containerFeaturesOrder.test.ts b/src/test/container-features/containerFeaturesOrder.test.ts index 32a617afb..782cd904d 100644 --- a/src/test/container-features/containerFeaturesOrder.test.ts +++ b/src/test/container-features/containerFeaturesOrder.test.ts @@ -151,6 +151,7 @@ describe('Container features install order', function () { owner: spiltOnSlash[1], registry: spiltOnSlash[0], resource: splitOnColon[0], + tag: splitOnColon[1], version: splitOnColon[1], path: `${spiltOnSlash[1]}/${spiltOnSlash[2]}/spiltOnSlash[3]` }, diff --git a/src/test/container-features/featureHelpers.test.ts b/src/test/container-features/featureHelpers.test.ts index 6da1ae77b..167859805 100644 --- a/src/test/container-features/featureHelpers.test.ts +++ b/src/test/container-features/featureHelpers.test.ts @@ -206,6 +206,8 @@ describe('validate processFeatureIdentifier', async function () { owner: 'codspace', namespace: 'codspace/features', registry: 'ghcr.io', + tag: 'latest', + digest: undefined, version: 'latest', resource: 'ghcr.io/codspace/features/ruby', path: 'codspace/features/ruby', @@ -219,6 +221,42 @@ describe('validate processFeatureIdentifier', async function () { } }); + it('should process oci registry (with a digest)', async function () { + const userFeature: DevContainerFeature = { + id: 'ghcr.io/devcontainers/features/ruby@sha256:4ef08c9c3b708f3c2faecc5a898b39736423dd639f09f2a9f8bf9b0b9252ef0a', + options: {}, + }; + + const featureSet = await processFeatureIdentifier(params, defaultConfigPath, workspaceRoot, userFeature); + if (!featureSet) { + assert.fail('processFeatureIdentifier returned null'); + } + const featureId = featureSet.features[0].id; + assertFeatureIdInvariant(featureId); + assert.strictEqual(featureSet?.features[0].id, 'ruby'); + + assert.exists(featureSet); + + const expectedFeatureRef: OCIRef = { + id: 'ruby', + owner: 'devcontainers', + namespace: 'devcontainers/features', + registry: 'ghcr.io', + tag: undefined, + digest: 'sha256:4ef08c9c3b708f3c2faecc5a898b39736423dd639f09f2a9f8bf9b0b9252ef0a', + version: 'sha256:4ef08c9c3b708f3c2faecc5a898b39736423dd639f09f2a9f8bf9b0b9252ef0a', + resource: 'ghcr.io/devcontainers/features/ruby', + path: 'devcontainers/features/ruby', + }; + + if (featureSet.sourceInformation.type === 'oci') { + assert.ok(featureSet.sourceInformation.type === 'oci'); + assert.deepEqual(featureSet.sourceInformation.featureRef, expectedFeatureRef); + } else { + assert.fail('sourceInformation.type is not oci'); + } + }); + it('should process oci registry (with a tag)', async function () { const userFeature: DevContainerFeature = { id: 'ghcr.io/codspace/features/ruby:1.0.13', @@ -240,6 +278,8 @@ describe('validate processFeatureIdentifier', async function () { owner: 'codspace', namespace: 'codspace/features', registry: 'ghcr.io', + tag: '1.0.13', + digest: undefined, version: '1.0.13', resource: 'ghcr.io/codspace/features/ruby', path: 'codspace/features/ruby', @@ -582,6 +622,7 @@ chmod +x ./install.sh path: 'my-org/my-repo/test', resource: 'ghcr.io/my-org/my-repo/test', id: 'test', + tag: '1.2.3', version: '1.2.3', }, manifest: { @@ -664,6 +705,7 @@ chmod +x ./install.sh path: 'my-org/my-repo/test', resource: 'ghcr.io/my-org/my-repo/test', id: 'test', + tag: '1.2.3', version: '1.2.3', }, manifest: { @@ -749,6 +791,7 @@ chmod +x ./install.sh path: 'my-org/my-repo/test', resource: 'ghcr.io/my-org/my-repo/test', id: 'test', + tag: '1.2.3', version: '1.2.3', }, manifest: { diff --git a/src/test/imageMetadata.test.ts b/src/test/imageMetadata.test.ts index 4553bdd47..4dd56be4b 100644 --- a/src/test/imageMetadata.test.ts +++ b/src/test/imageMetadata.test.ts @@ -539,6 +539,7 @@ function getFeaturesConfig(features: Feature[]): FeaturesConfig { path: 'my-org/my-repo/test', resource: 'ghcr.io/my-org/my-repo/test', id: 'test', + tag: '1.2.3', version: '1.2.3', }, manifest: {