From a199363153e530ac6f6cfe61771a743b64173a07 Mon Sep 17 00:00:00 2001
From: Philippe Scorsolini <p.scorsolini@gmail.com>
Date: Wed, 22 Nov 2023 10:18:44 +0100
Subject: [PATCH] feat(manager/crossplane): add Crossplane xpkgs support
 (#25896)

Signed-off-by: Philippe Scorsolini <p.scorsolini@gmail.com>
---
 lib/modules/manager/api.ts                    |   2 +
 .../__fixtures__/malformedPackages.yml        |  21 +++
 .../crossplane/__fixtures__/mixedManifest.yml |  38 +++++
 .../__fixtures__/randomManifest.yml           |  21 +++
 .../crossplane/__fixtures__/validPackages.yml |  22 +++
 .../manager/crossplane/extract.spec.ts        | 155 ++++++++++++++++++
 lib/modules/manager/crossplane/extract.ts     |  50 ++++++
 lib/modules/manager/crossplane/index.ts       |  15 ++
 lib/modules/manager/crossplane/readme.md      |  40 +++++
 lib/modules/manager/crossplane/schema.ts      |  11 ++
 10 files changed, 375 insertions(+)
 create mode 100644 lib/modules/manager/crossplane/__fixtures__/malformedPackages.yml
 create mode 100644 lib/modules/manager/crossplane/__fixtures__/mixedManifest.yml
 create mode 100644 lib/modules/manager/crossplane/__fixtures__/randomManifest.yml
 create mode 100644 lib/modules/manager/crossplane/__fixtures__/validPackages.yml
 create mode 100644 lib/modules/manager/crossplane/extract.spec.ts
 create mode 100644 lib/modules/manager/crossplane/extract.ts
 create mode 100644 lib/modules/manager/crossplane/index.ts
 create mode 100644 lib/modules/manager/crossplane/readme.md
 create mode 100644 lib/modules/manager/crossplane/schema.ts

diff --git a/lib/modules/manager/api.ts b/lib/modules/manager/api.ts
index c5cbbea651..ba08a39881 100644
--- a/lib/modules/manager/api.ts
+++ b/lib/modules/manager/api.ts
@@ -22,6 +22,7 @@ import * as cocoapods from './cocoapods';
 import * as composer from './composer';
 import * as conan from './conan';
 import * as cpanfile from './cpanfile';
+import * as crossplane from './crossplane';
 import * as depsEdn from './deps-edn';
 import * as dockerCompose from './docker-compose';
 import * as dockerfile from './dockerfile';
@@ -113,6 +114,7 @@ api.set('cocoapods', cocoapods);
 api.set('composer', composer);
 api.set('conan', conan);
 api.set('cpanfile', cpanfile);
+api.set('crossplane', crossplane);
 api.set('deps-edn', depsEdn);
 api.set('docker-compose', dockerCompose);
 api.set('dockerfile', dockerfile);
diff --git a/lib/modules/manager/crossplane/__fixtures__/malformedPackages.yml b/lib/modules/manager/crossplane/__fixtures__/malformedPackages.yml
new file mode 100644
index 0000000000..5b76e6dfc1
--- /dev/null
+++ b/lib/modules/manager/crossplane/__fixtures__/malformedPackages.yml
@@ -0,0 +1,21 @@
+---
+apiVersion: pkg.crossplane.io/v1
+kind: Provider
+metadata:
+  name: provider-nop
+spec:
+  package: null
+  ignoreCrossplaneConstraints: true
+---
+apiVersion: pkg.crossplane.io/v1beta1
+kind: Function
+metadata:
+  name: function-dummy
+spec:
+  package: ""
+---
+apiVersion: pkg.crossplane.io/v1
+kind: Configuration
+metadata:
+  name: platform-ref-aws
+spec:
diff --git a/lib/modules/manager/crossplane/__fixtures__/mixedManifest.yml b/lib/modules/manager/crossplane/__fixtures__/mixedManifest.yml
new file mode 100644
index 0000000000..791c9735a8
--- /dev/null
+++ b/lib/modules/manager/crossplane/__fixtures__/mixedManifest.yml
@@ -0,0 +1,38 @@
+---
+apiVersion: pkg.crossplane.io/v1
+kind: Provider
+metadata:
+  name: provider-nop
+spec:
+  package: xpkg.upbound.io/crossplane-contrib/provider-nop:v0.2.0
+  ignoreCrossplaneConstraints: true
+---
+apiVersion: pkg.crossplane.io/v1
+kind: Provider
+metadata:
+  name: provider-nop
+spec:
+  package: null
+  ignoreCrossplaneConstraints: true
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+  labels:
+    app: nginx
+spec:
+  replicas: 3
+  selector:
+    matchLabels:
+      app: nginx
+  template:
+    metadata:
+      labels:
+        app: nginx
+    spec:
+      containers:
+        - name: nginx
+          image: nginx:1.14.2
+          ports:
+            - containerPort: 80
diff --git a/lib/modules/manager/crossplane/__fixtures__/randomManifest.yml b/lib/modules/manager/crossplane/__fixtures__/randomManifest.yml
new file mode 100644
index 0000000000..007ecd317d
--- /dev/null
+++ b/lib/modules/manager/crossplane/__fixtures__/randomManifest.yml
@@ -0,0 +1,21 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: nginx-deployment
+  labels:
+    app: nginx
+spec:
+  replicas: 3
+  selector:
+    matchLabels:
+      app: nginx
+  template:
+    metadata:
+      labels:
+        app: nginx
+    spec:
+      containers:
+        - name: nginx
+          image: nginx:1.14.2
+          ports:
+            - containerPort: 80
diff --git a/lib/modules/manager/crossplane/__fixtures__/validPackages.yml b/lib/modules/manager/crossplane/__fixtures__/validPackages.yml
new file mode 100644
index 0000000000..a5cadaa94e
--- /dev/null
+++ b/lib/modules/manager/crossplane/__fixtures__/validPackages.yml
@@ -0,0 +1,22 @@
+---
+apiVersion: pkg.crossplane.io/v1
+kind: Provider
+metadata:
+  name: provider-nop
+spec:
+  package: xpkg.upbound.io/crossplane-contrib/provider-nop:v0.2.0
+  ignoreCrossplaneConstraints: true
+---
+apiVersion: pkg.crossplane.io/v1beta1
+kind: Function
+metadata:
+  name: function-dummy
+spec:
+  package: xpkg.upbound.io/crossplane-contrib/function-dummy:v0.2.1
+---
+apiVersion: pkg.crossplane.io/v1
+kind: Configuration
+metadata:
+  name: platform-ref-aws
+spec:
+  package: xpkg.upbound.io/upbound/platform-ref-aws:v0.6.0
diff --git a/lib/modules/manager/crossplane/extract.spec.ts b/lib/modules/manager/crossplane/extract.spec.ts
new file mode 100644
index 0000000000..dff7b0e7dd
--- /dev/null
+++ b/lib/modules/manager/crossplane/extract.spec.ts
@@ -0,0 +1,155 @@
+import { codeBlock } from 'common-tags';
+import { Fixtures } from '../../../../test/fixtures';
+import { extractPackageFile } from '.';
+
+const validPackages = Fixtures.get('validPackages.yml');
+const malformedPackages = Fixtures.get('malformedPackages.yml');
+const randomManifest = Fixtures.get('randomManifest.yml');
+const mixedManifest = Fixtures.get('mixedManifest.yml');
+
+describe('modules/manager/crossplane/extract', () => {
+  describe('extractPackageFile()', () => {
+    it('returns null for empty', () => {
+      expect(extractPackageFile('nothing here', 'packages.yml')).toBeNull();
+    });
+
+    it('returns null for invalid', () => {
+      expect(
+        extractPackageFile(`${malformedPackages}\n123`, 'packages.yml'),
+      ).toBeNull();
+    });
+
+    it('return null for kubernetes manifest', () => {
+      const result = extractPackageFile(randomManifest, 'packages.yml');
+      expect(result).toBeNull();
+    });
+
+    it('return invalid-value if deps are not valid images and ignore if missing', () => {
+      const result = extractPackageFile(malformedPackages, 'packages.yml');
+      expect(result).toMatchObject({
+        deps: [
+          {
+            depType: 'function',
+            skipReason: 'invalid-value',
+          },
+        ],
+      });
+    });
+
+    it('return result for double quoted pkg.crossplane.io apiVersion reference', () => {
+      const result = extractPackageFile(
+        codeBlock`
+        apiVersion: "pkg.crossplane.io/v1"
+        kind: Configuration
+        spec:
+          package: "xpkg.upbound.io/upbound/platform-ref-aws:v0.6.0"
+        `,
+        'packages.yml',
+      );
+      expect(result).toMatchObject({
+        deps: [
+          {
+            currentValue: 'v0.6.0',
+            datasource: 'docker',
+            depName: 'xpkg.upbound.io/upbound/platform-ref-aws',
+          },
+        ],
+      });
+    });
+
+    it('return result for single quoted pkg.crossplane.io apiVersion reference', () => {
+      const result = extractPackageFile(
+        codeBlock`
+        apiVersion: 'pkg.crossplane.io/v1'
+        kind: Configuration
+        spec:
+          package: 'xpkg.upbound.io/upbound/platform-ref-aws:v0.6.0'
+        `,
+        'packages.yml',
+      );
+      expect(result).toMatchObject({
+        deps: [
+          {
+            currentValue: 'v0.6.0',
+            datasource: 'docker',
+            depName: 'xpkg.upbound.io/upbound/platform-ref-aws',
+          },
+        ],
+      });
+    });
+
+    it('return no results for invalid resource', () => {
+      const result = extractPackageFile(
+        codeBlock`
+        ---
+        apiVersion: pkg.crossplane.io/v1
+        kind: Configuration
+        metadata:
+          name: platform-ref-aws
+        spec:
+        `,
+        'packages.yml',
+      );
+      expect(result).toBeNull();
+    });
+
+    it('full test', () => {
+      const result = extractPackageFile(validPackages, 'packages.yml');
+      expect(result).toEqual({
+        deps: [
+          {
+            autoReplaceStringTemplate:
+              '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+            currentDigest: undefined,
+            currentValue: 'v0.2.0',
+            datasource: 'docker',
+            depName: 'xpkg.upbound.io/crossplane-contrib/provider-nop',
+            depType: 'provider',
+            replaceString:
+              'xpkg.upbound.io/crossplane-contrib/provider-nop:v0.2.0',
+          },
+          {
+            autoReplaceStringTemplate:
+              '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+            currentDigest: undefined,
+            currentValue: 'v0.2.1',
+            datasource: 'docker',
+            depName: 'xpkg.upbound.io/crossplane-contrib/function-dummy',
+            depType: 'function',
+            replaceString:
+              'xpkg.upbound.io/crossplane-contrib/function-dummy:v0.2.1',
+          },
+          {
+            autoReplaceStringTemplate:
+              '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+            currentDigest: undefined,
+            currentValue: 'v0.6.0',
+            datasource: 'docker',
+            depName: 'xpkg.upbound.io/upbound/platform-ref-aws',
+            depType: 'configuration',
+            replaceString: 'xpkg.upbound.io/upbound/platform-ref-aws:v0.6.0',
+          },
+        ],
+      });
+    });
+
+    it('should work even if there are other resources in the file', () => {
+      const result = extractPackageFile(mixedManifest, 'packages.yml');
+      expect(result).toEqual({
+        deps: [
+          {
+            autoReplaceStringTemplate:
+              '{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}',
+            currentDigest: undefined,
+            currentValue: 'v0.2.0',
+            datasource: 'docker',
+            depName: 'xpkg.upbound.io/crossplane-contrib/provider-nop',
+            depType: 'provider',
+            replaceString:
+              'xpkg.upbound.io/crossplane-contrib/provider-nop:v0.2.0',
+          },
+        ],
+      });
+    });
+  });
+});
diff --git a/lib/modules/manager/crossplane/extract.ts b/lib/modules/manager/crossplane/extract.ts
new file mode 100644
index 0000000000..35f70e8783
--- /dev/null
+++ b/lib/modules/manager/crossplane/extract.ts
@@ -0,0 +1,50 @@
+import { loadAll } from 'js-yaml';
+import { logger } from '../../../logger';
+import { getDep } from '../dockerfile/extract';
+import type {
+  ExtractConfig,
+  PackageDependency,
+  PackageFileContent,
+} from '../types';
+import { XPKGSchema } from './schema';
+
+export function extractPackageFile(
+  content: string,
+  packageFile: string,
+  extractConfig?: ExtractConfig,
+): PackageFileContent | null {
+  // avoid parsing the whole file if it doesn't contain any resource having any pkg.crossplane.io/v*
+  if (!/apiVersion:\s+["']?pkg\.crossplane\.io\/v.+["']?/.test(content)) {
+    logger.trace({ packageFile }, 'No Crossplane package found in file.');
+    return null;
+  }
+
+  let list = [];
+  try {
+    list = loadAll(content);
+  } catch (err) {
+    logger.debug(
+      { err, packageFile },
+      'Failed to parse Crossplane package file.',
+    );
+    return null;
+  }
+
+  const deps: PackageDependency[] = [];
+  for (const item of list) {
+    const parsed = XPKGSchema.safeParse(item);
+    if (!parsed.success) {
+      logger.trace(
+        { item, errors: parsed.error },
+        'Invalid Crossplane package',
+      );
+      continue;
+    }
+    const xpkg = parsed.data;
+    const dep = getDep(xpkg.spec.package, true, extractConfig?.registryAliases);
+    dep.depType = xpkg.kind.toLowerCase();
+    deps.push(dep);
+  }
+
+  return deps.length ? { deps } : null;
+}
diff --git a/lib/modules/manager/crossplane/index.ts b/lib/modules/manager/crossplane/index.ts
new file mode 100644
index 0000000000..dde1c0608b
--- /dev/null
+++ b/lib/modules/manager/crossplane/index.ts
@@ -0,0 +1,15 @@
+import type { Category } from '../../../constants';
+import { DockerDatasource } from '../../datasource/docker';
+
+export { extractPackageFile } from './extract';
+
+export const displayName = 'Crossplane';
+export const url = 'https://docs.crossplane.io/';
+
+export const defaultConfig = {
+  fileMatch: [],
+};
+
+export const categories: Category[] = ['kubernetes', 'iac'];
+
+export const supportedDatasources = [DockerDatasource.id];
diff --git a/lib/modules/manager/crossplane/readme.md b/lib/modules/manager/crossplane/readme.md
new file mode 100644
index 0000000000..0cfc4fd95e
--- /dev/null
+++ b/lib/modules/manager/crossplane/readme.md
@@ -0,0 +1,40 @@
+The `crossplane` manager has no `fileMatch` default patterns, so it won't match any files until you configure it with a pattern.
+This is because there is no commonly accepted file/directory naming convention for crossplane YAML files and we don't want to check every single `*.yaml` file in repositories just in case any of them have Crossplane Packages definitions: Configurations, Providers, Functions.
+
+If most `.yaml` files in your repository are Crossplane ones, then you could add this to your config:
+
+```json
+{
+  "crossplane": {
+    "fileMatch": ["\\.yaml$"]
+  }
+}
+```
+
+If instead you have them all inside a `packages/` directory, you would add this:
+
+```json
+{
+  "crossplane": {
+    "fileMatch": ["packages/.+\\.yaml$"]
+  }
+}
+```
+
+Or if it's only a single file then something like this:
+
+```json
+{
+  "crossplane": {
+    "fileMatch": ["^config/provider\\.yaml$"]
+  }
+}
+```
+
+If you need to change the versioning format, read the [versioning](https://docs.renovatebot.com/modules/versioning/) documentation to learn more.
+
+The `crossplane` manager has three `depType`s to allow a fine-grained control of which dependencies are upgraded:
+
+- `configuration`
+- `function`
+- `provider`
diff --git a/lib/modules/manager/crossplane/schema.ts b/lib/modules/manager/crossplane/schema.ts
new file mode 100644
index 0000000000..e0149ef06d
--- /dev/null
+++ b/lib/modules/manager/crossplane/schema.ts
@@ -0,0 +1,11 @@
+import { z } from 'zod';
+
+export const XPKGSchema = z.object({
+  apiVersion: z.string().regex(/^pkg\.crossplane\.io\//),
+  kind: z.enum(['Provider', 'Configuration', 'Function']),
+  spec: z.object({
+    package: z.string(),
+  }),
+});
+
+export type XPKG = z.infer<typeof XPKGSchema>;
-- 
GitLab