From bbedb2d7379bff9b222fde7ac298e7156648f5ac Mon Sep 17 00:00:00 2001
From: Pavel Busko <busko.pavel@gmail.com>
Date: Thu, 21 Nov 2024 13:11:58 +0100
Subject: [PATCH] feat(manager): Cloud Native Buildpacks project descriptor
 manager (#30799)

Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>
Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
Co-authored-by: loewenstein-sap <jan.von.loewenstein@sap.com>
---
 lib/config/options/index.ts                   |   1 +
 lib/modules/manager/api.ts                    |   2 +
 .../manager/buildpacks/extract.spec.ts        | 111 ++++++++++++++++++
 lib/modules/manager/buildpacks/extract.ts     | 103 ++++++++++++++++
 lib/modules/manager/buildpacks/index.ts       |  12 ++
 lib/modules/manager/buildpacks/readme.md      |  26 ++++
 lib/modules/manager/buildpacks/schema.ts      |  25 ++++
 7 files changed, 280 insertions(+)
 create mode 100644 lib/modules/manager/buildpacks/extract.spec.ts
 create mode 100644 lib/modules/manager/buildpacks/extract.ts
 create mode 100644 lib/modules/manager/buildpacks/index.ts
 create mode 100644 lib/modules/manager/buildpacks/readme.md
 create mode 100644 lib/modules/manager/buildpacks/schema.ts

diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts
index c91af4a604..0575942785 100644
--- a/lib/config/options/index.ts
+++ b/lib/config/options/index.ts
@@ -1135,6 +1135,7 @@ const options: RenovateOptions[] = [
     supportedManagers: [
       'ansible',
       'bitbucket-pipelines',
+      'buildpacks',
       'crossplane',
       'devcontainer',
       'docker-compose',
diff --git a/lib/modules/manager/api.ts b/lib/modules/manager/api.ts
index 466ce2f0f6..e8522f7332 100644
--- a/lib/modules/manager/api.ts
+++ b/lib/modules/manager/api.ts
@@ -12,6 +12,7 @@ import * as bicep from './bicep';
 import * as bitbucketPipelines from './bitbucket-pipelines';
 import * as bitrise from './bitrise';
 import * as buildkite from './buildkite';
+import * as buildpacks from './buildpacks';
 import * as bun from './bun';
 import * as bunVersion from './bun-version';
 import * as bundler from './bundler';
@@ -117,6 +118,7 @@ api.set('bicep', bicep);
 api.set('bitbucket-pipelines', bitbucketPipelines);
 api.set('bitrise', bitrise);
 api.set('buildkite', buildkite);
+api.set('buildpacks', buildpacks);
 api.set('bun', bun);
 api.set('bun-version', bunVersion);
 api.set('bundler', bundler);
diff --git a/lib/modules/manager/buildpacks/extract.spec.ts b/lib/modules/manager/buildpacks/extract.spec.ts
new file mode 100644
index 0000000000..dedcefcbd4
--- /dev/null
+++ b/lib/modules/manager/buildpacks/extract.spec.ts
@@ -0,0 +1,111 @@
+import { codeBlock } from 'common-tags';
+
+import { extractPackageFile } from '.';
+
+describe('modules/manager/buildpacks/extract', () => {
+  describe('extractPackageFile()', () => {
+    it('returns null for invalid files', () => {
+      expect(extractPackageFile('not a project toml', '', {})).toBeNull();
+    });
+
+    it('returns null for empty package.toml', () => {
+      const res = extractPackageFile(
+        '[_]\nschema-version = "0.2"',
+        'project.toml',
+        {},
+      );
+      expect(res).toBeNull();
+    });
+
+    it('extracts builder and buildpack images', () => {
+      const res = extractPackageFile(
+        codeBlock`
+[_]
+schema-version = "0.2"
+
+[io.buildpacks]
+builder = "registry.corp/builder/noble:1.1.1"
+
+[[io.buildpacks.group]]
+uri = "docker://buildpacks/java:2.2.2"
+
+[[io.buildpacks.group]]
+uri = "buildpacks/nodejs:3.3.3"
+
+[[io.buildpacks.group]]
+uri = "example/foo@1.0.0"
+
+[[io.buildpacks.group]]
+uri = "example/registry-cnb"
+
+[[io.buildpacks.group]]
+uri = "urn:cnb:registry:example/foo@1.0.0"
+
+[[io.buildpacks.group]]
+uri = "some-bp@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+
+[[io.buildpacks.group]]
+uri = "cnbs/some-bp:some-tag@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+
+[[io.buildpacks.group]]
+uri = "from=builder:foobar"
+
+[[io.buildpacks.group]]
+uri = "file://local.oci"
+
+[[io.buildpacks.group]]
+uri = "foo://fake.oci"`,
+        'project.toml',
+        {},
+      );
+      expect(res?.deps).toEqual([
+        {
+          autoReplaceStringTemplate:
+            '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+          commitMessageTopic: 'builder {{depName}}',
+          currentValue: '1.1.1',
+          datasource: 'docker',
+          depName: 'registry.corp/builder/noble',
+          replaceString: 'registry.corp/builder/noble:1.1.1',
+        },
+        {
+          autoReplaceStringTemplate:
+            '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+          currentValue: '2.2.2',
+          datasource: 'docker',
+          depName: 'buildpacks/java',
+          replaceString: 'buildpacks/java:2.2.2',
+        },
+        {
+          autoReplaceStringTemplate:
+            '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+          currentValue: '3.3.3',
+          datasource: 'docker',
+          depName: 'buildpacks/nodejs',
+          replaceString: 'buildpacks/nodejs:3.3.3',
+        },
+        {
+          autoReplaceStringTemplate:
+            '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+          currentDigest:
+            'sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
+          datasource: 'docker',
+          depName: 'some-bp',
+          replaceString:
+            'some-bp@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
+        },
+        {
+          autoReplaceStringTemplate:
+            '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+          currentDigest:
+            'sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
+          currentValue: 'some-tag',
+          datasource: 'docker',
+          depName: 'cnbs/some-bp',
+          replaceString:
+            'cnbs/some-bp:some-tag@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
+        },
+      ]);
+    });
+  });
+});
diff --git a/lib/modules/manager/buildpacks/extract.ts b/lib/modules/manager/buildpacks/extract.ts
new file mode 100644
index 0000000000..c00d4a4c68
--- /dev/null
+++ b/lib/modules/manager/buildpacks/extract.ts
@@ -0,0 +1,103 @@
+import is from '@sindresorhus/is';
+import { logger } from '../../../logger';
+import { regEx } from '../../../util/regex';
+import { getDep } from '../dockerfile/extract';
+import type {
+  ExtractConfig,
+  PackageDependency,
+  PackageFileContent,
+} from '../types';
+import { type ProjectDescriptor, ProjectDescriptorToml } from './schema';
+
+const dockerPrefix = regEx(/^docker:\/?\//);
+const dockerRef = regEx(
+  /^((?:[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?)*)(?::\d{2,5}\/)?)?[a-z\d]+((\.|_|__|-+)[a-z\d]+)*(\/[a-z\d]+((\.|_|__|-+)[a-z\d]+)*)*(?::(\w[\w.-]{0,127})(?:@sha256:[A-Fa-f\d]{32,})?|@sha256:[A-Fa-f\d]{32,})$/,
+);
+
+function isDockerRef(ref: string): boolean {
+  if (ref.startsWith('docker:/') || dockerRef.test(ref)) {
+    return true;
+  }
+  return false;
+}
+
+function parseProjectToml(
+  content: string,
+  packageFile: string,
+): ProjectDescriptor | null {
+  const res = ProjectDescriptorToml.safeParse(content);
+  if (res.success) {
+    return res.data;
+  }
+
+  logger.debug(
+    { packageFile, err: res.error },
+    'Failed to parse buildpacks project descriptor TOML',
+  );
+
+  return null;
+}
+
+export function extractPackageFile(
+  content: string,
+  packageFile: string,
+  config: ExtractConfig,
+): PackageFileContent | null {
+  const deps: PackageDependency[] = [];
+
+  const descriptor = parseProjectToml(content, packageFile);
+  if (!descriptor) {
+    return null;
+  }
+
+  if (
+    descriptor.io?.buildpacks?.builder &&
+    isDockerRef(descriptor.io.buildpacks.builder)
+  ) {
+    const dep = getDep(
+      descriptor.io.buildpacks.builder.replace(dockerPrefix, ''),
+      true,
+      config.registryAliases,
+    );
+    logger.trace(
+      {
+        depName: dep.depName,
+        currentValue: dep.currentValue,
+        currentDigest: dep.currentDigest,
+      },
+      'Cloud Native Buildpacks builder',
+    );
+
+    deps.push({ ...dep, commitMessageTopic: 'builder {{depName}}' });
+  }
+
+  if (
+    descriptor.io?.buildpacks?.group &&
+    is.array(descriptor.io.buildpacks.group)
+  ) {
+    for (const group of descriptor.io.buildpacks.group) {
+      if (group.uri && isDockerRef(group.uri)) {
+        const dep = getDep(
+          group.uri.replace(dockerPrefix, ''),
+          true,
+          config.registryAliases,
+        );
+        logger.trace(
+          {
+            depName: dep.depName,
+            currentValue: dep.currentValue,
+            currentDigest: dep.currentDigest,
+          },
+          'Cloud Native Buildpack',
+        );
+
+        deps.push(dep);
+      }
+    }
+  }
+
+  if (!deps.length) {
+    return null;
+  }
+  return { deps };
+}
diff --git a/lib/modules/manager/buildpacks/index.ts b/lib/modules/manager/buildpacks/index.ts
new file mode 100644
index 0000000000..53a3dcfeb4
--- /dev/null
+++ b/lib/modules/manager/buildpacks/index.ts
@@ -0,0 +1,12 @@
+import type { Category } from '../../../constants';
+import { DockerDatasource } from '../../datasource/docker';
+export { extractPackageFile } from './extract';
+
+export const defaultConfig = {
+  commitMessageTopic: 'buildpack {{depName}}',
+  fileMatch: ['(^|/)project\\.toml$'],
+  pinDigests: false,
+};
+
+export const categories: Category[] = ['docker'];
+export const supportedDatasources = [DockerDatasource.id];
diff --git a/lib/modules/manager/buildpacks/readme.md b/lib/modules/manager/buildpacks/readme.md
new file mode 100644
index 0000000000..d2f7b6c613
--- /dev/null
+++ b/lib/modules/manager/buildpacks/readme.md
@@ -0,0 +1,26 @@
+The `buildpacks` manager updates Cloud Native Buildpacks project descriptors in `project.toml` files.
+A `project.toml` file can reference builder / buildpack images by URIs.
+Renovate can update a `project.toml` file if:
+
+- It can find the file
+- The file follows the [project descriptor specifications](https://github.com/buildpacks/spec/blob/main/extensions/project-descriptor.md)
+- The buildpack `uri` is an OCI image reference (references to a local file or buildpack registry are ignored)
+
+If you use buildpacks in the `io.buildpacks.group` array, then you _must_ configure the Docker reference (`uri`) for Renovate to work.
+
+```toml title="Example of a project.toml file with Docker reference URIs"
+[_]
+schema-version = "0.2"
+
+[io.buildpacks]
+builder = "registry.corp/builder/noble:1.1.1"
+
+[[io.buildpacks.group]]
+uri = "docker://buildpacks/java:2.2.2"
+
+[[io.buildpacks.group]]
+uri = "buildpacks/nodejs:3.3.3"
+
+[[io.buildpacks.group]]
+uri = "file://local.oci" # will be ignored
+```
diff --git a/lib/modules/manager/buildpacks/schema.ts b/lib/modules/manager/buildpacks/schema.ts
new file mode 100644
index 0000000000..dc90371ca4
--- /dev/null
+++ b/lib/modules/manager/buildpacks/schema.ts
@@ -0,0 +1,25 @@
+import { z } from 'zod';
+import { Toml } from '../../../util/schema-utils';
+
+const BuildpackGroup = z.object({
+  uri: z.string().optional(),
+});
+
+const IoBuildpacks = z.object({
+  builder: z.string().optional(),
+  group: z.array(BuildpackGroup).optional(),
+});
+
+export const ProjectDescriptor = z.object({
+  _: z.object({
+    'schema-version': z.string(),
+  }),
+  io: z
+    .object({
+      buildpacks: IoBuildpacks.optional(),
+    })
+    .optional(),
+});
+
+export type ProjectDescriptor = z.infer<typeof ProjectDescriptor>;
+export const ProjectDescriptorToml = Toml.pipe(ProjectDescriptor);
-- 
GitLab