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
203 changes: 203 additions & 0 deletions lib/manager/flux/extract.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
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 '.';

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

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

describe('extractPackageFile()', () => {
it('extracts multiple resources', () => {
const result = extractPackageFile(loadFixture('multidoc.yaml'));
expect(result).toEqual({
datasource: 'helm',
deps: [
{
currentValue: '1.7.0',
depName: 'external-dns',
registryUrls: ['https://kubernetes-sigs.github.io/external-dns/'],
},
],
});
});
it('extracts releases without repositories', () => {
const result = extractPackageFile(loadFixture('release.yaml'));
expect(result.deps[0].skipReason).toBe(SkipReason.UnknownRegistry);
});
it('ignores HelmRelease resources without an apiVersion', () => {
const result = extractPackageFile('kind: HelmRelease');
expect(result).toBeNull();
});
it('ignores HelmRepository resources without an apiVersion', () => {
const result = extractPackageFile('kind: HelmRepository');
expect(result).toBeNull();
});
it('ignores HelmRepository resources without metadata', () => {
const result = extractPackageFile(
`${loadFixture('release.yaml')}
---
apiVersion: source.toolkit.fluxcd.io/v1beta1
kind: HelmRepository
`
);
expect(result.deps[0].skipReason).toBe(SkipReason.UnknownRegistry);
});
it('ignores HelmRelease resources without a chart name', () => {
const result = extractPackageFile(
`apiVersion: helm.toolkit.fluxcd.io/v2beta1
kind: HelmRelease
metadata:
name: sealed-secrets
namespace: kube-system
spec:
chart:
spec:
sourceRef:
kind: HelmRepository
name: sealed-secrets
version: "2.0.2"
`
);
expect(result).toBeNull();
});
it('does not match HelmRelease resources without a namespace to HelmRepository resources without a namespace', () => {
const result = extractPackageFile(
`apiVersion: source.toolkit.fluxcd.io/v1beta1
kind: HelmRepository
metadata:
name: sealed-secrets
spec:
url: https://bitnami-labs.github.io/sealed-secrets
---
apiVersion: helm.toolkit.fluxcd.io/v2beta1
kind: HelmRelease
spec:
chart:
spec:
chart: sealed-secrets
sourceRef:
kind: HelmRepository
name: sealed-secrets
version: "2.0.2"
`
);
expect(result.deps[0].skipReason).toBe(SkipReason.UnknownRegistry);
});
it('does not match HelmRelease resources without a sourceRef', () => {
const result = extractPackageFile(
`${loadFixture('source.yaml')}
---
apiVersion: helm.toolkit.fluxcd.io/v2beta1
kind: HelmRelease
metadata:
name: sealed-secrets
spec:
chart:
spec:
chart: sealed-secrets
version: "2.0.2"
`
);
expect(result.deps[0].skipReason).toBe(SkipReason.UnknownRegistry);
});
it('does not match HelmRelease resources without a namespace', () => {
const result = extractPackageFile(
`${loadFixture('source.yaml')}
---
apiVersion: helm.toolkit.fluxcd.io/v2beta1
kind: HelmRelease
spec:
chart:
spec:
chart: sealed-secrets
sourceRef:
kind: HelmRepository
name: sealed-secrets
version: "2.0.2"
`
);
expect(result.deps[0].skipReason).toBe(SkipReason.UnknownRegistry);
});
it('ignores HelmRepository resources without a namespace', () => {
const result = extractPackageFile(
`${loadFixture('release.yaml')}
---
apiVersion: source.toolkit.fluxcd.io/v1beta1
kind: HelmRepository
metadata:
name: test
`
);
expect(result.deps[0].skipReason).toBe(SkipReason.UnknownRegistry);
});
it('ignores HelmRepository resources without a URL', () => {
const result = extractPackageFile(
`${loadFixture('release.yaml')}
---
apiVersion: source.toolkit.fluxcd.io/v1beta1
kind: HelmRepository
metadata:
name: sealed-secrets
namespace: kube-system
`
);
expect(result.deps[0].skipReason).toBe(SkipReason.UnknownRegistry);
});
it('ignores resources of an unknown kind', () => {
const result = extractPackageFile(
`kind: SomethingElse
apiVersion: helm.toolkit.fluxcd.io/v2beta1`
);
expect(result).toBeNull();
});
it('ignores resources without a kind', () => {
const result = extractPackageFile(
'apiVersion: helm.toolkit.fluxcd.io/v2beta1'
);
expect(result).toBeNull();
});
it('ignores bad manifests', () => {
const result = extractPackageFile('"bad YAML');
expect(result).toBeNull();
});
it('ignores null resources', () => {
const result = extractPackageFile('null');
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).toEqual([
{
datasource: 'helm',
deps: [
{
depName: 'sealed-secrets',
currentValue: '2.0.2',
registryUrls: ['https://bitnami-labs.github.io/sealed-secrets'],
},
],
packageFile: 'lib/manager/flux/__fixtures__/release.yaml',
},
]);
});
it('ignores files that do not exist', async () => {
const result = await extractAllPackageFiles(config, [
'lib/manager/flux/__fixtures__/bogus.yaml',
]);
expect(result).toBeNull();
});
});
});
117 changes: 117 additions & 0 deletions lib/manager/flux/extract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
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 | null {
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/') &&
viceice marked this conversation as resolved.
Show resolved Hide resolved
resource.spec?.chart?.spec?.chart
) {
manifest.releases.push(resource);
}
break;
case 'HelmRepository':
if (
resource.apiVersion?.startsWith('source.toolkit.fluxcd.io/') &&
resource.metadata?.name &&
resource.metadata.namespace &&
resource.spec?.url
) {
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 {
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');
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,
});
}
}

return results.length ? results : null;
}
Loading