diff --git a/docs/resource-validation/README.md b/docs/resource-validation/README.md index 4676e80338..d515b304b9 100644 --- a/docs/resource-validation/README.md +++ b/docs/resource-validation/README.md @@ -56,6 +56,43 @@ Policies can also reference rules in different rule sets as long as they are loa To see the full specifications, check the [example rule-set](../../examples/resource-validation/rule-set.yaml). +### Alternative format: Custom Rules + +Rules and policies can also be given using [datree's Custom Rules](https://hub.datree.io/custom-rules/custom-rules-overview) format as part of a rule set. + +```yaml +apiVersion: v1 +policies: + - name: TestPolicy + isDefault: true + rules: + - identifier: TEST + messageOnFailure: This is a test rule +customRules: + - identifier: TEST + name: This is a test rule + defaultMessageOnFailure: This is a test rule + schema: + required: + - test + properties: + test: + type: object + required: + - hello + properties: + hello: + type: string + enum: + - kyma +``` + +## Download policies + +You can download the currently enabled policies. + +In the user preferences, go to **Clusters > Resource Validation**. Click on **Download** in the **Enabled Policies** section. + ## Scan the cluster With the **CLUSTER_VALIDATION** [feature flag](../features.md) enabled, you can use these rules to scan existing resources in your cluster. diff --git a/docs/resource-validation/assets/customize-policy-preferences.png b/docs/resource-validation/assets/customize-policy-preferences.png index 2e12afed01..d0b924d241 100644 Binary files a/docs/resource-validation/assets/customize-policy-preferences.png and b/docs/resource-validation/assets/customize-policy-preferences.png differ diff --git a/examples/resource-validation/custom-policies.yaml b/examples/resource-validation/custom-policies.yaml new file mode 100644 index 0000000000..2bb51cff10 --- /dev/null +++ b/examples/resource-validation/custom-policies.yaml @@ -0,0 +1,28 @@ +# datree 'policy as code' format +# Can be validated using datree command: +# datree test --ignore-missing-schemas --no-record --policy-config examples/resource-validation/custom-policies.yaml examples/resource-validation/pod.yaml + +apiVersion: v1 +policies: + - name: TestPolicy + isDefault: true + rules: + - identifier: TEST + messageOnFailure: This is a test rule +customRules: + - identifier: TEST + name: This is a test rule + defaultMessageOnFailure: This is a test rule + schema: + required: + - test + properties: + test: + type: object + required: + - hello + properties: + hello: + type: string + enum: + - kyma diff --git a/public/i18n/en.yaml b/public/i18n/en.yaml index e9674f2cd3..e3c0bdd6dd 100644 --- a/public/i18n/en.yaml +++ b/public/i18n/en.yaml @@ -219,6 +219,7 @@ common: copy: Copy create: Create delete: Delete + download: Download edit: Edit edit-yaml: Edit YAML generate-name: Generate @@ -1334,6 +1335,8 @@ settings: customize: Customize validation-disabled: Resource validation is disabled no-policies-found: No policies found + download-error: Error while downloading rule set + rule-set-missing: No rule set found interaction: title: Cluster Interaction interface: diff --git a/resources/resource-validation/rule-sets/kubernetes-pod-security-standards/rules.yaml b/resources/resource-validation/rule-sets/kubernetes-pod-security-standards/rules.yaml index 20860d3200..85978fd7d9 100644 --- a/resources/resource-validation/rule-sets/kubernetes-pod-security-standards/rules.yaml +++ b/resources/resource-validation/rule-sets/kubernetes-pod-security-standards/rules.yaml @@ -346,7 +346,6 @@ rules: properties: runAsNonRoot: enum: - - null - null - true validSpec: @@ -472,7 +471,6 @@ rules: properties: type: enum: - - null - null - RuntimeDefault - Localhost diff --git a/src/components/Preferences/ResourceValidation/ResourceValidationSettings.tsx b/src/components/Preferences/ResourceValidation/ResourceValidationSettings.tsx index 0a4a4718eb..aabcfb8343 100644 --- a/src/components/Preferences/ResourceValidation/ResourceValidationSettings.tsx +++ b/src/components/Preferences/ResourceValidation/ResourceValidationSettings.tsx @@ -1,4 +1,6 @@ import { useTranslation } from 'react-i18next'; +import jsyaml from 'js-yaml'; +import { saveAs } from 'file-saver'; import { useRecoilState, useRecoilValue } from 'recoil'; import { Button, LayoutPanel, Switch } from 'fundamental-react'; import { @@ -6,12 +8,17 @@ import { validateResourcesState, } from 'state/preferences/validateResourcesAtom'; import { validationSchemasState } from 'state/validationSchemasAtom'; -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { GenericList } from 'shared/components/GenericList/GenericList'; import './ResourceValidationSettings.scss'; import { useFeature } from 'hooks/useFeature'; -import { ValidationFeatureConfig } from 'state/validationEnabledSchemasAtom'; +import { + convertPoliciesForDatree, + usePolicySet, + ValidationFeatureConfig, +} from 'state/validationEnabledSchemasAtom'; +import { useNotification } from 'shared/contexts/NotificationContext'; export default function ResourceValidationSettings() { const { t } = useTranslation(); @@ -39,6 +46,34 @@ export default function ResourceValidationSettings() { [validationSchemas], ); + const policySet = usePolicySet(); + const notification = useNotification(); + + const download = useCallback(() => { + if (validationSchemas) { + try { + const customPolicyDefinition = convertPoliciesForDatree( + validationSchemas, + policySet, + ); + const kubeconfigYaml = jsyaml.dump(customPolicyDefinition); + const blob = new Blob([kubeconfigYaml], { + type: 'application/yaml;charset=utf-8', + }); + saveAs(blob, `customPolicies.yaml`); + } catch (e) { + console.error(e); + notification.notifyError({ + content: t('settings.clusters.resourcesValidation.download-error'), + }); + } + } else { + notification.notifyError({ + content: t('settings.clusters.resourcesValidation.rule-set-missing'), + }); + } + }, [validationSchemas, policySet, notification, t]); + const policyList = useMemo(() => { const selectedPolicySet = selectedPolicies.reduce( (agg, name) => agg.add(name), @@ -182,6 +217,14 @@ export default function ResourceValidationSettings() { {t('settings.clusters.resourcesValidation.reset')} )} + } searchSettings={{ diff --git a/src/state/validationEnabledSchemasAtom.ts b/src/state/validationEnabledSchemasAtom.ts index b1e7bcdb2b..6d4cca7acd 100644 --- a/src/state/validationEnabledSchemasAtom.ts +++ b/src/state/validationEnabledSchemasAtom.ts @@ -9,6 +9,8 @@ import { import { emptyValidationSchema, getEnabledRules, + getResolvedPolicy, + DatreeCustomRule, ValidationSchema, validationSchemasState, } from './validationSchemasAtom'; @@ -22,6 +24,85 @@ export type ValidationFeatureConfig = { }; }; +type DatreeCustomPolicies = { + apiVersion: string; + policies: { + name: string; + isDefault: boolean; + rules: { + identifier: string; + messageOnFailure: string; + }[]; + }[]; + customRules: DatreeCustomRule[]; +}; + +/** + * The datree format differs between its own rules and the custom rules via 'policy as code'. + * This function converts the rules of a given rule set and selected policies into a format, + * which can be used in datree via the --policy-config option. + */ +export const convertPoliciesForDatree = ( + validationSchemas: ValidationSchema, + policySet: Set, +): DatreeCustomPolicies => { + const enabledSchemas = getValidationEnabledSchemas( + validationSchemas, + policySet, + ); + + const customRules = enabledSchemas.rules.map( + ({ uniqueName, name, schema, messageOnFailure }) => { + return { + identifier: uniqueName, + name: name ?? uniqueName, + defaultMessageOnFailure: + messageOnFailure ?? `Rule ${uniqueName} failed`, + schema, + }; + }, + ); + + const policies = enabledSchemas.policies.map(policy => { + const resolvedPolicy = getResolvedPolicy( + policy, + validationSchemas.policies, + ); + const rules = resolvedPolicy.rules.map(rule => { + if (typeof rule === 'string') { + const messageOnFailure = + customRules.find(({ identifier }) => identifier) + ?.defaultMessageOnFailure || `Rule ${rule} failed`; + return { + identifier: rule, + messageOnFailure, + }; + } else { + return { + identifier: rule.identifier, + messageOnFailure: rule.messageOnFailure ?? `Rule ${rule} failed`, + }; + } + }); + + return { + name: resolvedPolicy.name, + isDefault: false, + rules, + }; + }); + if (policies.length > 0) { + policies[0].isDefault = true; + } + + const customPolicyDefinition = { + apiVersion: 'v1', + policies, + customRules, + }; + return customPolicyDefinition; +}; + const getEnabledPolicyNames = ( validationFeature: ValidationFeatureConfig, validationPreferences: ExtendedValidateResources, diff --git a/src/state/validationSchemasAtom.ts b/src/state/validationSchemasAtom.ts index d46ce26705..c5527bd31f 100644 --- a/src/state/validationSchemasAtom.ts +++ b/src/state/validationSchemasAtom.ts @@ -15,6 +15,7 @@ import { FetchFn } from 'shared/hooks/BackendAPI/useFetch'; type Rule = { uniqueName: string; + name?: string; messageOnFailure?: string; documentationUrl?: string; category?: string; @@ -22,9 +23,17 @@ type Rule = { schema: JSONSchema4; }; +export type DatreeCustomRule = { + identifier: string; + name: string; + defaultMessageOnFailure: string; + schema: JSONSchema4; +}; + type ValidationConfig = { rules?: Array; policies?: Array; + customRules?: Array; }; export type ValidationSchema = { @@ -41,6 +50,7 @@ type RuleReference = | string | { identifier: string; + messageOnFailure?: string; }; type ValidationPolicy = { @@ -50,6 +60,20 @@ type ValidationPolicy = { rules: Array; }; +const convertCustomRules = (customRules?: DatreeCustomRule[]): Rule[] => { + if (!customRules) return []; + return customRules?.map( + ({ identifier, name, defaultMessageOnFailure, schema }) => { + return { + uniqueName: identifier, + name, + messageOnFailure: defaultMessageOnFailure, + schema, + }; + }, + ); +}; + const fetchBaseValidationConfig = async (): Promise => { try { const response = await fetch(`/resource-validation/rule-set.yaml`); @@ -104,9 +128,13 @@ const fetchValidationConfig = async ( permissionSet, ); - const rules = [...validationConfig, ...configFromConfigMap].flatMap( - schema => schema.rules ?? [], - ); + const rules = [ + ...validationConfig, + ...configFromConfigMap, + ].flatMap(schema => [ + ...(schema.rules ?? []), + ...convertCustomRules(schema.customRules), + ]); const policies = [...validationConfig, ...configFromConfigMap].flatMap( schema => schema.policies ?? [], @@ -115,6 +143,14 @@ const fetchValidationConfig = async ( return { rules, policies }; }; +const getPolicyMap = (policies?: ValidationPolicy[]) => { + return ( + policies?.reduce((agg, policy) => { + return agg.set(policy.name, policy); + }, new Map()) ?? (new Map() as Map) + ); +}; + const getRuleKey = (rule: RuleReference) => { return typeof rule === 'string' ? rule : rule.identifier; }; @@ -152,6 +188,17 @@ const resolveRulesForPolicy = ( return [...rules, ...additionalRules]; }; +export const getResolvedPolicy = ( + policy: ValidationPolicy, + allPolicies: ValidationPolicy[], +) => { + const policyMap = getPolicyMap(allPolicies); + return { + ...policy, + rules: resolveRulesForPolicy(policy, policyMap), + }; +}; + export const getEnabledRules = ( rules: Rule[], selectedPolicies: ValidationPolicy[], @@ -162,10 +209,7 @@ export const getEnabledRules = ( {}, ) as { [key: string]: Rule }; - const policyMap = - allPolicies?.reduce((agg, policy) => { - return agg.set(policy.name, policy); - }, new Map()) ?? (new Map() as Map); + const policyMap = getPolicyMap(allPolicies); const enabledRulesByName = selectedPolicies.reduce((agg, policy) => { const policyRules = resolveRulesForPolicy(policy, policyMap); diff --git a/tests/integration/tests/namespace/test-resource-validation.spec.js b/tests/integration/tests/namespace/test-resource-validation.spec.js index 38f4bf254a..653ee942d0 100644 --- a/tests/integration/tests/namespace/test-resource-validation.spec.js +++ b/tests/integration/tests/namespace/test-resource-validation.spec.js @@ -1,3 +1,6 @@ +import path from 'path'; +import jsyaml from 'js-yaml'; + context('Test resource validation', () => { Cypress.skipAfterFail(); @@ -123,6 +126,73 @@ context('Test resource validation', () => { cy.contains('Close').click(); }); + it('Download and reupload policies', () => { + cy.get('[aria-label="topnav-profile-btn"]').click(); + + cy.contains('Preferences').click(); + + cy.contains('Clusters').click(); + + cy.contains('Resource Validation').click(); + + cy.contains('Customize').click(); + + cy.contains('.policy-row', 'Default') + .find('.fd-switch') + .click(); + + cy.contains('.policy-row', 'TestPolicy') + .find('.fd-switch') + .click(); + + cy.contains('Download').click(); + + const downloadsFolder = Cypress.config('downloadsFolder'); + cy.readFile(path.join(downloadsFolder, 'customPolicies.yaml')) + .then(downloadedYaml => { + cy.fixture('examples/resource-validation/custom-policies.yaml') + .then(customPolicies => jsyaml.load(customPolicies)) + .should('deep.equal', jsyaml.load(downloadedYaml)); + return cy.wrap(downloadedYaml); + }) + .then(downloadedYaml => { + cy.mockConfigMap({ + label: 'busola.io/resource-validation=rule-set', + data: downloadedYaml, + }); + + return cy.reload(); + }); + + cy.contains('Upload YAML').click(); + + cy.fixture('examples/resource-validation/pod.yaml').then(podConfig => { + cy.pasteToMonaco(podConfig); + }); + + cy.contains('nginx:latest').should('be.visible'); + + cy.contains('Show warnings') + .should('be.visible') + .click(); + + cy.contains('This is a test rule').should('be.visible'); + + cy.contains('Cancel').click(); + + cy.get('[aria-label="topnav-profile-btn"]').click(); + + cy.contains('Preferences').click(); + + cy.contains('Clusters').click(); + + cy.contains('Resource Validation').click(); + + cy.contains('Reset').click(); + + cy.contains('Close').click(); + }); + it('Customize resource validation policies via feature flag', () => { cy.setBusolaFeature('RESOURCE_VALIDATION', true, { config: {