Files
renovate/lib/modules/datasource/helm/index.spec.ts
RahulGautamSingh c6578a476b feat(datasource/helm): support aws s3 as helm charts repository (#45584)
* support AWS S3 as a chart repository

* support creds via hostRules

* apply suggestions

* fix(datasource/helm): classify s3 lookup errors

* test: use await expect().resolves
2026-09-07 14:04:08 +00:00

460 lines
14 KiB
TypeScript

import { Readable } from 'node:stream';
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { mockClient } from 'aws-sdk-client-mock';
import { codeBlock } from 'common-tags';
import { Fixtures } from '~test/fixtures.ts';
import * as httpMock from '~test/http-mock.ts';
import { EXTERNAL_HOST_ERROR } from '../../../constants/error-messages.ts';
import * as hostRules from '../../../util/host-rules.ts';
import { getPkgReleases } from '../index.ts';
import { HelmDatasource } from './index.ts';
// Truncated index.yaml file
const indexYaml = Fixtures.get('index.yaml');
describe('modules/datasource/helm/index', () => {
describe('getReleases', () => {
it('returns null if packageName was not provided', async () => {
await expect(
getPkgReleases({
datasource: HelmDatasource.id,
packageName: undefined as never, // #22198
registryUrls: ['https://example-repository.com'],
}),
).resolves.toBeNull();
});
it('returns null if repository was not provided', async () => {
// FIXME: should it call default rtegisty?
httpMock
.scope('https://charts.helm.sh')
.get('/stable/index.yaml')
.reply(404);
await expect(
getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'some_chart',
registryUrls: [],
}),
).resolves.toBeNull();
});
it('returns null for empty response', async () => {
httpMock
.scope('https://example-repository.com')
.get('/index.yaml')
.reply(200);
await expect(
getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'non_existent_chart',
registryUrls: ['https://example-repository.com'],
}),
).resolves.toBeNull();
});
it('returns null for missing response body', async () => {
httpMock
.scope('https://example-repository.com')
.get('/index.yaml')
.reply(200);
await expect(
getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'non_existent_chart',
registryUrls: ['https://example-repository.com'],
}),
).resolves.toBeNull();
});
it('returns null for 404', async () => {
httpMock
.scope('https://example-repository.com')
.get('/index.yaml')
.reply(404);
await expect(
getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'some_chart',
registryUrls: ['https://example-repository.com'],
}),
).resolves.toBeNull();
});
it('throws for 5xx', async () => {
httpMock
.scope('https://example-repository.com')
.get('/index.yaml')
.reply(502);
await expect(
getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'some_chart',
registryUrls: ['https://example-repository.com'],
}),
).rejects.toThrow(EXTERNAL_HOST_ERROR);
});
it('returns null for unknown error', async () => {
httpMock
.scope('https://example-repository.com')
.get('/index.yaml')
.replyWithError('');
await expect(
getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'some_chart',
registryUrls: ['https://example-repository.com'],
}),
).resolves.toBeNull();
});
it('returns null if index.yaml in response is empty', async () => {
httpMock
.scope('https://example-repository.com')
.get('/index.yaml')
.reply(200, '# A comment');
const releases = await getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'non_existent_chart',
registryUrls: ['https://example-repository.com'],
});
expect(releases).toBeNull();
});
it('returns null if index.yaml in response is invalid', async () => {
const res = {
body: codeBlock`
some
invalid:
[
yaml
`,
};
httpMock
.scope('https://example-repository.com')
.get('/index.yaml')
.reply(200, res);
const releases = await getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'non_existent_chart',
registryUrls: ['https://example-repository.com'],
});
expect(releases).toBeNull();
});
it('returns null if packageName is not in index.yaml', async () => {
httpMock
.scope('https://example-repository.com')
.get('/index.yaml')
.reply(200, indexYaml);
const releases = await getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'non_existent_chart',
registryUrls: ['https://example-repository.com'],
});
expect(releases).toBeNull();
});
it('returns list of versions for normal response', async () => {
httpMock
.scope('https://example-repository.com')
.get('/index.yaml')
.reply(200, indexYaml);
const releases = await getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'ambassador',
registryUrls: ['https://example-repository.com'],
});
expect(releases).toMatchObject({
releases: [
{
newDigest:
'aa09c62be843190cc85736ba59d6411579d83ba30e9305e6b2420ea013bb5979',
version: '1.0.0',
},
{
newDigest:
'01da3c15cdec999b5afd73ee9186c62859c35a716688359c425fc04100a22144',
releaseTimestamp: '2019-02-14T15:25:43.743Z',
version: '1.1.0',
},
{ version: '1.1.1' },
{ version: '1.1.2' },
{ version: '1.1.3' },
{ version: '1.1.4' },
{ version: '1.1.5' },
{ version: '2.0.0' },
{ version: '2.0.1' },
{ version: '2.0.2' },
{ version: '2.1.0' },
{ version: '2.2.0' },
{ version: '2.2.1' },
{ version: '2.2.2' },
{ version: '2.2.3' },
{ version: '2.2.4' },
{ version: '2.2.5' },
{ version: '2.3.0' },
{ version: '2.3.1' },
{ version: '2.4.0' },
{ version: '2.4.1' },
{ version: '2.5.0' },
{ version: '2.5.1' },
{ version: '2.6.0' },
{ version: '2.6.1' },
{ version: '2.6.2' },
{ version: '2.7.0' },
],
});
});
it('returns list of versions for other packages if one packages has no versions', async () => {
httpMock
.scope('https://example-repository.com')
.get('/index.yaml')
.reply(200, Fixtures.get('index_emptypackage.yaml'));
const releases = await getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'ambassador',
registryUrls: ['https://example-repository.com'],
});
expect(releases).toMatchObject({
homepage: 'https://www.getambassador.io/',
registryUrl: 'https://example-repository.com',
sourceUrl: 'https://github.com/datawire/ambassador',
releases: expect.toBeArrayOfSize(1),
});
});
it('adds trailing slash to subdirectories', async () => {
httpMock
.scope('https://example-repository.com')
.get('/subdir/index.yaml')
.reply(200, indexYaml);
const res = await getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'ambassador',
registryUrls: ['https://example-repository.com/subdir'],
});
expect(res).toMatchObject({
homepage: 'https://www.getambassador.io/',
registryUrl: 'https://example-repository.com/subdir',
sourceUrl: 'https://github.com/datawire/ambassador',
releases: expect.toBeArrayOfSize(27),
});
});
it('uses undefined as the newDigest when no digest is provided', async () => {
httpMock
.scope('https://example-repository.com')
.get('/index.yaml')
.reply(200, Fixtures.get('index_blank-digest.yaml'));
const releases = await getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'blank-digest',
registryUrls: ['https://example-repository.com'],
});
expect(releases).toMatchObject({
registryUrl: 'https://example-repository.com',
releases: [
{
newDigest: undefined,
releaseTimestamp: '2023-09-05T13:24:19.046Z',
version: '3.2.1',
},
],
});
});
});
describe('S3', () => {
const s3mock = mockClient(S3Client);
// The AWS SDK puts the error code in `name`, not `message`
function s3Error(
name: string,
message: string,
metadata?: { httpStatusCode: number },
): Error {
const err = Object.assign(new Error(message), { $metadata: metadata });
err.name = name;
return err;
}
afterEach(() => {
s3mock.reset();
hostRules.clear();
});
it('returns releases from an S3 bucket', async () => {
s3mock
.on(GetObjectCommand, {
Bucket: 'chart-bucket',
Key: 'charts/index.yaml',
})
.resolvesOnce({ Body: Readable.from([indexYaml]) as never });
const res = await getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'ambassador',
registryUrls: ['s3://chart-bucket/charts'],
});
expect(res).toMatchObject({
homepage: 'https://www.getambassador.io/',
registryUrl: 's3://chart-bucket/charts',
sourceUrl: 'https://github.com/datawire/ambassador',
releases: expect.toBeArrayOfSize(27),
});
});
it('uses credentials from host rules', async () => {
hostRules.add({
hostType: HelmDatasource.id,
matchHost: 'chart-bucket',
username: 'some-access-key',
password: 'some-secret-key',
token: 'some-session-token',
});
s3mock
.on(GetObjectCommand, {
Bucket: 'chart-bucket',
Key: 'charts/index.yaml',
})
.resolvesOnce({ Body: Readable.from([indexYaml]) as never });
const res = await getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'ambassador',
registryUrls: ['s3://chart-bucket/charts'],
});
expect(res).toMatchObject({ registryUrl: 's3://chart-bucket/charts' });
const client = s3mock.call(0).thisValue as S3Client;
await expect(client.config.credentials()).resolves.toMatchObject({
accessKeyId: 'some-access-key',
secretAccessKey: 'some-secret-key',
sessionToken: 'some-session-token',
});
});
it('returns null when the S3 object is missing', async () => {
s3mock
.on(GetObjectCommand)
.rejectsOnce(s3Error('NoSuchKey', 'The specified key does not exist.'));
await expect(
getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'ambassador',
registryUrls: ['s3://chart-bucket/charts'],
}),
).resolves.toBeNull();
});
it('returns null for an unsupported response body', async () => {
s3mock.on(GetObjectCommand).resolvesOnce({ Body: undefined });
await expect(
getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'ambassador',
registryUrls: ['s3://chart-bucket/charts'],
}),
).resolves.toBeNull();
});
it('returns null when the S3 object is not found', async () => {
s3mock.on(GetObjectCommand).rejectsOnce(s3Error('NotFound', 'Not Found'));
await expect(
getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'ambassador',
registryUrls: ['s3://chart-bucket/charts'],
}),
).resolves.toBeNull();
});
it('returns null when the S3 object is deleted', async () => {
s3mock.on(GetObjectCommand).resolvesOnce({ DeleteMarker: true });
await expect(
getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'ambassador',
registryUrls: ['s3://chart-bucket/charts'],
}),
).resolves.toBeNull();
});
it('throws for credentials errors', async () => {
s3mock
.on(GetObjectCommand)
.rejectsOnce(
s3Error(
'CredentialsProviderError',
'Could not load credentials from any providers',
),
);
await expect(
getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'ambassador',
registryUrls: ['s3://chart-bucket/charts'],
}),
).rejects.toThrow(EXTERNAL_HOST_ERROR);
});
it('throws when the AWS region is missing', async () => {
s3mock
.on(GetObjectCommand)
.rejectsOnce(s3Error('Error', 'Region is missing'));
await expect(
getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'ambassador',
registryUrls: ['s3://chart-bucket/charts'],
}),
).rejects.toThrow(EXTERNAL_HOST_ERROR);
});
it('throws when the bucket rejects the credentials', async () => {
s3mock
.on(GetObjectCommand)
.rejectsOnce(
s3Error(
'InvalidAccessKeyId',
'The Access Key Id you provided does not exist in our records.',
{ httpStatusCode: 403 },
),
);
await expect(
getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'ambassador',
registryUrls: ['s3://chart-bucket/charts'],
}),
).rejects.toThrow(EXTERNAL_HOST_ERROR);
});
it('throws for unknown S3 errors', async () => {
s3mock
.on(GetObjectCommand)
.rejectsOnce(s3Error('AggregateError', 'connect ECONNREFUSED'));
await expect(
getPkgReleases({
datasource: HelmDatasource.id,
packageName: 'ambassador',
registryUrls: ['s3://chart-bucket/charts'],
}),
).rejects.toThrow(EXTERNAL_HOST_ERROR);
});
});
});