From 9fea985b950ec2a8bbac1eefc1e986af8016f4b0 Mon Sep 17 00:00:00 2001 From: jjcaballero <juan_fernandez@nuance.com> Date: Fri, 1 Oct 2021 10:39:29 +0200 Subject: [PATCH] feat: create datasource for artifactory registry (#11602) --- lib/datasource/api.ts | 2 + .../__fixtures__/releases-as-files.html | 21 +++ .../__fixtures__/releases-as-folders.html | 21 +++ .../__snapshots__/index.spec.ts.snap | 80 +++++++++ lib/datasource/artifactory/common.ts | 1 + lib/datasource/artifactory/index.spec.ts | 156 ++++++++++++++++++ lib/datasource/artifactory/index.ts | 113 +++++++++++++ lib/datasource/artifactory/readme.md | 5 + lib/util/html.spec.ts | 18 ++ lib/util/html.ts | 8 +- 10 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 lib/datasource/artifactory/__fixtures__/releases-as-files.html create mode 100644 lib/datasource/artifactory/__fixtures__/releases-as-folders.html create mode 100644 lib/datasource/artifactory/__snapshots__/index.spec.ts.snap create mode 100644 lib/datasource/artifactory/common.ts create mode 100644 lib/datasource/artifactory/index.spec.ts create mode 100644 lib/datasource/artifactory/index.ts create mode 100644 lib/datasource/artifactory/readme.md diff --git a/lib/datasource/api.ts b/lib/datasource/api.ts index 33631ce7be..3e45c789a1 100644 --- a/lib/datasource/api.ts +++ b/lib/datasource/api.ts @@ -1,4 +1,5 @@ import { AdoptiumJavaDatasource } from './adoptium-java'; +import { ArtifactoryDatasource } from './artifactory'; import { BitBucketTagsDatasource } from './bitbucket-tags'; import { CdnJsDatasource } from './cdnjs'; import { ClojureDatasource } from './clojure'; @@ -40,6 +41,7 @@ const api = new Map<string, DatasourceApi>(); export default api; api.set(AdoptiumJavaDatasource.id, new AdoptiumJavaDatasource()); +api.set(ArtifactoryDatasource.id, new ArtifactoryDatasource()); api.set('bitbucket-tags', new BitBucketTagsDatasource()); api.set('cdnjs', new CdnJsDatasource()); api.set('clojure', new ClojureDatasource()); diff --git a/lib/datasource/artifactory/__fixtures__/releases-as-files.html b/lib/datasource/artifactory/__fixtures__/releases-as-files.html new file mode 100644 index 0000000000..2bdde58399 --- /dev/null +++ b/lib/datasource/artifactory/__fixtures__/releases-as-files.html @@ -0,0 +1,21 @@ +<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN"> +<html> + <head> + <meta name="robots" content="noindex" /> + <title>Repository Title</title> + </head> + + <body> + <h1>Index</h1> + <pre>Name Last modified Size</pre><hr/> + <pre> + <a href="..">..</a> + <a href="1.0.0">1.0.0</a> 21-Jul-2021 20:08 - + <a href="1.0.1">1.0.1</a> 23-Aug-2021 20:03 - + <a href="1.0.2">1.0.2</a> 21-Jul-2021 20:09 - + <a href="1.0.3">1.0.3</a> 06-Feb-2021 09:54 - + </pre> + <hr/> + <address style="font-size:small;">Artifactory Port 8080</address> + </body> +</html> diff --git a/lib/datasource/artifactory/__fixtures__/releases-as-folders.html b/lib/datasource/artifactory/__fixtures__/releases-as-folders.html new file mode 100644 index 0000000000..16b86d87d6 --- /dev/null +++ b/lib/datasource/artifactory/__fixtures__/releases-as-folders.html @@ -0,0 +1,21 @@ +<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN"> +<html> + <head> + <meta name="robots" content="noindex" /> + <title>Repository Title</title> + </head> + + <body> + <h1>Index</h1> + <pre>Name Last modified Size</pre><hr/> + <pre> + <a href="../">../</a> + <a href="1.0.0/">1.0.0/</a> 21-Jul-2021 20:08 - + <a href="1.0.1/">1.0.1/</a> 23-Aug-2021 20:03 - + <a href="1.0.2/">1.0.2/</a> 21-Jul-2021 20:09 - + <a href="1.0.3/">1.0.3/</a> 06-Feb-2021 09:54 - + </pre> + <hr/> + <address style="font-size:small;">Artifactory Port 8080</address> + </body> +</html> diff --git a/lib/datasource/artifactory/__snapshots__/index.spec.ts.snap b/lib/datasource/artifactory/__snapshots__/index.spec.ts.snap new file mode 100644 index 0000000000..9c3eaf66a2 --- /dev/null +++ b/lib/datasource/artifactory/__snapshots__/index.spec.ts.snap @@ -0,0 +1,80 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`datasource/artifactory/index getReleases parses real data (files): without slash at the end 1`] = ` +Object { + "registryUrl": "https://jfrog.company.com/artifactory", + "releases": Array [ + Object { + "releaseTimestamp": "2021-07-21T20:08:00.000Z", + "version": "1.0.0", + }, + Object { + "releaseTimestamp": "2021-08-23T20:03:00.000Z", + "version": "1.0.1", + }, + Object { + "releaseTimestamp": "2021-07-21T20:09:00.000Z", + "version": "1.0.2", + }, + Object { + "releaseTimestamp": "2021-02-06T09:54:00.000Z", + "version": "1.0.3", + }, + ], +} +`; + +exports[`datasource/artifactory/index getReleases parses real data (folders): with slash at the end 1`] = ` +Object { + "registryUrl": "https://jfrog.company.com/artifactory", + "releases": Array [ + Object { + "releaseTimestamp": "2021-07-21T20:08:00.000Z", + "version": "1.0.0", + }, + Object { + "releaseTimestamp": "2021-08-23T20:03:00.000Z", + "version": "1.0.1", + }, + Object { + "releaseTimestamp": "2021-07-21T20:09:00.000Z", + "version": "1.0.2", + }, + Object { + "releaseTimestamp": "2021-02-06T09:54:00.000Z", + "version": "1.0.3", + }, + ], +} +`; + +exports[`datasource/artifactory/index getReleases parses real data (merge strategy with 2 registries) 1`] = ` +Object { + "releases": Array [ + Object { + "registryUrl": "https://jfrog.company.com/artifactory", + "releaseTimestamp": "2021-07-21T20:08:00.000Z", + "version": "1.0.0", + }, + Object { + "registryUrl": "https://jfrog.company.com/artifactory", + "releaseTimestamp": "2021-08-23T20:03:00.000Z", + "version": "1.0.1", + }, + Object { + "registryUrl": "https://jfrog.company.com/artifactory", + "releaseTimestamp": "2021-07-21T20:09:00.000Z", + "version": "1.0.2", + }, + Object { + "registryUrl": "https://jfrog.company.com/artifactory", + "releaseTimestamp": "2021-02-06T09:54:00.000Z", + "version": "1.0.3", + }, + Object { + "registryUrl": "https://jfrog.company.com/artifactory/production", + "version": "1.3.0", + }, + ], +} +`; diff --git a/lib/datasource/artifactory/common.ts b/lib/datasource/artifactory/common.ts new file mode 100644 index 0000000000..7dd522f507 --- /dev/null +++ b/lib/datasource/artifactory/common.ts @@ -0,0 +1 @@ +export const datasource = 'artifactory'; diff --git a/lib/datasource/artifactory/index.spec.ts b/lib/datasource/artifactory/index.spec.ts new file mode 100644 index 0000000000..ff16119030 --- /dev/null +++ b/lib/datasource/artifactory/index.spec.ts @@ -0,0 +1,156 @@ +import { getPkgReleases } from '..'; +import * as httpMock from '../../../test/http-mock'; +import { loadFixture } from '../../../test/util'; +import { EXTERNAL_HOST_ERROR } from '../../constants/error-messages'; +import { logger } from '../../logger'; +import { joinUrlParts } from '../../util/url'; +import { ArtifactoryDatasource } from '.'; + +const datasource = ArtifactoryDatasource.id; + +const testRegistryUrl = 'https://jfrog.company.com/artifactory'; +const testLookupName = 'project'; +const testConfig = { + registryUrls: [testRegistryUrl], + depName: testLookupName, +}; +const fixtureReleasesAsFolders = loadFixture('releases-as-folders.html'); +const fixtureReleasesAsFiles = loadFixture('releases-as-files.html'); + +function getPath(folder: string): string { + return `/${folder}`; +} + +describe('datasource/artifactory/index', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('getReleases', () => { + it('parses real data (folders): with slash at the end', async () => { + httpMock + .scope(testRegistryUrl) + .get(getPath(testLookupName)) + .reply(200, fixtureReleasesAsFolders); + const res = await getPkgReleases({ + ...testConfig, + datasource, + lookupName: testLookupName, + }); + expect(res.releases).toHaveLength(4); + expect(res).toMatchSnapshot({ + registryUrl: 'https://jfrog.company.com/artifactory', + }); + }); + + it('parses real data (files): without slash at the end', async () => { + httpMock + .scope(testRegistryUrl) + .get(getPath(testLookupName)) + .reply(200, fixtureReleasesAsFiles); + const res = await getPkgReleases({ + ...testConfig, + datasource, + lookupName: testLookupName, + }); + expect(res.releases).toHaveLength(4); + expect(res).toMatchSnapshot({ + registryUrl: 'https://jfrog.company.com/artifactory', + }); + }); + + it('parses real data (merge strategy with 2 registries)', async () => { + const secondRegistryUrl: string = joinUrlParts( + testRegistryUrl, + 'production' + ); + httpMock + .scope(testRegistryUrl) + .get(getPath(testLookupName)) + .reply(200, fixtureReleasesAsFiles); + httpMock + .scope(secondRegistryUrl) + .get(getPath(testLookupName)) + .reply(200, '<html>\n<h1>Header</h1>\n<a>1.3.0</a>\n<hmtl/>'); + const res = await getPkgReleases({ + registryUrls: [testRegistryUrl, secondRegistryUrl], + depName: testLookupName, + datasource, + lookupName: testLookupName, + }); + expect(res.releases).toHaveLength(5); + expect(res).toMatchSnapshot(); + }); + + it('returns null without registryUrl + warning', async () => { + const res = await getPkgReleases({ + datasource, + depName: testLookupName, + lookupName: testLookupName, + }); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + { lookupName: 'project' }, + 'artifactory datasource requires custom registryUrl. Skipping datasource' + ); + expect(res).toBeNull(); + }); + + it('returns null for empty 200 OK', async () => { + httpMock + .scope(testRegistryUrl) + .get(getPath(testLookupName)) + .reply(200, '<html>\n<h1>Header wo. nodes</h1>\n<hmtl/>'); + expect( + await getPkgReleases({ + ...testConfig, + datasource, + lookupName: testLookupName, + }) + ).toBeNull(); + }); + + it('404 returns null', async () => { + httpMock.scope(testRegistryUrl).get(getPath(testLookupName)).reply(404); + expect( + await getPkgReleases({ + ...testConfig, + datasource, + lookupName: testLookupName, + }) + ).toBeNull(); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + { + lookupName: 'project', + registryUrl: 'https://jfrog.company.com/artifactory', + }, + 'artifactory: `Not Found` error' + ); + }); + + it('throws for error diff than 404', async () => { + httpMock.scope(testRegistryUrl).get(getPath(testLookupName)).reply(502); + await expect( + getPkgReleases({ + ...testConfig, + datasource, + lookupName: testLookupName, + }) + ).rejects.toThrow(EXTERNAL_HOST_ERROR); + }); + + it('throws no Http error', async () => { + httpMock + .scope(testRegistryUrl) + .get(getPath(testLookupName)) + .replyWithError('unknown error'); + const res = await getPkgReleases({ + ...testConfig, + datasource, + lookupName: testLookupName, + }); + expect(res).toBeNull(); + }); + }); +}); diff --git a/lib/datasource/artifactory/index.ts b/lib/datasource/artifactory/index.ts new file mode 100644 index 0000000000..2c462be9c9 --- /dev/null +++ b/lib/datasource/artifactory/index.ts @@ -0,0 +1,113 @@ +import { logger } from '../../logger'; +import { cache } from '../../util/cache/package/decorator'; +import { parse } from '../../util/html'; +import { HttpError } from '../../util/http/types'; +import { joinUrlParts } from '../../util/url'; +import { Datasource } from '../datasource'; +import type { GetReleasesConfig, Release, ReleaseResult } from '../types'; +import { datasource } from './common'; + +export class ArtifactoryDatasource extends Datasource { + static readonly id = datasource; + + constructor() { + super(datasource); + } + + override readonly customRegistrySupport = true; + + override readonly caching = true; + + override readonly registryStrategy = 'merge'; + + @cache({ + namespace: `datasource-${datasource}`, + key: ({ registryUrl, lookupName }: GetReleasesConfig) => + `${registryUrl}:${lookupName}`, + }) + async getReleases({ + lookupName, + registryUrl, + }: GetReleasesConfig): Promise<ReleaseResult | null> { + if (!registryUrl) { + logger.warn( + { lookupName }, + 'artifactory datasource requires custom registryUrl. Skipping datasource' + ); + return null; + } + + const url = joinUrlParts(registryUrl, lookupName); + + const result: ReleaseResult = { + releases: [], + }; + try { + const response = await this.http.get(url); + const body = parse(response.body, { + blockTextElements: { + script: true, + noscript: true, + style: true, + }, + }); + const nodes = body.querySelectorAll('a'); + + nodes + .filter( + // filter out hyperlink to navigate to parent folder + (node) => node.innerHTML !== '../' && node.innerHTML !== '..' + ) + .forEach( + // extract version and published time for each node + (node) => { + const version: string = + node.innerHTML.slice(-1) === '/' + ? node.innerHTML.slice(0, -1) + : node.innerHTML; + + const published = ArtifactoryDatasource.parseReleaseTimestamp( + node.nextSibling?.text + ); + + const thisRelease: Release = { + version, + releaseTimestamp: published, + }; + + result.releases.push(thisRelease); + } + ); + + if (result.releases.length) { + logger.trace( + { registryUrl, lookupName, versions: result.releases.length }, + 'artifactory: Found versions' + ); + } else { + logger.trace( + { registryUrl, lookupName }, + 'artifactory: No versions found' + ); + } + } catch (err) { + // istanbul ignore else: not testable with nock + if (err instanceof HttpError) { + if (err.response?.statusCode === 404) { + logger.warn( + { registryUrl, lookupName }, + 'artifactory: `Not Found` error' + ); + return null; + } + } + this.handleGenericErrors(err); + } + + return result.releases.length ? result : null; + } + + private static parseReleaseTimestamp(rawText: string): string { + return rawText.trim().replace(/ ?-$/, ''); + } +} diff --git a/lib/datasource/artifactory/readme.md b/lib/datasource/artifactory/readme.md new file mode 100644 index 0000000000..e8e40c6fe4 --- /dev/null +++ b/lib/datasource/artifactory/readme.md @@ -0,0 +1,5 @@ +Artifactory is the recommended registry for Conan packages. + +This datasource returns releases from given custom `registryUrl`(s). + +The target URL is composed by the `registryUrl` and the `lookupName`, which defaults to `depName` when `lookupName` is not defined. diff --git a/lib/util/html.spec.ts b/lib/util/html.spec.ts index c4727d8234..a78a70ae96 100644 --- a/lib/util/html.spec.ts +++ b/lib/util/html.spec.ts @@ -13,4 +13,22 @@ describe('util/html', () => { const body = parse(''); expect(body.childNodes).toHaveLength(0); }); + + it('parses HTML: PRE block hides child nodes', () => { + const body = parse('<div>Hello, world!</div>\n<pre><a>node A</a></pre>'); + const childNodesA = body.querySelectorAll('a'); + expect(childNodesA).toHaveLength(0); + }); + + it('parses HTML: use additional options to discover child nodes on PRE blocks', () => { + const body = parse('<div>Hello, world!</div>\n<pre><a>node A</a></pre>', { + blockTextElements: {}, + }); + const childNodesA = body.querySelectorAll('a'); + expect(childNodesA).toHaveLength(1); + const div = childNodesA[0]; + expect(div.tagName).toBe('A'); + expect(div.textContent).toBe('node A'); + expect(div instanceof HTMLElement).toBe(true); + }); }); diff --git a/lib/util/html.ts b/lib/util/html.ts index 26ac555833..d0ee29fd8b 100644 --- a/lib/util/html.ts +++ b/lib/util/html.ts @@ -1,7 +1,11 @@ -import { HTMLElement, parse as _parse } from 'node-html-parser'; +import { HTMLElement, Options, parse as _parse } from 'node-html-parser'; export { HTMLElement }; -export function parse(html: string): HTMLElement { +export function parse(html: string, config?: Partial<Options>): HTMLElement { + if (typeof config !== 'undefined') { + return _parse(html, config); + } + return _parse(html); } -- GitLab