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: Flux package manager that can renovate HelmRelease manifests #13566

Merged
merged 14 commits into from
Jan 19, 2022
2 changes: 2 additions & 0 deletions lib/manager/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as depsEdn from './deps-edn';
import * as dockerCompose from './docker-compose';
import * as dockerfile from './dockerfile';
import * as droneci from './droneci';
import * as flux from './flux';
import * as gitSubmodules from './git-submodules';
import * as githubActions from './github-actions';
import * as gitlabci from './gitlabci';
Expand Down Expand Up @@ -89,6 +90,7 @@ api.set('deps-edn', depsEdn);
api.set('docker-compose', dockerCompose);
api.set('dockerfile', dockerfile);
api.set('droneci', droneci);
api.set('flux', flux);
api.set('git-submodules', gitSubmodules);
api.set('github-actions', githubActions);
api.set('gitlabci', gitlabci);
Expand Down
24 changes: 24 additions & 0 deletions lib/manager/flux/__fixtures__/multidoc.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
apiVersion: helm.toolkit.fluxcd.io/v2beta1
kind: HelmRelease
metadata:
name: external-dns
namespace: kube-system
spec:
releaseName: external-dns
chart:
spec:
chart: external-dns
sourceRef:
kind: HelmRepository
name: external-dns
version: "1.7.0"
interval: 1h0m0s
---
apiVersion: source.toolkit.fluxcd.io/v1beta1
kind: HelmRepository
metadata:
name: external-dns
namespace: kube-system
spec:
interval: 1h0m0s
url: https://kubernetes-sigs.github.io/external-dns/
24 changes: 24 additions & 0 deletions lib/manager/flux/__fixtures__/release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
apiVersion: helm.toolkit.fluxcd.io/v2beta1
kind: HelmRelease
metadata:
name: sealed-secrets
namespace: kube-system
spec:
releaseName: sealed-secrets-controller
chart:
spec:
chart: sealed-secrets
sourceRef:
kind: HelmRepository
name: sealed-secrets
namespace: kube-system
version: "2.0.2"
interval: 1h0m0s
values:
resources:
limits:
cpu: 250m
memory: 512Mi
requests:
cpu: 50m
memory: 64Mi
8 changes: 8 additions & 0 deletions lib/manager/flux/__fixtures__/source.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
apiVersion: source.toolkit.fluxcd.io/v1beta1
kind: HelmRepository
metadata:
name: sealed-secrets
namespace: kube-system
spec:
interval: 1h0m0s
url: https://bitnami-labs.github.io/sealed-secrets
34 changes: 34 additions & 0 deletions lib/manager/flux/__snapshots__/extract.spec.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`manager/flux/extract extractAllPackageFiles() extracts multiple files 1`] = `
Array [
Object {
"datasource": "helm",
"deps": Array [
Object {
"currentValue": "2.0.2",
"depName": "sealed-secrets",
"registryUrls": Array [
"https://bitnami-labs.github.io/sealed-secrets",
],
},
],
"packageFile": "lib/manager/flux/__fixtures__/release.yaml",
},
]
`;

exports[`manager/flux/extract extractPackageFile() extracts multiple resources 1`] = `
Object {
"datasource": "helm",
"deps": Array [
Object {
"currentValue": "1.7.0",
"depName": "external-dns",
"registryUrls": Array [
"https://kubernetes-sigs.github.io/external-dns/",
],
},
],
}
`;
51 changes: 51 additions & 0 deletions lib/manager/flux/extract.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { loadFixture } from '../../../test/util';
import { GlobalConfig } from '../../config/global';
import type { RepoGlobalConfig } from '../../config/types';
import { SkipReason } from '../../types';
import type { ExtractConfig } from '../types';
import { extractAllPackageFiles, extractPackageFile } from './extract';
danports marked this conversation as resolved.
Show resolved Hide resolved

const config: ExtractConfig = {};
const adminConfig: RepoGlobalConfig = { localDir: '' };

