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: debian datasource #13463

Closed
wants to merge 42 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
bc79714
Add Debian package datasource
Ka0o0 Jan 10, 2022
1c5a116
use http class
Ka0o0 Jan 13, 2022
270e363
fix caching
Ka0o0 Jan 13, 2022
3c2f433
use different url format
Ka0o0 Jan 13, 2022
5fe458c
switch to gz compression using Node.JS zlib
Ka0o0 Jan 13, 2022
0cf2328
cleanup
Ka0o0 Jan 13, 2022
8faee2d
cleanup
Ka0o0 Jan 14, 2022
bd88abb
more cleanup
Ka0o0 Jan 14, 2022
3206401
add comment
Ka0o0 Jan 14, 2022
1de78f1
use custom fs layer
Ka0o0 Jan 14, 2022
314f709
remove cache dir config
Ka0o0 Jan 14, 2022
096c1f3
fix tests, make binary arch configurable per repo link
Ka0o0 Jan 14, 2022
c79f809
cleanup
Ka0o0 Jan 14, 2022
a8e7a32
allow returning multiple releases per package
Ka0o0 Jan 14, 2022
bc30c95
use for of
Ka0o0 Jan 14, 2022
b4bc85c
use creation timestamp of extracted file
Ka0o0 Jan 17, 2022
727b292
put cache artifacts into one folder
Ka0o0 Jan 17, 2022
ddeeced
remove possibility to specify a default binary architecture
Ka0o0 Jan 17, 2022
a2ed06e
add typehint for intellisense
Ka0o0 Jan 17, 2022
01b606e
remove remainings of config
Ka0o0 Jan 17, 2022
d9a24f6
Merge branch 'main' into feat/deb-datasource
rarkins Jan 18, 2022
ea50609
Merge remote-tracking branch 'main' into feat/deb-datasource
Ka0o0 Feb 14, 2022
0bcb6ff
Add test for invalid server response
Ka0o0 Feb 14, 2022
e8f1468
refactor: extract extraction of componentUrl from registryUrl
Ka0o0 Feb 14, 2022
615f8a1
change test to cover two components case
Ka0o0 Feb 14, 2022
56d4964
add test describing parsing of registry url
Ka0o0 Feb 14, 2022
8b8f6da
add test for behavior if different metadata across components
Ka0o0 Feb 14, 2022
389aa60
add test suite/release synonym
Ka0o0 Feb 14, 2022
c439a68
update comment
Ka0o0 Feb 14, 2022
283055e
add deb datasource in correct order
Ka0o0 Feb 14, 2022
6caa868
Merge branch 'main' into feat/deb-datasource
viceice Feb 18, 2022
d95a3bb
enable cache
Ka0o0 Feb 21, 2022
27196a0
refactor logging
Ka0o0 Feb 21, 2022
ba1a006
remove duplicate test
Ka0o0 Feb 21, 2022
483bc2a
use tmp-promise
Ka0o0 Feb 21, 2022
331a39f
fix local path creation
Ka0o0 Feb 21, 2022
90b3d72
make some methods static
Ka0o0 Feb 21, 2022
30913ea
fix optional access
Ka0o0 Feb 21, 2022
c7dd40a
remove unused import
Ka0o0 Feb 21, 2022
dcae944
update contributor list
Ka0o0 Feb 21, 2022
193ae88
Merge branch 'main' into feat/deb-datasource
rarkins Mar 4, 2022
f01b8ba
Merge branch 'main' into feat/deb-datasource
rarkins Apr 28, 2022
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: 2 additions & 0 deletions lib/datasource/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { CdnJsDatasource } from './cdnjs';
import { ClojureDatasource } from './clojure';
Ka0o0 marked this conversation as resolved.
Show resolved Hide resolved
import { CrateDatasource } from './crate';
import { DartDatasource } from './dart';
import { DebDatasource } from './deb';
import * as docker from './docker';
import { GalaxyDatasource } from './galaxy';
import { GalaxyCollectionDatasource } from './galaxy-collection';
Expand Down Expand Up @@ -59,6 +60,7 @@ api.set('github-tags', githubTags);
api.set('gitlab-packages', new GitlabPackagesDatasource());
api.set('gitlab-tags', gitlabTags);
api.set(GitlabReleasesDatasource.id, new GitlabReleasesDatasource());
api.set(DebDatasource.id, new DebDatasource());
api.set('go', go);
api.set('gradle-version', new GradleVersionDatasource());
api.set('helm', new HelmDatasource());
Expand Down
51 changes: 51 additions & 0 deletions lib/datasource/deb/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { GetPkgReleasesConfig, getPkgReleases } from '..';
import { DebLanguageConfig } from './types';
// import * as httpMock from '../../../test/http-mock';
Ka0o0 marked this conversation as resolved.
Show resolved Hide resolved

