From a1f79bcf39f7dcf0951ed4260092ff8407dcfbbd Mon Sep 17 00:00:00 2001
From: Rhys Arkins <rhys@arkins.net>
Date: Tue, 15 Aug 2023 15:21:17 +0200
Subject: [PATCH] feat(docker): use Docker Hub tags api (#23876)

---
 lib/modules/datasource/docker/index.spec.ts | 35 ++++++++++---------
 lib/modules/datasource/docker/index.ts      | 37 ++++++++++++++++++++-
 lib/modules/datasource/docker/types.ts      |  5 +++
 3 files changed, 61 insertions(+), 16 deletions(-)

diff --git a/lib/modules/datasource/docker/index.spec.ts b/lib/modules/datasource/docker/index.spec.ts
index 5b4fa32cd7..4089c38cc6 100644
--- a/lib/modules/datasource/docker/index.spec.ts
+++ b/lib/modules/datasource/docker/index.spec.ts
@@ -21,6 +21,7 @@ const ecrMock = mockClient(ECRClient);
 const baseUrl = 'https://index.docker.io/v2';
 const authUrl = 'https://auth.docker.io';
 const amazonUrl = 'https://123456789.dkr.ecr.us-east-1.amazonaws.com/v2';
+const dockerHubUrl = 'https://hub.docker.com/v2/repositories';
 
 function mockEcrAuthResolve(
   res: Partial<GetAuthorizationTokenCommandOutput> = {}
@@ -41,6 +42,7 @@ describe('modules/datasource/docker/index', () => {
     });
     hostRules.hosts.mockReturnValue([]);
     delete process.env.RENOVATE_X_DOCKER_MAX_PAGES;
+    delete process.env.RENOVATE_X_DOCKER_HUB_TAGS;
   });
 
   describe('getDigest', () => {
@@ -1520,7 +1522,12 @@ describe('modules/datasource/docker/index', () => {
     });
 
     it('adds library/ prefix for Docker Hub (implicit)', async () => {
+      process.env.RENOVATE_X_DOCKER_HUB_TAGS = 'true';
       const tags = ['1.0.0'];
+      httpMock
+        .scope(dockerHubUrl)
+        .get('/library/node/tags?page_size=100')
+        .reply(404);
       httpMock
         .scope(baseUrl)
         .get('/library/node/tags/list?n=10000')
@@ -1548,31 +1555,29 @@ describe('modules/datasource/docker/index', () => {
     });
 
     it('adds library/ prefix for Docker Hub (explicit)', async () => {
-      const tags = ['1.0.0'];
+      process.env.RENOVATE_X_DOCKER_HUB_TAGS = 'true';
       httpMock
-        .scope(baseUrl)
-        .get('/library/node/tags/list?n=10000')
-        .reply(401, '', {
-          'www-authenticate':
-            'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/node:pull"',
+        .scope(dockerHubUrl)
+        .get('/library/node/tags?page_size=100')
+        .reply(200, {
+          next: `${dockerHubUrl}/library/node/tags?page=2&page_size=100`,
+          results: [{ name: '1.0.0' }],
         })
-        .get('/library/node/tags/list?n=10000')
-        .reply(200, { tags }, {})
+        .get('/library/node/tags?page=2&page_size=100')
+        .reply(200, {
+          results: [{ name: '0.9.0' }],
+        });
+      httpMock
+        .scope(baseUrl)
         .get('/')
         .reply(200)
         .get('/library/node/manifests/1.0.0')
         .reply(200);
-      httpMock
-        .scope(authUrl)
-        .get(
-          '/token?service=registry.docker.io&scope=repository:library/node:pull'
-        )
-        .reply(200, { token: 'test' });
       const res = await getPkgReleases({
         datasource: DockerDatasource.id,
         packageName: 'docker.io/node',
       });
-      expect(res?.releases).toHaveLength(1);
+      expect(res?.releases).toHaveLength(2);
     });
 
     it('adds no library/ prefix for other registries', async () => {
diff --git a/lib/modules/datasource/docker/index.ts b/lib/modules/datasource/docker/index.ts
index f71c82bbb9..01d3b5e8eb 100644
--- a/lib/modules/datasource/docker/index.ts
+++ b/lib/modules/datasource/docker/index.ts
@@ -30,6 +30,7 @@ import {
 } from './common';
 import { ecrPublicRegex, ecrRegex, isECRMaxResultsError } from './ecr';
 import type { Manifest, OciImageConfig } from './schema';
+import type { DockerHubTags } from './types';
 
 const defaultConfig = {
   commitMessageTopic: '{{{depName}}} Docker tag',
@@ -819,6 +820,36 @@ export class DockerDatasource extends Datasource {
     return digest;
   }
 
+  async getDockerHubTags(dockerRepository: string): Promise<string[] | null> {
+    if (!process.env.RENOVATE_X_DOCKER_HUB_TAGS) {
+      return null;
+    }
+    try {
+      let index = 0;
+      let tags: string[] = [];
+      let url:
+        | string
+        | undefined = `https://hub.docker.com/v2/repositories/${dockerRepository}/tags?page_size=100`;
+      do {
+        const res: DockerHubTags = (await this.http.getJson<DockerHubTags>(url))
+          .body;
+        tags = tags.concat(res.results.map((tag) => tag.name));
+        url = res.next;
+        index += 1;
+      } while (url && index < 100);
+      logger.debug(
+        `getDockerHubTags(${dockerRepository}): found ${tags.length} tags`
+      );
+      return tags;
+    } catch (err) {
+      logger.debug(
+        { dockerRepository, errMessage: err.message },
+        `No Docker Hub tags result - falling back to docker.io api`
+      );
+    }
+    return null;
+  }
+
   /**
    * docker.getReleases
    *
@@ -838,7 +869,11 @@ export class DockerDatasource extends Datasource {
       packageName,
       registryUrl!
     );
-    const tags = await this.getTags(registryHost, dockerRepository);
+    let tags: string[] | null = null;
+    if (registryHost === 'https://index.docker.io') {
+      tags = await this.getDockerHubTags(dockerRepository);
+    }
+    tags ??= await this.getTags(registryHost, dockerRepository);
     if (!tags) {
       return null;
     }
diff --git a/lib/modules/datasource/docker/types.ts b/lib/modules/datasource/docker/types.ts
index 9f72be577a..0026ca7d39 100644
--- a/lib/modules/datasource/docker/types.ts
+++ b/lib/modules/datasource/docker/types.ts
@@ -2,3 +2,8 @@ export interface RegistryRepository {
   registryHost: string;
   dockerRepository: string;
 }
+
+export interface DockerHubTags {
+  next?: string;
+  results: { name: string }[];
+}
-- 
GitLab