Commit 0c78bd7a authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '271169-update-yaml-parsing' into 'master'

Throw error if yaml policy has unparseable attributes for Rule mode

See merge request gitlab-org/gitlab!58457
parents fa686696 667d36ca
import { s__ } from '~/locale';
export const EditorModeRule = 'rule';
export const EditorModeYAML = 'yaml';
......@@ -35,3 +37,7 @@ export const DisabledByLabel = 'network-policy.gitlab.com/disabled_by';
export const CiliumNetworkPolicyKind = 'CiliumNetworkPolicy';
export const ProjectIdLabel = 'app.gitlab.com/proj';
export const PARSING_ERROR_MESSAGE = s__(
'NetworkPolicies|Rule mode is unavailable for this policy. In some cases, we cannot parse the YAML file back into the rules editor.',
);
......@@ -106,13 +106,87 @@ function parseRule(item, direction) {
};
}
/**
* Checks for parameters unsupported by the network policy "Rule Mode"
* @param {String} manifest YAML of network policy
* @returns {Boolean} whether the YAML is valid to be parsed into "Rule Mode"
*/
const hasUnsupportedAttribute = (manifest) => {
const primaryKeys = ['apiVersion', 'description', 'kind', 'metadata', 'spec'];
const metadataKeys = ['annotations', 'labels', 'name', 'namespace', 'resourceVersion'];
const specKeys = ['egress', 'endpointSelector', 'ingress'];
const ruleKeys = [
'fromEntities',
'toEntities',
'fromCIDR',
'toCIDR',
'toFQDNs',
'fromEndpoints',
'toEndpoints',
'toPorts',
];
const toPortKeys = ['ports'];
const portKeys = ['port', 'protocol'];
let isUnsupported = false;
const hasInvalidKey = (object, allowedValues) => {
return !Object.keys(object).every((item) => allowedValues.includes(item));
};
const hasInvalidPolicy = (ingress = [], egress = []) => {
let isInvalidPolicy = false;
[...ingress, ...egress].forEach((item) => {
isInvalidPolicy = hasInvalidKey(item, ruleKeys);
if (item.toPorts?.length && !isInvalidPolicy) {
item.toPorts.forEach((entry) => {
isInvalidPolicy = hasInvalidKey(entry, toPortKeys);
if (entry.ports?.length && !isInvalidPolicy) {
entry.ports.forEach((portEntry) => {
isInvalidPolicy = hasInvalidKey(portEntry, portKeys);
});
}
});
}
});
return isInvalidPolicy;
};
isUnsupported = hasInvalidKey(manifest, primaryKeys);
if (manifest?.metadata && !isUnsupported) {
isUnsupported = hasInvalidKey(manifest.metadata, metadataKeys);
}
if (manifest?.spec && !isUnsupported) {
isUnsupported = hasInvalidKey(manifest.spec, specKeys);
}
if (!isUnsupported && (manifest?.spec?.ingress?.length || manifest?.spec?.egress?.length)) {
isUnsupported = hasInvalidPolicy(manifest.spec.ingress, manifest.spec.egress);
}
return isUnsupported;
};
/**
* Removes inital line dashes from a policy YAML that is received from the API, which
* is not required for the user.
* @param {String} manifest the policy from the API request
* @returns {String} the policy without the initial dashes or the initial string
*/
export const removeUnnecessaryDashes = (manifest) => {
return manifest.replace('---\n', '');
};
/*
Construct a policy object expected by the policy editor from a yaml manifest.
Expected yaml structure is defined in the official documentation:
https://docs.cilium.io/en/v1.8/policy/language
*/
export default function fromYaml(manifest) {
const { description, metadata, spec } = safeLoad(manifest, { json: true });
const policy = safeLoad(manifest, { json: true });
const unsupportedAttribute = hasUnsupportedAttribute(policy);
if (unsupportedAttribute) return { error: unsupportedAttribute };
const { description, metadata, spec } = policy;
const { name, resourceVersion, annotations, labels } = metadata;
const { endpointSelector = {}, ingress = [], egress = [] } = spec;
const matchLabels = endpointSelector.matchLabels || {};
......
<script>
import { GlFormTextarea } from '@gitlab/ui';
import fromYaml from './lib/from_yaml';
import fromYaml, { removeUnnecessaryDashes } from './lib/from_yaml';
import humanizeNetworkPolicy from './lib/humanize';
import toYaml from './lib/to_yaml';
import PolicyPreview from './policy_preview.vue';
......@@ -17,14 +17,18 @@ export default {
},
},
computed: {
initialTab() {
return this.policy ? 1 : 0;
},
policy() {
return fromYaml(this.value);
const policy = fromYaml(this.value);
return policy.error ? null : policy;
},
humanizedPolicy() {
return humanizeNetworkPolicy(this.policy);
return this.policy ? humanizeNetworkPolicy(this.policy) : this.policy;
},
policyYaml() {
return toYaml(this.policy);
return removeUnnecessaryDashes(this.value);
},
},
methods: {
......@@ -43,12 +47,14 @@ export default {
<h5 class="gl-mt-6">{{ s__('NetworkPolicies|Policy type') }}</h5>
<p>{{ s__('NetworkPolicies|Network Policy') }}</p>
<h5 class="gl-mt-6">{{ s__('NetworkPolicies|Description') }}</h5>
<gl-form-textarea :value="policy.description" @input="updateManifest" />
<div v-if="policy">
<h5 class="gl-mt-6">{{ s__('NetworkPolicies|Description') }}</h5>
<gl-form-textarea :value="policy.description" @input="updateManifest" />
</div>
<policy-preview
class="gl-mt-4"
:initial-tab="1"
:initial-tab="initialTab"
:policy-yaml="policyYaml"
:policy-description="humanizedPolicy"
/>
......
......@@ -22,9 +22,10 @@ import {
EndpointMatchModeAny,
RuleTypeEndpoint,
ProjectIdLabel,
PARSING_ERROR_MESSAGE,
} from './constants';
import DimDisableContainer from './dim_disable_container.vue';
import fromYaml from './lib/from_yaml';
import fromYaml, { removeUnnecessaryDashes } from './lib/from_yaml';
import humanizeNetworkPolicy from './lib/humanize';
import { buildRule } from './lib/rules';
import toYaml from './lib/to_yaml';
......@@ -36,6 +37,7 @@ import PolicyRuleBuilder from './policy_rule_builder.vue';
export default {
i18n: {
toggleLabel: s__('NetworkPolicies|Policy status'),
PARSING_ERROR_MESSAGE,
},
components: {
GlFormGroup,
......@@ -87,22 +89,27 @@ export default {
labels: '',
};
policy.labels = { [ProjectIdLabel]: this.projectId };
const yamlEditorValue = this.existingPolicy
? removeUnnecessaryDashes(this.existingPolicy.manifest)
: '';
return {
editorMode: EditorModeRule,
yamlEditorValue: '',
yamlEditorError: null,
yamlEditorValue,
yamlEditorError: policy.error ? true : null,
policy,
};
},
computed: {
humanizedPolicy() {
return humanizeNetworkPolicy(this.policy);
return this.policy.error ? null : humanizeNetworkPolicy(this.policy);
},
policyAlert() {
return Boolean(this.policy.annotations);
},
policyYaml() {
return toYaml(this.policy);
return this.hasParsingError ? '' : toYaml(this.policy);
},
...mapState('threatMonitoring', ['currentEnvironmentId']),
...mapState('networkPolicies', [
......@@ -165,7 +172,11 @@ export default {
this.yamlEditorError = null;
try {
Object.assign(this.policy, fromYaml(manifest));
const newPolicy = fromYaml(manifest);
if (newPolicy.error) {
throw new Error(newPolicy.error);
}
Object.assign(this.policy, newPolicy);
} catch (error) {
this.yamlEditorError = error;
}
......@@ -203,9 +214,6 @@ export default {
{ value: EditorModeRule, text: s__('NetworkPolicies|Rule mode') },
{ value: EditorModeYAML, text: s__('NetworkPolicies|.yaml mode') },
],
parsingErrorMessage: s__(
'NetworkPolicies|Rule mode is unavailable for this policy. In some cases, we cannot parse the YAML file back into the rules editor.',
),
deleteModal: {
id: 'delete-modal',
secondary: {
......@@ -240,14 +248,18 @@ export default {
</div>
<div class="col-sm-6 col-md-6 col-lg-5 col-xl-4">
<gl-form-group :label="s__('NetworkPolicies|Name')" label-for="policyName">
<gl-form-input id="policyName" v-model="policy.name" />
<gl-form-input id="policyName" v-model="policy.name" :disabled="hasParsingError" />
</gl-form-group>
</div>
</div>
<div class="row">
<div class="col-sm-12 col-md-10 col-lg-8 col-xl-6">
<gl-form-group :label="s__('NetworkPolicies|Description')" label-for="policyDescription">
<gl-form-textarea id="policyDescription" v-model="policy.description" />
<gl-form-textarea
id="policyDescription"
v-model="policy.description"
:disabled="hasParsingError"
/>
</gl-form-group>
</div>
</div>
......@@ -256,7 +268,7 @@ export default {
</div>
<div class="row">
<div class="col-md-auto">
<gl-form-group>
<gl-form-group :disabled="hasParsingError" data-testid="policy-enable">
<gl-toggle v-model="policy.isEnabled" :label="$options.i18n.toggleLabel" />
</gl-form-group>
</div>
......@@ -281,7 +293,7 @@ export default {
class="gl-z-index-1"
data-testid="parsing-alert"
:dismissible="false"
>{{ $options.parsingErrorMessage }}</gl-alert
>{{ $options.i18n.PARSING_ERROR_MESSAGE }}</gl-alert
>
<dim-disable-container data-testid="rule-builder-container" :disabled="hasParsingError">
......
<script>
import { GlTabs, GlTab, GlSafeHtmlDirective } from '@gitlab/ui';
import { GlAlert, GlTabs, GlTab, GlSafeHtmlDirective } from '@gitlab/ui';
import { PARSING_ERROR_MESSAGE } from './constants';
export default {
i18n: {
PARSING_ERROR_MESSAGE,
},
components: {
GlAlert,
GlTabs,
GlTab,
},
......@@ -16,7 +21,8 @@ export default {
},
policyDescription: {
type: String,
required: true,
required: false,
default: '',
},
initialTab: {
type: Number,
......@@ -40,9 +46,15 @@ export default {
</gl-tab>
<gl-tab :title="s__('NetworkPolicies|Rule')">
<div
v-if="policyDescription"
v-safe-html:[$options.safeHtmlConfig]="policyDescription"
class="gl-bg-white gl-rounded-top-left-none gl-rounded-top-right-none gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-py-3 gl-px-4 gl-border-1 gl-border-solid gl-border-gray-100"
></div>
<div v-else>
<gl-alert variant="info" :dismissible="false"
>{{ $options.i18n.PARSING_ERROR_MESSAGE }}
</gl-alert>
</div>
</gl-tab>
</gl-tabs>
</template>
---
title: Throw an error when yaml mode contains unparsable attributes
merge_request: 58457
author:
type: fixed
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PolicyDrawer component renders policy preview tabs 1`] = `
exports[`PolicyDrawer component supported YAML renders policy preview tabs 1`] = `
<div>
<h4>
Policy description
......@@ -16,16 +16,18 @@ exports[`PolicyDrawer component renders policy preview tabs 1`] = `
Network Policy
</p>
<h5
class="gl-mt-6"
>
Description
</h5>
<gl-form-textarea-stub
noresize="true"
value="test description"
/>
<div>
<h5
class="gl-mt-6"
>
Description
</h5>
<gl-form-textarea-stub
noresize="true"
value="test description"
/>
</div>
<policy-preview-stub
class="gl-mt-4"
......@@ -44,3 +46,29 @@ spec:
/>
</div>
`;
exports[`PolicyDrawer component unsupported YAML renders policy preview tabs 1`] = `
<div>
<h4>
Policy description
</h4>
<h5
class="gl-mt-6"
>
Policy type
</h5>
<p>
Network Policy
</p>
<!---->
<policy-preview-stub
class="gl-mt-4"
initialtab="0"
policyyaml="unsupportedPrimaryKey: test"
/>
</div>
`;
......@@ -111,7 +111,9 @@ exports[`PolicyEditorApp component renders the policy editor layout 1`] = `
<div
class="col-md-auto"
>
<gl-form-group-stub>
<gl-form-group-stub
data-testid="policy-enable"
>
<gl-toggle-stub
label="Policy status"
labelposition="top"
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PolicyPreview component renders policy preview tabs 1`] = `
exports[`PolicyPreview component with policy description renders policy preview tabs 1`] = `
<gl-tabs-stub
contentclass="gl-pt-0"
theme="indigo"
......
......@@ -11,7 +11,9 @@ import {
RuleTypeFQDN,
EntityTypes,
} from 'ee/threat_monitoring/components/policy_editor/constants';
import fromYaml from 'ee/threat_monitoring/components/policy_editor/lib/from_yaml';
import fromYaml, {
removeUnnecessaryDashes,
} from 'ee/threat_monitoring/components/policy_editor/lib/from_yaml';
import { buildRule } from 'ee/threat_monitoring/components/policy_editor/lib/rules';
import toYaml from 'ee/threat_monitoring/components/policy_editor/lib/to_yaml';
......@@ -310,4 +312,36 @@ spec:
});
});
});
describe('unsupported attributes', () => {
const unsupportedYaml = [
'unsupportedPrimaryKey: test',
'apiVersion: 1\nunsupportedPrimaryKey: test',
'unsupportedPrimaryKey: test\nkind: test',
'metadata:\n unsupportedMetaKey: test',
'spec:\n unsupportedSpecKey: test',
'spec:\n ingress:\n - unsupportedRuleKey: test',
'spec:\n engress:\n - toPorts:\n - unsupportedToPortKey: test',
'spec:\n ingress:\n - toPorts:\n - ports:\n - port: 80\n unsupportedPortKey: test',
];
it.each(unsupportedYaml)(
'returns the unsupported attributes object for YAML with unsupported attributes',
(manifest) => {
expect(fromYaml(manifest)).toStrictEqual({ error: true });
},
);
});
});
describe('removeUnnecessaryDashes', () => {
it.each`
input | output
${'---\none'} | ${'one'}
${'two'} | ${'two'}
${'--\nthree'} | ${'--\nthree'}
${'four---\n'} | ${'four'}
`('returns $output when used on $input', ({ input, output }) => {
expect(removeUnnecessaryDashes(input)).toBe(output);
});
});
......@@ -3,6 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import fromYaml from 'ee/threat_monitoring/components/policy_editor/lib/from_yaml';
import toYaml from 'ee/threat_monitoring/components/policy_editor/lib/to_yaml';
import PolicyDrawer from 'ee/threat_monitoring/components/policy_editor/policy_drawer.vue';
import PolicyPreview from 'ee/threat_monitoring/components/policy_editor/policy_preview.vue';
describe('PolicyDrawer component', () => {
let wrapper;
......@@ -12,6 +13,10 @@ describe('PolicyDrawer component', () => {
endpointLabels: '',
rules: [],
};
const unsupportedYaml = 'unsupportedPrimaryKey: test';
const findPolicyPreview = () => wrapper.findComponent(PolicyPreview);
const findTextForm = () => wrapper.findComponent(GlFormTextarea);
const factory = ({ propsData } = {}) => {
wrapper = shallowMount(PolicyDrawer, {
......@@ -21,27 +26,62 @@ describe('PolicyDrawer component', () => {
});
};
beforeEach(() => {
factory({
propsData: {
value: toYaml(policy),
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('renders policy preview tabs', () => {
expect(wrapper.find('div').element).toMatchSnapshot();
describe('supported YAML', () => {
beforeEach(() => {
factory({ propsData: { value: toYaml(policy) } });
});
it('renders policy preview tabs', () => {
expect(wrapper.find('div').element).toMatchSnapshot();
});
it('does render the policy description', () => {
expect(findTextForm().exists()).toBe(true);
expect(findTextForm().props()).toMatchObject({ value: 'test description' });
});
it('does render the policy preview', () => {
expect(findPolicyPreview().exists()).toBe(true);
expect(findPolicyPreview().props()).toStrictEqual({
initialTab: 1,
policyDescription: 'Deny all traffic',
policyYaml: toYaml(policy),
});
});
it('emits input event on description change', () => {
wrapper.find(GlFormTextarea).vm.$emit('input', 'new description');
expect(wrapper.emitted().input.length).toEqual(1);
const updatedPolicy = fromYaml(wrapper.emitted().input[0][0]);
expect(updatedPolicy.description).toEqual('new description');
});
});
it('emits input event on description change', () => {
wrapper.find(GlFormTextarea).vm.$emit('input', 'new description');
describe('unsupported YAML', () => {
beforeEach(() => {
factory({ propsData: { value: unsupportedYaml } });
});
expect(wrapper.emitted().input.length).toEqual(1);
const updatedPolicy = fromYaml(wrapper.emitted().input[0][0]);
expect(updatedPolicy.description).toEqual('new description');
it('renders policy preview tabs', () => {
expect(wrapper.find('div').element).toMatchSnapshot();
});
it('does not render the policy description', () => {
expect(findTextForm().exists()).toBe(false);
});
it('does render the policy preview', () => {
expect(findPolicyPreview().exists()).toBe(true);
expect(findPolicyPreview().props()).toStrictEqual({
initialTab: 0,
policyDescription: null,
policyYaml: unsupportedYaml,
});
});
});
});
......@@ -15,6 +15,7 @@ import PolicyEditorApp from 'ee/threat_monitoring/components/policy_editor/polic
import PolicyPreview from 'ee/threat_monitoring/components/policy_editor/policy_preview.vue';
import PolicyRuleBuilder from 'ee/threat_monitoring/components/policy_editor/policy_rule_builder.vue';
import createStore from 'ee/threat_monitoring/store';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { redirectTo } from '~/lib/utils/url_utility';
jest.mock('~/lib/utils/url_utility');
......@@ -53,33 +54,37 @@ spec:
jest.spyOn(store, 'dispatch').mockImplementation(() => Promise.resolve());
wrapper = shallowMount(PolicyEditorApp, {
propsData: {
threatMonitoringPath: '/threat-monitoring',
projectId: '21',
...propsData,
},
provide: {
glFeatures: { threatMonitoringAlerts: false },
...provide,
},
store,
data,
stubs: { NetworkPolicyEditor: true },
});
wrapper = extendedWrapper(
shallowMount(PolicyEditorApp, {
propsData: {
threatMonitoringPath: '/threat-monitoring',
projectId: '21',
...propsData,
},
provide: {
glFeatures: { threatMonitoringAlerts: false },
...provide,
},
store,
data,
stubs: { NetworkPolicyEditor: true },
}),
);
};
const findRuleEditor = () => wrapper.find('[data-testid="rule-editor"]');
const findYamlEditor = () => wrapper.find('[data-testid="yaml-editor"]');
const findRuleEditor = () => wrapper.findByTestId('rule-editor');
const findYamlEditor = () => wrapper.findByTestId('yaml-editor');
const findPreview = () => wrapper.find(PolicyPreview);
const findAddRuleButton = () => wrapper.find('[data-testid="add-rule"]');
const findYAMLParsingAlert = () => wrapper.find('[data-testid="parsing-alert"]');
const findNetworkPolicyEditor = () => wrapper.find('[data-testid="network-policy-editor"]');
const findAddRuleButton = () => wrapper.findByTestId('add-rule');
const findYAMLParsingAlert = () => wrapper.findByTestId('parsing-alert');
const findNetworkPolicyEditor = () => wrapper.findByTestId('network-policy-editor');
const findPolicyAlertPicker = () => wrapper.find(PolicyAlertPicker);
const findPolicyDescription = () => wrapper.find("[id='policyDescription']");
const findPolicyEnableContainer = () => wrapper.findByTestId('policy-enable');
const findPolicyName = () => wrapper.find("[id='policyName']");
const findSavePolicy = () => wrapper.find("[data-testid='save-policy']");
const findDeletePolicy = () => wrapper.find("[data-testid='delete-policy']");
const findEditorModeToggle = () => wrapper.find("[data-testid='editor-mode']");
const findSavePolicy = () => wrapper.findByTestId('save-policy');
const findDeletePolicy = () => wrapper.findByTestId('delete-policy');
const findEditorModeToggle = () => wrapper.findByTestId('editor-mode');
const modifyPolicyAlert = async ({ isAlertEnabled }) => {
const policyAlertPicker = findPolicyAlertPicker();
......@@ -213,7 +218,7 @@ spec:
beforeEach(() => {
initialValue = findPreview().props('policyDescription');
wrapper.find("[data-testid='add-rule']").vm.$emit('click');
wrapper.findByTestId('add-rule').vm.$emit('click');
});
it('updates policy description preview', () => {
......@@ -273,20 +278,32 @@ spec:
});
});
it('disables policy name field', () => {
expect(findPolicyName().attributes().disabled).toBe('true');
});
it('disables policy description field', () => {
expect(findPolicyDescription().attributes().disabled).toBe('true');
});
it('disables policy enable/disable toggle', () => {
expect(findPolicyEnableContainer().attributes().disabled).toBe('true');
});
it('renders parsing error alert', () => {
expect(findYAMLParsingAlert().exists()).toBe(true);
});
it('disables rule builder', () => {
expect(wrapper.find("[data-testid='rule-builder-container']").props().disabled).toBe(true);
expect(wrapper.findByTestId('rule-builder-container').props().disabled).toBe(true);
});
it('disables action picker', () => {
expect(wrapper.find("[data-testid='policy-action-container']").props().disabled).toBe(true);
expect(wrapper.findByTestId('policy-action-container').props().disabled).toBe(true);
});
it('disables policy preview', () => {
expect(wrapper.find("[data-testid='policy-preview-container']").props().disabled).toBe(true);
expect(wrapper.findByTestId('policy-preview-container').props().disabled).toBe(true);
});
it('does not update yaml editor value on switch to yaml editor', async () => {
......
import { GlTabs } from '@gitlab/ui';
import { GlAlert, GlTabs } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import PolicyPreview from 'ee/threat_monitoring/components/policy_editor/policy_preview.vue';
describe('PolicyPreview component', () => {
let wrapper;
const findAlert = () => wrapper.findComponent(GlAlert);
const findTabs = () => wrapper.findComponent(GlTabs);
const factory = ({ propsData } = {}) => {
wrapper = shallowMount(PolicyPreview, {
propsData: {
......@@ -13,36 +16,57 @@ describe('PolicyPreview component', () => {
});
};
beforeEach(() => {
factory({
propsData: {
policyYaml: 'foo',
policyDescription: '<strong>bar</strong><br><div>test</div><script></script>',
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('renders policy preview tabs', () => {
expect(wrapper.find(GlTabs).element).toMatchSnapshot();
describe('with policy description', () => {
beforeEach(() => {
factory({
propsData: {
policyYaml: 'foo',
policyDescription: '<strong>bar</strong><br><div>test</div><script></script>',
},
});
});
it('renders policy preview tabs', () => {
expect(findTabs().element).toMatchSnapshot();
});
it('renders the first tab', () => {
expect(findTabs().attributes().value).toEqual('0');
});
it('does not render the unsupported attributes alert', () => {
expect(findAlert().exists()).toBe(false);
});
describe('initial tab', () => {
it('selects initial tab', () => {
factory({
propsData: {
policyYaml: 'foo',
policyDescription: 'bar',
initialTab: 1,
},
});
expect(findTabs().attributes().value).toEqual('1');
});
});
});
describe('with initialTab', () => {
describe('without policy description', () => {
beforeEach(() => {
factory({
propsData: {
policyYaml: 'foo',
policyDescription: 'bar',
initialTab: 1,
},
});
});
it('selects initial tab', () => {
expect(wrapper.find(GlTabs).attributes().value).toEqual('1');
it('does render the unsupported attributes alert', () => {
expect(findAlert().exists()).toBe(true);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment