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(datasource): Add datasource for buildpack registry #32721

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
2 changes: 2 additions & 0 deletions lib/modules/datasource/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AzurePipelinesTasksDatasource } from './azure-pipelines-tasks';
import { BazelDatasource } from './bazel';
import { BitbucketTagsDatasource } from './bitbucket-tags';
import { BitriseDatasource } from './bitrise';
import { BuildpacksRegistryDatasource } from './buildpacks-registry';
import { CdnjsDatasource } from './cdnjs';
import { ClojureDatasource } from './clojure';
import { ConanDatasource } from './conan';
Expand Down Expand Up @@ -76,6 +77,7 @@ api.set(AzurePipelinesTasksDatasource.id, new AzurePipelinesTasksDatasource());
api.set(BazelDatasource.id, new BazelDatasource());
api.set(BitbucketTagsDatasource.id, new BitbucketTagsDatasource());
api.set(BitriseDatasource.id, new BitriseDatasource());
api.set(BuildpacksRegistryDatasource.id, new BuildpacksRegistryDatasource());
api.set(CdnjsDatasource.id, new CdnjsDatasource());
api.set(ClojureDatasource.id, new ClojureDatasource());
api.set(ConanDatasource.id, new ConanDatasource());
Expand Down
66 changes: 66 additions & 0 deletions lib/modules/datasource/buildpacks-registry/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { getPkgReleases } from '..';
import * as httpMock from '../../../../test/http-mock';
import { BuildpacksRegistryDatasource } from '.';

const baseUrl = 'https://registry.buildpacks.io/api/v1/buildpacks/';

describe('modules/datasource/buildpacks-registry/index', () => {
describe('getReleases', () => {
it('processes real data', async () => {
httpMock
.scope(baseUrl)
.get('/heroku/python')
.reply(200, {
latest: {
version: '0.17.1',
namespace: 'heroku',
name: 'python',
description: "Heroku's buildpack for Python applications.",
homepage: 'https://github.com/heroku/buildpacks-python',
licenses: ['BSD-3-Clause'],
stacks: ['*'],
id: '75946bf8-3f6a-4af0-a757-614bebfdfcd6',
},
versions: [
{
version: '0.17.1',
_link:
'https://registry.buildpacks.io//api/v1/buildpacks/heroku/python/0.17.1',
},
{
version: '0.17.0',
_link:
'https://registry.buildpacks.io//api/v1/buildpacks/heroku/python/0.17.0',
},
],
});
const res = await getPkgReleases({
datasource: BuildpacksRegistryDatasource.id,
packageName: 'heroku/python',
});
expect(res).toEqual({
registryUrl: 'https://registry.buildpacks.io/',
releases: [{ version: '0.17.0' }, { version: '0.17.1' }],
sourceUrl: 'https://github.com/heroku/buildpacks-python',
});
});

it('returns null on empty result', async () => {
httpMock.scope(baseUrl).get('/heroku/empty').reply(200, {});
const res = await getPkgReleases({
datasource: BuildpacksRegistryDatasource.id,
packageName: 'heroku/empty',
});
expect(res).toBeNull();
});

it('handles not found', async () => {
httpMock.scope(baseUrl).get('/heroku/notexisting').reply(404);
const res = await getPkgReleases({
datasource: BuildpacksRegistryDatasource.id,
packageName: 'heroku/notexisting',
});
expect(res).toBeNull();
});
});
});
72 changes: 72 additions & 0 deletions lib/modules/datasource/buildpacks-registry/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import urlJoin from 'url-join';
import { ZodError } from 'zod';
import { logger } from '../../../logger';
import { cache } from '../../../util/cache/package/decorator';
import { Result } from '../../../util/result';
import { Datasource } from '../datasource';
import { ReleasesConfig } from '../schema';
import type { GetReleasesConfig, Release, ReleaseResult } from '../types';
import { BuildpacksRegistryResponseSchema } from './schema';

