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