describe('datasource/deb/index', () => {
describe('getReleases', () => {
it('returns a valid version for the package `curl` (standard Debian package)', async () => {
const cfg: GetPkgReleasesConfig & DebLanguageConfig = {
datasource: 'deb',
depName: 'curl',
deb: {
binaryArch: 'amd64',
downloadDirectory: '/tmp/deb/download',
extractionDirectory: '/tmp/deb/extract',
},
};
const res = await getPkgReleases(cfg);
expect(res).toBeObject();
expect(res.releases).toHaveLength(1);
});

it('returns null for a package not found in the standard Debian package repo', async () => {
const cfg: GetPkgReleasesConfig & DebLanguageConfig = {
datasource: 'deb',
depName: 'you-will-never-find-me',
deb: {
binaryArch: 'amd64',
downloadDirectory: '/tmp/deb/download',
extractionDirectory: '/tmp/deb/extract',
},
};
const res = await getPkgReleases(cfg);
expect(res).toBeNull();
});

it('returns null when repo contains missing component', async () => {
const cfg: GetPkgReleasesConfig & DebLanguageConfig = {
datasource: 'deb',
depName: 'curl',
deb: {
binaryArch: 'amd64',
downloadDirectory: '/tmp/deb/download',
extractionDirectory: '/tmp/deb/extract',
},
registryUrls: ['deb https://ftp.debian.org/debian stable'],
};
const res = await getPkgReleases(cfg);
expect(res).toBeNull();
});
});
});
248 changes: 248 additions & 0 deletions lib/datasource/deb/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import { exec } from 'child_process';
Ka0o0 marked this conversation as resolved.
Show resolved Hide resolved
import { createHash } from 'crypto';
import { createReadStream, existsSync, mkdirSync, statSync } from 'fs';
import { performance } from 'perf_hooks';
Ka0o0 marked this conversation as resolved.
Show resolved Hide resolved
import readline from 'readline';
import { promisify } from 'util';
Ka0o0 marked this conversation as resolved.
Show resolved Hide resolved
import { GetReleasesConfig, ReleaseResult } from '..';
Ka0o0 marked this conversation as resolved.
Show resolved Hide resolved
import { logger } from '../../logger';
import { Datasource } from '../datasource';
import { DebLanguageConfig, PackageDescription } from './types';
Ka0o0 marked this conversation as resolved.
Show resolved Hide resolved

