From fdf1db5fdc57ed0908fb81b426f89f3811df2c2f Mon Sep 17 00:00:00 2001 From: Abhi Date: Wed, 15 Jan 2025 20:00:40 +0530 Subject: [PATCH] feat(secret): add source secrets --- src/components/Secrets/SecretForm.tsx | 37 +++++--- src/components/Secrets/SecretModal.tsx | 14 ++- .../Secrets/__tests___/SecretModal.spec.tsx | 2 +- .../__tests___/SecretTypeSelector.spec.tsx | 2 +- src/types/secret.ts | 20 +++-- src/utils/__tests__/create-utils.spec.ts | 90 +++++++++++++++++-- src/utils/create-utils.ts | 61 +++++++++---- src/utils/validation-utils.ts | 46 ++++++++-- 8 files changed, 228 insertions(+), 44 deletions(-) diff --git a/src/components/Secrets/SecretForm.tsx b/src/components/Secrets/SecretForm.tsx index 543ae307..ec21a9c9 100644 --- a/src/components/Secrets/SecretForm.tsx +++ b/src/components/Secrets/SecretForm.tsx @@ -7,6 +7,7 @@ import KeyValueFileInputField from '../../shared/components/formik-fields/key-va import SelectInputField from '../../shared/components/formik-fields/SelectInputField'; import { SecretFormValues, SecretTypeDropdownLabel } from '../../types'; import { RawComponentProps } from '../modal/createModalLauncher'; +import { SourceSecretForm } from './SecretsForm/SourceSecretForm'; import SecretTypeSelector from './SecretTypeSelector'; import { getSupportedPartnerTaskKeyValuePairs, @@ -30,13 +31,13 @@ const SecretForm: React.FC> = ({ existi const currentTypeRef = React.useRef(values.type); const clearKeyValues = () => { - const newKeyValues = values.keyValues.filter((kv) => !kv.readOnlyKey); + const newKeyValues = values.opaque.keyValues.filter((kv) => !kv.readOnlyKey); void setFieldValue('keyValues', [...(newKeyValues.length ? newKeyValues : defaultKeyValues)]); }; const resetKeyValues = () => { setOptions([]); - const newKeyValues = values.keyValues.filter( + const newKeyValues = values.opaque.keyValues.filter( (kv) => !kv.readOnlyKey && (!!kv.key || !!kv.value), ); void setFieldValue('keyValues', [...newKeyValues, ...defaultImageKeyValues]); @@ -44,12 +45,14 @@ const SecretForm: React.FC> = ({ existi const dropdownItems: DropdownItemObject[] = Object.entries(SecretTypeDropdownLabel).reduce( (acc, [key, value]) => { - value !== SecretTypeDropdownLabel.source && acc.push({ key, value }); + acc.push({ key, value }); return acc; }, [], ); + const currentType = currentTypeRef.current; + return (
> = ({ existi values.secretName && isPartnerTask(values.secretName) && void setFieldValue('secretName', ''); + } + if (type === SecretTypeDropdownLabel.source) { + resetKeyValues(); + values.secretName && + isPartnerTask(values.secretName) && + void setFieldValue('secretName', ''); } else { setOptions(initialOptions); clearKeyValues(); @@ -88,19 +97,27 @@ const SecretForm: React.FC> = ({ existi onSelect={(_, value: string) => { if (isPartnerTask(value)) { void setFieldValue('keyValues', [ - ...values.keyValues.filter((kv) => !kv.readOnlyKey && (!!kv.key || !!kv.value)), + ...values.opaque.keyValues.filter( + (kv) => !kv.readOnlyKey && (!!kv.key || !!kv.value), + ), ...getSupportedPartnerTaskKeyValuePairs(value), ]); } void setFieldValue('secretName', value); }} /> - + {currentType === SecretTypeDropdownLabel.source && } + {currentType !== SecretTypeDropdownLabel.source && ( + + )} ); }; diff --git a/src/components/Secrets/SecretModal.tsx b/src/components/Secrets/SecretModal.tsx index 0ac79dfc..608df917 100644 --- a/src/components/Secrets/SecretModal.tsx +++ b/src/components/Secrets/SecretModal.tsx @@ -8,7 +8,8 @@ import { ModalVariant, } from '@patternfly/react-core'; import { Formik } from 'formik'; -import { ImportSecret, SecretTypeDropdownLabel } from '../../types'; +import { isEmpty } from 'lodash-es'; +import { ImportSecret, SecretTypeDropdownLabel, SourceSecretType } from '../../types'; import { SecretFromSchema } from '../../utils/validation-utils'; import { RawComponentProps } from '../modal/createModalLauncher'; import SecretForm from './SecretForm'; @@ -42,7 +43,15 @@ const SecretModal: React.FC> = ({ const initialValues: SecretModalValues = { secretName: '', type: SecretTypeDropdownLabel.opaque, - keyValues: defaultKeyValues, + opaque: { + keyValues: defaultKeyValues, + }, + image: { + keyValues: defaultKeyValues, + }, + source: { + authType: SourceSecretType.basic, + }, existingSecrets, }; @@ -68,6 +77,7 @@ const SecretModal: React.FC> = ({ onClick={() => { props.handleSubmit(); }} + isDisabled={!props.dirty || !isEmpty(props.errors) || props.isSubmitting} > Create , diff --git a/src/components/Secrets/__tests___/SecretModal.spec.tsx b/src/components/Secrets/__tests___/SecretModal.spec.tsx index 5bf9564a..7685a5b1 100644 --- a/src/components/Secrets/__tests___/SecretModal.spec.tsx +++ b/src/components/Secrets/__tests___/SecretModal.spec.tsx @@ -7,7 +7,7 @@ import { supportedPartnerTasksSecrets } from '../utils/secret-utils'; const initialValues: SecretModalValues = { secretName: '', type: SecretTypeDropdownLabel.opaque, - keyValues: [{ key: '', value: '', readOnlyKey: false }], + opaque: { keyValues: [{ key: '', value: '', readOnlyKey: false }] }, existingSecrets: [], }; diff --git a/src/components/Secrets/__tests___/SecretTypeSelector.spec.tsx b/src/components/Secrets/__tests___/SecretTypeSelector.spec.tsx index 14186fef..476376a2 100644 --- a/src/components/Secrets/__tests___/SecretTypeSelector.spec.tsx +++ b/src/components/Secrets/__tests___/SecretTypeSelector.spec.tsx @@ -8,7 +8,7 @@ import SecretTypeSelector from '../SecretTypeSelector'; const initialValues: SecretModalValues = { secretName: '', type: SecretTypeDropdownLabel.opaque, - keyValues: [{ key: '', value: '', readOnlyKey: false }], + opaque: { keyValues: [{ key: '', value: '', readOnlyKey: false }] }, existingSecrets: [], }; diff --git a/src/types/secret.ts b/src/types/secret.ts index bd10adc5..a01452d0 100644 --- a/src/types/secret.ts +++ b/src/types/secret.ts @@ -5,11 +5,21 @@ export const SecretByUILabel = 'ui.appstudio.redhat.com/secret-for'; export type ImportSecret = { secretName: string; type: string; - keyValues: { - key: string; - value: string; - readOnlyKey?: boolean; - }[]; + source?: Source; + opaque?: { + keyValues: { + key: string; + value: string; + readOnlyKey?: boolean; + }[]; + }; + image?: { + keyValues: { + key: string; + value: string; + readOnlyKey?: boolean; + }[]; + }; }; export enum SecretSPILabel { diff --git a/src/utils/__tests__/create-utils.spec.ts b/src/utils/__tests__/create-utils.spec.ts index 6d610cf0..944df754 100644 --- a/src/utils/__tests__/create-utils.spec.ts +++ b/src/utils/__tests__/create-utils.spec.ts @@ -5,7 +5,12 @@ import { commonFetch } from '../../k8s/fetch'; import { k8sCreateResource, k8sUpdateResource } from '../../k8s/k8s-fetch'; import { ApplicationModel } from '../../models/application'; import { ComponentModel } from '../../models/component'; -import { AddSecretFormValues, SecretFor, SecretTypeDropdownLabel } from '../../types'; +import { + AddSecretFormValues, + SecretFor, + SecretTypeDropdownLabel, + SourceSecretType, +} from '../../types'; import { ComponentKind, ComponentSpecs } from '../../types/component'; import { createApplication, @@ -467,7 +472,7 @@ describe('Create Utils', () => { { secretName: 'my-snyk-secret', type: SecretTypeDropdownLabel.opaque, - keyValues: [{ key: 'token', value: 'my-token-data' }], + opaque: { keyValues: [{ key: 'token', value: 'my-token-data' }] }, }, 'test-ws', 'test-ns', @@ -492,7 +497,7 @@ describe('Create Utils', () => { { secretName: 'my-snyk-secret', type: SecretTypeDropdownLabel.opaque, - keyValues: [{ key: 'token', value: 'my-token-data' }], + opaque: { keyValues: [{ key: 'token', value: 'my-token-data' }] }, }, 'test-ws', 'test-ns', @@ -517,7 +522,7 @@ describe('Create Utils', () => { { secretName: 'registry-creds', type: SecretTypeDropdownLabel.image, - keyValues: [{ key: 'token', value: 'my-token-data' }], + image: { keyValues: [{ key: 'token', value: 'my-token-data' }] }, }, 'test-ws', 'test-ns', @@ -534,6 +539,81 @@ describe('Create Utils', () => { ); }); + it('should add correct values for Image pull secret', async () => { + commonFetchMock.mockClear(); + commonFetchMock.mockImplementationOnce((props) => Promise.resolve(props)); + + await createSecret( + { + secretName: 'registry-creds', + type: SecretTypeDropdownLabel.image, + image: { keyValues: [{ key: 'test', value: 'test-value' }] }, + }, + 'test-ws', + 'test-ns', + false, + ); + + expect(commonFetchMock).toHaveBeenCalledTimes(1); + + expect(commonFetchMock).toHaveBeenCalledWith( + '/workspaces/test-ws/api/v1/namespaces/test-ns/secrets', + expect.objectContaining({ + body: expect.stringContaining('"test":"test-value"'), + }), + ); + }); + + it('should create a Source secret', async () => { + commonFetchMock.mockClear(); + commonFetchMock.mockImplementationOnce((props) => Promise.resolve(props)); + + await createSecret( + { + secretName: 'registry-creds', + type: SecretTypeDropdownLabel.source, + source: { authType: SourceSecretType.basic, username: 'test1', password: 'pass-test' }, + }, + 'test-ws', + 'test-ns', + false, + ); + + expect(commonFetchMock).toHaveBeenCalledTimes(1); + + expect(commonFetchMock).toHaveBeenCalledWith( + '/workspaces/test-ws/api/v1/namespaces/test-ns/secrets', + expect.objectContaining({ + body: expect.stringContaining('"type":"kubernetes.io/basic-auth"'), + }), + ); + }); + + it('should add correct data for Source secret', async () => { + commonFetchMock.mockClear(); + commonFetchMock.mockImplementationOnce((props) => Promise.resolve(props)); + + await createSecret( + { + secretName: 'registry-creds', + type: SecretTypeDropdownLabel.source, + source: { authType: SourceSecretType.basic, username: 'test1', password: 'pass-test' }, + }, + 'test-ws', + 'test-ns', + false, + ); + + expect(commonFetchMock).toHaveBeenCalledTimes(1); + + expect(commonFetchMock).toHaveBeenCalledWith( + '/workspaces/test-ws/api/v1/namespaces/test-ns/secrets', + expect.objectContaining({ + body: expect.stringContaining('"username":"dGVzdDE=","password":"cGFzcy10ZXN0"'), + }), + ); + }); + it('should create partner task secret', async () => { commonFetchMock.mockClear(); createResourceMock @@ -545,7 +625,7 @@ describe('Create Utils', () => { { secretName: 'snyk-secret', type: SecretTypeDropdownLabel.opaque, - keyValues: [{ key: 'token', value: 'my-token-data' }], + opaque: { keyValues: [{ key: 'token', value: 'my-token-data' }] }, }, 'test-ws', 'test-ns', diff --git a/src/utils/create-utils.ts b/src/utils/create-utils.ts index daa93b4a..a1d364e2 100644 --- a/src/utils/create-utils.ts +++ b/src/utils/create-utils.ts @@ -1,4 +1,5 @@ -import { isEqual, isNumber } from 'lodash-es'; +import { Base64 } from 'js-base64'; +import { isEqual, isNumber, pick } from 'lodash-es'; import { v4 as uuidv4 } from 'uuid'; import { getRandomSvgNumber, THUMBNAIL_ANNOTATION } from '../components/ApplicationThumbnail'; import { @@ -31,6 +32,9 @@ import { ImportSecret, ImageRepositoryKind, ImageRepositoryVisibility, + SecretTypeDropdownLabel, + SourceSecretType, + SecretFormValues, } from '../types'; import { ComponentSpecs } from './../types/component'; import { @@ -39,7 +43,6 @@ import { GIT_PROVIDER_ANNOTATION, GITLAB_PROVIDER_URL_ANNOTATION, } from './component-utils'; - export const sanitizeName = (name: string) => name.split(/ |\./).join('-').toLowerCase(); /** @@ -316,6 +319,46 @@ export const initiateAccessTokenBinding = async (url: string, namespace: string) return createAccessTokenBinding(url, namespace); }; +export const getSecretObject = (values: SecretFormValues, namespace: string): SecretKind => { + let data = {}; + if (values.type === SecretTypeDropdownLabel.source) { + if (values.source.authType === SourceSecretType.basic) { + const authObj = pick(values.source, ['username', 'password']); + data = Object.entries(authObj).reduce((acc, [key, value]) => { + acc[key] = Base64.encode(value); + return acc; + }, {}); + } else { + const SSH_KEY = 'ssh-privatekey'; + data[SSH_KEY] = values.source[SSH_KEY]; + } + } else { + const keyValues = + values.type === SecretTypeDropdownLabel.opaque + ? values.opaque.keyValues + : values.image.keyValues; + data = keyValues?.reduce((acc, s) => { + acc[s.key] = s.value ? s.value : ''; + return acc; + }, {}); + } + const secretResource: SecretKind = { + apiVersion: SecretModel.apiVersion, + kind: SecretModel.kind, + metadata: { + name: values.secretName, + namespace, + }, + type: + values.type === SecretTypeDropdownLabel.source + ? K8sSecretType[values.source?.authType] + : K8sSecretType[values.type], + stringData: data, + }; + + return secretResource; +}; + export const createSecretResource = async ( values: AddSecretFormValues, workspace: string, @@ -369,19 +412,7 @@ export const createSecret = async ( namespace: string, dryRun: boolean, ) => { - const secretResource = { - apiVersion: SecretModel.apiVersion, - kind: SecretModel.kind, - metadata: { - name: secret.secretName, - namespace, - }, - type: K8sSecretType[secret.type], - stringData: secret.keyValues.reduce((acc, s) => { - acc[s.key] = s.value ? s.value : ''; - return acc; - }, {}), - }; + const secretResource = getSecretObject(secret, namespace); // Todo: K8sCreateResource appends the resource name and errors out. // Fix the below code when this sdk-utils issue is resolved https://issues.redhat.com/browse/RHCLOUD-21655. diff --git a/src/utils/validation-utils.ts b/src/utils/validation-utils.ts index c8544fd5..3539fc7d 100644 --- a/src/utils/validation-utils.ts +++ b/src/utils/validation-utils.ts @@ -1,4 +1,5 @@ import * as yup from 'yup'; +import { SecretTypeDropdownLabel, SourceSecretType } from '../types'; export const GIT_URL_REGEX = /^((((ssh|git|https?:?):\/\/:?)(([^\s@]+@|[^@]:?)[-\w.]+(:\d\d+:?)?(\/[-\w.~/?[\]!$&'()*+,;=:@%]*:?)?:?))|([^\s@]+@[-\w.]+:[-\w.~/?[\]!$&'()*+,;=:@%]*?:?))$/; @@ -28,10 +29,45 @@ export const SecretFromSchema = yup.object({ return !existingSecrets.includes(value); }, ), - keyValues: yup.array().of( - yup.object({ - key: yup.string().required('Required'), - value: yup.string().required('Required'), + type: yup.string(), + source: yup.object().when('type', { + is: SecretTypeDropdownLabel.source, + then: yup.object({ + authType: yup.string(), + username: yup.string().when('authType', { + is: SourceSecretType.basic, + then: yup.string().required('Required'), + }), + password: yup.string().when('authType', { + is: SourceSecretType.basic, + then: yup.string().required('Required'), + }), + ['ssh-privatekey']: yup.string().when('authType', { + is: SourceSecretType.ssh, + then: yup.string().required('Required'), + }), }), - ), + }), + opaque: yup.object().when('type', { + is: SecretTypeDropdownLabel.opaque, + then: yup.object({ + keyValues: yup.array().of( + yup.object({ + key: yup.string().required('Required'), + value: yup.string().required('Required'), + }), + ), + }), + }), + image: yup.object().when('type', { + is: SecretTypeDropdownLabel.image, + then: yup.object({ + keyValues: yup.array().of( + yup.object({ + key: yup.string().required('Required'), + value: yup.string().required('Required'), + }), + ), + }), + }), });