diff --git a/lib/modules/datasource/docker/index.ts b/lib/modules/datasource/docker/index.ts index a349ce3ac66604ca710b0f328df651ba7a21efff..24f842558ffdb178a2a6c48ad95d55fcf135b5db 100644 --- a/lib/modules/datasource/docker/index.ts +++ b/lib/modules/datasource/docker/index.ts @@ -29,13 +29,7 @@ import { sourceLabels, } from './common'; import { ecrPublicRegex, ecrRegex, isECRMaxResultsError } from './ecr'; -import type { - Image, - ImageConfig, - ImageList, - OciImage, - OciImageList, -} from './types'; +import type { Manifest, OciImageConfig } from './schema'; const defaultConfig = { commitMessageTopic: '{{{depName}}} Docker tag', @@ -170,7 +164,7 @@ export class DockerDatasource extends Datasource { registryHost: string, dockerRepository: string, configDigest: string - ): Promise<HttpResponse<ImageConfig> | undefined> { + ): Promise<HttpResponse<OciImageConfig> | undefined> { logger.trace( `getImageConfig(${registryHost}, ${dockerRepository}, ${configDigest})` ); @@ -192,7 +186,7 @@ export class DockerDatasource extends Datasource { 'blobs', configDigest ); - return await this.http.getJson<ImageConfig>(url, { + return await this.http.getJson<OciImageConfig>(url, { headers, noAuth: true, }); @@ -215,11 +209,8 @@ export class DockerDatasource extends Datasource { if (!manifestResponse) { return null; } - const manifest = JSON.parse(manifestResponse.body) as - | ImageList - | Image - | OciImageList - | OciImage; + // TODO: validate schema + const manifest = JSON.parse(manifestResponse.body) as Manifest; if (manifest.schemaVersion !== 2) { logger.debug( { registry, dockerRepository, tag }, @@ -398,10 +389,10 @@ export class DockerDatasource extends Datasource { registryHost: string, dockerRepository: string, tag: string - ): Promise<Record<string, string>> { + ): Promise<Record<string, string> | undefined> { logger.debug(`getLabels(${registryHost}, ${dockerRepository}, ${tag})`); try { - let labels: Record<string, string> = {}; + let labels: Record<string, string> | undefined = {}; const configDigest = await this.getConfigDigest( registryHost, dockerRepository, @@ -427,7 +418,8 @@ export class DockerDatasource extends Datasource { noAuth: true, }); - const body = JSON.parse(configResponse.body); + // TODO: validate schema + const body = JSON.parse(configResponse.body) as OciImageConfig; if (body.config) { labels = body.config.Labels; } else { @@ -765,11 +757,8 @@ export class DockerDatasource extends Datasource { ); if (architecture && manifestResponse) { - const manifestList = JSON.parse(manifestResponse.body) as - | ImageList - | Image - | OciImageList - | OciImage; + // TODO: validate Schema + const manifestList = JSON.parse(manifestResponse.body) as Manifest; if ( manifestList.schemaVersion === 2 && (manifestList.mediaType === @@ -779,7 +768,7 @@ export class DockerDatasource extends Datasource { (!manifestList.mediaType && 'manifests' in manifestList)) ) { for (const manifest of manifestList.manifests) { - if (manifest.platform['architecture'] === architecture) { + if (manifest.platform?.architecture === architecture) { digest = manifest.digest; break; } diff --git a/lib/modules/datasource/docker/schema.spec.ts b/lib/modules/datasource/docker/schema.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2cabc659a1e7cfed3a1f2fa649740c5b97478c2 --- /dev/null +++ b/lib/modules/datasource/docker/schema.spec.ts @@ -0,0 +1,283 @@ +import { + DistributionListManifest, + DistributionManifest, + HelmConfigBlob, + Manifest, + OciImageIndexManifest, + OciImageManifest, +} from './schema'; + +describe('modules/datasource/docker/schema', () => { + it('parses OCI image manifest', () => { + const manifest = { + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + config: { + mediaType: 'application/vnd.oci.image.config.v1+json', + digest: 'sha256:1234567890abcdef', + size: 12345, + }, + layers: [ + { + mediaType: 'application/vnd.oci.image.layer.v1.tar+gzip', + digest: 'sha256:1234567890abcdef', + size: 12345, + }, + ], + }; + expect(OciImageManifest.parse(manifest)).toMatchObject({ + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + config: { + mediaType: 'application/vnd.oci.image.config.v1+json', + digest: 'sha256:1234567890abcdef', + size: 12345, + }, + }); + + expect(Manifest.parse(manifest)).toMatchObject({ + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + }); + + delete (manifest as any).mediaType; + + expect(OciImageManifest.parse(manifest)).toMatchObject({ + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + config: { + mediaType: 'application/vnd.oci.image.config.v1+json', + digest: 'sha256:1234567890abcdef', + size: 12345, + }, + }); + }); + + it('parses OCI helm manifest', () => { + const manifest = { + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + config: { + mediaType: 'application/vnd.cncf.helm.config.v1+json', + digest: + 'sha256:179618538d9f341897e5e05b2997f8c6f3009afe825525e87cf38a877fcb028e', + size: 1066, + }, + layers: [ + { + mediaType: 'application/vnd.cncf.helm.chart.content.v1.tar+gzip', + digest: + 'sha256:0dc5d782d04596548f91d54a412013d844baabede2daa7ec9e201e9c80daf533', + size: 234103, + }, + ], + }; + expect(OciImageManifest.parse(manifest)).toMatchObject({ + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + config: { + mediaType: 'application/vnd.cncf.helm.config.v1+json', + digest: + 'sha256:179618538d9f341897e5e05b2997f8c6f3009afe825525e87cf38a877fcb028e', + size: 1066, + }, + }); + + expect(Manifest.parse(manifest)).toMatchObject({ + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + }); + + delete (manifest as any).mediaType; + + expect(OciImageManifest.parse(manifest)).toMatchObject({ + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + config: { + mediaType: 'application/vnd.cncf.helm.config.v1+json', + digest: + 'sha256:179618538d9f341897e5e05b2997f8c6f3009afe825525e87cf38a877fcb028e', + size: 1066, + }, + }); + }); + + it('parses OCI image index', () => { + const manifest = { + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.index.v1+json', + manifests: [ + { + mediaType: 'application/vnd.oci.image.manifest.v1+json', + size: 7143, + digest: + 'sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f', + platform: { + architecture: 'ppc64le', + os: 'linux', + }, + }, + { + mediaType: 'application/vnd.oci.image.manifest.v1+json', + size: 7682, + digest: + 'sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270', + platform: { + architecture: 'amd64', + os: 'linux', + }, + }, + ], + annotations: { + 'com.example.key1': 'value1', + 'com.example.key2': 'value2', + }, + }; + + expect(OciImageIndexManifest.parse(manifest)).toMatchObject({ + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.index.v1+json', + }); + + expect(Manifest.parse(manifest)).toMatchObject({ + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.index.v1+json', + }); + + delete (manifest as any).mediaType; + expect(OciImageIndexManifest.parse(manifest)).toMatchObject({ + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.index.v1+json', + }); + }); + + it('parses distribution manifest', () => { + const manifest = { + schemaVersion: 2, + mediaType: 'application/vnd.docker.distribution.manifest.v2+json', + config: { + mediaType: 'application/vnd.docker.container.image.v1+json', + digest: + 'sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7', + size: 7023, + }, + layers: [ + { + mediaType: 'application/vnd.docker.image.rootfs.diff.tar.gzip', + digest: + 'sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f', + size: 32654, + }, + { + mediaType: 'application/vnd.docker.image.rootfs.diff.tar.gzip', + digest: + 'sha256:3c3a4604a545cdc127456d94e421cd355bca5b528f4a9c1905b15da2eb4a4c6b', + size: 16724, + }, + { + mediaType: 'application/vnd.docker.image.rootfs.diff.tar.gzip', + digest: + 'sha256:ec4b8955958665577945c89419d1af06b5f7636b4ac3da7f12184802ad867736', + size: 73109, + }, + ], + }; + + expect(DistributionManifest.parse(manifest)).toMatchObject({ + schemaVersion: 2, + mediaType: 'application/vnd.docker.distribution.manifest.v2+json', + }); + + expect(Manifest.parse(manifest)).toMatchObject({ + schemaVersion: 2, + mediaType: 'application/vnd.docker.distribution.manifest.v2+json', + }); + }); + + it('parses distribution manifest list', () => { + const manifest = { + schemaVersion: 2, + mediaType: 'application/vnd.docker.distribution.manifest.list.v2+json', + manifests: [ + { + mediaType: 'application/vnd.docker.distribution.manifest.v2+json', + digest: + 'sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f', + size: 7143, + platform: { + architecture: 'ppc64le', + os: 'linux', + }, + }, + { + mediaType: 'application/vnd.docker.distribution.manifest.v2+json', + digest: + 'sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270', + size: 7682, + platform: { + architecture: 'amd64', + os: 'linux', + features: ['sse4'], + }, + }, + ], + }; + + expect(DistributionListManifest.parse(manifest)).toMatchObject({ + schemaVersion: 2, + mediaType: 'application/vnd.docker.distribution.manifest.list.v2+json', + }); + + expect(Manifest.parse(manifest)).toMatchObject({ + schemaVersion: 2, + mediaType: 'application/vnd.docker.distribution.manifest.list.v2+json', + }); + }); + + it('parses OCI helm chart config', () => { + const manifest = { + annotations: { + category: 'Infrastructure', + licenses: 'Apache-2.0', + }, + apiVersion: 'v2', + appVersion: '2.8.2', + dependencies: [ + { + condition: 'redis.enabled', + name: 'redis', + repository: 'oci://registry-1.docker.io/bitnamicharts', + version: '17.x.x', + }, + { + condition: 'postgresql.enabled', + name: 'postgresql', + repository: 'oci://registry-1.docker.io/bitnamicharts', + version: '12.x.x', + }, + { + name: 'common', + repository: 'oci://registry-1.docker.io/bitnamicharts', + tags: ['bitnami-common'], + version: '2.x.x', + }, + ], + home: 'https://bitnami.com', + icon: 'https://bitnami.com/assets/stacks/harbor-core/img/harbor-core-stack-220x234.png', + keywords: ['docker', 'registry', 'vulnerability', 'scan'], + maintainers: [ + { + name: 'VMware, Inc.', + url: 'https://github.com/bitnami/charts', + }, + ], + name: 'harbor', + sources: ['https://github.com/bitnami/charts/tree/main/bitnami/harbor'], + version: '16.7.2', + }; + + expect(HelmConfigBlob.parse(manifest)).toMatchObject({ + sources: ['https://github.com/bitnami/charts/tree/main/bitnami/harbor'], + }); + }); +}); diff --git a/lib/modules/datasource/docker/schema.ts b/lib/modules/datasource/docker/schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..43e51b0cfe51b68f2d602ad1574fc389eee6fa60 --- /dev/null +++ b/lib/modules/datasource/docker/schema.ts @@ -0,0 +1,129 @@ +import { z } from 'zod'; + +// Helm manifests +export const HelmConfigBlob = z.object({ + home: z.string().optional(), + sources: z.array(z.string()).optional(), +}); + +// OCI manifests + +/** + * OCI manifest object + */ +export const ManifestObject = z.object({ + schemaVersion: z.literal(2), + mediaType: z.string(), +}); + +/** + * Oci descriptor + * https://github.com/opencontainers/image-spec/blob/main/descriptor.md + */ +export const Descriptor = z.object({ + mediaType: z.string(), + digest: z.string(), + size: z.number(), +}); +/** + * OCI platform properties + * https://github.com/opencontainers/image-spec/blob/main/image-index.md + */ +const OciPlatform = z + .object({ + architecture: z.string().optional(), + }) + .optional(); + +/** + * OCI Image Configuration. + * + * Compatible with old docker configiguration. + https://github.com/opencontainers/image-spec/blob/main/config.md + */ +export const OciImageConfig = z.object({ + architecture: z.string(), + config: z.object({ Labels: z.record(z.string()).optional() }).optional(), +}); +export type OciImageConfig = z.infer<typeof OciImageConfig>; + +/** + * OCI Image Manifest + * The same structure as docker image manifest, but mediaType is not required and is not present in the wild. + * https://github.com/opencontainers/image-spec/blob/main/manifest.md + */ +export const OciImageManifest = ManifestObject.extend({ + mediaType: z + .literal('application/vnd.oci.image.manifest.v1+json') + .default('application/vnd.oci.image.manifest.v1+json'), + config: Descriptor.extend({ + mediaType: z.enum([ + 'application/vnd.oci.image.config.v1+json', + 'application/vnd.cncf.helm.config.v1+json', + ]), + }), + annotations: z.record(z.string()).optional(), +}); + +/** + * OCI Image List + * mediaType is not required. + * https://github.com/opencontainers/image-spec/blob/main/image-index.md + */ +export const OciImageIndexManifest = ManifestObject.extend({ + mediaType: z + .literal('application/vnd.oci.image.index.v1+json') + .default('application/vnd.oci.image.index.v1+json'), + manifests: z.array( + Descriptor.extend({ + mediaType: z.enum([ + 'application/vnd.oci.image.manifest.v1+json', + 'application/vnd.oci.image.index.v1+json', + ]), + platform: OciPlatform, + }) + ), + annotations: z.record(z.string()).optional(), +}); + +// Old Docker manifests + +/** + * Image Manifest + * https://docs.docker.com/registry/spec/manifest-v2-2/#image-manifest + */ +export const DistributionManifest = ManifestObject.extend({ + mediaType: z.literal('application/vnd.docker.distribution.manifest.v2+json'), + config: Descriptor.extend({ + mediaType: z.literal('application/vnd.docker.container.image.v1+json'), + }), +}); + +/** + * Manifest List + * https://docs.docker.com/registry/spec/manifest-v2-2/#manifest-list + */ +export const DistributionListManifest = ManifestObject.extend({ + mediaType: z.literal( + 'application/vnd.docker.distribution.manifest.list.v2+json' + ), + manifests: z.array( + Descriptor.extend({ + mediaType: z.literal( + 'application/vnd.docker.distribution.manifest.v2+json' + ), + platform: OciPlatform, + }) + ), +}); + +// Combined manifests + +export const Manifest = z.union([ + DistributionManifest, + DistributionListManifest, + OciImageManifest, + OciImageIndexManifest, +]); + +export type Manifest = z.infer<typeof Manifest>; diff --git a/lib/modules/datasource/docker/types.ts b/lib/modules/datasource/docker/types.ts index 3b5a6440140b642135d55542a42ee9d06a4f55a6..9f72be577a051dba4bf5de3867c321f3a4bfad27 100644 --- a/lib/modules/datasource/docker/types.ts +++ b/lib/modules/datasource/docker/types.ts @@ -1,114 +1,4 @@ -/** - * Media Types - * https://docs.docker.com/registry/spec/manifest-v2-2/#media-types - * https://github.com/opencontainers/image-spec/blob/main/media-types.md - */ -export type MediaType = - | 'application/vnd.docker.distribution.manifest.v1+json' // manifestV1 - | 'application/vnd.docker.distribution.manifest.v2+json' // manifestV2 - | 'application/vnd.docker.distribution.manifest.list.v2+json' // manifestListV2 - | 'application/vnd.oci.image.manifest.v1+json' // ociManifestV1 - | 'application/vnd.oci.image.index.v1+json'; // ociManifestIndexV1 - -export interface MediaObject { - readonly digest: string; - readonly mediaType: MediaType; - readonly size: number; -} - -export interface ImageListImage extends MediaObject { - readonly mediaType: - | 'application/vnd.docker.distribution.manifest.v1+json' - | 'application/vnd.docker.distribution.manifest.v2+json'; - - readonly platform: OciPlatform; -} - -/** - * Manifest List - * https://docs.docker.com/registry/spec/manifest-v2-2/#manifest-list-field-descriptions - */ -export interface ImageList { - readonly schemaVersion: 2; - readonly mediaType: 'application/vnd.docker.distribution.manifest.list.v2+json'; - readonly manifests: ImageListImage[]; -} - -/** - * Image Manifest - * https://docs.docker.com/registry/spec/manifest-v2-2/#image-manifest - */ -export interface Image extends MediaObject { - readonly schemaVersion: 2; - readonly mediaType: 'application/vnd.docker.distribution.manifest.v2+json'; - readonly config: MediaObject; -} - -/** - * OCI platform properties - * https://github.com/opencontainers/image-spec/blob/main/image-index.md - */ -export interface OciPlatform { - architecture?: string; - features?: string[]; - os?: string; - 'os.features'?: string[]; - 'os.version'?: string; - variant?: string; -} - -/** - * OCI content descriptor - * https://github.com/opencontainers/image-spec/blob/main/descriptor.md - */ -export interface OciDescriptor { - readonly mediaType?: MediaType; - readonly digest: string; - readonly size: number; - readonly urls: string[]; - readonly annotations: Record<string, string>; -} - -/** - * OCI Image Manifest - * The same structure as docker image manifest, but mediaType is not required and is not present in the wild. - * https://github.com/opencontainers/image-spec/blob/main/manifest.md - */ -export interface OciImage { - readonly schemaVersion: 2; - readonly mediaType?: 'application/vnd.oci.image.manifest.v1+json'; - readonly config: OciDescriptor; - readonly layers: OciDescriptor[]; - readonly annotations: Record<string, string>; -} - -export interface OciImageListManifest extends OciDescriptor { - readonly mediaType?: - | 'application/vnd.oci.image.manifest.v1+json' - | 'application/vnd.oci.image.index.v1+json'; - readonly platform: OciPlatform; -} - -/** - * OCI Image List - * mediaType is not required. - * https://github.com/opencontainers/image-spec/blob/main/image-index.md - */ -export interface OciImageList { - readonly schemaVersion: 2; - readonly mediaType?: 'application/vnd.oci.image.index.v1+json'; - readonly manifests: OciImageListManifest[]; -} - export interface RegistryRepository { registryHost: string; dockerRepository: string; } - -/** - * OCI Image Configuration - * https://github.com/opencontainers/image-spec/blob/main/config.md - */ -export interface ImageConfig { - readonly architecture: string; -}