Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: create datasource for artifactory registry #11602

Merged
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
0d8637a
Added artifactory datasource; still not working
jjcaballero Sep 1, 2021
c4b6e2f
Added cleanHtml method before parsing to remote preformated text; add…
jjcaballero Sep 3, 2021
3fc0a8b
adapting tests resources for files
jjcaballero Sep 6, 2021
46425a2
Addressing coverage: unit test for 404 http response
jjcaballero Sep 6, 2021
4479c9e
Set merge strategy; enable caching; added readme
jjcaballero Sep 6, 2021
fbeb20a
Artifactory datasource requires custom registryUrl; raised a warning …
jjcaballero Sep 6, 2021
5c580e5
Mentioning custom registryUrl explicitly in readme
jjcaballero Sep 6, 2021
5e681be
Log messages at trace level and with more context
jjcaballero Sep 7, 2021
acd35ef
Removed homepage and updated snapshot
jjcaballero Sep 7, 2021
b2faa3e
Simplify Release definition
jjcaballero Sep 7, 2021
177f7e5
Removed defaultRegistryUrl var from common
jjcaballero Sep 7, 2021
e5f7428
Returning null for 404 code (Not Found)
jjcaballero Sep 7, 2021
bdc244f
Added warning message for 404; a bit refactor logger mocking on unit …
jjcaballero Sep 7, 2021
de6579c
Only error 404 handled differently; Added test to verify merge strategy
jjcaballero Sep 8, 2021
201d921
Passing configuration to parser to process nodes around preformatted …
jjcaballero Sep 8, 2021
269bf0d
Extracting releaseTimestamp from node data too
jjcaballero Sep 8, 2021
ab91d87
Removed defaultRegistryUrls defined as empty
jjcaballero Sep 8, 2021
a1f7796
Unit test to cover when html parser is called with options
jjcaballero Sep 9, 2021
c4fcaf5
Merge branch 'main' into feat/11474-artifactory-datasource
rarkins Sep 11, 2021
59d0630
readme.md: URL in uppercase; single sentence per line
jjcaballero Sep 13, 2021
a6d7b17
Merge branch 'main' into feat/11474-artifactory-datasource
rarkins Sep 13, 2021
bbdb936
Mentioning Conan on readme.md
jjcaballero Sep 13, 2021
6c69298
Merge branch 'main' into feat/11474-artifactory-datasource
rarkins Sep 16, 2021
5760bc2
Apply suggestions from code review
viceice Sep 16, 2021
31abba3
Update lib/util/html.spec.ts
viceice Sep 16, 2021
4c59f5e
Using logger metadata feature
jjcaballero Sep 17, 2021
4983dd8
Using logger trace metadata; adjusted tests
jjcaballero Sep 17, 2021
8228c7c
Addressing comments in unit test
jjcaballero Sep 17, 2021
be96f3f
Merge branch 'main' into feat/11474-artifactory-datasource
rarkins Sep 19, 2021
f12aedb
Merge branch 'main' into feat/11474-artifactory-datasource
viceice Sep 24, 2021
8386153
loadFixtures only once
jjcaballero Sep 30, 2021
837454b
using string template
jjcaballero Sep 30, 2021
f291b8d
Merge branch 'main' into feat/11474-artifactory-datasource
viceice Sep 30, 2021
b671e6b
Merge branch 'main' into feat/11474-artifactory-datasource
rarkins Oct 1, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/datasource/api.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -38,6 +39,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());
Expand Down
21 changes: 21 additions & 0 deletions lib/datasource/artifactory/__fixtures__/releases-as-files.html
Original file line number Diff line number Diff line change
@@ -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>
21 changes: 21 additions & 0 deletions lib/datasource/artifactory/__fixtures__/releases-as-folders.html
Original file line number Diff line number Diff line change
@@ -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 -
rarkins marked this conversation as resolved.
Show resolved Hide resolved
<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>
70 changes: 70 additions & 0 deletions lib/datasource/artifactory/__snapshots__/index.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// 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 {
"version": "1.0.0",
},
Object {
"version": "1.0.1",
},
Object {
"version": "1.0.2",
},
Object {
"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 {
"version": "1.0.0",
},
Object {
"version": "1.0.1",
},
Object {
"version": "1.0.2",
},
Object {
"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",
"version": "1.0.0",
},
Object {
"registryUrl": "https://jfrog.company.com/artifactory",
"version": "1.0.1",
},
Object {
"registryUrl": "https://jfrog.company.com/artifactory",
"version": "1.0.2",
},
Object {
"registryUrl": "https://jfrog.company.com/artifactory",
"version": "1.0.3",
},
Object {
"registryUrl": "https://jfrog.company.com/artifactory",
"version": "1.3.0",
},
],
}
`;

exports[`datasource/artifactory/index getReleases throws no Http error 1`] = `null`;
1 change: 1 addition & 0 deletions lib/datasource/artifactory/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const datasource = 'artifactory';
142 changes: 142 additions & 0 deletions lib/datasource/artifactory/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
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 { ArtifactoryDatasource } from '.';

const datasource = ArtifactoryDatasource.id;

const testRegistryUrl = 'https://jfrog.company.com/artifactory';
const testLookupName = 'project';
const testConfig = {
registryUrls: [testRegistryUrl],
depName: testLookupName,
};

function getPath(folder: string): string {
return '/' + folder;
jjcaballero marked this conversation as resolved.
Show resolved Hide resolved
}

describe('datasource/artifactory/index', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mock('../../logger');
jjcaballero marked this conversation as resolved.
Show resolved Hide resolved
});

describe('getReleases', () => {
it('parses real data (folders): with slash at the end', async () => {
httpMock
.scope(testRegistryUrl)
.get(getPath(testLookupName))
.reply(200, loadFixture('releases-as-folders.html'));
jjcaballero marked this conversation as resolved.
Show resolved Hide resolved
const res = await getPkgReleases({
...testConfig,
datasource,
lookupName: testLookupName,
});
expect(res.releases).toHaveLength(4);
expect(res).toMatchSnapshot();
jjcaballero marked this conversation as resolved.
Show resolved Hide resolved
});

it('parses real data (files): without slash at the end', async () => {
httpMock
.scope(testRegistryUrl)
.get(getPath(testLookupName))
.reply(200, loadFixture('releases-as-files.html'));
const res = await getPkgReleases({
...testConfig,
datasource,
lookupName: testLookupName,
});
expect(res.releases).toHaveLength(4);
expect(res).toMatchSnapshot();
jjcaballero marked this conversation as resolved.
Show resolved Hide resolved
});

it('parses real data (merge strategy with 2 registries)', async () => {
httpMock
.scope(testRegistryUrl)
.get(getPath(testLookupName))
.reply(200, loadFixture('releases-as-files.html'));
httpMock
.scope(testRegistryUrl)
.get(getPath(testLookupName))
.reply(200, '<html>\n<h1>Header</h1>\n<a>1.3.0</a>\n<hmtl/>');
const res = await getPkgReleases({
registryUrls: [testRegistryUrl, testRegistryUrl],
depName: testLookupName,
datasource,
lookupName: testLookupName,
});
expect(res.releases).toHaveLength(5);
expect(res).toMatchSnapshot();
jjcaballero marked this conversation as resolved.
Show resolved Hide resolved
});

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(
'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(
'artifactory: "Not Found" error for project under https://jfrog.company.com/artifactory/project'
);
});

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();
expect(res).toMatchSnapshot();
jjcaballero marked this conversation as resolved.
Show resolved Hide resolved
});
});
});
102 changes: 102 additions & 0 deletions lib/datasource/artifactory/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { logger } from '../../logger';
import { cache } from '../../util/cache/package/decorator';
import { parse } from '../../util/html';
import { HttpError } from '../../util/http/types';
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 defaultRegistryUrls = [];

rarkins marked this conversation as resolved.
Show resolved Hide resolved
override readonly caching = true;

override readonly registryStrategy = 'merge';

@cache({
namespace: `datasource-${datasource}`,
key: ({ registryUrl }: GetReleasesConfig) => `${registryUrl}`,
viceice marked this conversation as resolved.
Show resolved Hide resolved
})
async getReleases({
lookupName,
registryUrl,
}: GetReleasesConfig): Promise<ReleaseResult | null> {
if (!registryUrl) {
logger.warn(
'artifactory datasource requires custom registryUrl. Skipping datasource'
jjcaballero marked this conversation as resolved.
Show resolved Hide resolved
);
return null;
}

const url = `${registryUrl}/${lookupName}`;
rarkins marked this conversation as resolved.
Show resolved Hide resolved
jjcaballero marked this conversation as resolved.
Show resolved Hide resolved
const contextForLogging: string = lookupName + ' under ' + url;

jjcaballero marked this conversation as resolved.
Show resolved Hide resolved
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');

let candidates: string[] = [];
nodes.forEach((node) => candidates.push(node.innerHTML));

// filter out hyperlink to navigate to parent folder
candidates = candidates.filter(
(candidate) => candidate !== '../' && candidate !== '..'
);

candidates.forEach((candidate) => {
const parsedCandidate: string =
candidate.slice(-1) === '/' ? candidate.slice(0, -1) : candidate;

const thisRelease: Release = {
version: parsedCandidate,
};
result.releases.push(thisRelease);
});

if (result.releases.length) {
logger.trace(
'artifactory: Found ' +
String(result.releases.length) +
' versions of ' +
contextForLogging
);
jjcaballero marked this conversation as resolved.
Show resolved Hide resolved
} else {
logger.trace(
'artifactory: Not found any version of ' + contextForLogging
);
jjcaballero marked this conversation as resolved.
Show resolved Hide resolved
}
} catch (err) {
// istanbul ignore else: not testable with nock
if (err instanceof HttpError) {
if (err.response?.statusCode === 404) {
logger.warn(
'artifactory: "Not Found" error for ' + contextForLogging
);
jjcaballero marked this conversation as resolved.
Show resolved Hide resolved
return null;
}
}
this.handleGenericErrors(err);
}

return result.releases.length ? result : null;
}
}
4 changes: 4 additions & 0 deletions lib/datasource/artifactory/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
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.
jjcaballero marked this conversation as resolved.
Show resolved Hide resolved
8 changes: 6 additions & 2 deletions lib/util/html.ts
Original file line number Diff line number Diff line change
@@ -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);
rarkins marked this conversation as resolved.
Show resolved Hide resolved
}