diff --git a/lib/modules/manager/flux/__fixtures__/helmChart.yaml b/lib/modules/manager/flux/__fixtures__/helmChart.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a96fbe201d7c493a0a74c2e9f9074e8f9c9f1846 --- /dev/null +++ b/lib/modules/manager/flux/__fixtures__/helmChart.yaml @@ -0,0 +1,14 @@ +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmChart +metadata: + name: sealed-secrets + namespace: kube-system +spec: + interval: 10m + chart: sealed-secrets + sourceRef: + kind: HelmRepository + name: sealed-secrets + version: "2.0.2" + valuesFiles: + - values-prod.yaml diff --git a/lib/modules/manager/flux/__fixtures__/helmChartRefRelease.yaml b/lib/modules/manager/flux/__fixtures__/helmChartRefRelease.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a25c42a14325bc43d84ef7884d2170b1948ae306 --- /dev/null +++ b/lib/modules/manager/flux/__fixtures__/helmChartRefRelease.yaml @@ -0,0 +1,13 @@ +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: sealed-secrets + namespace: kube-system +spec: + interval: 10m + chartRef: + kind: HelmChart + name: sealed-secrets + namespace: kube-system + values: + replicaCount: 2 diff --git a/lib/modules/manager/flux/extract.spec.ts b/lib/modules/manager/flux/extract.spec.ts index eb90d5add9e6fbaa4ae7ba4780f126b580854666..c2cb2c866163855295bc6866b20e8cd39f752809 100644 --- a/lib/modules/manager/flux/extract.spec.ts +++ b/lib/modules/manager/flux/extract.spec.ts @@ -14,6 +14,9 @@ import { extractAllPackageFiles, extractPackageFile } from '.'; const config: ExtractConfig = {}; const adminConfig: RepoGlobalConfig = { localDir: '' }; +const fixtureHelmSource = Fixtures.get('helmSource.yaml'); +const fixtureHelmChart = Fixtures.get('helmChart.yaml'); +const fixtureHelmChartRefRelease = Fixtures.get('helmChartRefRelease.yaml'); describe('modules/manager/flux/extract', () => { beforeEach(() => { @@ -162,6 +165,22 @@ describe('modules/manager/flux/extract', () => { }); }); + it('ignores HelmRelease resources without any chart reference', () => { + const result = extractPackageFile( + codeBlock` + apiVersion: helm.toolkit.fluxcd.io/v2beta1 + kind: HelmRelease + metadata: + name: sealed-secrets + namespace: kube-system + spec: + interval: 10m + `, + 'test.yaml', + ); + expect(result).toBeNull(); + }); + it('ignores HelmRelease resources without a chart name', () => { const result = extractPackageFile( codeBlock` @@ -240,7 +259,7 @@ describe('modules/manager/flux/extract', () => { it('does not match HelmRelease resources without a sourceRef', () => { const result = extractPackageFile( codeBlock` - ${Fixtures.get('helmSource.yaml')} + ${fixtureHelmSource} --- apiVersion: helm.toolkit.fluxcd.io/v2beta1 kind: HelmRelease @@ -270,7 +289,7 @@ describe('modules/manager/flux/extract', () => { it('does not match HelmRelease resources without a namespace', () => { const result = extractPackageFile( codeBlock` - ${Fixtures.get('helmSource.yaml')} + ${fixtureHelmSource} --- apiVersion: helm.toolkit.fluxcd.io/v2beta1 kind: HelmRelease @@ -337,6 +356,182 @@ describe('modules/manager/flux/extract', () => { }); }); + it('ignores HelmRelease resources using an invalid chartRef', () => { + const result = extractPackageFile( + fixtureHelmChartRefRelease, + 'test.yaml', + ); + expect(result).toBeNull(); + }); + + it('ignores HelmRelease resources using a chartRef targetting a HelmChart', () => { + const result = extractPackageFile( + codeBlock` + ${fixtureHelmChartRefRelease} + --- + ${fixtureHelmChart} + --- + ${fixtureHelmSource} + `, + 'test.yaml', + ); + // HelmRelease is ignored, only HelmChart itself is processed (-> no duplicates expected) + expect(result).toEqual({ + deps: [ + { + currentValue: '2.0.2', + datasource: HelmDatasource.id, + depName: 'sealed-secrets', + registryUrls: ['https://bitnami-labs.github.io/sealed-secrets'], + }, + ], + }); + }); + + it('ignores HelmRelease resources using a chartRef targetting an OCIRepository', () => { + const result = extractPackageFile( + codeBlock` + ${Fixtures.get('ociSource.yaml')} + --- + apiVersion: helm.toolkit.fluxcd.io/v2 + kind: HelmRelease + metadata: + name: kyverno-controller + namespace: kube-system + spec: + chartRef: + kind: OCIRepository + name: kyverno-controller + namespace: kube-system + `, + 'test.yaml', + ); + // HelmRelease is ignored, only OCIRepository itself is processed (-> no duplicates expected) + expect(result).toEqual({ + deps: [ + { + autoReplaceStringTemplate: + '{{#if newValue}}{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}', + currentDigest: undefined, + currentValue: 'v1.8.2', + depName: 'ghcr.io/kyverno/manifests/kyverno', + packageName: 'ghcr.io/kyverno/manifests/kyverno', + datasource: DockerDatasource.id, + replaceString: 'v1.8.2', + }, + ], + }); + }); + + it('extracts HelmChart version', () => { + const result = extractPackageFile( + codeBlock` + ${fixtureHelmSource} + --- + ${fixtureHelmChart} + `, + 'test.yaml', + ); + expect(result).toEqual({ + deps: [ + { + currentValue: '2.0.2', + datasource: HelmDatasource.id, + depName: 'sealed-secrets', + registryUrls: ['https://bitnami-labs.github.io/sealed-secrets'], + }, + ], + }); + }); + + it('does not match HelmChart resources without a namespace', () => { + const result = extractPackageFile( + codeBlock` + ${fixtureHelmSource} + --- + apiVersion: source.toolkit.fluxcd.io/v1 + kind: HelmChart + metadata: + name: sealed-secrets + spec: + interval: 10m + chart: sealed-secrets + sourceRef: + kind: HelmRepository + name: sealed-secrets + version: "2.0.2" + `, + 'test.yaml', + ); + expect(result).toEqual({ + deps: [ + { + currentValue: '2.0.2', + datasource: HelmDatasource.id, + depName: 'sealed-secrets', + skipReason: 'unknown-registry', + }, + ], + }); + }); + + it('ignores HelmChart resources using git sources', () => { + const result = extractPackageFile( + codeBlock` + apiVersion: source.toolkit.fluxcd.io/v1 + kind: HelmChart + metadata: + name: sealed-secrets + namespace: kube-system + spec: + interval: 10m + chart: ./helm/sealed-secrets + sourceRef: + kind: GitRepository + name: sealed-secrets + `, + 'test.yaml', + ); + expect(result).toBeNull(); + }); + + it('ignores HelmChart resources using bucket sources', () => { + const result = extractPackageFile( + codeBlock` + apiVersion: source.toolkit.fluxcd.io/v1 + kind: Bucket + metadata: + name: sealed-secrets + namespace: kube-system + spec: + interval: 5m0s + endpoint: sealed-secrets.example.com + bucketName: example + --- + apiVersion: source.toolkit.fluxcd.io/v1 + kind: HelmChart + metadata: + name: sealed-secrets + namespace: kube-system + spec: + interval: 10m + chart: ./helm/sealed-secrets + sourceRef: + kind: Bucket + name: sealed-secrets + `, + 'test.yaml', + ); + expect(result).toEqual({ + deps: [ + { + depName: './helm/sealed-secrets', + skipReason: 'unsupported-datasource', + }, + ], + }); + }); + it('ignores GitRepository without a tag nor a commit', () => { const result = extractPackageFile( codeBlock` @@ -897,5 +1092,26 @@ describe('modules/manager/flux/extract', () => { ]); expect(result).toBeNull(); }); + + it('should pick correct package file when using HelmRepository with chartRef', async () => { + const result = await extractAllPackageFiles(config, [ + 'lib/modules/manager/flux/__fixtures__/helmChartRefRelease.yaml', + 'lib/modules/manager/flux/__fixtures__/helmChart.yaml', + 'lib/modules/manager/flux/__fixtures__/helmSource.yaml', + ]); + expect(result).toEqual([ + { + deps: [ + { + currentValue: '2.0.2', + datasource: HelmDatasource.id, + depName: 'sealed-secrets', + registryUrls: ['https://bitnami-labs.github.io/sealed-secrets'], + }, + ], + packageFile: 'lib/modules/manager/flux/__fixtures__/helmChart.yaml', + }, + ]); + }); }); }); diff --git a/lib/modules/manager/flux/extract.ts b/lib/modules/manager/flux/extract.ts index 85a0a13b92421f6e087b5d1a0e410a9ab36184f9..cce6fd742216f37cb1822314545029a39d0735e4 100644 --- a/lib/modules/manager/flux/extract.ts +++ b/lib/modules/manager/flux/extract.ts @@ -164,6 +164,17 @@ function resolveResourceManifest( for (const resource of manifest.resources) { switch (resource.kind) { case 'HelmRelease': { + if (resource.spec.chartRef) { + logger.trace( + 'HelmRelease using chartRef was found, skipping as version will be handled via referenced resource directly', + ); + continue; + } + if (!resource.spec.chart) { + logger.debug('invalid or incomplete HelmRelease spec, skipping'); + continue; + } + const chartSpec = resource.spec.chart.spec; const depName = chartSpec.chart; const dep: PackageDependency = { @@ -194,6 +205,37 @@ function resolveResourceManifest( } break; } + + case 'HelmChart': { + if (resource.spec.sourceRef.kind === 'GitRepository') { + logger.trace( + 'HelmChart using GitRepository was found, skipping as version will be handled via referenced resource directly', + ); + continue; + } + + const dep: PackageDependency = { + depName: resource.spec.chart, + }; + + if (resource.spec.sourceRef.kind === 'HelmRepository') { + dep.currentValue = resource.spec.version; + dep.datasource = HelmDatasource.id; + + const matchingRepositories = helmRepositories.filter( + (rep) => + rep.kind === resource.spec.sourceRef?.kind && + rep.metadata.name === resource.spec.sourceRef.name && + rep.metadata.namespace === resource.metadata?.namespace, + ); + resolveHelmRepository(dep, matchingRepositories, registryAliases); + } else { + dep.skipReason = 'unsupported-datasource'; + } + deps.push(dep); + break; + } + case 'GitRepository': { const dep: PackageDependency = { depName: resource.metadata.name, diff --git a/lib/modules/manager/flux/readme.md b/lib/modules/manager/flux/readme.md index 508331962138f00f928922acb0f62f2f6a283156..bf23458a8401bc22f0cc728f1f707dd97aad5c77 100644 --- a/lib/modules/manager/flux/readme.md +++ b/lib/modules/manager/flux/readme.md @@ -10,13 +10,15 @@ This manager parses [Flux](https://fluxcd.io/) YAML manifests and supports: Extracts `helm` dependencies from `HelmRelease` resources. The `flux` manager extracts `helm` dependencies for `HelmRelease` resources linked to `HelmRepository` or `GitRepository` sources. +`HelmRepository` resources can be referenced via `spec.chart` or indirectly via a `HelmChart` when +using [`spec.chartRef`](https://fluxcd.io/flux/components/helm/helmreleases/#chart-reference). Renovate supports OCI `HelmRepository` sources, those with `type: oci`. Renovate will then extract the `docker` dependencies for the referenced `HelmRelease` resources. In addition, for the `flux` manager to properly link `HelmRelease` and `HelmRepository` resources, _both_ of the following conditions must be met: -1. The `HelmRelease` resource must either have its `metadata.namespace` property set or its `spec.chart.spec.sourceRef.namespace` property set -2. The referenced `HelmRepository` resource must have its `metadata.namespace` property set +1. The `HelmRelease` resource must either have its `metadata.namespace` property set or its `spec.chart.spec.sourceRef.namespace` property (when not using `chartRef`) set +2. The referenced `HelmRepository` and `HelmChart` (when using `chartRef`) resources must have their `metadata.namespace` property set Namespaces will not be inferred from the context (e.g. from the parent `Kustomization`). diff --git a/lib/modules/manager/flux/schema.ts b/lib/modules/manager/flux/schema.ts index d76ff8fb5b2222a53f3181dc3098c42c86581ef7..87535be52551333780eb053e6c693de583c33273 100644 --- a/lib/modules/manager/flux/schema.ts +++ b/lib/modules/manager/flux/schema.ts @@ -16,22 +16,32 @@ export const HelmRelease = KubernetesResource.extend({ apiVersion: z.string().startsWith('helm.toolkit.fluxcd.io/'), kind: z.literal('HelmRelease'), spec: z.object({ - chart: z.object({ - spec: z.object({ - chart: z.string(), - version: z.string().optional(), - sourceRef: z - .object({ - kind: z.string().optional(), - name: z.string().optional(), - namespace: z.string().optional(), - }) - .optional(), - }), - }), + chart: z + .object({ + spec: z.object({ + chart: z.string(), + version: z.string().optional(), + sourceRef: z + .object({ + kind: z.string().optional(), + name: z.string().optional(), + namespace: z.string().optional(), + }) + .optional(), + }), + }) + .optional(), + chartRef: z + .object({ + kind: z.string().optional(), + name: z.string().optional(), + namespace: z.string().optional(), + }) + .optional(), values: z.record(z.unknown()).optional(), }), }); +export type HelmRelease = z.infer<typeof HelmRelease>; export const HelmRepository = KubernetesResource.extend({ apiVersion: z.string().startsWith('source.toolkit.fluxcd.io/'), @@ -43,6 +53,20 @@ export const HelmRepository = KubernetesResource.extend({ }); export type HelmRepository = z.infer<typeof HelmRepository>; +export const HelmChart = KubernetesResource.extend({ + apiVersion: z.string().startsWith('source.toolkit.fluxcd.io/'), + kind: z.literal('HelmChart'), + spec: z.object({ + chart: z.string(), + version: z.string().optional(), + sourceRef: z.object({ + kind: z.string().optional(), + name: z.string().optional(), + }), + }), +}); +export type HelmChart = z.infer<typeof HelmChart>; + export const GitRepository = KubernetesResource.extend({ apiVersion: z.string().startsWith('source.toolkit.fluxcd.io/'), kind: z.literal('GitRepository'), @@ -89,6 +113,7 @@ export const Kustomization = KubernetesResource.extend({ }); export const FluxResource = HelmRelease.or(HelmRepository) + .or(HelmChart) .or(GitRepository) .or(OCIRepository) .or(Kustomization);