/**
* @brief This datasource allows to download
*/
export class DebDatasource extends Datasource {
static readonly id = 'deb';

constructor() {
super(DebDatasource.id);
}

/**
* Users are able to specify custom Debian repositories as long as they follow
* the Debian package repository format as specified here
* @see{https://wiki.debian.org/DebianRepository/Format}
*/
override readonly customRegistrySupport = true;

/**
* deb uri distribution [component1] [component2] [...]
* @see{https://wiki.debian.org/DebianRepository/Format}
*/
override readonly defaultRegistryUrls = [
'deb https://ftp.debian.org/debian stable main contrib non-free',
Ka0o0 marked this conversation as resolved.
Show resolved Hide resolved
];

override readonly defaultConfig: DebLanguageConfig = {
deb: {
binaryArch: 'amd64',
Ka0o0 marked this conversation as resolved.
Show resolved Hide resolved
downloadDirectory: '/tmp/renovate-deb/packages-download',
extractionDirectory: '/tmp/renovate-deb/packages',
Ka0o0 marked this conversation as resolved.
Show resolved Hide resolved
},
};

override readonly caching = false; // TODO: how can this be used?
Ka0o0 marked this conversation as resolved.
Show resolved Hide resolved

/**
* Here, we tell Renovate that this data source can respect multiple upstream repositories
*/
override readonly registryStrategy = 'merge';

requiredPackageKeys = ['Package', 'Version', 'Homepage'];
Ka0o0 marked this conversation as resolved.
Show resolved Hide resolved

initCacheDir(cfg: DebLanguageConfig): void {
mkdirSync(cfg.deb.downloadDirectory, { recursive: true });
mkdirSync(cfg.deb.extractionDirectory, { recursive: true });
}

async runCommand(wd: string, command: string): Promise<void> {
const pexec = promisify(exec);
logger.trace('running command ' + command + ' in directory ' + wd);
const { stdout, stderr } = await pexec(
['cd', '"' + wd + '"', '&&', command].join(' ')
);
logger.debug(stdout);
logger.debug(stderr);
}

Ka0o0 marked this conversation as resolved.
Show resolved Hide resolved
async downloadAndExtractPackage(
cfg: DebLanguageConfig,
packageUrl: string
): Promise<string> {
// we hash the package URL and export the hex to make it file system friendly
// we use the hashed url as the filename for the local directories/files
const hash = createHash('sha256');
hash.update(packageUrl);
const hashedPackageUrl = hash.copy().digest('hex');
Ka0o0 marked this conversation as resolved.
Show resolved Hide resolved
const downloadLocation = cfg.deb.downloadDirectory + '/' + hashedPackageUrl;
const compressedFile = downloadLocation + '/Packages.xz';
const extractedFile =
cfg.deb.extractionDirectory + '/' + hashedPackageUrl + '.txt';

// curl will not modify the local file, if the upstream HTTP server
// does not return a modified version. We can use this information
// to not re-extract the same file again. For this to work we need
// the current modification timestamp of the local package file.
let lastTimestamp = -1;
try {
const stats = statSync(compressedFile);
lastTimestamp = stats.mtime.getTime();
} catch (e) {
// ignore if the file doesnt exist
}

logger.debug(
'Downloading package file from ' + packageUrl + ' as ' + downloadLocation
);
/**
* curl -o "$file" -z "$file" "$uri"
*/
mkdirSync(downloadLocation, { recursive: true });
await this.runCommand(
downloadLocation,
['curl', '-o', 'Packages.xz', '-z', 'Packages.xz', packageUrl].join(' ')
Ka0o0 marked this conversation as resolved.
Show resolved Hide resolved
);

const stats = statSync(compressedFile);
const newTimestamp = stats.mtime.getTime();

if (newTimestamp <= lastTimestamp && existsSync(extractedFile)) {
logger.debug(
"No need to extract file as wget didn't update the file and it exists"
);
return extractedFile;
}

logger.debug(
'Extracting package file ' + downloadLocation + ' to ' + extractedFile
);
/**
* --threads=0 use all available cores
* -k keep the downloaded file (needed by wget to not redownload)
* -c print to stdout (we pipe)
* -d decompress
*/
await this.runCommand(
downloadLocation,
['xz --threads=0 -k -c -d', 'Packages.xz', '>', extractedFile].join(' ')
Ka0o0 marked this conversation as resolved.
Show resolved Hide resolved
);

return extractedFile;
}

async probeExtractedPackage(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a goot candidate to add cache decorator, sample:

@cache({
namespace: `datasource-${TerraformProviderDatasource.id}-build-hashes`,
key: (build: TerraformBuild) => build.url,
ttlMinutes: TerraformProviderHash.hashCacheTTL,
})
static async calculateSingleHash(
build: TerraformBuild,
cacheDir: string
): Promise<string> {

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mhm but what if the server returns a new compressed file, can I manually invalidate the cache entry then?

extractedFile,
packageName
): Promise<ReleaseResult | null> {
// read line by line to avoid high memory consumption as the extracted Packages
// files can be multiple MBs in size
const rl = readline.createInterface({
input: createReadStream(extractedFile),
Ka0o0 marked this conversation as resolved.
Show resolved Hide resolved
terminal: false,
});
let pd: PackageDescription = {};
for await (const line of rl) {
if (line === '') {
// now we should have all information available
if (pd.Package === packageName) {
return { releases: [{ version: pd.Version }], homepage: pd.Homepage };
}
pd = {};
continue;
}

for (let i = 0; i < this.requiredPackageKeys.length; i++) {
if (line.startsWith(this.requiredPackageKeys[i])) {
pd[this.requiredPackageKeys[i]] = line
.substring(this.requiredPackageKeys[i].length + 1)
.trim();
break;
}
}
}

return null;
}

async getReleases(
cfg: GetReleasesConfig & DebLanguageConfig
): Promise<ReleaseResult | null> {
const fullComponentUrls: string[] = [];
const registryUrls = cfg.registryUrls || [cfg.registryUrl];
registryUrls.forEach((aptUrl: string) => {
Ka0o0 marked this conversation as resolved.
Show resolved Hide resolved
const aptUrlSplit = aptUrl.split(' ');
if (aptUrlSplit.length < 4) {
logger.warn(
'Skipping invalid apt repository url ' +
aptUrl +
' - has invalid number of elements'
);
return;
} else if (aptUrlSplit[0] !== 'deb') {
logger.warn(
'Skipping invalid apt repository url ' +
aptUrl +
' - can only deal with "deb" repos'
);
return;
}
let baseUrl: URL;
try {
baseUrl = new URL(aptUrlSplit[1]);
} catch (e) {
logger.warn(
'Skipping invalid apt repository url ' +
aptUrl +
' - got error while parsing URL - ' +
JSON.stringify(e)
);
return;
}
baseUrl.pathname += '/dists/' + aptUrlSplit[2];

// now we are at the components, we require at least one but can have multiple components
for (let i = 3; i < aptUrlSplit.length; i++) {
const newUrl = new URL(baseUrl);
newUrl.pathname +=
'/' +
aptUrlSplit[i] +
'/binary-' +
cfg.deb.binaryArch +
'/Packages.xz';
fullComponentUrls.push(newUrl.toString());
}
});

this.initCacheDir(cfg);

let release: ReleaseResult = null;
for (let i = 0; i < fullComponentUrls.length && release === null; i++) {
let downloadedPackage: string;
try {
downloadedPackage = await this.downloadAndExtractPackage(
cfg,
fullComponentUrls[i]
);
} catch (e) {
logger.warn(
'Skipping package ' +
fullComponentUrls[i] +
' because of error ' +
JSON.stringify(e)
);
continue;
}

const startTime = performance.now();
Ka0o0 marked this conversation as resolved.
Show resolved Hide resolved
release = await this.probeExtractedPackage(
downloadedPackage,
cfg.lookupName
);
const endTime = performance.now();
logger.trace(`Inspected package file within ${endTime - startTime} ms`);
Ka0o0 marked this conversation as resolved.
Show resolved Hide resolved
}

return release;
}
}
34 changes: 34 additions & 0 deletions lib/datasource/deb/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* A package file contains multiple package descriptions which are each separated by an completely empty line.
* A package description contains meta data properties in the form of
*
* PropertyName: value
*/
export interface PackageDescription {
Package?: string; // Package
Version?: string; // Version
Homepage?: string; // Homepage
}

export interface DebLanguageConfig extends Record<string, unknown> {
deb: {
/**
* This is part of the download URL, e.g. http://ftp.debian.org/debian/dists/stable/non-free/binary-amd64/ defaults to amd64
*/
binaryArch: string;

/**
* This specifies the download directory into which the packages file should be downloaded.
* This should be a folder on the "host" of renovate, e.g. the docker image.
* The folder will be created automatically if it doesn't exist.
*/
downloadDirectory: string;

/**
* This specifies the directory where the extracted packages files are stored.
* This should be a folder on the "host" of renovate, e.g. the docker image.
* The folder will be created automatically if it doesn't exist.
*/
extractionDirectory: string;
};
}