From a4a0f331d181b74d7c3a8c1b46724757be17a9f0 Mon Sep 17 00:00:00 2001 From: thameezbo Date: Thu, 20 Jul 2023 12:35:08 +0200 Subject: [PATCH] feat(stages/bakeManifests): add helmfile support (#9998) Co-authored-by: Jason --- packages/core/src/help/help.contents.ts | 5 + .../bakeManifest/BakeManifestConfig.tsx | 5 +- .../bakeManifest/BakeManifestStageForm.tsx | 11 +- .../stages/bakeManifest/ManifestRenderers.ts | 2 + .../helmfile/BakeHelmfileConfigForm.spec.tsx | 132 +++++++++ .../helmfile/BakeHelmfileConfigForm.tsx | 271 ++++++++++++++++++ 6 files changed, 423 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/pipeline/config/stages/bakeManifest/helmfile/BakeHelmfileConfigForm.spec.tsx create mode 100644 packages/core/src/pipeline/config/stages/bakeManifest/helmfile/BakeHelmfileConfigForm.tsx diff --git a/packages/core/src/help/help.contents.ts b/packages/core/src/help/help.contents.ts index f0e43b269b7..fb220a47fde 100644 --- a/packages/core/src/help/help.contents.ts +++ b/packages/core/src/help/help.contents.ts @@ -312,6 +312,11 @@ const helpContents: { [key: string]: string } = { 'pipeline.config.bake.manifest.kustomize.filePath': `

This is the relative path to the kustomization.yaml file within your Git repo.

e.g.: examples/wordpress/mysql/kustomization.yaml

`, + 'pipeline.config.bake.manifest.helmfile.filePath': ` +

This is the relative path to the directory containing the helmfile.yaml file within your Git repo.

+

e.g.: chart/helmfile.yml

`, + 'pipeline.config.bake.manifest.helmfile.name': + '

Name is used to set the expected artifact in the Produces Artifact section.

', 'pipeline.config.bake.cf.manifest.name': '

Name should be the same as the expected artifact in the Produces Artifact section.

', 'pipeline.config.bake.cf.manifest.templateArtifact': ` diff --git a/packages/core/src/pipeline/config/stages/bakeManifest/BakeManifestConfig.tsx b/packages/core/src/pipeline/config/stages/bakeManifest/BakeManifestConfig.tsx index afc34ef8c76..fda2fadfef9 100644 --- a/packages/core/src/pipeline/config/stages/bakeManifest/BakeManifestConfig.tsx +++ b/packages/core/src/pipeline/config/stages/bakeManifest/BakeManifestConfig.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { BakeManifestStageForm, validateProducedArtifacts } from './BakeManifestStageForm'; import { FormikStageConfig } from '../FormikStageConfig'; -import { HELM_RENDERERS } from './ManifestRenderers'; +import { HELM_RENDERERS, HELMFILE_RENDERER } from './ManifestRenderers'; import type { IStageConfigProps } from '../common'; import type { IExpectedArtifact, IStage } from '../../../../domain'; import { FormValidator } from '../../../../presentation'; @@ -46,6 +46,9 @@ export function validateBakeManifestStage(stage: IStage): FormikErrors { if (HELM_RENDERERS.includes(stage.templateRenderer)) { formValidator.field('outputName', 'Name').required(); } + if (HELMFILE_RENDERER === stage.templateRenderer) { + formValidator.field('outputName', 'Name').required(); + } return formValidator.validateForm(); } diff --git a/packages/core/src/pipeline/config/stages/bakeManifest/BakeManifestStageForm.tsx b/packages/core/src/pipeline/config/stages/bakeManifest/BakeManifestStageForm.tsx index 9da513a5af4..623f17b4677 100644 --- a/packages/core/src/pipeline/config/stages/bakeManifest/BakeManifestStageForm.tsx +++ b/packages/core/src/pipeline/config/stages/bakeManifest/BakeManifestStageForm.tsx @@ -2,11 +2,12 @@ import { isNil } from 'lodash'; import React from 'react'; import type { IFormikStageConfigInjectedProps } from '../FormikStageConfig'; -import { HELM_RENDERERS, KUSTOMIZE_RENDERERS } from './ManifestRenderers'; +import { HELM_RENDERERS, HELMFILE_RENDERER, KUSTOMIZE_RENDERERS } from './ManifestRenderers'; import { ExpectedArtifactService } from '../../../../artifact'; import { StageConfigField } from '../common'; import type { IExpectedArtifact } from '../../../../domain'; import { BakeHelmConfigForm } from './helm/BakeHelmConfigForm'; +import { BakeHelmfileConfigForm } from './helmfile/BakeHelmfileConfigForm'; import { BakeKustomizeConfigForm } from './kustomize/BakeKustomizeConfigForm'; import { ReactSelectInput } from '../../../../presentation'; import { BASE_64_ARTIFACT_ACCOUNT, BASE_64_ARTIFACT_TYPE } from '../../triggers/artifacts/base64/Base64ArtifactEditor'; @@ -32,10 +33,13 @@ export function BakeManifestStageForm({ application, formik, pipeline }: IFormik if (HELM_RENDERERS.includes(stage.templateRenderer) && !isNil(stage.inputArtifact)) { formik.setFieldValue('inputArtifact', null); } + if (HELMFILE_RENDERER === stage.templateRenderer && !isNil(stage.inputArtifact)) { + formik.setFieldValue('inputArtifact', null); + } }, [stage.templateRenderer]); const templateRenderers = React.useMemo(() => { - return [...KUSTOMIZE_RENDERERS, ...HELM_RENDERERS]; + return [...KUSTOMIZE_RENDERERS, ...HELM_RENDERERS, HELMFILE_RENDERER]; }, []); return ( @@ -62,6 +66,9 @@ export function BakeManifestStageForm({ application, formik, pipeline }: IFormik {HELM_RENDERERS.includes(stage.templateRenderer) && ( )} + {HELMFILE_RENDERER === stage.templateRenderer && ( + + )} ); diff --git a/packages/core/src/pipeline/config/stages/bakeManifest/ManifestRenderers.ts b/packages/core/src/pipeline/config/stages/bakeManifest/ManifestRenderers.ts index 91a06ab0b59..f41d52815cd 100644 --- a/packages/core/src/pipeline/config/stages/bakeManifest/ManifestRenderers.ts +++ b/packages/core/src/pipeline/config/stages/bakeManifest/ManifestRenderers.ts @@ -1,11 +1,13 @@ export enum ManifestRenderers { HELM2 = 'HELM2', HELM3 = 'HELM3', + HELMFILE = 'HELMFILE', KUSTOMIZE = 'KUSTOMIZE', KUSTOMIZE4 = 'KUSTOMIZE4', } export const HELM_RENDERERS: Readonly = [ManifestRenderers.HELM2, ManifestRenderers.HELM3]; +export const HELMFILE_RENDERER: Readonly = ManifestRenderers.HELMFILE; export const KUSTOMIZE_RENDERERS: Readonly = [ ManifestRenderers.KUSTOMIZE, ManifestRenderers.KUSTOMIZE4, diff --git a/packages/core/src/pipeline/config/stages/bakeManifest/helmfile/BakeHelmfileConfigForm.spec.tsx b/packages/core/src/pipeline/config/stages/bakeManifest/helmfile/BakeHelmfileConfigForm.spec.tsx new file mode 100644 index 00000000000..c2746d12fb3 --- /dev/null +++ b/packages/core/src/pipeline/config/stages/bakeManifest/helmfile/BakeHelmfileConfigForm.spec.tsx @@ -0,0 +1,132 @@ +import { mock } from 'angular'; +import { mount } from 'enzyme'; +import React from 'react'; + +import { StageConfigField } from '../../../..'; +import { BakeHelmfileConfigForm } from './BakeHelmfileConfigForm'; +import { AccountService } from '../../../../../account'; +import { ApplicationModelBuilder } from '../../../../../application'; +import { ExpectedArtifactService } from '../../../../../artifact'; +import type { IExpectedArtifact, IStage } from '../../../../../domain'; +import { SpinFormik } from '../../../../../presentation'; +import { REACT_MODULE } from '../../../../../reactShims'; + +describe('', () => { + beforeEach(mock.module(REACT_MODULE)); + beforeEach(mock.inject()); + + const helmfileFilePathFieldName = 'Helmfile File Path'; + + const getProps = () => { + return { + application: ApplicationModelBuilder.createApplicationForTests('my-application'), + pipeline: { + application: 'my-application', + id: 'pipeline-id', + limitConcurrent: true, + keepWaitingPipelines: true, + name: 'My Pipeline', + parameterConfig: [], + stages: [], + triggers: [], + }, + } as any; + }; + + beforeEach(() => + spyOn(AccountService, 'getArtifactAccounts').and.returnValue( + Promise.resolve([ + { name: 'gitrepo', types: ['something-else', 'git/repo'] }, + { name: 'notgitrepo', types: ['something-else'] }, + ]), + ), + ); + + it('renders the helmfile file path element when the template artifact is from an account that handles git/repo artifacts', async () => { + const stage = ({ + inputArtifacts: [{ account: 'gitrepo' }], + } as unknown) as IStage; + + const props = getProps(); + + const component = mount( + null} + validate={() => null} + render={(formik) => } + />, + ); + + await new Promise((resolve) => setTimeout(resolve)); // wait one js tick for promise to resolve + component.setProps({}); // force a re-render + + expect(component.find(StageConfigField).findWhere((x) => x.text() === helmfileFilePathFieldName).length).toBe(1); + }); + + it('does not render the helmfile file path element when the template artifact is from an account that does not handle git/repo artifacts', async () => { + const stage = ({ + inputArtifacts: [{ account: 'notgitrepo' }], + } as unknown) as IStage; + + const props = getProps(); + + const component = mount( + null} + validate={() => null} + render={(formik) => } + />, + ); + + await new Promise((resolve) => setTimeout(resolve)); // wait one js tick for promise to resolve + component.setProps({}); // force a re-render + + expect(component.find(StageConfigField).findWhere((x) => x.text() === helmfileFilePathFieldName).length).toBe(0); + }); + + it('render the helmfile file path if the id of the git artifact is given but the account value does not exist', async () => { + const expectedArtifactDisplayName = 'test-artifact'; + const expectedArtifactId = 'test-artifact-id'; + const expectedGitArtifact: IExpectedArtifact = { + defaultArtifact: { + customKind: true, + id: 'defaultArtifact-id', + }, + displayName: expectedArtifactDisplayName, + id: expectedArtifactId, + matchArtifact: { + artifactAccount: 'gitrepo', + id: expectedArtifactId, + reference: 'git repo', + type: 'git/repo', + version: 'master', + }, + useDefaultArtifact: false, + usePriorArtifact: false, + }; + const stage = ({ + inputArtifacts: [{ id: expectedArtifactId }], + } as unknown) as IStage; + + spyOn(ExpectedArtifactService, 'getExpectedArtifactsAvailableToStage').and.returnValue([expectedGitArtifact]); + + const props = getProps(); + + const component = mount( + null} + validate={() => null} + render={(formik) => } + />, + ); + + await new Promise((resolve) => setTimeout(resolve)); // wait one js tick for promise to resolve + component.setProps({}); // force a re-render + + expect(component.find('.Select-value-label > span').text().includes(expectedArtifactDisplayName)).toBe(true); + expect(component.find(StageConfigField).findWhere((x) => x.text() === helmfileFilePathFieldName).length).toBe(1); + }); +}); diff --git a/packages/core/src/pipeline/config/stages/bakeManifest/helmfile/BakeHelmfileConfigForm.tsx b/packages/core/src/pipeline/config/stages/bakeManifest/helmfile/BakeHelmfileConfigForm.tsx new file mode 100644 index 00000000000..0f01263ce61 --- /dev/null +++ b/packages/core/src/pipeline/config/stages/bakeManifest/helmfile/BakeHelmfileConfigForm.tsx @@ -0,0 +1,271 @@ +import React from 'react'; + +import type { IFormikStageConfigInjectedProps } from '../../FormikStageConfig'; +import { AccountService } from '../../../../../account'; +import { + ArtifactTypePatterns, + excludeAllTypesExcept, + ExpectedArtifactService, + StageArtifactSelectorDelegate, +} from '../../../../../artifact'; +import { StageConfigField } from '../../common/stageConfigField/StageConfigField'; +import type { IArtifact, IExpectedArtifact } from '../../../../../domain'; +import { MapEditor } from '../../../../../forms'; +import { CheckboxInput, TextInput } from '../../../../../presentation'; + +export interface IBakeHelmfileConfigFormState { + gitRepoArtifactAccountNames: string[]; +} + +export class BakeHelmfileConfigForm extends React.Component< + IFormikStageConfigInjectedProps, + IBakeHelmfileConfigFormState +> { + constructor(props: IFormikStageConfigInjectedProps) { + super(props); + this.state = { gitRepoArtifactAccountNames: [] }; + } + + private static readonly excludedArtifactTypes = excludeAllTypesExcept( + ArtifactTypePatterns.BITBUCKET_FILE, + ArtifactTypePatterns.CUSTOM_OBJECT, + ArtifactTypePatterns.EMBEDDED_BASE64, + ArtifactTypePatterns.GCS_OBJECT, + ArtifactTypePatterns.GIT_REPO, + ArtifactTypePatterns.GITHUB_FILE, + ArtifactTypePatterns.GITLAB_FILE, + ArtifactTypePatterns.S3_OBJECT, + ArtifactTypePatterns.HELM_CHART, + ArtifactTypePatterns.HTTP_FILE, + ArtifactTypePatterns.ORACLE_OBJECT, + ); + + public componentDidMount() { + const stage = this.props.formik.values; + if (stage.inputArtifacts && stage.inputArtifacts.length === 0) { + this.props.formik.setFieldValue('inputArtifacts', [ + { + account: '', + id: '', + }, + ]); + } + + // If the Expected Artifact id is provided but the account is not, then attempt to find the artifact from + // upstream stages and set the account value. + // This is needed because helmfile file path field will need to be rendered if the artifact has a git repo account type + const expectedArtifact = this.getInputArtifact(stage, 0); + if (expectedArtifact.id && !expectedArtifact.account) { + const availableArtifacts = ExpectedArtifactService.getExpectedArtifactsAvailableToStage( + stage, + this.props.pipeline, + ); + const expectedMatchedArtifact = availableArtifacts.find((a) => a.id === expectedArtifact.id); + if (expectedMatchedArtifact && expectedMatchedArtifact.matchArtifact) { + this.props.formik.setFieldValue( + `inputArtifacts[0].account`, + expectedMatchedArtifact.matchArtifact.artifactAccount, + ); + } + } + + AccountService.getArtifactAccounts().then((artifactAccounts) => { + this.setState({ + gitRepoArtifactAccountNames: artifactAccounts + .filter((account) => account.types.some((type) => ArtifactTypePatterns.GIT_REPO.test(type))) + .map((account) => account.name), + }); + }); + } + + private onTemplateArtifactEdited = (artifact: IArtifact, index: number) => { + this.props.formik.setFieldValue(`inputArtifacts[${index}].id`, null); + this.props.formik.setFieldValue(`inputArtifacts[${index}].artifact`, artifact); + this.props.formik.setFieldValue(`inputArtifacts[${index}].account`, artifact.artifactAccount); + }; + + private onTemplateArtifactSelected = (artifact: IExpectedArtifact, index: number) => { + this.props.formik.setFieldValue(`inputArtifacts[${index}].id`, artifact.id); + this.props.formik.setFieldValue(`inputArtifacts[${index}].artifact`, null); + // Set the account to matchArtifact.artifactAccount if it exists. + // This account value will be used to determine if the Helm Chart File Path should be displayed. + if (artifact.matchArtifact) { + this.props.formik.setFieldValue(`inputArtifacts[${index}].account`, artifact.matchArtifact.artifactAccount); + } else { + this.props.formik.setFieldValue(`inputArtifacts[${index}].account`, null); + } + }; + + private addInputArtifact = () => { + const stage = this.props.formik.values; + const newInputArtifacts = [ + ...stage.inputArtifacts, + { + account: '', + id: '', + }, + ]; + + this.props.formik.setFieldValue('inputArtifacts', newInputArtifacts); + }; + + private removeInputArtifact = (index: number) => { + const stage = this.props.formik.values; + const newInputArtifacts = [...stage.inputArtifacts]; + newInputArtifacts.splice(index, 1); + this.props.formik.setFieldValue('inputArtifacts', newInputArtifacts); + }; + + private getInputArtifact = (stage: any, index: number) => { + if (!stage.inputArtifacts || stage.inputArtifacts.length === 0) { + return { + account: '', + id: '', + }; + } else { + return stage.inputArtifacts[index]; + } + }; + + private outputNameChange = (outputName: string) => { + const stage = this.props.formik.values; + const expectedArtifacts = stage.expectedArtifacts; + if ( + expectedArtifacts && + expectedArtifacts.length === 1 && + expectedArtifacts[0].matchArtifact && + expectedArtifacts[0].matchArtifact.type === 'embedded/base64' + ) { + this.props.formik.setFieldValue('expectedArtifacts', [ + { + ...expectedArtifacts[0], + matchArtifact: { + ...expectedArtifacts[0].matchArtifact, + name: outputName, + }, + }, + ]); + } + }; + + private overrideChanged = (overrides: any) => { + this.props.formik.setFieldValue('overrides', overrides); + }; + + public render() { + const stage = this.props.formik.values; + return ( + <> +

Helmfile Options

+ + ) => { + this.props.formik.setFieldValue('outputName', e.target.value); + this.outputNameChange(e.target.value); + }} + value={stage.outputName} + /> + +

Template Artifact

+ { + this.onTemplateArtifactEdited(artifact, 0); + }} + onExpectedArtifactSelected={(artifact: IExpectedArtifact) => this.onTemplateArtifactSelected(artifact, 0)} + pipeline={this.props.pipeline} + stage={stage} + /> + {this.state.gitRepoArtifactAccountNames.includes(this.getInputArtifact(stage, 0).account) && ( + + ) => { + this.props.formik.setFieldValue('helmfileFilePath', e.target.value); + }} + value={stage.helmfileFilePath} + /> + + )} +

Overrides

+ {stage.inputArtifacts && stage.inputArtifacts.length > 1 && ( +
+ {stage.inputArtifacts.slice(1).map((a: any, index: number) => { + return ( +
+
+ { + this.onTemplateArtifactEdited(artifact, index + 1); + }} + onExpectedArtifactSelected={(artifact: IExpectedArtifact) => + this.onTemplateArtifactSelected(artifact, index + 1) + } + pipeline={this.props.pipeline} + stage={stage} + /> +
+
+
+ +
+
+
+ ); + })} +
+ )} + + + + + {stage.overrides && ( + this.overrideChanged(o)} + /> + )} + + + this.props.formik.setFieldValue('includeCRDs', !stage.includeCRDs)} + /> + + + + this.props.formik.setFieldValue('evaluateOverrideExpressions', !stage.evaluateOverrideExpressions) + } + /> + + + ); + } +}