export class BuildpacksRegistryDatasource extends Datasource {
static readonly id = 'buildpacks-registry';

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

override readonly customRegistrySupport = false;

override readonly defaultRegistryUrls = ['https://registry.buildpacks.io/'];
nicolasbender marked this conversation as resolved.
Show resolved Hide resolved

override readonly releaseTimestampSupport = true;
override readonly releaseTimestampNote =
'The release timestamp is determined from the `published_at` field in the results.';
override readonly sourceUrlSupport = 'release';
override readonly sourceUrlNote =
'The source URL is determined from the `source_code_url` field of the release object in the results.';

@cache({
namespace: `datasource-${BuildpacksRegistryDatasource.id}`,
key: ({ registryUrl, packageName }: GetReleasesConfig) =>
`${registryUrl}:${packageName}`,
})
async getReleases(config: GetReleasesConfig): Promise<ReleaseResult | null> {
c0d1ngm0nk3y marked this conversation as resolved.
Show resolved Hide resolved
const result = Result.parse(config, ReleasesConfig)
.transform(({ packageName, registryUrl }) => {
const url = urlJoin(
registryUrl,
'api',
'v1',
'buildpacks',
packageName,
);

return this.http.getJsonSafe(url, BuildpacksRegistryResponseSchema);
})
.transform(({ versions, latest }): ReleaseResult => {
const releases: Release[] = versions;

const res: ReleaseResult = { releases };

if (latest?.homepage) {
res.homepage = latest.homepage;
}

return res;
});

const { val, err } = await result.unwrap();

if (err instanceof ZodError) {
logger.debug({ err }, 'buildpacks: validation error');
return null;
}

if (err) {
this.handleGenericErrors(err);
}

return val;
}
}
43 changes: 43 additions & 0 deletions lib/modules/datasource/buildpacks-registry/schema.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { BuildpacksRegistryResponseSchema } from './schema';

describe('modules/datasource/buildpacks-registry/schema', () => {
it('parses buildpack-registry schema', () => {
const response = {
latest: {
version: '0.17.1',
namespace: 'heroku',
name: 'python',
description: "Heroku's buildpack for Python applications.",
homepage: 'https://github.com/heroku/buildpacks-python',
licenses: ['BSD-3-Clause'],
stacks: ['*'],
id: '75946bf8-3f6a-4af0-a757-614bebfdfcd6',
},
versions: [
c0d1ngm0nk3y marked this conversation as resolved.
Show resolved Hide resolved
{
version: '0.2.0',
_link:
'https://registry.buildpacks.io//api/v1/buildpacks/heroku/python/0.2.0',
},
{
version: '0.1.0',
_link:
'https://registry.buildpacks.io//api/v1/buildpacks/heroku/python/0.1.0',
},
],
};
expect(BuildpacksRegistryResponseSchema.parse(response)).toMatchObject({
latest: {
homepage: 'https://github.com/heroku/buildpacks-python',
},
versions: [
{
version: '0.2.0',
},
{
version: '0.1.0',
},
],
});
});
});
17 changes: 17 additions & 0 deletions lib/modules/datasource/buildpacks-registry/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { z } from 'zod';

