diff --git a/lib/modules/datasource/docker/dockerhub-cache.spec.ts b/lib/modules/datasource/docker/dockerhub-cache.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe8ad65504229e01b317e134a1395b45ce348cf7 --- /dev/null +++ b/lib/modules/datasource/docker/dockerhub-cache.spec.ts @@ -0,0 +1,176 @@ +import { mocked } from '../../../../test/util'; +import * as _packageCache from '../../../util/cache/package'; +import { DockerHubCache, DockerHubCacheData } from './dockerhub-cache'; +import type { DockerHubTag } from './schema'; + +jest.mock('../../../util/cache/package'); +const packageCache = mocked(_packageCache); + +function oldCacheData(): DockerHubCacheData { + return { + items: { + 1: { + id: 1, + last_updated: '2022-01-01', + name: '1', + tag_last_pushed: '2022-01-01', + digest: 'sha256:111', + }, + 2: { + id: 2, + last_updated: '2022-01-02', + name: '2', + tag_last_pushed: '2022-01-02', + digest: 'sha256:222', + }, + 3: { + id: 3, + last_updated: '2022-01-03', + name: '3', + tag_last_pushed: '2022-01-03', + digest: 'sha256:333', + }, + }, + updatedAt: '2022-01-01', + }; +} + +function newItem(): DockerHubTag { + return { + id: 4, + last_updated: '2022-01-04', + name: '4', + tag_last_pushed: '2022-01-04', + digest: 'sha256:444', + }; +} + +function newCacheData(): DockerHubCacheData { + const { items } = oldCacheData(); + const item = newItem(); + return { + items: { + ...items, + [item.id]: item, + }, + updatedAt: '2022-01-04', + }; +} + +describe('modules/datasource/docker/dockerhub-cache', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + const dockerRepository = 'foo/bar'; + + it('initializes empty cache', async () => { + packageCache.get.mockResolvedValue(undefined); + + const res = await DockerHubCache.init(dockerRepository); + + expect(res).toEqual({ + dockerRepository, + cache: { + items: {}, + updatedAt: null, + }, + isChanged: false, + }); + }); + + it('initializes cache with data', async () => { + const oldCache = oldCacheData(); + packageCache.get.mockResolvedValue(oldCache); + + const res = await DockerHubCache.init(dockerRepository); + + expect(res).toEqual({ + dockerRepository, + cache: oldCache, + isChanged: false, + }); + }); + + it('reconciles new items', async () => { + const oldCache = oldCacheData(); + const newCache = newCacheData(); + + packageCache.get.mockResolvedValue(oldCache); + const cache = await DockerHubCache.init(dockerRepository); + const newItems: DockerHubTag[] = [newItem()]; + + const needNextPage = cache.reconcile(newItems); + + expect(needNextPage).toBe(true); + expect(cache).toEqual({ + cache: newCache, + dockerRepository: 'foo/bar', + isChanged: true, + }); + + const res = cache.getItems(); + expect(res).toEqual(Object.values(newCache.items)); + + await cache.save(); + expect(packageCache.set).toHaveBeenCalledWith( + 'datasource-docker-hub-cache', + 'foo/bar', + newCache, + 3 * 60 * 24 * 30, + ); + }); + + it('reconciles existing items', async () => { + const oldCache = oldCacheData(); + + packageCache.get.mockResolvedValue(oldCache); + const cache = await DockerHubCache.init(dockerRepository); + const items: DockerHubTag[] = Object.values(oldCache.items); + + const needNextPage = cache.reconcile(items); + + expect(needNextPage).toBe(false); + expect(cache).toEqual({ + cache: oldCache, + dockerRepository: 'foo/bar', + isChanged: false, + }); + + const res = cache.getItems(); + expect(res).toEqual(items); + + await cache.save(); + expect(packageCache.set).not.toHaveBeenCalled(); + }); + + it('reconciles from empty cache', async () => { + const item = newItem(); + const expectedCache = { + items: { + [item.id]: item, + }, + updatedAt: item.last_updated, + }; + const cache = await DockerHubCache.init(dockerRepository); + + const needNextPage = cache.reconcile([item]); + expect(needNextPage).toBe(true); + expect(cache).toEqual({ + cache: expectedCache, + dockerRepository: 'foo/bar', + isChanged: true, + }); + + const res = cache.getItems(); + expect(res).toEqual([item]); + + await cache.save(); + expect(packageCache.set).toHaveBeenCalledWith( + 'datasource-docker-hub-cache', + 'foo/bar', + expectedCache, + 3 * 60 * 24 * 30, + ); + }); +}); diff --git a/lib/modules/datasource/docker/dockerhub-cache.ts b/lib/modules/datasource/docker/dockerhub-cache.ts new file mode 100644 index 0000000000000000000000000000000000000000..0e97726fc01fb2690fdc9f6d1dea69b028e8a0b9 --- /dev/null +++ b/lib/modules/datasource/docker/dockerhub-cache.ts @@ -0,0 +1,78 @@ +import { dequal } from 'dequal'; +import { DateTime } from 'luxon'; +import * as packageCache from '../../../util/cache/package'; +import type { DockerHubTag } from './schema'; + +export interface DockerHubCacheData { + items: Record<number, DockerHubTag>; + updatedAt: string | null; +} + +const cacheNamespace = 'datasource-docker-hub-cache'; + +export class DockerHubCache { + private isChanged = false; + + private constructor( + private dockerRepository: string, + private cache: DockerHubCacheData, + ) {} + + static async init(dockerRepository: string): Promise<DockerHubCache> { + let repoCache = await packageCache.get<DockerHubCacheData>( + cacheNamespace, + dockerRepository, + ); + + repoCache ??= { + items: {}, + updatedAt: null, + }; + + return new DockerHubCache(dockerRepository, repoCache); + } + + reconcile(items: DockerHubTag[]): boolean { + let needNextPage = true; + + let { updatedAt } = this.cache; + let latestDate = updatedAt ? DateTime.fromISO(updatedAt) : null; + + for (const newItem of items) { + const id = newItem.id; + const oldItem = this.cache.items[id]; + + if (dequal(oldItem, newItem)) { + needNextPage = false; + continue; + } + + this.cache.items[newItem.id] = newItem; + const newItemDate = DateTime.fromISO(newItem.last_updated); + if (!latestDate || latestDate < newItemDate) { + updatedAt = newItem.last_updated; + latestDate = newItemDate; + } + + this.isChanged = true; + } + + this.cache.updatedAt = updatedAt; + return needNextPage; + } + + async save(): Promise<void> { + if (this.isChanged) { + await packageCache.set( + cacheNamespace, + this.dockerRepository, + this.cache, + 3 * 60 * 24 * 30, + ); + } + } + + getItems(): DockerHubTag[] { + return Object.values(this.cache.items); + } +} diff --git a/lib/modules/datasource/docker/index.spec.ts b/lib/modules/datasource/docker/index.spec.ts index d450cbad1837222f32522261838ee1a24362258a..e16d42ed533b86f8ab90606251c3e0b6bb1696a1 100644 --- a/lib/modules/datasource/docker/index.spec.ts +++ b/lib/modules/datasource/docker/index.spec.ts @@ -1796,21 +1796,25 @@ describe('modules/datasource/docker/index', () => { process.env.RENOVATE_X_DOCKER_HUB_TAGS = 'true'; httpMock .scope(dockerHubUrl) - .get('/library/node/tags?page_size=1000') + .get('/library/node/tags?page_size=1000&ordering=last_updated') .reply(200, { - next: `${dockerHubUrl}/library/node/tags?page=2&page_size=1000`, + next: `${dockerHubUrl}/library/node/tags?page=2&page_size=1000&ordering=last_updated`, results: [ { + id: 2, + last_updated: '2021-01-01T00:00:00.000Z', name: '1.0.0', tag_last_pushed: '2021-01-01T00:00:00.000Z', digest: 'aaa', }, ], }) - .get('/library/node/tags?page=2&page_size=1000') + .get('/library/node/tags?page=2&page_size=1000&ordering=last_updated') .reply(200, { results: [ { + id: 1, + last_updated: '2020-01-01T00:00:00.000Z', name: '0.9.0', tag_last_pushed: '2020-01-01T00:00:00.000Z', digest: 'bbb', @@ -1838,7 +1842,7 @@ describe('modules/datasource/docker/index', () => { const tags = ['1.0.0']; httpMock .scope(dockerHubUrl) - .get('/library/node/tags?page_size=1000') + .get('/library/node/tags?page_size=1000&ordering=last_updated') .reply(404); httpMock .scope(baseUrl) @@ -1866,21 +1870,25 @@ describe('modules/datasource/docker/index', () => { process.env.RENOVATE_X_DOCKER_HUB_TAGS = 'true'; httpMock .scope(dockerHubUrl) - .get('/library/node/tags?page_size=1000') + .get('/library/node/tags?page_size=1000&ordering=last_updated') .reply(200, { - next: `${dockerHubUrl}/library/node/tags?page=2&page_size=1000`, + next: `${dockerHubUrl}/library/node/tags?page=2&page_size=1000&ordering=last_updated`, results: [ { + id: 2, + last_updated: '2021-01-01T00:00:00.000Z', name: '1.0.0', tag_last_pushed: '2021-01-01T00:00:00.000Z', digest: 'aaa', }, ], }) - .get('/library/node/tags?page=2&page_size=1000') + .get('/library/node/tags?page=2&page_size=1000&ordering=last_updated') .reply(200, { results: [ { + id: 1, + last_updated: '2020-01-01T00:00:00.000Z', name: '0.9.0', tag_last_pushed: '2020-01-01T00:00:00.000Z', digest: 'bbb', diff --git a/lib/modules/datasource/docker/index.ts b/lib/modules/datasource/docker/index.ts index 5d896ed774dee744f2fed4a2d365e83a32856297..0f5563551d38c85245d05171d04421c876fcff53 100644 --- a/lib/modules/datasource/docker/index.ts +++ b/lib/modules/datasource/docker/index.ts @@ -37,6 +37,7 @@ import { sourceLabel, sourceLabels, } from './common'; +import { DockerHubCache } from './dockerhub-cache'; import { ecrPublicRegex, ecrRegex, isECRMaxResultsError } from './ecr'; import { DistributionManifest, @@ -927,10 +928,11 @@ export class DockerDatasource extends Datasource { key: (dockerRepository: string) => `${dockerRepository}`, }) async getDockerHubTags(dockerRepository: string): Promise<Release[] | null> { - const result: Release[] = []; - let url: null | string = - `https://hub.docker.com/v2/repositories/${dockerRepository}/tags?page_size=1000`; - while (url) { + let url = `https://hub.docker.com/v2/repositories/${dockerRepository}/tags?page_size=1000&ordering=last_updated`; + + const cache = await DockerHubCache.init(dockerRepository); + let needNextPage: boolean = true; + while (needNextPage) { const { val, err } = await this.http .getJsonSafe(url, DockerHubTagsPage) .unwrap(); @@ -940,11 +942,39 @@ export class DockerDatasource extends Datasource { return null; } - result.push(...val.items); - url = val.nextPage; + const { results, next } = val; + + needNextPage = cache.reconcile(results); + + if (!next) { + break; + } + + url = next; } - return result; + await cache.save(); + + const items = cache.getItems(); + return items.map( + ({ + name: version, + tag_last_pushed: releaseTimestamp, + digest: newDigest, + }) => { + const release: Release = { version }; + + if (releaseTimestamp) { + release.releaseTimestamp = releaseTimestamp; + } + + if (newDigest) { + release.newDigest = newDigest; + } + + return release; + }, + ); } /** diff --git a/lib/modules/datasource/docker/schema.ts b/lib/modules/datasource/docker/schema.ts index 1af87da24b49d45e8e3f6c259304f88e592a4aa9..6a58e08abc513a4f86f3b0cd1dfd0ac09fd99e96 100644 --- a/lib/modules/datasource/docker/schema.ts +++ b/lib/modules/datasource/docker/schema.ts @@ -1,7 +1,6 @@ import { z } from 'zod'; import { logger } from '../../../logger'; import { Json, LooseArray } from '../../../util/schema-utils'; -import type { Release } from '../types'; // OCI manifests @@ -155,39 +154,23 @@ export const Manifest = ManifestObject.passthrough() export type Manifest = z.infer<typeof Manifest>; export const ManifestJson = Json.pipe(Manifest); -export const DockerHubTag = z - .object({ - name: z.string(), - tag_last_pushed: z.string().datetime().nullable().catch(null), - digest: z.string().nullable().catch(null), - }) - .transform(({ name, tag_last_pushed, digest }) => { - const release: Release = { version: name }; - - if (tag_last_pushed) { - release.releaseTimestamp = tag_last_pushed; - } - - if (digest) { - release.newDigest = digest; - } - - return release; - }); - -export const DockerHubTagsPage = z - .object({ - next: z.string().nullable().catch(null), - results: LooseArray(DockerHubTag, { - onError: /* istanbul ignore next */ ({ error }) => { - logger.debug( - { error }, - 'Docker: Failed to parse some tags from Docker Hub', - ); - }, - }), - }) - .transform(({ next, results }) => ({ - nextPage: next, - items: results, - })); +export const DockerHubTag = z.object({ + id: z.number(), + last_updated: z.string().datetime(), + name: z.string(), + tag_last_pushed: z.string().datetime().nullable().catch(null), + digest: z.string().nullable().catch(null), +}); +export type DockerHubTag = z.infer<typeof DockerHubTag>; + +export const DockerHubTagsPage = z.object({ + next: z.string().nullable().catch(null), + results: LooseArray(DockerHubTag, { + onError: /* istanbul ignore next */ ({ error }) => { + logger.debug( + { error }, + 'Docker: Failed to parse some tags from Docker Hub', + ); + }, + }), +}); diff --git a/lib/util/cache/package/types.ts b/lib/util/cache/package/types.ts index bb1673c399d8ebaf070fa00a662b0e53386e9906..6d600df7e14e7020d383224e36a9e787b6e3539f 100644 --- a/lib/util/cache/package/types.ts +++ b/lib/util/cache/package/types.ts @@ -45,6 +45,7 @@ export type PackageCacheNamespace = | 'datasource-deno-versions' | 'datasource-deno' | 'datasource-docker-architecture' + | 'datasource-docker-hub-cache' | 'datasource-docker-digest' | 'datasource-docker-hub-tags' | 'datasource-docker-imageconfig'