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(github-releases): getDigest() #10947

Merged
merged 30 commits into from
Aug 5, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d43b1d9
github-releases: getDigest()
thepwagner Jul 23, 2021
fe52e89
github-releases: getDigest cache
thepwagner Jul 23, 2021
cabb0d5
github-releases: test failure cases
thepwagner Jul 23, 2021
c8ae0eb
github-releases: getDigest() without digest file
thepwagner Jul 23, 2021
5a8a041
Merge branch 'main' into renovate-7928
rarkins Jul 27, 2021
5a8bc0d
Apply suggestions from code review
thepwagner Jul 28, 2021
daa3d52
github-releases: getDigest cache 24h
thepwagner Jul 28, 2021
e275df4
github-releases: DigestAsset to types.ts
thepwagner Jul 28, 2021
c46e93b
Merge remote-tracking branch 'renovatebot/main' into renovate-7928
thepwagner Jul 28, 2021
1c9e0a6
github-releases: cache expensive asset digests
thepwagner Jul 29, 2021
644ae1b
github-releases: extract+test common.ts
thepwagner Jul 30, 2021
c3342fa
github-release t ReleaseMocker test helper
thepwagner Jul 30, 2021
1f1f90e
github-releases: extract digest.ts
thepwagner Jul 30, 2021
f021dfe
github-releases: add digest.spec.ts
thepwagner Jul 30, 2021
0b9a548
github-releases: findNewDigest to digest.ts
thepwagner Jul 30, 2021
3e09bc5
github-releases: mapDigestAssetToRelease spec
thepwagner Jul 30, 2021
b1a3653
github-releases: remove cache test
thepwagner Aug 2, 2021
ca4afde
github-release: rename __testutil__
thepwagner Aug 2, 2021
32cad87
github-release: getDigest success case
thepwagner Aug 2, 2021
11f09a4
Merge remote-tracking branch 'renovatebot/main' into renovate-7928
thepwagner Aug 2, 2021
0633d4d
Merge branch 'main' into renovate-7928
viceice Aug 2, 2021
46c06f2
Merge remote-tracking branch 'renovatebot/main' into renovate-7928
thepwagner Aug 5, 2021
965e531
add "**/test/**" to tsconfig.app.json exclude
thepwagner Aug 5, 2021
efdaf81
Merge branch 'main' into renovate-7928
rarkins Aug 5, 2021
a084052
Merge branch 'main' into renovate-7928
viceice Aug 5, 2021
d4284cb
chore: exclude tests from coverage
viceice Aug 5, 2021
953ce28
Merge branch 'main' into renovate-7928
viceice Aug 5, 2021
deca82b
Update lib/datasource/github-releases/digest.ts
rarkins Aug 5, 2021
5b286af
Merge branch 'main' into renovate-7928
rarkins Aug 5, 2021
63865a1
Merge branch 'main' into renovate-7928
rarkins Aug 5, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const config: InitialOptionsTsJest = {
collectCoverageFrom: [
'lib/**/*.{js,ts}',
'!lib/**/*.{d,spec}.ts',
'!lib/**/{__fixtures__,__mocks__,__testutil__}/**/*.{js,ts}',
'!lib/**/{__fixtures__,__mocks__,__testutil__,test}/**/*.{js,ts}',
'!lib/**/types.ts',
],
coverageReporters: ci
Expand Down
42 changes: 42 additions & 0 deletions lib/datasource/github-releases/common.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { getName } from '../../../test/util';
import { getApiBaseUrl, getGithubRelease, getSourceUrlBase } from './common';
import { GitHubReleaseMocker } from './test';

describe(getName(), () => {
describe('getSourceUrlBase', () => {
it('ensures trailing slash', () => {
const sourceUrl = getSourceUrlBase('https://gh.my-company.com');
expect(sourceUrl).toBe('https://gh.my-company.com/');
});
it('defaults to github.com', () => {
const sourceUrl = getSourceUrlBase(null);
expect(sourceUrl).toBe('https://github.com/');
});
});

describe('getApiBaseUrl', () => {
it('maps to api.github.com', () => {
const apiUrl = getApiBaseUrl('https://github.com/');
expect(apiUrl).toBe('https://api.github.com/');
});

it('supports local github installations', () => {
const apiUrl = getApiBaseUrl('https://gh.my-company.com/');
expect(apiUrl).toBe('https://gh.my-company.com/api/v3/');
});
});

describe('getGithubRelease', () => {
const apiUrl = 'https://github.com/';
const lookupName = 'someDep';
const releaseMock = new GitHubReleaseMocker(apiUrl, lookupName);

it('returns release', async () => {
const version = 'v1.0.0';
releaseMock.release(version);

const release = await getGithubRelease(apiUrl, lookupName, version);
expect(release.tag_name).toBe(version);
});
});
});
29 changes: 29 additions & 0 deletions lib/datasource/github-releases/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { GithubHttp } from '../../util/http/github';
import { ensureTrailingSlash } from '../../util/url';
import type { GithubRelease } from './types';

const defaultSourceUrlBase = 'https://github.com/';

export const cacheNamespace = 'datasource-github-releases';
export const http = new GithubHttp();

export function getSourceUrlBase(registryUrl: string): string {
// default to GitHub.com if no GHE host is specified.
return ensureTrailingSlash(registryUrl ?? defaultSourceUrlBase);
}

export function getApiBaseUrl(sourceUrlBase: string): string {
return sourceUrlBase === defaultSourceUrlBase
? `https://api.github.com/`
: `${sourceUrlBase}api/v3/`;
}

export async function getGithubRelease(
apiBaseUrl: string,
repo: string,
version: string
): Promise<GithubRelease> {
const url = `${apiBaseUrl}repos/${repo}/releases/tags/${version}`;
const res = await http.getJson<GithubRelease>(url);
return res.body;
}
143 changes: 143 additions & 0 deletions lib/datasource/github-releases/digest.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import hasha from 'hasha';
import * as httpMock from '../../../test/http-mock';
import { getName } from '../../../test/util';
import { findDigestAsset, mapDigestAssetToRelease } from './digest';
import { GitHubReleaseMocker } from './test';
import { DigestAsset } from './types';

describe(getName(), () => {
const lookupName = 'some/dep';
const releaseMock = new GitHubReleaseMocker(
'https://api.github.com',
lookupName
);

describe('findDigestAsset', () => {
it('finds SHASUMS.txt file containing digest', async () => {
const release = releaseMock.withDigestFileAsset(
'v1.0.0',
'test-digest linux-amd64.tar.gz',
'another-digest linux-arm64.tar.gz'
);

const digestAsset = await findDigestAsset(release, 'test-digest');
expect(digestAsset.assetName).toBe('SHASUMS.txt');
expect(digestAsset.digestedFileName).toBe('linux-amd64.tar.gz');
});

it('returns null when not found in digest file asset', async () => {
const release = releaseMock.withDigestFileAsset(
'v1.0.0',
'another-digest linux-arm64.tar.gz'
);
// Small assets like this digest file may be downloaded twice
httpMock
.scope('https://api.github.com')
.get(`/repos/${lookupName}/releases/download/v1.0.0/SHASUMS.txt`)
.reply(200, '');

const digestAsset = await findDigestAsset(release, 'test-digest');
expect(digestAsset).toBeNull();
});

it('finds asset by digest', async () => {
const content = '1'.repeat(10 * 1024);
const release = releaseMock.withAssets('v1.0.0', {
'smaller.zip': '1'.repeat(9 * 1024),
'same-size.zip': '2'.repeat(10 * 1024),
'asset.zip': content,
'smallest.zip': '1'.repeat(8 * 1024),
});
const contentDigest = await hasha.async(content, { algorithm: 'sha256' });

const digestAsset = await findDigestAsset(release, contentDigest);
expect(digestAsset.assetName).toBe('asset.zip');
expect(digestAsset.digestedFileName).toBeUndefined();
});

it('returns null when no assets available', async () => {
const release = releaseMock.release('v1.0.0');
const digestAsset = await findDigestAsset(release, 'test-digest');
expect(digestAsset).toBeNull();
});
});

describe('mapDigestAssetToRelease', () => {
describe('with digest file', () => {
const digestAsset: DigestAsset = {
assetName: 'SHASUMS.txt',
currentVersion: 'v1.0.0',
currentDigest: 'old-digest',
digestedFileName: 'asset.zip',
};

it('downloads updated digest file', async () => {
const release = releaseMock.withDigestFileAsset(
'v1.0.1',
'updated-digest asset.zip'
);
const digest = await mapDigestAssetToRelease(digestAsset, release);
expect(digest).toBe('updated-digest');
});

it('maps digested file name to new version', async () => {
const digestAssetWithVersion = {
...digestAsset,
digestedFileName: 'asset-1.0.0.zip',
};

const release = releaseMock.withDigestFileAsset(
'v1.0.1',
'updated-digest asset-1.0.1.zip'
);
const digest = await mapDigestAssetToRelease(
digestAssetWithVersion,
release
);
expect(digest).toBe('updated-digest');
});

it('returns null when not found in digest file', async () => {
const release = releaseMock.withDigestFileAsset(
'v1.0.1',
'moot-digest asset.tar.gz'
);
const digest = await mapDigestAssetToRelease(digestAsset, release);
expect(digest).toBeNull();
});

it('returns null when digest file not found', async () => {
const release = releaseMock.release('v1.0.1');
const digest = await mapDigestAssetToRelease(digestAsset, release);
expect(digest).toBeNull();
});
});

describe('with digested file', () => {
const digestAsset: DigestAsset = {
assetName: 'asset.zip',
currentVersion: 'v1.0.0',
currentDigest: '0'.repeat(64),
};

it('digests updated file', async () => {
const updatedContent = 'new content';
const release = releaseMock.withAssets('v1.0.1', {
'asset.zip': updatedContent,
});
const contentDigest = await hasha.async(updatedContent, {
algorithm: 'sha256',
});

const digest = await mapDigestAssetToRelease(digestAsset, release);
expect(digest).toEqual(contentDigest);
});

it('returns null when not found', async () => {
const release = releaseMock.release('v1.0.1');
const digest = await mapDigestAssetToRelease(digestAsset, release);
expect(digest).toBeNull();
});
});
});
});
141 changes: 141 additions & 0 deletions lib/datasource/github-releases/digest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import hasha from 'hasha';
import * as packageCache from '../../util/cache/package';
import { cacheNamespace, http } from './common';
import type { DigestAsset, GithubRelease, GithubReleaseAsset } from './types';

async function findDigestFile(
release: GithubRelease,
digest: string
): Promise<DigestAsset | null> {
const smallAssets = release.assets.filter(
(a: GithubReleaseAsset) => a.size < 5 * 1024
);
for (const asset of smallAssets) {
const res = await http.get(asset.browser_download_url);
for (const line of res.body.split('\n')) {
const [lineDigest, lineFn] = line.split(/\s+/, 2);
if (lineDigest === digest) {
return {
assetName: asset.name,
digestedFileName: lineFn,
currentVersion: release.tag_name,
currentDigest: lineDigest,
};
}
}
}
return null;
}

function inferHashAlg(digest: string): string {
switch (digest.length) {
case 64:
return 'sha256';
default:
case 96:
return 'sha512';
}
}

function getAssetDigestCacheKey(
downloadUrl: string,
algorithm: string
): string {
const type = 'assetDigest';
return `${downloadUrl}:${algorithm}:${type}`;
}

async function downloadAndDigest(
asset: GithubReleaseAsset,
algorithm: string
): Promise<string> {
const downloadUrl = asset.browser_download_url;
const cacheKey = getAssetDigestCacheKey(downloadUrl, algorithm);
const cachedResult = await packageCache.get<string>(cacheNamespace, cacheKey);
// istanbul ignore if
if (cachedResult) {
rarkins marked this conversation as resolved.
Show resolved Hide resolved
return cachedResult;
}

const res = http.stream(downloadUrl);
const digest = await hasha.fromStream(res, { algorithm });

const cacheMinutes = 1440;
await packageCache.set(cacheNamespace, cacheKey, digest, cacheMinutes);
return digest;
}

async function findAssetWithDigest(
release: GithubRelease,
digest: string
): Promise<DigestAsset | null> {
const algorithm = inferHashAlg(digest);
const assetsBySize = release.assets.sort(
(a: GithubReleaseAsset, b: GithubReleaseAsset) => {
if (a.size < b.size) {
return -1;
}
if (a.size > b.size) {
return 1;
}
return 0;
}
);

for (const asset of assetsBySize) {
const assetDigest = await downloadAndDigest(asset, algorithm);
if (assetDigest === digest) {
return {
assetName: asset.name,
currentVersion: release.tag_name,
currentDigest: assetDigest,
};
}
}
return null;
}

/** Identify the asset associated with a known digest. */
export async function findDigestAsset(
release: GithubRelease,
digest: string
): Promise<DigestAsset> {
const digestFile = await findDigestFile(release, digest);
if (digestFile) {
return digestFile;
}

const asset = await findAssetWithDigest(release, digest);
return asset;
}

/** Given a digest asset, find the equivalent digest in a different release. */
export async function mapDigestAssetToRelease(
digestAsset: DigestAsset,
release: GithubRelease
): Promise<string | null> {
const current = digestAsset.currentVersion.replace(/^v/, '');
const next = release.tag_name.replace(/^v/, '');
const releaseChecksumAssetName = digestAsset.assetName.replace(current, next);
const releaseAsset = release.assets.find(
(a: GithubReleaseAsset) => a.name === releaseChecksumAssetName
);
if (!releaseAsset) {
return null;
}
if (digestAsset.digestedFileName) {
const releaseFilename = digestAsset.digestedFileName.replace(current, next);
const res = await http.get(releaseAsset.browser_download_url);
for (const line of res.body.split('\n')) {
const [lineDigest, lineFn] = line.split(/\s+/, 2);
if (lineFn === releaseFilename) {
return lineDigest;
}
}
} else {
const algorithm = inferHashAlg(digestAsset.currentDigest);
const newDigest = await downloadAndDigest(releaseAsset, algorithm);
return newDigest;
}
return null;
}
Loading