Commit 9cbdb0b2 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'network-policy-editor-yaml-drop' into 'master'

Implement yaml loading in the policy editor

See merge request gitlab-org/gitlab!40210
parents 3f4b98f8 3a6f0e28
......@@ -7,6 +7,16 @@ export default {
type: String,
required: true,
},
readOnly: {
type: Boolean,
required: false,
default: true,
},
height: {
type: Number,
required: false,
default: 300,
},
},
data() {
return { editor: null };
......@@ -42,7 +52,7 @@ export default {
occurrencesHighlight: false,
hideCursorInOverviewRuler: true,
overviewRulerBorder: false,
readOnly: true,
readOnly: this.readOnly,
});
this.editor.onDidChangeModelContent(() => {
this.$emit('input', this.editor.getValue());
......@@ -53,8 +63,5 @@ export default {
</script>
<template>
<div
ref="editor"
class="multi-file-editor-holer network-policy-editor gl-bg-gray-50 p-2 gl-overflow-x-hidden"
></div>
<div ref="editor" class="gl-overflow-hidden" :style="{ height: `${height}px` }"></div>
</template>
......@@ -235,7 +235,13 @@ export default {
<div v-if="hasSelectedPolicy">
<h5>{{ s__('NetworkPolicies|Policy definition') }}</h5>
<p>{{ s__("NetworkPolicies|Define this policy's location, conditions and actions.") }}</p>
<network-policy-editor ref="policyEditor" v-model="selectedPolicy.manifest" />
<div class="gl-p-3 gl-bg-gray-50">
<network-policy-editor
ref="policyEditor"
v-model="selectedPolicy.manifest"
class="network-policy-editor"
/>
</div>
<h5 class="mt-4">{{ s__('NetworkPolicies|Enforcement status') }}</h5>
<p>{{ s__('NetworkPolicies|Choose whether to enforce this policy.') }}</p>
......
......@@ -27,3 +27,5 @@ export const EntityTypes = {
export const PortMatchModeAny = 'any';
export const PortMatchModePortProtocol = 'port/protocol';
export const DisabledByLabel = 'network-policy.gitlab.com/disabled_by';
import { safeLoad } from 'js-yaml';
import { buildRule } from './rules';
import {
DisabledByLabel,
EndpointMatchModeAny,
EndpointMatchModeLabel,
RuleDirectionInbound,
RuleDirectionOutbound,
PortMatchModeAny,
PortMatchModePortProtocol,
RuleTypeEndpoint,
RuleTypeEntity,
RuleTypeCIDR,
RuleTypeFQDN,
} from '../constants';
/*
Convert list of matchLabel selectors used by the endpoint rule to an
entity rule object expected by the rule builder.
We expect list of object in format:
[{ matchLabels: { foo: 'bar' } }, { matchLabels: { bar: 'baz' } }]
And will return a single rule object:
{ matchLabels: 'foo:bar baz:bar' }
*/
function ruleTypeEndpointFunc(items) {
const labels = items
.reduce(
(acc, { matchLabels }) =>
acc.concat(Object.keys(matchLabels).map(key => `${key}:${matchLabels[key]}`)),
[],
)
.join(' ');
return { matchLabels: labels };
}
function ruleTypeEntityFunc(entities) {
return { entities };
}
function ruleTypeCIDRFunc(items) {
const cidr = items.join(' ');
return { cidr };
}
/*
Convert list of matchName selectors used by the fqdn rule to a
fqdn rule object expected by the rule builder.
We expect list of object in format:
[{ matchName: 'remote-service.com' }, { matchName: 'another-service.com' }]
And will return a single rule object:
{ fqdn: 'remote-service.com another-service.com' }
*/
function ruleTypeFQDNFunc(items) {
const fqdn = items.map(({ matchName }) => matchName).join(' ');
return { fqdn };
}
const rulesFunc = {
[RuleTypeEndpoint]: ruleTypeEndpointFunc,
[RuleTypeEntity]: ruleTypeEntityFunc,
[RuleTypeCIDR]: ruleTypeCIDRFunc,
[RuleTypeFQDN]: ruleTypeFQDNFunc,
};
/*
Parse yaml rule into an object expected by the policy editor.
*/
function parseRule(item, direction) {
let ruleItem;
let ruleType;
if (item.fromEntities || item.toEntities) {
ruleType = RuleTypeEntity;
ruleItem = item.fromEntities || item.toEntities;
} else if (item.fromCIDR || item.toCIDR) {
ruleType = RuleTypeCIDR;
ruleItem = item.fromCIDR || item.toCIDR;
} else if (item.toFQDNs) {
ruleType = RuleTypeFQDN;
ruleItem = item.toFQDNs;
} else {
ruleItem = item.fromEndpoints || item.toEndpoints || [];
ruleType = RuleTypeEndpoint;
}
let portMatchMode = PortMatchModeAny;
let portList = [];
if (item.toPorts?.length > 0) {
portMatchMode = PortMatchModePortProtocol;
portList = item.toPorts.reduce(
(acc, { ports }) =>
acc.concat(ports.map(({ port, protocol = 'TCP' }) => `${port}/${protocol.toLowerCase()}`)),
[],
);
}
return {
...buildRule(ruleType, {
direction,
portMatchMode,
ports: portList.join(' '),
}),
...rulesFunc[ruleType](ruleItem),
};
}
/*
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 { metadata, spec } = safeLoad(manifest, { json: true });
const { endpointSelector = {}, ingress = [], egress = [] } = spec;
const matchLabels = endpointSelector.matchLabels || {};
const endpointLabels = Object.keys(matchLabels).reduce((acc, key) => {
if (key === DisabledByLabel) return acc;
acc.push(`${key}:${matchLabels[key]}`);
return acc;
}, []);
const rules = []
.concat(
ingress.map(item => parseRule(item, RuleDirectionInbound)),
egress.map(item => parseRule(item, RuleDirectionOutbound)),
)
.filter(rule => Boolean(rule));
return {
name: metadata.name,
description: spec.description,
isEnabled: !Object.keys(matchLabels).includes(DisabledByLabel),
endpointMatchMode: endpointLabels.length > 0 ? EndpointMatchModeLabel : EndpointMatchModeAny,
endpointLabels: endpointLabels.join(' '),
rules,
};
}
import { safeDump } from 'js-yaml';
import { ruleSpec } from './rules';
import { labelSelector } from './utils';
import { EndpointMatchModeAny } from '../constants';
import { EndpointMatchModeAny, DisabledByLabel } from '../constants';
/*
Return kubernetes resource specification object for a policy.
......@@ -26,7 +26,7 @@ function spec({ description, rules, isEnabled, endpointMatchMode, endpointLabels
if (!isEnabled) {
policySpec.endpointSelector.matchLabels = {
...policySpec.endpointSelector.matchLabels,
'network-policy.gitlab.com/disabled_by': 'gitlab',
[DisabledByLabel]: 'gitlab',
};
}
......
......@@ -8,6 +8,7 @@ import {
GlToggle,
GlSegmentedControl,
GlButton,
GlAlert,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import EnvironmentPicker from '../environment_picker.vue';
......@@ -22,6 +23,7 @@ import {
RuleTypeEndpoint,
} from './constants';
import toYaml from './lib/to_yaml';
import fromYaml from './lib/from_yaml';
import { buildRule } from './lib/rules';
import humanizeNetworkPolicy from './lib/humanize';
......@@ -34,6 +36,7 @@ export default {
GlToggle,
GlSegmentedControl,
GlButton,
GlAlert,
EnvironmentPicker,
NetworkPolicyEditor,
PolicyRuleBuilder,
......@@ -43,6 +46,8 @@ export default {
data() {
return {
editorMode: EditorModeRule,
yamlEditorValue: '',
yamlEditorError: null,
policy: {
name: '',
description: '',
......@@ -66,6 +71,9 @@ export default {
shouldShowYamlEditor() {
return this.editorMode === EditorModeYAML;
},
hasParsingError() {
return Boolean(this.yamlEditorError);
},
},
created() {
this.fetchEnvironments();
......@@ -85,12 +93,32 @@ export default {
const rule = this.policy.rules[ruleIdx];
this.policy.rules.splice(ruleIdx, 1, buildRule(ruleType, rule));
},
loadYaml(manifest) {
this.yamlEditorValue = manifest;
this.yamlEditorError = null;
try {
Object.assign(this.policy, fromYaml(manifest));
} catch (error) {
this.yamlEditorError = error;
}
},
changeEditorMode(mode) {
if (mode === EditorModeYAML) {
this.yamlEditorValue = toYaml(this.policy);
}
this.editorMode = mode;
},
},
policyTypes: [{ value: 'networkPolicy', text: s__('NetworkPolicies|Network Policy') }],
editorModes: [
{ 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.',
),
};
</script>
......@@ -139,13 +167,22 @@ export default {
<div class="row">
<div class="col-md-auto">
<gl-form-group :label="s__('NetworkPolicies|Editor mode')" label-for="editorMode">
<gl-segmented-control v-model="editorMode" :options="$options.editorModes" />
<gl-segmented-control
data-testid="editor-mode"
:options="$options.editorModes"
:checked="editorMode"
@input="changeEditorMode"
/>
</gl-form-group>
</div>
</div>
<hr />
<div v-if="shouldShowRuleEditor" class="row" data-testid="rule-editor">
<div class="col-sm-12 col-md-6 col-lg-7 col-xl-8">
<gl-alert v-if="hasParsingError" data-testid="parsing-alert" :dismissible="false">{{
$options.parsingErrorMessage
}}</gl-alert>
<h4>{{ s__('NetworkPolicies|Rules') }}</h4>
<policy-rule-builder
v-for="(rule, idx) in policy.rules"
......@@ -161,9 +198,14 @@ export default {
/>
<div class="gl-p-3 gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100">
<gl-button variant="link" category="primary" data-testid="add-rule" @click="addRule">{{
s__('Network Policy|New rule')
}}</gl-button>
<gl-button
variant="link"
category="primary"
data-testid="add-rule"
:disabled="hasParsingError"
@click="addRule"
>{{ s__('Network Policy|New rule') }}</gl-button
>
</div>
<h4>{{ s__('NetworkPolicies|Actions') }}</h4>
......@@ -177,10 +219,19 @@ export default {
<div v-if="shouldShowYamlEditor" class="row" data-testid="yaml-editor">
<div class="col-sm-12 col-md-12 col-lg-10 col-xl-8">
<div class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100">
<h5 class="gl-m-0 gl-p-3 gl-bg-gray-10 gl-border-b-gray-100">
<h5
class="gl-m-0 gl-p-4 gl-bg-gray-10 gl-border-1 gl-border-b-solid gl-border-b-gray-100"
>
{{ s__('NetworkPolicies|YAML editor') }}
</h5>
<network-policy-editor id="yamlEditor" value="" />
<div class="gl-p-4">
<network-policy-editor
:value="yamlEditorValue"
:height="400"
:read-only="false"
@input="loadYaml"
/>
</div>
</div>
</div>
</div>
......
.network-policy-editor {
min-height: 300px;
.monaco-editor,
.monaco-editor-background,
.monaco-editor .inputarea.ime-input {
......
......@@ -12,17 +12,21 @@ exports[`PolicyEditorApp component given .yaml editor mode is enabled renders ya
class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100"
>
<h5
class="gl-m-0 gl-p-3 gl-bg-gray-10 gl-border-b-gray-100"
class="gl-m-0 gl-p-4 gl-bg-gray-10 gl-border-1 gl-border-b-solid gl-border-b-gray-100"
>
YAML editor
</h5>
<network-policy-editor-stub
id="yamlEditor"
value=""
/>
<div
class="gl-p-4"
>
<network-policy-editor-stub
height="400"
value=""
/>
</div>
</div>
</div>
</div>
......@@ -131,6 +135,7 @@ exports[`PolicyEditorApp component renders the policy editor layout 1`] = `
>
<gl-segmented-control-stub
checked="rule"
data-testid="editor-mode"
options="[object Object],[object Object]"
/>
</gl-form-group-stub>
......@@ -146,6 +151,8 @@ exports[`PolicyEditorApp component renders the policy editor layout 1`] = `
<div
class="col-sm-12 col-md-6 col-lg-7 col-xl-8"
>
<!---->
<h4>
Rules
</h4>
......
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 { buildRule } from 'ee/threat_monitoring/components/policy_editor/lib/rules';
import {
EndpointMatchModeAny,
EndpointMatchModeLabel,
RuleDirectionInbound,
RuleDirectionOutbound,
PortMatchModeAny,
PortMatchModePortProtocol,
RuleTypeEndpoint,
RuleTypeEntity,
RuleTypeCIDR,
RuleTypeFQDN,
EntityTypes,
} from 'ee/threat_monitoring/components/policy_editor/constants';
describe('fromYaml', () => {
let policy;
const cidrExample = '20.1.1.1/32 20.1.1.2/32';
const portExample = '80 81/udp 82/tcp';
beforeEach(() => {
policy = {
name: 'test-policy',
endpointLabels: '',
rules: [],
isEnabled: true,
};
});
it('returns policy object', () => {
expect(fromYaml(toYaml(policy))).toMatchObject({
name: 'test-policy',
isEnabled: true,
endpointMatchMode: EndpointMatchModeAny,
endpointLabels: '',
rules: [],
});
});
describe('when description is not empty', () => {
beforeEach(() => {
policy.description = 'test description';
});
it('returns policy object', () => {
expect(fromYaml(toYaml(policy))).toMatchObject({
description: 'test description',
});
});
});
describe('when policy is disabled', () => {
beforeEach(() => {
policy.isEnabled = false;
});
it('returns policy object', () => {
expect(fromYaml(toYaml(policy))).toMatchObject({
isEnabled: false,
});
});
});
describe('when endpoint labels are not empty', () => {
it('returns policy object', () => {
// test that duplicated keys are supported
const manifest = `apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: test-policy
spec:
endpointSelector:
matchLabels:
one: ''
two: value
two: overwrite
three: ''
four: ''
five: ''
network-policy.gitlab.com/disabled_by: gitlab
`;
expect(fromYaml(manifest)).toMatchObject({
endpointMatchMode: EndpointMatchModeLabel,
endpointLabels: 'one: two:overwrite three: four: five:',
});
});
});
describe('with an inbound endpoint rule', () => {
beforeEach(() => {
const rule = buildRule(RuleTypeEndpoint);
rule.matchLabels = 'one two:value two:overwrite';
policy.rules = [rule];
});
it('returns yaml representation', () => {
expect(fromYaml(toYaml(policy))).toMatchObject({
rules: [
{
ruleType: RuleTypeEndpoint,
direction: RuleDirectionInbound,
matchLabels: 'one: two:overwrite',
portMatchMode: PortMatchModeAny,
ports: '',
},
],
});
});
});
describe('with an outbound endpoint rule', () => {
beforeEach(() => {
const rule = buildRule(RuleTypeEndpoint);
rule.matchLabels = 'one two:value two:overwrite';
rule.direction = RuleDirectionOutbound;
policy.rules = [rule];
});
it('returns yaml representation', () => {
expect(fromYaml(toYaml(policy))).toMatchObject({
rules: [
{
ruleType: RuleTypeEndpoint,
direction: RuleDirectionOutbound,
matchLabels: 'one: two:overwrite',
},
],
});
});
});
describe('with an inbound entity rule', () => {
beforeEach(() => {
const rule = buildRule(RuleTypeEntity);
rule.entities = [EntityTypes.HOST, EntityTypes.WORLD];
policy.rules = [rule];
});
it('returns yaml representation', () => {
expect(fromYaml(toYaml(policy))).toMatchObject({
rules: [
{
ruleType: RuleTypeEntity,
direction: RuleDirectionInbound,
entities: [EntityTypes.HOST, EntityTypes.WORLD],
},
],
});
});
});
describe('with an outbound entity rule', () => {
beforeEach(() => {
const rule = buildRule(RuleTypeEntity);
rule.entities = [EntityTypes.HOST, EntityTypes.WORLD];
rule.direction = RuleDirectionOutbound;
policy.rules = [rule];
});
it('returns yaml representation', () => {
expect(fromYaml(toYaml(policy))).toMatchObject({
rules: [
{
ruleType: RuleTypeEntity,
direction: RuleDirectionOutbound,
entities: [EntityTypes.HOST, EntityTypes.WORLD],
},
],
});
});
});
describe('with an inbound cidr rule', () => {
beforeEach(() => {
const rule = buildRule(RuleTypeCIDR);
rule.cidr = cidrExample;
policy.rules = [rule];
});
it('returns yaml representation', () => {
expect(fromYaml(toYaml(policy))).toMatchObject({
rules: [
{
ruleType: RuleTypeCIDR,
direction: RuleDirectionInbound,
cidr: cidrExample,
},
],
});
});
});
describe('with an outbound cidr rule', () => {
beforeEach(() => {
const rule = buildRule(RuleTypeCIDR);
rule.cidr = cidrExample;
rule.direction = RuleDirectionOutbound;
policy.rules = [rule];
});
it('returns yaml representation', () => {
expect(fromYaml(toYaml(policy))).toMatchObject({
rules: [
{
ruleType: RuleTypeCIDR,
direction: RuleDirectionOutbound,
cidr: cidrExample,
},
],
});
});
});
describe('with an outbound fqdn rule', () => {
beforeEach(() => {
const rule = buildRule(RuleTypeFQDN);
rule.fqdn = 'remote-service.com another-service.com';
rule.direction = RuleDirectionOutbound;
policy.rules = [rule];
});
it('returns yaml representation', () => {
expect(fromYaml(toYaml(policy))).toMatchObject({
rules: [
{
ruleType: RuleTypeFQDN,
direction: RuleDirectionOutbound,
fqdn: 'remote-service.com another-service.com',
},
],
});
});
});
describe('with an empty inbound rule and port matcher', () => {
beforeEach(() => {
const rule = buildRule(RuleTypeEndpoint);
rule.portMatchMode = PortMatchModePortProtocol;
rule.ports = portExample;
policy.rules = [rule];
});
it('returns yaml representation', () => {
expect(fromYaml(toYaml(policy))).toMatchObject({
rules: [
{
portMatchMode: PortMatchModePortProtocol,
ports: '80/tcp 81/udp 82/tcp',
},
],
});
});
});
describe('with an empty outbound rule and port matcher', () => {
beforeEach(() => {
const rule = buildRule(RuleTypeEndpoint);
rule.portMatchMode = PortMatchModePortProtocol;
rule.ports = portExample;
rule.direction = RuleDirectionOutbound;
policy.rules = [rule];
});
it('returns yaml representation', () => {
expect(fromYaml(toYaml(policy))).toMatchObject({
rules: [
{
portMatchMode: PortMatchModePortProtocol,
ports: '80/tcp 81/udp 82/tcp',
},
],
});
});
});
});
......@@ -2,12 +2,16 @@ import { shallowMount } from '@vue/test-utils';
import PolicyEditorApp from 'ee/threat_monitoring/components/policy_editor/policy_editor.vue';
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 NetworkPolicyEditor from 'ee/threat_monitoring/components/network_policy_editor.vue';
import createStore from 'ee/threat_monitoring/store';
import {
RuleDirectionInbound,
PortMatchModeAny,
RuleTypeEndpoint,
EditorModeYAML,
EndpointMatchModeLabel,
} from 'ee/threat_monitoring/components/policy_editor/constants';
import fromYaml from 'ee/threat_monitoring/components/policy_editor/lib/from_yaml';
describe('PolicyEditorApp component', () => {
let store;
......@@ -31,6 +35,9 @@ describe('PolicyEditorApp component', () => {
const findRuleEditor = () => wrapper.find('[data-testid="rule-editor"]');
const findYamlEditor = () => wrapper.find('[data-testid="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(NetworkPolicyEditor);
beforeEach(() => {
factory();
......@@ -49,11 +56,15 @@ describe('PolicyEditorApp component', () => {
expect(findYamlEditor().exists()).toBe(false);
});
it('does not render parsing error alert', () => {
expect(findYAMLParsingAlert().exists()).toBe(false);
});
describe('given .yaml editor mode is enabled', () => {
beforeEach(() => {
factory({
data: () => ({
editorMode: 'yaml',
editorMode: EditorModeYAML,
}),
});
});
......@@ -67,6 +78,38 @@ describe('PolicyEditorApp component', () => {
expect(editor.exists()).toBe(true);
expect(editor.element).toMatchSnapshot();
});
it('updates policy on yaml editor value change', async () => {
const manifest = `apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: test-policy
spec:
description: test description
endpointSelector:
matchLabels:
network-policy.gitlab.com/disabled_by: gitlab
foo: bar
ingress:
- fromEndpoints:
- matchLabels:
foo: bar`;
findNetworkPolicyEditor().vm.$emit('input', manifest);
expect(wrapper.vm.policy).toMatchObject({
name: 'test-policy',
description: 'test description',
isEnabled: false,
endpointMatchMode: EndpointMatchModeLabel,
endpointLabels: 'foo:bar',
rules: [
{
ruleType: RuleTypeEndpoint,
matchLabels: 'foo:bar',
},
],
});
});
});
describe('given there is a name change', () => {
......@@ -97,7 +140,7 @@ describe('PolicyEditorApp component', () => {
it('adds a new rule', async () => {
expect(wrapper.findAll(PolicyRuleBuilder).length).toEqual(0);
const button = wrapper.find("[data-testid='add-rule']");
const button = findAddRuleButton();
button.vm.$emit('click');
button.vm.$emit('click');
await wrapper.vm.$nextTick();
......@@ -115,4 +158,34 @@ describe('PolicyEditorApp component', () => {
expect(builder.props().endpointSelectorDisabled).toEqual(idx !== 0);
});
});
it('updates yaml editor value on switch to yaml editor', async () => {
wrapper.find("[id='policyName']").vm.$emit('input', 'test-policy');
wrapper.find("[data-testid='editor-mode']").vm.$emit('input', EditorModeYAML);
await wrapper.vm.$nextTick();
const editor = findNetworkPolicyEditor();
expect(editor.exists()).toBe(true);
expect(fromYaml(editor.props('value'))).toMatchObject({
name: 'test-policy',
});
});
describe('given there is a yaml parsing error', () => {
beforeEach(() => {
factory({
data: () => ({
yamlEditorError: {},
}),
});
});
it('renders parsing error alert', () => {
expect(findYAMLParsingAlert().exists()).toBe(true);
});
it('disables add rule button', () => {
expect(findAddRuleButton().props('disabled')).toBe(true);
});
});
});
......@@ -16278,6 +16278,9 @@ msgstr ""
msgid "NetworkPolicies|Rule mode"
msgstr ""
msgid "NetworkPolicies|Rule mode is unavailable for this policy. In some cases, we cannot parse the YAML file back into the rules editor."
msgstr ""
msgid "NetworkPolicies|Rules"
msgstr ""
......
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