describe('manager/flux/extract', () => {
beforeEach(() => {
GlobalConfig.set(adminConfig);
});

afterEach(() => {
GlobalConfig.reset();
});

danports marked this conversation as resolved.
Show resolved Hide resolved
describe('extractPackageFile()', () => {
it('extracts multiple resources', () => {
const result = extractPackageFile(loadFixture('multidoc.yaml'));
expect(result).toMatchSnapshot();
danports marked this conversation as resolved.
Show resolved Hide resolved
});
it('extracts releases without repositories', () => {
const result = extractPackageFile(loadFixture('release.yaml'));
expect(result.deps[0].skipReason).toBe(SkipReason.UnknownRegistry);
});
it('ignores bad manifests', () => {
const result = extractPackageFile('"bad YAML');
expect(result).toBeNull();
});
});

describe('extractAllPackageFiles()', () => {
it('extracts multiple files', async () => {
const result = await extractAllPackageFiles(config, [
'lib/manager/flux/__fixtures__/release.yaml',
'lib/manager/flux/__fixtures__/source.yaml',
]);
expect(result).toMatchSnapshot();
danports marked this conversation as resolved.
Show resolved Hide resolved
expect(result).toHaveLength(1);
});
it('ignores files that do not exist', async () => {
const result = await extractAllPackageFiles(config, [
'lib/manager/flux/__fixtures__/bogus.yaml',
]);
expect(result).toBeNull();
});
});
});
116 changes: 116 additions & 0 deletions lib/manager/flux/extract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { loadAll } from 'js-yaml';
import { HelmDatasource } from '../../datasource/helm';
import { logger } from '../../logger';
import { SkipReason } from '../../types';
import { readLocalFile } from '../../util/fs';
import type { ExtractConfig, PackageDependency, PackageFile } from '../types';
import type {
FluxManifest,
FluxResource,
HelmRelease,
HelmRepository,
} from './types';

function readManifest(content: string): FluxManifest {
danports marked this conversation as resolved.
Show resolved Hide resolved
const manifest: FluxManifest = { releases: [], repositories: [] };
let resources: FluxResource[];
try {
resources = loadAll(content, null, { json: true }) as FluxResource[];
} catch (err) {
logger.debug({ err }, 'Failed to parse Flux manifest');
return null;
}

// It's possible there are other non-Flux HelmRelease/HelmRepository CRs out there, so we filter based on apiVersion.
for (const resource of resources) {
danports marked this conversation as resolved.
Show resolved Hide resolved
switch (resource.kind) {
danports marked this conversation as resolved.
Show resolved Hide resolved
case 'HelmRelease':
if (resource.apiVersion.startsWith('helm.toolkit.fluxcd.io/')) {
danports marked this conversation as resolved.
Show resolved Hide resolved
manifest.releases.push(resource);
}
break;
case 'HelmRepository':
if (resource.apiVersion.startsWith('source.toolkit.fluxcd.io/')) {
danports marked this conversation as resolved.
Show resolved Hide resolved
manifest.repositories.push(resource);
}
break;
}
}

return manifest;
}

function resolveReleases(
releases: HelmRelease[],
repositories: HelmRepository[]
): PackageDependency[] {
return releases.map((release) => {
const res: PackageDependency = {
depName: release.spec.chart.spec.chart,
currentValue: release.spec.chart.spec.version,
danports marked this conversation as resolved.
Show resolved Hide resolved
};

const matchingRepositories = repositories.filter(
(rep) =>
rep.kind === release.spec.chart.spec.sourceRef.kind &&
rep.metadata.name === release.spec.chart.spec.sourceRef.name &&
rep.metadata.namespace ===
(release.spec.chart.spec.sourceRef.namespace ||
release.metadata.namespace)
);
if (matchingRepositories.length) {
res.registryUrls = matchingRepositories.map((repo) => repo.spec.url);
} else {
// TODO: Not sure if this is the right SkipReason.
res.skipReason = SkipReason.UnknownRegistry;
}

return res;
});
}

export function extractPackageFile(content: string): PackageFile | null {
const manifest = readManifest(content);
if (!manifest) {
return null;
}
const deps = resolveReleases(manifest.releases, manifest.repositories);
return deps.length ? { deps: deps, datasource: HelmDatasource.id } : null;
}

