Vue3 fix workload storage (#12070)

* fix workload storage codemirror not rendering

* workload storage default component  yamleditor instead of codemirror

* test editing projected vols

* add container mount test

* fix lint

* refactor deployment tests to improve retry-ability

* add to workoad storage tests and improve retry
This commit is contained in:
Nancy 2024-10-03 15:00:34 -07:00 committed by GitHub
parent 3e89716a6e
commit f28214f9c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 325 additions and 136 deletions

View File

@ -25,6 +25,36 @@ export const createDeploymentBlueprint = {
}
}
}
],
volumes: [
{
name: 'test-vol',
projected: {
defaultMode: 420,
sources: [
{
configMap: {
items: [{ key: 'test-vol-key', path: 'test-vol-path' }],
name: 'configmap-name'
}
}
]
}
},
{
name: 'test-vol1',
projected: {
defaultMode: 420,
sources: [
{
configMap: {
items: [{ key: 'test-vol-key1', path: 'test-vol-path1' }],
name: 'configmap-name1'
}
}
]
}
}
]
},
metadata: {

View File

@ -0,0 +1,7 @@
import LabeledSelectPo from '@/cypress/e2e/po/components/labeled-select.po';
export default class ButtonDropdownPo extends LabeledSelectPo {
toggle() {
return this.self().find('[data-testid="dropdown-button"]').click();
}
}

View File

@ -22,7 +22,9 @@ export default class LabeledSelectPo extends ComponentPo {
}
clickLabel(label: string) {
return this.getOptions().contains('li', label).click();
const labelRegex = new RegExp(`^${ label } $`, 'g');
return this.getOptions().contains(labelRegex).click();
}
/**

View File

@ -10,7 +10,7 @@ export default class TabbedPo extends ComponentPo {
}
clickTabWithSelector(selector: string) {
return this.self().get(`${ selector }`).click();
return this.self().find(`${ selector }`).click();
}
allTabs() {

View File

@ -0,0 +1,37 @@
import ComponentPo from '@/cypress/e2e/po/components/component.po';
import ButtonDropdownPo from '@/cypress/e2e/po/components/button-dropdown.po';
import InputPo from '@/cypress/e2e/po/components/input.po';
class ContainerMountPo extends ComponentPo {
constructor(selector = '.dashboard-root') {
super(selector);
}
nthMountPoint(i: number) {
return new InputPo(`[data-testid="mount-path-${ i }"] input:first-child`);
}
}
export default class ContainerMountPathPo extends ComponentPo {
constructor(selector = '.dashboard-root') {
super(selector);
}
addVolumeButton() : ButtonDropdownPo {
// return this.self().find('[data-testid="container-storage-add-button"]');
return new ButtonDropdownPo('[data-testid="container-storage-add-button"]');
}
addVolume(label: string) {
this.addVolumeButton().toggle();
this.addVolumeButton().clickOptionWithLabel(label);
}
nthVolumeMount(i: number): ContainerMountPo {
return new ContainerMountPo(`[data-testid="container-storage-mount-${ i }"]`);
}
removeVolume(i: number) {
this.self().find(`[data-testid="container-storage-array-list"] [data-testid="remove-item-${ i }"]`).click();
}
}

View File

@ -0,0 +1,18 @@
import ComponentPo from '@/cypress/e2e/po/components/component.po';
import CodeMirrorPo from '@/cypress/e2e/po/components/code-mirror.po';
class WorkloadVolumePo extends ComponentPo {
yamlEditor(): CodeMirrorPo {
return CodeMirrorPo.bySelector(this.self(), '[data-testid="yaml-editor-code-mirror"]');
}
}
export default class WorkloadPodStoragePo extends ComponentPo {
constructor(selector = '.dashboard-root') {
super(selector);
}
nthVolumeComponent(n: number) {
return new WorkloadVolumePo(`[data-testid="volume-component-${ n }"]`);
}
}

View File

@ -0,0 +1,8 @@
import CodeMirrorPo from '@/cypress/e2e/po/components/code-mirror.po';
import ComponentPo from '@/cypress/e2e/po/components/component.po';
export default class YamlEditorPo extends ComponentPo {
input(): CodeMirrorPo {
return CodeMirrorPo.bySelector(this.self(), '[data-testid="yaml-editor-code-mirror"]');
}
}

View File

@ -5,7 +5,11 @@ import AsyncButtonPo from '@/cypress/e2e/po/components/async-button.po';
import LabeledSelectPo from '@/cypress/e2e/po/components/labeled-select.po';
import WorkloadPagePo from '@/cypress/e2e/po/pages/explorer/workloads.po';
import PromptRemove from '@/cypress/e2e/po/prompts/promptRemove.po';
import TabbedPo from '@/cypress/e2e/po/components/tabbed.po';
import WorkloadPodStoragePo from '@/cypress/e2e/po/components/workloads/pod-storage.po';
import ContainerMountPathPo from '@/cypress/e2e/po/components/workloads/container-mount-paths.po';
import { WorkloadType } from '@shell/types/fleet';
export class workloadDetailsPageBasePo extends PagePo {
static url: string;
@ -115,6 +119,10 @@ export class WorkloadsListPageBasePo extends PagePo {
return this.sortableTable().rowActionMenuOpen(elemName).getMenuItem('Edit YAML').click();
}
goToEditConfigPage(elemName: string) {
return this.sortableTable().rowActionMenuOpen(elemName).getMenuItem('Edit Config').click();
}
private workload() {
return new WorkloadPagePo();
}
@ -164,6 +172,49 @@ export class WorkloadsCreatePageBasePo extends PagePo {
return new AsyncButtonPo('[data-testid="form-save"]', this.self());
}
/**
*
* @returns po for the top level tabs in workloads ie general workload, pod, and one more per container
*/
horizontalTabs(): TabbedPo {
return new TabbedPo('[data-testid="workload-horizontal-tabs"]');
}
/**
*
* @returns po for the vertical tabs within the first horizontal tab, ie non-pod workload configuration
*/
generalTabs(): TabbedPo {
return new TabbedPo('[data-testid="workload-general-tabs"]');
}
/**
*
* @returns po for the vertical tabs within the pod tab
*/
podTabs(): TabbedPo {
return new TabbedPo('[data-testid="workload-pod-tabs"]');
}
/**
*
* @param containerIndex
* @returns po for vertical tabs used to configure nth container
*/
nthContainerTabs(containerIndex: number) {
this.horizontalTabs().clickTabWithSelector(`>ul>li:nth-child(${ containerIndex + 3 })`);
return new TabbedPo(`[data-testid="workload-container-tabs-${ containerIndex }"]`);
}
podStorage(): WorkloadPodStoragePo {
return new WorkloadPodStoragePo();
}
containerStorage(): ContainerMountPathPo {
return new ContainerMountPathPo();
}
createWithUI(name: string, containerImage: string, namespace = 'default') {
// NB: namespace is already selected by default
this.selectNamespace(namespace);

View File

@ -10,6 +10,8 @@ describe('Cluster Explorer', () => {
let deploymentsListPage: WorkloadsDeploymentsListPagePo;
let deploymentsCreatePage: WorkloadsDeploymentsCreatePagePo;
// collect name/namespace of all workloads created in this test suite & delete them afterwards
// edit deployment tests each create a workload per run to improve their retryability
const e2eWorkloads: { name: string; namespace: string; }[] = [];
beforeEach(() => {
@ -23,7 +25,10 @@ describe('Cluster Explorer', () => {
});
it('should be able to create a new deployment with basic options', () => {
const { name, namespace } = deploymentCreateRequest.metadata;
const name = `e2e-deployment-${ Math.random().toString(36).substr(2, 6) }`;
deploymentCreateRequest.metadata.name = name;
const { namespace } = deploymentCreateRequest.metadata;
const containerImage = 'nginx';
deploymentsCreatePage.goTo();
@ -31,7 +36,8 @@ describe('Cluster Explorer', () => {
deploymentsCreatePage.createWithUI(name, containerImage, namespace);
cy.wait('@createDeployment').then(({ request, response }) => {
expect(request.body).to.deep.eq(deploymentCreateRequest);
// comparing pod spec instead of the entire request body to avoid needing to compare labels that include the dynamic test name
expect(request.body.spec.template.spec).to.deep.eq(deploymentCreateRequest.spec.template.spec);
expect(response.statusCode).to.eq(201);
expect(response.body.metadata.name).to.eq(name);
expect(response.body.metadata.namespace).to.eq(namespace);
@ -43,16 +49,26 @@ describe('Cluster Explorer', () => {
});
describe('Update: Deployments', () => {
const { name: workloadName, namespace } = createDeploymentBlueprint.metadata;
const workloadDetailsPage = new WorkloadsDeploymentsDetailsPagePo(workloadName);
let workloadName;
let workloadDetailsPage;
const { namespace } = createDeploymentBlueprint.metadata;
let deploymentEditConfigPage;
beforeEach(() => {
cy.intercept('GET', `/v1/apps.deployments/${ namespace }/${ workloadName }`).as('testWorkload');
cy.intercept('GET', `/v1/apps.deployments/${ namespace }/${ workloadName }`).as('clonedPod');
workloadName = `e2e-deployment-${ Math.random().toString(36).substr(2, 6) }`;
const testDeployment = { ...createDeploymentBlueprint };
workloadDetailsPage = new WorkloadsDeploymentsDetailsPagePo(workloadName);
testDeployment.metadata.name = workloadName;
deploymentsListPage.goTo();
deploymentsListPage.createWithKubectl(createDeploymentBlueprint);
cy.intercept('PUT', `/v1/apps.deployments/${ namespace }/${ workloadName }`).as('editDeployment');
deploymentsListPage.goTo();
deploymentsListPage.goToEditConfigPage(workloadName);
deploymentEditConfigPage = new WorkloadsDeploymentsCreatePagePo();
// Collect the name of the workload for cleanup
e2eWorkloads.push({ name: workloadName, namespace });
});
@ -61,10 +77,65 @@ describe('Cluster Explorer', () => {
workloadDetailsPage.goTo();
workloadDetailsPage.mastheadTitle().should('contain', workloadName);
});
it('Should be able to view and edit configuration of pod volumes with no custom component', () => {
// open the pod tab
deploymentEditConfigPage.horizontalTabs().clickTabWithSelector('li#pod');
// open the pod storage tab
deploymentEditConfigPage.podTabs().clickTabWithSelector('li#storage');
// check that there is a component rendered for each workload volume and that that component has rendered some information about the volume
deploymentEditConfigPage.podStorage().nthVolumeComponent(0).yamlEditor().value()
.should('contain', 'name: test-vol');
deploymentEditConfigPage.podStorage().nthVolumeComponent(1).yamlEditor().value()
.should('contain', 'name: test-vol1');
// now try editing
deploymentEditConfigPage.podStorage().nthVolumeComponent(0).yamlEditor().set('name: test-vol-changed\nprojected:\n defaultMode: 420');
// verify that the list of volumes in the container tab has updated
deploymentEditConfigPage.nthContainerTabs(0).clickTabWithSelector('li#storage');
deploymentEditConfigPage.containerStorage().addVolumeButton().toggle();
deploymentEditConfigPage.containerStorage().addVolumeButton().getOptions().should('contain', 'test-vol-changed (projected)');
deploymentEditConfigPage.containerStorage().addVolumeButton().getOptions().should('not.contain', 'test-vol (projected)');
deploymentEditConfigPage.saveCreateForm().click();
cy.wait('@editDeployment').then(({ request, response }) => {
expect(request.body.spec.template.spec.volumes[0]).to.deep.eq({ name: 'test-vol-changed', projected: { defaultMode: 420 } });
expect(response.body.spec.template.spec.volumes[0]).to.deep.eq({ name: 'test-vol-changed', projected: { defaultMode: 420, sources: null } });
});
});
it('should be able to add container volume mounts', () => {
deploymentEditConfigPage.nthContainerTabs(0).clickTabWithSelector('li#storage');
deploymentEditConfigPage.containerStorage().addVolume('test-vol1');
deploymentEditConfigPage.containerStorage().nthVolumeMount(0).nthMountPoint(0).set('test-123');
deploymentEditConfigPage.saveCreateForm().click();
cy.wait('@editDeployment').then(({ request, response }) => {
expect(request.body.spec.template.spec.containers[0].volumeMounts).to.deep.eq([{ mountPath: 'test-123', name: 'test-vol1' }]);
expect(response.body.spec.template.spec.containers[0].volumeMounts).to.deep.eq([{ mountPath: 'test-123', name: 'test-vol1' }]);
});
// test removing volumes
deploymentsListPage.goToEditConfigPage(workloadName);
deploymentEditConfigPage.nthContainerTabs(0).clickTabWithSelector('li#storage');
deploymentEditConfigPage.containerStorage().removeVolume(0);
deploymentEditConfigPage.saveCreateForm().click();
cy.wait('@editDeployment').then(({ request, response }) => {
expect(request.body.spec.template.spec.containers[0].volumeMounts).to.deep.eq([]);
expect(response.body.spec.template.spec.containers[0].volumeMounts).to.eq(undefined);
});
});
});
describe('List: Deployments', () => {
// To reduce test runtime, will use the same workload for all the tests
it('Should list the workloads', () => {
deploymentsListPage.goTo();
e2eWorkloads.forEach(({ name }) => {
@ -74,10 +145,9 @@ describe('Cluster Explorer', () => {
});
describe('Delete: Deployments', () => {
const deploymentName = deploymentCreateRequest.metadata.name;
// To reduce test runtime, will use the same workload for all the tests
it('Should be able to delete a workload', () => {
const deploymentName = e2eWorkloads[0].name;
deploymentsListPage.goTo();
deploymentsListPage.listElementWithName(deploymentName).should('exist');

View File

@ -204,6 +204,7 @@ export default {
tabindex="-1"
type="button"
class="dropdown-button-two btn"
data-testid="dropdown-button"
@click="ddButtonAction(option)"
@focus="focusSearch"
>

View File

@ -163,6 +163,7 @@ export default {
:show-tabs-add-remove="true"
:default-tab="defaultTab"
:flat="true"
data-testid="workload-horizontal-tabs"
@changed="changed"
>
<Tab
@ -176,6 +177,7 @@ export default {
<Tabbed
:side-tabs="true"
:weight="99"
:data-testid="`workload-container-tabs-${i}`"
>
<Tab
:label="t('workload.container.titles.general')"
@ -347,13 +349,13 @@ export default {
:weight="tabWeightMap['storage']"
>
<ContainerMountPaths
v-model:value="podTemplateSpec"
v-model:container="allContainers[i]"
:value="podTemplateSpec"
:namespace="value.metadata.namespace"
:register-before-hook="registerBeforeHook"
:mode="mode"
:secrets="namespacedSecrets"
:config-maps="namespacedConfigMaps"
:container="allContainers[i]"
:save-pvc-hook-name="savePvcHookName"
@removePvcForm="clearPvcFormState"
/>
@ -366,7 +368,10 @@ export default {
:name="nameDisplayFor(type)"
:weight="99"
>
<Tabbed :side-tabs="true">
<Tabbed
data-testid="workload-general-tabs"
:side-tabs="true"
>
<Tab
name="labels"
label-key="generic.labelsAndAnnotations"
@ -404,13 +409,18 @@ export default {
:name="'pod'"
:weight="98"
>
<Tabbed :side-tabs="true">
<Tabbed
data-testid="workload-pod-tabs"
:side-tabs="true"
>
<Tab
:label="t('workload.storage.title')"
name="storage"
:weight="tabWeightMap['storage']"
@active="$refs.storage.refresh()"
>
<Storage
ref="storage"
v-model:value="podTemplateSpec"
:namespace="value.metadata.namespace"
:register-before-hook="registerBeforeHook"

View File

@ -1,14 +1,16 @@
<script>
import { clone } from '@shell/utils/object';
import { clone, set } from '@shell/utils/object';
import { _VIEW } from '@shell/config/query-params';
import { randomStr } from '@shell/utils/string';
import Mount from '@shell/edit/workload/storage/Mount';
import ButtonDropdown from '@shell/components/ButtonDropdown';
import ArrayListGrouped from '@shell/components/form/ArrayListGrouped';
export default {
name: 'ContainerMountPaths',
name: 'ContainerMountPaths',
emits: ['update:container'],
components: {
ArrayListGrouped, ButtonDropdown, Mount
},
@ -36,13 +38,10 @@ export default {
},
data() {
// set volumeMount field
this.initializeStorage();
return {
containerVolumes: [],
storageVolumes: this.getStorageVolumes(),
selectedContainerVolumes: this.getSelectedContainerVolumes()
};
return { selectedContainerVolumes: this.getSelectedContainerVolumes() };
},
computed: {
@ -51,9 +50,9 @@ export default {
},
availableVolumeOptions() {
const containerVolumes = this.container.volumeMounts.map((item) => item.name);
const containerVolumes = (this.container?.volumeMounts || []).map((item) => item.name);
return this.value.volumes.filter((vol) => !containerVolumes.includes(vol.name)).map((item) => {
return (this.value?.volumes || []).filter((vol) => !containerVolumes.includes(vol.name)).map((item) => {
return {
label: `${ item.name } (${ this.headerFor(item) })`,
action: this.selectVolume,
@ -64,27 +63,13 @@ export default {
},
watch: {
value(neu, old) {
this.selectedVolumes = this.getSelectedContainerVolumes();
},
storageVolumes(neu, old) {
// removeObjects(this.value.volumes, old);
// addObjects(this.value.volumes, neu);
const names = neu.reduce((all, each) => {
all.push(each.name);
selectedContainerVolumes: {
deep: true,
handler(neu, old) {
const names = neu.map((item) => item.name);
return all;
}, []);
this.container.volumeMounts = this.container.volumeMounts.filter((mount) => names.includes(mount.name));
},
selectedContainerVolumes(neu, old) {
// removeObjects(this.value.volumes, old);
// addObjects(this.value.volumes, neu);
const names = neu.map((item) => item.name);
this.container.volumeMounts = this.container.volumeMounts.filter((mount) => names.includes(mount.name));
this.container.volumeMounts = this.container.volumeMounts.filter((mount) => names.includes(mount.name));
}
}
},
@ -95,23 +80,9 @@ export default {
*/
initializeStorage() {
if (!this.container.volumeMounts) {
this.container['volumeMounts'] = [];
set(this.container, 'volumeMounts', []);
this.$emit('update:container', this.container);
}
if (!this.value.volumes) {
this.value['volumes'] = [];
}
},
/**
* Get existing paired storage volumes
*/
getStorageVolumes() {
// Extract volume mounts to map storage volumes
const { volumeMounts = [] } = this.container;
const names = volumeMounts.map(({ name }) => name);
// Extract storage volumes to allow mutation, if matches mount map
return clone(this.value.volumes.filter((volume) => names.includes(volume.name)));
},
getSelectedContainerVolumes() {
@ -120,7 +91,7 @@ export default {
const names = volumeMounts.map(({ name }) => name);
// Extract storage volumes to allow mutation, if matches mount map
return clone(this.value.volumes.filter((volume) => names.includes(volume.name)));
return clone((this.value?.volumes || []).filter((volume) => names.includes(volume.name)));
},
/**
@ -133,39 +104,14 @@ export default {
},
selectVolume(event) {
const selectedVolume = this.value.volumes.find((vol) => vol.name === event.value);
const selectedVolume = (this.value?.volumes || []).find((vol) => vol.name === event.value);
this.selectedContainerVolumes.push(selectedVolume);
const { name } = selectedVolume;
this.container.volumeMounts.push(name);
},
addVolume(type) {
const name = `vol-${ randomStr(5).toLowerCase() }`;
if (type === 'createPVC') {
this.storageVolumes.push({
_type: 'createPVC',
persistentVolumeClaim: {},
name,
});
} else if (type === 'csi') {
this.storageVolumes.push({
_type: type,
csi: { volumeAttributes: {} },
name,
});
} else {
this.storageVolumes.push({
_type: type,
[type]: {},
name,
});
}
this.container.volumeMounts.push({ name });
this.$emit('update:container', this.container);
},
headerFor(value) {
@ -182,13 +128,6 @@ export default {
}
},
openPopover() {
const button = this.$refs.buttonDropdown;
try {
button.togglePopover();
} catch (e) {}
},
},
};
</script>
@ -197,8 +136,11 @@ export default {
<div>
<!-- Storage Volumes -->
<ArrayListGrouped
:key="selectedContainerVolumes.length"
v-model:value="selectedContainerVolumes"
:add-allowed="false"
:mode="mode"
data-testid="container-storage-array-list"
@remove="removeVolume"
>
<!-- Custom/default storage volume form -->
@ -208,24 +150,22 @@ export default {
:container="container"
:name="props.row.value.name"
:mode="mode"
:data-testid="`container-storage-mount-${props.i}`"
/>
</template>
<!-- Add Storage Volume -->
<template #add>
<ButtonDropdown
v-if="!isView"
id="add-volume"
:button-label="t('workload.storage.selectVolume')"
:dropdown-options="availableVolumeOptions"
size="sm"
@click-action="e=>selectVolume(e)"
>
<template #no-options>
{{ t('workload.storage.noVolumes') }}
</template>
</ButtonDropdown>
</template>
</ArrayListGrouped>
<ButtonDropdown
v-if="!isView"
id="add-volume"
:button-label="t('workload.storage.selectVolume')"
:dropdown-options="availableVolumeOptions"
size="sm"
data-testid="container-storage-add-button"
@click-action="e=>selectVolume(e)"
>
<template #no-options>
{{ t('workload.storage.noVolumes') }}
</template>
</ButtonDropdown>
</div>
</template>

View File

@ -5,6 +5,7 @@ import { _VIEW } from '@shell/config/query-params';
import CodeMirror from '@shell/components/CodeMirror';
import jsyaml from 'js-yaml';
import ArrayListGrouped from '@shell/components/form/ArrayListGrouped';
import YamlEditor, { EDITOR_MODES } from '@shell/components/YamlEditor.vue';
import { randomStr } from '@shell/utils/string';
import { uniq } from '@shell/utils/array';
@ -14,7 +15,11 @@ export default {
emits: ['removePvcForm'],
components: {
ArrayListGrouped, ButtonDropdown, Mount, CodeMirror
ArrayListGrouped,
ButtonDropdown,
Mount,
CodeMirror,
YamlEditor
},
props: {
@ -110,21 +115,21 @@ export default {
pvcNames() {
return this.namespacedPvcs.map((pvc) => pvc.metadata.name);
},
yamlEditorMode() {
return this.isView ? EDITOR_MODES.VIEW_CODE : EDITOR_MODES.EDIT_CODE;
}
},
// watch: {
// storageVolumes(neu, old) {
// removeObjects(this.value.volumes, old);
// addObjects(this.value.volumes, neu);
// const names = neu.reduce((all, each) => {
// all.push(each.name);
// return all;
// }, []);
// this.container.volumeMounts = this.container.volumeMounts.filter(mount => names.includes(mount.name));
// }
// },
// need to refresh codemirror when the tab is opened and hash change === tab change
watch: {
'$route.hash': {
deep: true,
handler() {
this.refresh();
}
}
},
methods: {
/**
@ -223,14 +228,18 @@ export default {
// codemirror needs to refresh if it is in a tab that wasn't visible on page load
refresh() {
if (this.$refs.cm) {
this.$refs.cm.forEach((component) => component.refresh());
if (this.$refs) {
// if a constant ref is assigned to the codemirror component in the template below, only the last instance of that codemirror component gets the ref
const cmRefs = Object.keys(this.$refs).filter((ref) => ref.startsWith('cm-'));
cmRefs.forEach((r) => this.$refs[r].refresh());
}
},
removePvcForm(hookName) {
this.$emit('removePvcForm', hookName);
}
},
},
};
</script>
@ -260,13 +269,18 @@ export default {
:register-before-hook="registerBeforeHook"
:save-pvc-hook-name="savePvcHookName"
:loading="loading"
:data-testid="`volume-component-${props.i}`"
@removePvcForm="removePvcForm"
/>
<div v-else-if="isView">
<CodeMirror
ref="cm"
:value="yamlDisplay(props.row.value)"
:options="{ readOnly: true, cursorBlinkRate: -1 }"
<div
v-else
>
<YamlEditor
:ref="`cm-${props.i}`"
v-model:value="props.row.value"
:as-object="true"
:data-testid="`volume-component-${props.i}`"
:editor-mode="yamlEditorMode"
/>
</div>
</div>

View File

@ -15,7 +15,8 @@ export const defaultContainer = {
readOnlyRootFilesystem: false,
privileged: false,
allowPrivilegeEscalation: false,
}
},
volumeMounts: []
};
export default class Workload extends WorkloadService {
// remove clone as yaml/edit as yaml until API supported