/**
* Response from registry.buildpacks.io
*/
export const BuildpacksRegistryResponseSchema = z.object({
latest: z
.object({
homepage: z.string().optional(),
})
.optional(),
versions: z
.object({
version: z.string(),
})
.array(),
});
42 changes: 35 additions & 7 deletions lib/modules/manager/buildpacks/extract.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe('modules/manager/buildpacks/extract', () => {
[_]
schema-version = "0.2"

# valid cases
[io.buildpacks]
builder = "registry.corp/builder/noble:1.1.1"

Expand All @@ -36,16 +37,22 @@ uri = "buildpacks/nodejs:3.3.3"
uri = "example/[email protected]"

[[io.buildpacks.group]]
uri = "example/registry-cnb"
uri = "urn:cnb:registry:example/[email protected]"

[[io.buildpacks.group]]
uri = "urn:cnb:registry:example/[email protected]"
uri = "cnbs/some-bp@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"

[[io.buildpacks.group]]
uri = "some-bp@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
uri = "cnbs/some-bp:some-tag@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"

[[io.buildpacks.group]]
uri = "cnbs/some-bp:some-tag@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
id = "example/tee"
version = "2.3.4"

#invalid cases

[[io.buildpacks.group]]
uri = "example/registry-cnb"

[[io.buildpacks.group]]
uri = "from=builder:foobar"
Expand All @@ -54,7 +61,10 @@ uri = "from=builder:foobar"
uri = "file://local.oci"

[[io.buildpacks.group]]
uri = "foo://fake.oci"`,
uri = "foo://fake.oci"

[[io.buildpacks.group]]
id = "not/valid"`,
'project.toml',
{},
);
Expand Down Expand Up @@ -84,15 +94,27 @@ uri = "foo://fake.oci"`,
depName: 'buildpacks/nodejs',
replaceString: 'buildpacks/nodejs:3.3.3',
},
{
datasource: 'buildpacks-registry',
currentValue: '1.0.0',
packageName: 'example/foo',
replaceString: 'example/[email protected]',
},
{
datasource: 'buildpacks-registry',
currentValue: '1.2.3',
packageName: 'example/bar',
replaceString: 'example/[email protected]',
},
{
autoReplaceStringTemplate:
'{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
currentDigest:
'sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
datasource: 'docker',
depName: 'some-bp',
depName: 'cnbs/some-bp',
replaceString:
'some-bp@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
'cnbs/some-bp@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
},
{
autoReplaceStringTemplate:
Expand All @@ -105,6 +127,12 @@ uri = "foo://fake.oci"`,
replaceString:
'cnbs/some-bp:some-tag@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
},
{
datasource: 'buildpacks-registry',
currentValue: '2.3.4',
packageName: 'example/tee',
replaceString: '2.3.4',
},
]);
});
});
Expand Down
54 changes: 52 additions & 2 deletions lib/modules/manager/buildpacks/extract.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import is from '@sindresorhus/is';
import { logger } from '../../../logger';
import { regEx } from '../../../util/regex';
import { BuildpacksRegistryDatasource } from '../../datasource/buildpacks-registry';
import { isVersion } from '../../versioning/semver';
import { getDep } from '../dockerfile/extract';
import type {
ExtractConfig,
PackageDependency,
PackageFileContent,
} from '../types';
import { type ProjectDescriptor, ProjectDescriptorToml } from './schema';
import {
type ProjectDescriptor,
ProjectDescriptorToml,
isBuildpackByName,
isBuildpackByURI,
} from './schema';

const dockerPrefix = regEx(/^docker:\/?\//);
const dockerRef = regEx(
Expand All @@ -20,6 +27,24 @@ function isDockerRef(ref: string): boolean {
}
return false;
}
const buildpackRegistryPrefix = 'urn:cnb:registry:';
const buildpackRegistryId = regEx(
/^[a-z0-9\-.]+\/[a-z0-9\-.]+(?:@(?<version>.+))?$/,
);

function isBuildpackRegistryId(ref: string): boolean {
const bpRegistryMatch = buildpackRegistryId.exec(ref);
if (!bpRegistryMatch) {
return false;
} else if (!bpRegistryMatch.groups?.version) {
return true;
}
return isVersion(bpRegistryMatch.groups.version);
}

function isBuildpackRegistryRef(ref: string): boolean {
return isBuildpackRegistryId(ref) || ref.startsWith(buildpackRegistryPrefix);
}

function parseProjectToml(
content: string,
Expand Down Expand Up @@ -76,7 +101,7 @@ export function extractPackageFile(
is.array(descriptor.io.buildpacks.group)
) {
for (const group of descriptor.io.buildpacks.group) {
if (group.uri && isDockerRef(group.uri)) {
if (isBuildpackByURI(group) && isDockerRef(group.uri)) {
c0d1ngm0nk3y marked this conversation as resolved.
Show resolved Hide resolved
const dep = getDep(
group.uri.replace(dockerPrefix, ''),
true,
Expand All @@ -92,6 +117,31 @@ export function extractPackageFile(
);

deps.push(dep);
} else if (isBuildpackByURI(group) && isBuildpackRegistryRef(group.uri)) {
const dependency = group.uri.replace(buildpackRegistryPrefix, '');

if (dependency.includes('@')) {
const version = dependency.split('@')[1];
const dep: PackageDependency = {
datasource: BuildpacksRegistryDatasource.id,
currentValue: version,
packageName: dependency.split('@')[0],
replaceString: `${dependency}`,
nicolasbender marked this conversation as resolved.
Show resolved Hide resolved
};
deps.push(dep);
}
} else if (isBuildpackByName(group)) {
const version = group.version;

if (version) {
const dep: PackageDependency = {
datasource: BuildpacksRegistryDatasource.id,
currentValue: version,
packageName: group.id,
replaceString: `${version}`,
nicolasbender marked this conversation as resolved.
Show resolved Hide resolved
};
deps.push(dep);
}
}
}
}
Expand Down
Loading