export async function extractAllPackageFiles(
danports marked this conversation as resolved.
Show resolved Hide resolved
_config: ExtractConfig,
packageFiles: string[]
): Promise<PackageFile[] | null> {
const releases = new Map<string, HelmRelease[]>();
const repositories: HelmRepository[] = [];
const results: PackageFile[] = [];

for (const file of packageFiles) {
const content = await readLocalFile(file, 'utf8');
if (!content) {
logger.debug({ file }, 'Empty or non existent Flux manifest');
danports marked this conversation as resolved.
Show resolved Hide resolved
continue;
}

const manifest = readManifest(content);
if (manifest) {
releases.set(file, manifest.releases);
repositories.push(...manifest.repositories);
}
}

for (const file of releases) {
const deps = resolveReleases(file[1], repositories);
if (deps.length) {
results.push({
packageFile: file[0],
deps: deps,
datasource: HelmDatasource.id,
});
}
}

logger.debug({ packageFiles }, 'extracted all Flux manifests');
danports marked this conversation as resolved.
Show resolved Hide resolved
return results.length ? results : null;
}
7 changes: 7 additions & 0 deletions lib/manager/flux/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { extractAllPackageFiles, extractPackageFile } from './extract';
danports marked this conversation as resolved.
Show resolved Hide resolved

export { extractAllPackageFiles, extractPackageFile };

danports marked this conversation as resolved.
Show resolved Hide resolved
export const defaultConfig = {
fileMatch: [],
};
29 changes: 29 additions & 0 deletions lib/manager/flux/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
Checks YAML manifests for [Flux](https://fluxcd.io/) `HelmRelease` resources and extracts dependencies for the `helm` datasource.

Note that the `flux` manager will only extract `HelmRelease` resources linked to `HelmRepository` sources. `HelmRelease` resources linked to other kinds of sources like `GitRepository` or `Bucket` will be ignored.
danports marked this conversation as resolved.
Show resolved Hide resolved

Also, for the `flux` manager to properly link `HelmRelease` and `HelmRepository` resources, both must have their `namespace` explicitly set in their `metadata`. Dependencies will not be extracted from resources where `metadata.namespace` is omitted. (However, the namespace can be omitted from the `HelmRelease` resource's `spec.chart.spec.sourceRef` property.)
danports marked this conversation as resolved.
Show resolved Hide resolved

The `flux` 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 Flux manifests and we don't want to check every single `*.yaml` file in repositories just in case any of them contain Flux definitions.
danports marked this conversation as resolved.
Show resolved Hide resolved

If most `.yaml` files in your repository are Flux manifests, then you could add this to your config:

```json
{
"flux": {
"fileMatch": ["\\.yaml$"]
}
}
```

If instead you have them all inside a `flux/` directory, you would add this:
danports marked this conversation as resolved.
Show resolved Hide resolved

```json
{
"flux": {
"fileMatch": ["flux/.+\\.yaml$"]
}
}
```

If you need to change the versioning format, read the [versioning](https://docs.renovatebot.com/modules/versioning/) documentation to learn more.
41 changes: 41 additions & 0 deletions lib/manager/flux/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export interface KubernetesResource {
apiVersion: string;
metadata: {
name: string;
// For Flux, the namespace property is optional, but matching HelmReleases to HelmRepositories would be
// much more difficult without it (we'd have to examine the parent Kustomizations to discover the value),
// so we require it for renovation.
namespace: string;
};
}

export interface HelmRelease extends KubernetesResource {
kind: 'HelmRelease';
spec: {
chart: {
spec: {
chart: string;
sourceRef: {
kind: string;
name: string;
namespace?: string;
};
version?: string;
};
};
};
}

export interface HelmRepository extends KubernetesResource {
kind: 'HelmRepository';
spec: {
url: string;
};
}

export type FluxResource = HelmRelease | HelmRepository;

export interface FluxManifest {
releases: HelmRelease[];
repositories: HelmRepository[];
}