diff --git a/.vscode/settings.json b/.vscode/settings.json index 6b9043449a4..62aa6ef2324 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,7 +11,7 @@ "eslint.packageManager": "yarn", "eslint.validate": ["vue","html","javascript","typescript"], "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, "eslint.workingDirectories": ["./", "./pkg/rancher-components/"], "javascript.preferences.importModuleSpecifier": "non-relative", diff --git a/cypress/e2e/blueprints/explorer/workload-pods.ts b/cypress/e2e/blueprints/explorer/workload-pods.ts index 97d7814108e..b5136017999 100644 --- a/cypress/e2e/blueprints/explorer/workload-pods.ts +++ b/cypress/e2e/blueprints/explorer/workload-pods.ts @@ -1,7 +1,11 @@ +// At some point these will come from somewhere central, then we can make tools to remove resources from this or all runs +const runTimestamp = +new Date(); +const runPrefix = `e2e-test-${ runTimestamp }-`; + export const createPodBlueprint = { apiVersion: 'v1', kind: 'Pod', - metadata: { name: 'nginx-test-pod', namespace: 'default' }, + metadata: { name: `${ runPrefix }nginx-test-pod`, namespace: 'default' }, spec: { containers: [ { @@ -25,5 +29,5 @@ export const createPodBlueprint = { export const clonePodBlueprint = { apiVersion: 'v1', kind: 'Pod', - metadata: { name: 'nginx-test-clone', namespace: 'default' }, + metadata: { name: `${ runPrefix }nginx-test-clone`, namespace: 'default' }, }; diff --git a/cypress/e2e/blueprints/explorer/workloads/deployments/deployment-create.ts b/cypress/e2e/blueprints/explorer/workloads/deployments/deployment-create.ts index a99e10608ae..7e50aa09553 100644 --- a/cypress/e2e/blueprints/explorer/workloads/deployments/deployment-create.ts +++ b/cypress/e2e/blueprints/explorer/workloads/deployments/deployment-create.ts @@ -37,7 +37,6 @@ export const createDeploymentBlueprint = { }; export const deploymentCreateRequest = { - type: 'apps.deployment', metadata: { namespace: 'default', labels: { 'workload.user.cattle.io/workloadselector': 'apps.deployment-default-test-deployment' }, @@ -58,9 +57,7 @@ export const deploymentCreateRequest = { privileged: false, allowPrivilegeEscalation: false }, - _init: false, volumeMounts: [], - __active: true, image: 'nginx' } ], @@ -80,7 +77,6 @@ export const deploymentCreateRequest = { export const deploymentCreateResponse = { id: 'default/test-deployment', - type: 'apps.deployment', links: { remove: 'https://localhost:8005/v1/apps.deployments/default/test-deployment', self: 'https://localhost:8005/v1/apps.deployments/default/test-deployment', diff --git a/cypress/e2e/blueprints/explorer/workloads/deployments/deployment-get.ts b/cypress/e2e/blueprints/explorer/workloads/deployments/deployment-get.ts index 18eb65ee30c..5518fe4f76e 100644 --- a/cypress/e2e/blueprints/explorer/workloads/deployments/deployment-get.ts +++ b/cypress/e2e/blueprints/explorer/workloads/deployments/deployment-get.ts @@ -1,6 +1,5 @@ export const deploymentGetResponse = { id: 'default/test-deployment', - type: 'apps.deployment', links: { remove: 'https://localhost:8005/v1/apps.deployments/default/test-deployment', self: 'https://localhost:8005/v1/apps.deployments/default/test-deployment', diff --git a/cypress/e2e/blueprints/explorer/workloads/deployments/deplyment-list.ts b/cypress/e2e/blueprints/explorer/workloads/deployments/deplyment-list.ts index e7b2000a647..2ad01e3b301 100644 --- a/cypress/e2e/blueprints/explorer/workloads/deployments/deplyment-list.ts +++ b/cypress/e2e/blueprints/explorer/workloads/deployments/deplyment-list.ts @@ -10,7 +10,6 @@ export const deploymentCollection = { { id: 'default/test-deployment', - type: 'apps.deployment', links: { remove: 'https://localhost:8005/v1/apps.deployments/default/test-deployment', self: 'https://localhost:8005/v1/apps.deployments/default/test-deployment', @@ -261,7 +260,6 @@ export const deploymentCollectionResponseFull = { data: [ { id: 'cattle-fleet-local-system/fleet-agent', - type: 'apps.deployment', links: { remove: 'https://localhost:8005/v1/apps.deployments/cattle-fleet-local-system/fleet-agent', self: 'https://localhost:8005/v1/apps.deployments/cattle-fleet-local-system/fleet-agent', @@ -597,7 +595,6 @@ export const deploymentCollectionResponseFull = { }, { id: 'cattle-fleet-system/fleet-controller', - type: 'apps.deployment', links: { remove: 'https://localhost:8005/v1/apps.deployments/cattle-fleet-system/fleet-controller', self: 'https://localhost:8005/v1/apps.deployments/cattle-fleet-system/fleet-controller', @@ -941,7 +938,6 @@ export const deploymentCollectionResponseFull = { }, { id: 'cattle-fleet-system/gitjob', - type: 'apps.deployment', links: { remove: 'https://localhost:8005/v1/apps.deployments/cattle-fleet-system/gitjob', self: 'https://localhost:8005/v1/apps.deployments/cattle-fleet-system/gitjob', @@ -1219,7 +1215,6 @@ export const deploymentCollectionResponseFull = { }, { id: 'cattle-monitoring-system/pushprox-k3s-server-proxy', - type: 'apps.deployment', links: { remove: 'https://localhost:8005/v1/apps.deployments/cattle-monitoring-system/pushprox-k3s-server-proxy', self: 'https://localhost:8005/v1/apps.deployments/cattle-monitoring-system/pushprox-k3s-server-proxy', @@ -1502,7 +1497,6 @@ export const deploymentCollectionResponseFull = { }, { id: 'cattle-monitoring-system/rancher-monitoring-grafana', - type: 'apps.deployment', links: { remove: 'https://localhost:8005/v1/apps.deployments/cattle-monitoring-system/rancher-monitoring-grafana', self: 'https://localhost:8005/v1/apps.deployments/cattle-monitoring-system/rancher-monitoring-grafana', @@ -2486,7 +2480,6 @@ export const deploymentCollectionResponseFull = { }, { id: 'cattle-monitoring-system/rancher-monitoring-kube-state-metrics', - type: 'apps.deployment', links: { remove: 'https://localhost:8005/v1/apps.deployments/cattle-monitoring-system/rancher-monitoring-kube-state-metrics', self: 'https://localhost:8005/v1/apps.deployments/cattle-monitoring-system/rancher-monitoring-kube-state-metrics', @@ -2852,7 +2845,6 @@ export const deploymentCollectionResponseFull = { }, { id: 'cattle-monitoring-system/rancher-monitoring-operator', - type: 'apps.deployment', links: { remove: 'https://localhost:8005/v1/apps.deployments/cattle-monitoring-system/rancher-monitoring-operator', self: 'https://localhost:8005/v1/apps.deployments/cattle-monitoring-system/rancher-monitoring-operator', @@ -3251,7 +3243,6 @@ export const deploymentCollectionResponseFull = { }, { id: 'cattle-monitoring-system/rancher-monitoring-prometheus-adapter', - type: 'apps.deployment', links: { remove: 'https://localhost:8005/v1/apps.deployments/cattle-monitoring-system/rancher-monitoring-prometheus-adapter', self: 'https://localhost:8005/v1/apps.deployments/cattle-monitoring-system/rancher-monitoring-prometheus-adapter', @@ -3704,7 +3695,6 @@ export const deploymentCollectionResponseFull = { }, { id: 'cattle-system/rancher-webhook', - type: 'apps.deployment', links: { remove: 'https://localhost:8005/v1/apps.deployments/cattle-system/rancher-webhook', self: 'https://localhost:8005/v1/apps.deployments/cattle-system/rancher-webhook', @@ -4117,7 +4107,6 @@ export const deploymentCollectionResponseFull = { }, { id: 'cattle-ui-plugin-system/ui-plugin-operator', - type: 'apps.deployment', links: { remove: 'https://localhost:8005/v1/apps.deployments/cattle-ui-plugin-system/ui-plugin-operator', self: 'https://localhost:8005/v1/apps.deployments/cattle-ui-plugin-system/ui-plugin-operator', @@ -4417,7 +4406,6 @@ export const deploymentCollectionResponseFull = { { id: 'default/test-deployment', - type: 'apps.deployment', links: { remove: 'https://localhost:8005/v1/apps.deployments/default/test-deployment', self: 'https://localhost:8005/v1/apps.deployments/default/test-deployment', @@ -4656,7 +4644,6 @@ export const deploymentCollectionResponseFull = { }, { id: 'kube-system/coredns', - type: 'apps.deployment', links: { remove: 'https://localhost:8005/v1/apps.deployments/kube-system/coredns', self: 'https://localhost:8005/v1/apps.deployments/kube-system/coredns', diff --git a/cypress/e2e/po/lists/base-resource-list.po.ts b/cypress/e2e/po/lists/base-resource-list.po.ts index 3007cf9ef7b..3f8612f1f39 100644 --- a/cypress/e2e/po/lists/base-resource-list.po.ts +++ b/cypress/e2e/po/lists/base-resource-list.po.ts @@ -19,6 +19,6 @@ export default class BaseResourceList extends ComponentPo { } rowWithName(rowLabel: string) { - this.resourceTable().sortableTable().rowWithName(rowLabel); + return this.resourceTable().sortableTable().rowWithName(rowLabel); } } diff --git a/cypress/e2e/po/lists/fleet/fleet.cattle.io.gitrepo.po.ts b/cypress/e2e/po/lists/fleet/fleet.cattle.io.gitrepo.po.ts new file mode 100644 index 00000000000..8cfc45a6901 --- /dev/null +++ b/cypress/e2e/po/lists/fleet/fleet.cattle.io.gitrepo.po.ts @@ -0,0 +1,10 @@ +import BaseResourceList from '@/cypress/e2e/po/lists/base-resource-list.po'; + +/** + * List component for fleet.cattle.io.gitrepo resources + */ +export default class FleetGitRepoList extends BaseResourceList { + create() { + return this.masthead().actions().eq(0).click(); + } +} diff --git a/cypress/e2e/po/pages/fleet/fleet-dashboard.po.ts b/cypress/e2e/po/pages/fleet/fleet-dashboard.po.ts index 9ec8fc30194..cf6809143f2 100644 --- a/cypress/e2e/po/pages/fleet/fleet-dashboard.po.ts +++ b/cypress/e2e/po/pages/fleet/fleet-dashboard.po.ts @@ -1,5 +1,6 @@ import PagePo from '@/cypress/e2e/po/pages/page.po'; import ResourceTablePo from '@/cypress/e2e/po/components/resource-table.po'; +import BurgerMenuPo from '@/cypress/e2e/po/side-bars/burger-side-menu.po'; export class FleetDashboardPagePo extends PagePo { static url: string; @@ -23,6 +24,11 @@ export class FleetDashboardPagePo extends PagePo { return super.goTo(FleetDashboardPagePo.createPath(clusterId)); } + static navTo() { + BurgerMenuPo.toggle(); + BurgerMenuPo.burgerMenuNavToMenubyLabel('Continuous Delivery'); + } + constructor(clusterId: string) { super(FleetDashboardPagePo.createPath(clusterId)); } diff --git a/cypress/e2e/po/pages/fleet/fleet.cattle.io.gitrepo.po.ts b/cypress/e2e/po/pages/fleet/fleet.cattle.io.gitrepo.po.ts new file mode 100644 index 00000000000..6174137145c --- /dev/null +++ b/cypress/e2e/po/pages/fleet/fleet.cattle.io.gitrepo.po.ts @@ -0,0 +1,30 @@ +import PagePo from '@/cypress/e2e/po/pages/page.po'; +import FleetGitRepoList from '@/cypress/e2e/po/lists/fleet/fleet.cattle.io.gitrepo.po'; +import { FleetDashboardPagePo } from '@/cypress/e2e/po/pages/fleet/fleet-dashboard.po'; +import ProductNavPo from '@/cypress/e2e/po/side-bars/product-side-nav.po'; + +export class FleetGitRepoListPagePo extends PagePo { + static url = `/c/_/fleet/fleet.cattle.io.gitrepo` + + constructor() { + super(FleetGitRepoListPagePo.url); + } + + goTo() { + return cy.visit(FleetGitRepoListPagePo.url); + } + + navTo() { + FleetDashboardPagePo.navTo(); + + const sideNav = new ProductNavPo(); + + sideNav.navToSideMenuEntryByLabel('Git Repos'); + + this.repoList().checkVisible(); + } + + repoList() { + return new FleetGitRepoList(this.self()); + } +} diff --git a/cypress/e2e/po/pages/fleet/gitrepo-create.po.ts b/cypress/e2e/po/pages/fleet/gitrepo-create.po.ts index 6bb90b688f1..a1fa7a923d4 100644 --- a/cypress/e2e/po/pages/fleet/gitrepo-create.po.ts +++ b/cypress/e2e/po/pages/fleet/gitrepo-create.po.ts @@ -4,6 +4,7 @@ import CreateEditViewPo from '@/cypress/e2e/po/components/create-edit-view.po'; import LabeledInputPo from '@/cypress/e2e/po/components/labeled-input.po'; import { WorkspaceSwitcherPo } from '@/cypress/e2e/po/components/workspace-switcher.po'; import SelectOrCreateAuthPo from '@/cypress/e2e/po/components/select-or-create-auth.po'; +import { FleetGitRepoListPagePo } from '@/cypress/e2e/po/pages/fleet/fleet.cattle.io.gitrepo.po'; export class GitRepoCreatePo extends PagePo { static url: string; @@ -31,6 +32,13 @@ export class GitRepoCreatePo extends PagePo { super(GitRepoCreatePo.createPath(clusterId)); } + static navTo() { + const listPage = new FleetGitRepoListPagePo(); + + listPage.navTo(); + listPage.repoList().create(); + } + selectWorkspace(name: string) { const wsSwitcher = new WorkspaceSwitcherPo(); diff --git a/cypress/e2e/tests/pages/explorer/workloads/pods.spec.ts b/cypress/e2e/tests/pages/explorer/workloads/pods.spec.ts index eb8af344d64..8d8054ef718 100644 --- a/cypress/e2e/tests/pages/explorer/workloads/pods.spec.ts +++ b/cypress/e2e/tests/pages/explorer/workloads/pods.spec.ts @@ -7,7 +7,7 @@ describe('Cluster Explorer', () => { cy.login(); }); - describe('Worklaods', () => { + describe('Workloads', () => { describe('Pods', () => { const workloadsPodPage = new WorkloadsPodsListPagePo('local'); @@ -45,7 +45,7 @@ describe('Cluster Explorer', () => { let origPodSpec: any; - cy.wait('@origPod', { timeout: 10000 }) + cy.wait('@origPod', { timeout: 20000 }) .then(({ response }) => { expect(response?.statusCode).to.eq(200); origPodSpec = response?.body.spec; @@ -58,12 +58,18 @@ describe('Cluster Explorer', () => { createClonePo.nameNsDescription().name().set(clonePodName); createClonePo.save(); + workloadsPodPage.waitForPage(); + workloadsPodPage.list().checkVisible(); + workloadsPodPage.list().resourceTable().sortableTable().filter(clonePodName); + workloadsPodPage.list().resourceTable().sortableTable().rowWithName(clonePodName) + .checkExists(); + const clonedPodPage = new WorkLoadsPodDetailsPagePo(clonePodName); - clonedPodPage.goTo(); + clonedPodPage.goTo();// Needs to be goTo to ensure http request is fired clonedPodPage.waitForPage(); - cy.wait('@clonedPod', { timeout: 10000 }) + cy.wait('@clonedPod', { timeout: 20000 }) .then(({ response }) => { expect(response?.statusCode).to.eq(200); diff --git a/cypress/e2e/tests/pages/fleet/gitrepo.spec.ts b/cypress/e2e/tests/pages/fleet/gitrepo.spec.ts index 8c7e309c7d1..b8212653a50 100644 --- a/cypress/e2e/tests/pages/fleet/gitrepo.spec.ts +++ b/cypress/e2e/tests/pages/fleet/gitrepo.spec.ts @@ -4,14 +4,18 @@ import { gitRepoCreateRequest } from '@/cypress/e2e/blueprints/fleet/gitrepos'; describe('Git Repo', { tags: ['@fleet', '@adminUser'] }, () => { describe('Create', () => { - let gitRepoCreatePage: GitRepoCreatePo; + const gitRepoCreatePage = new GitRepoCreatePo('local'); const repoList = []; before(() => { cy.login(); - gitRepoCreatePage = new GitRepoCreatePo('local'); - cy.interceptAllRequests('POST'); - gitRepoCreatePage.goTo(); + }); + + it('Should be able to create a git repo', () => { + cy.intercept('POST', '/v1/secrets/fleet-default').as('interceptSecret'); + cy.intercept('POST', '/v1/fleet.cattle.io.gitrepos').as('interceptGitRepo'); + + GitRepoCreatePo.goTo(); const { name } = gitRepoCreateRequest.metadata; const { repo, branch, paths, helmRepoURLRegex @@ -29,30 +33,31 @@ describe('Git Repo', { tags: ['@fleet', '@adminUser'] }, () => { gitRepoCreatePage.create(); repoList.push(name); - }); - it('Should be able to create a git repo', () => { // First request is for creating credentials let secretName = ''; - cy.wait('@interceptAllRequests0').then(({ request, response }) => { - expect(response.statusCode).to.eq(201); - secretName = response.body.metadata.name; - expect(secretName).not.to.eq(''); - }); - - // Second request is for creating the git repo - cy.wait('@interceptAllRequests0').then(({ request, response }) => { - gitRepoCreateRequest.spec.helmSecretName = secretName; - expect(response.statusCode).to.eq(201); - expect(request.body).to.deep.eq(gitRepoCreateRequest); - }); + cy.wait('@interceptSecret') + .then(({ request, response }) => { + expect(response.statusCode).to.eq(201); + secretName = response.body.metadata.name; + expect(secretName).not.to.eq(''); + + // Second request is for creating the git repo + return cy.wait('@interceptGitRepo'); + }) + .then(({ request, response }) => { + gitRepoCreateRequest.spec.helmSecretName = secretName; + expect(response.statusCode).to.eq(201); + expect(request.body).to.deep.eq(gitRepoCreateRequest); + }) + ; }); after(() => { const fleetDashboardPage = new FleetDashboardPagePo('local'); - fleetDashboardPage.goTo(); + FleetDashboardPagePo.navTo(); const fleetLocalResourceTable = fleetDashboardPage.resourceTable('fleet-default'); fleetLocalResourceTable.sortableTable().deleteItemWithUI('fleet-e2e-test-gitrepo'); diff --git a/cypress/support/commands/rancher-api-commands.ts b/cypress/support/commands/rancher-api-commands.ts index 3ae2f2a352b..aca9b13d98d 100644 --- a/cypress/support/commands/rancher-api-commands.ts +++ b/cypress/support/commands/rancher-api-commands.ts @@ -246,7 +246,6 @@ Cypress.Commands.add('createNamespace', (nsName, projId) => { Accept: 'application/json' }, body: { - type: 'namespace', metadata: { annotations: { 'field.cattle.io/containerDefaultResourceLimit': '{}', diff --git a/shell/models/__tests__/workload.test.ts b/shell/models/__tests__/workload.test.ts new file mode 100644 index 00000000000..311fbdd1602 --- /dev/null +++ b/shell/models/__tests__/workload.test.ts @@ -0,0 +1,91 @@ +import Workload from '@shell/models/workload.js'; +import { steveClassJunkObject } from '@shell/plugins/steve/__tests__/utils/steve-mocks'; + +describe('class: Workload', () => { + describe('given custom workload keys', () => { + const customContainerImage = 'image'; + const customContainer = { + image: customContainerImage, + __active: 'whatever', + active: 'whatever', + _init: 'whatever', + error: 'whatever', + }; + const customWorkload = { + ...steveClassJunkObject, + type: '123abv', + __rehydrate: 'whatever', + __clone: 'whatever', + spec: { + template: { + spec: { + containers: [customContainer], + initContainers: [customContainer], + } + } + } + }; + + customWorkload.metadata.name = 'abc'; + + it('should keep internal keys', () => { + const workload = new Workload(customWorkload, { + getters: { schemaFor: () => ({ linkFor: jest.fn() }) }, + dispatch: jest.fn(), + rootGetters: { 'i18n/t': jest.fn() }, + }); + + expect({ ...workload }).toStrictEqual(customWorkload); + }); + + describe('method: save', () => { + it('should remove all the internal keys', async() => { + const dispatch = jest.fn(); + const workload = new Workload(customWorkload, { + getters: { schemaFor: () => ({ linkFor: jest.fn() }) }, + dispatch, + rootGetters: { + 'i18n/t': jest.fn(), + 'i18n/exists': () => true, + }, + }); + const expectation = { + metadata: { + name: 'abc', + fields: 'whatever', + resourceVersion: 'whatever', + clusterName: 'whatever', + deletionGracePeriodSeconds: 'whatever', + generateName: 'whatever', + }, + spec: { + template: { + spec: { + containers: [{ image: customContainerImage }], + initContainers: [{ image: customContainerImage }] + } + } + } + }; + + await workload.save(); + + const opt = { + data: expectation, + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + method: 'post', + url: undefined, + }; + + // Data sent should have been cleaned + expect(dispatch).toHaveBeenCalledWith('request', { opt, type: customWorkload.type }); + + // Original workload model should remain unchanged + expect({ ...workload }).toStrictEqual(customWorkload); + }); + }); + }); +}); diff --git a/shell/models/pod.js b/shell/models/pod.js index 0e235f4c544..29fcfd4b11d 100644 --- a/shell/models/pod.js +++ b/shell/models/pod.js @@ -3,6 +3,7 @@ import { colorForState, stateDisplay } from '@shell/plugins/dashboard-store/reso import { NODE, WORKLOAD_TYPES } from '@shell/config/types'; import { escapeHtml, shortenedImage } from '@shell/utils/string'; import WorkloadService from '@shell/models/workload.service'; +import { deleteProperty } from '@shell/utils/object'; export const WORKLOAD_PRIORITY = { [WORKLOAD_TYPES.DEPLOYMENT]: 1, @@ -256,4 +257,23 @@ export default class Pod extends WorkloadService { return Promise.reject(e); }); } + + cleanForSave(data) { + const val = super.cleanForSave(data); + + // remove fields from containers + val.spec?.containers?.forEach((container) => { + this.cleanContainerForSave(container); + }); + + // remove fields from initContainers + val.spec?.initContainers?.forEach((container) => { + this.cleanContainerForSave(container); + }); + + // This is probably added by generic workload components that shouldn't be added to pods + deleteProperty(val, 'spec.selector'); + + return val; + } } diff --git a/shell/models/secret.js b/shell/models/secret.js index 0fdf065b3fa..8892e03c2e8 100644 --- a/shell/models/secret.js +++ b/shell/models/secret.js @@ -444,4 +444,16 @@ export default class Secret extends SteveModel { return color.replace('text-', 'bg-'); } + + cleanForSave(data, forNew) { + const val = super.cleanForSave(data, forNew); + + // Secrets on create with _type will return validation error + // Secrets on edit without _type will return http error + if (forNew) { + delete val._type; + } + + return val; + } } diff --git a/shell/models/workload.js b/shell/models/workload.js index 9016bf32aff..5f5b85827e8 100644 --- a/shell/models/workload.js +++ b/shell/models/workload.js @@ -649,4 +649,22 @@ export default class Workload extends WorkloadService { return matching(allInNamespace, selector); } + + cleanForSave(data) { + const val = super.cleanForSave(data); + + // remove fields from containers + if (val.spec?.template?.spec?.containers) { + val.spec?.template?.spec?.containers.forEach((container) => { + this.cleanContainerForSave(container); + }); + } + + // remove fields from initContainers + val.spec?.template?.spec?.initContainers.forEach((container) => { + this.cleanContainerForSave(container); + }); + + return val; + } } diff --git a/shell/models/workload.service.js b/shell/models/workload.service.js index 20717670f56..e7f2a8e160e 100644 --- a/shell/models/workload.service.js +++ b/shell/models/workload.service.js @@ -320,4 +320,22 @@ export default class WorkloadService extends SteveModel { return { toSave, toRemove }; } + + cleanForSave(data) { + const val = super.cleanForSave(data); + + delete val.__active; + delete val.type; + + return val; + } + + cleanContainerForSave(container) { + delete container.__active; + delete container.active; + delete container._init; + delete container.error; + + return container; + } } diff --git a/shell/plugins/dashboard-store/__tests__/resource-class.test.ts b/shell/plugins/dashboard-store/__tests__/resource-class.test.ts new file mode 100644 index 00000000000..a324853381f --- /dev/null +++ b/shell/plugins/dashboard-store/__tests__/resource-class.test.ts @@ -0,0 +1,49 @@ +import Resource from '@shell/plugins/dashboard-store/resource-class.js'; +import { resourceClassJunkObject } from '@shell/plugins/dashboard-store/__tests__/utils/store-mocks'; + +describe('class: Resource', () => { + describe('given custom resource keys', () => { + const customResource = resourceClassJunkObject; + + it('should keep internal keys', () => { + const resource = new Resource(customResource, { + getters: { schemaFor: () => ({ linkFor: jest.fn() }) }, + dispatch: jest.fn(), + rootGetters: { 'i18n/t': jest.fn() }, + }); + + expect({ ...resource }).toStrictEqual(customResource); + }); + + describe('method: save', () => { + it('should remove all the internal keys', async() => { + const dispatch = jest.fn(); + const resource = new Resource(customResource, { + getters: { schemaFor: () => ({ linkFor: jest.fn() }) }, + dispatch, + rootGetters: { 'i18n/t': jest.fn() }, + }); + + const expectation = { type: customResource.type }; + + await resource.save(); + + const opt = { + data: expectation, + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + method: 'post', + url: undefined, + }; + + // Data sent should have been cleaned + expect(dispatch).toHaveBeenCalledWith('request', { opt, type: customResource.type }); + + // Original workload model should remain unchanged + expect({ ...resource }).toStrictEqual(customResource); + }); + }); + }); +}); diff --git a/shell/plugins/dashboard-store/__tests__/utils/store-mocks.ts b/shell/plugins/dashboard-store/__tests__/utils/store-mocks.ts new file mode 100644 index 00000000000..74efe5eb64e --- /dev/null +++ b/shell/plugins/dashboard-store/__tests__/utils/store-mocks.ts @@ -0,0 +1,7 @@ +const customType = 'dsaf'; + +export const resourceClassJunkObject = { + type: customType, + __rehydrate: 'whatever', + __clone: 'whatever', +}; diff --git a/shell/plugins/dashboard-store/resource-class.js b/shell/plugins/dashboard-store/resource-class.js index bf45893b309..152e8b223b3 100644 --- a/shell/plugins/dashboard-store/resource-class.js +++ b/shell/plugins/dashboard-store/resource-class.js @@ -1093,6 +1093,16 @@ export default class Resource { return this._save(...arguments); } + /** + * Remove any unwanted properties from the object that will be saved + */ + cleanForSave(data, forNew) { + delete data.__rehydrate; + delete data.__clone; + + return data; + } + /** * Allow to handle the response of the save request * @param {*} res Full request response @@ -1100,9 +1110,6 @@ export default class Resource { processSaveResponse(res) { } async _save(opt = {}) { - delete this.__rehydrate; - delete this.__clone; - const forNew = !this.id; const errors = await this.validationErrors(this, opt.ignoreFields); @@ -1149,22 +1156,24 @@ export default class Resource { // @TODO remove this once the API maps steve _type <-> k8s type in both directions opt.data = this.toSave() || { ...this }; - if (opt?.data._type) { + if (opt.data._type) { opt.data.type = opt.data._type; } - if (opt?.data._name) { + if (opt.data._name) { opt.data.name = opt.data._name; } - if (opt?.data._labels) { + if (opt.data._labels) { opt.data.labels = opt.data._labels; } - if (opt?.data._annotations) { + if (opt.data._annotations) { opt.data.annotations = opt.data._annotations; } + opt.data = this.cleanForSave(opt.data, forNew); + // handle "replace" opt as a query param _replace=true for norman PUT requests if (opt?.replace && opt.method === 'put') { const argParam = opt.url.includes('?') ? '&' : '?'; diff --git a/shell/plugins/steve/__tests__/steve-class.spec.ts b/shell/plugins/steve/__tests__/steve-class.spec.ts new file mode 100644 index 00000000000..88eda160410 --- /dev/null +++ b/shell/plugins/steve/__tests__/steve-class.spec.ts @@ -0,0 +1,59 @@ +import Steve from '@shell/plugins/steve/steve-class.js'; +import { steveClassJunkObject } from './utils/steve-mocks'; + +describe('class: Steve', () => { + describe('given custom resource keys', () => { + const customResource = steveClassJunkObject; + + it('should keep internal keys', () => { + const steve = new Steve(customResource, { + getters: { schemaFor: () => ({ linkFor: jest.fn() }) }, + dispatch: jest.fn(), + rootGetters: { 'i18n/t': jest.fn() }, + }); + + expect({ ...steve }).toStrictEqual(customResource); + }); + + describe('method: save', () => { + it('should remove all the internal keys', async() => { + const dispatch = jest.fn(); + const steve = new Steve(customResource, { + getters: { schemaFor: () => ({ linkFor: jest.fn() }) }, + dispatch, + rootGetters: { 'i18n/t': jest.fn() }, + }); + + const expectation = { + type: customResource.type, + metadata: { + resourceVersion: 'whatever', + fields: 'whatever', + clusterName: 'whatever', + deletionGracePeriodSeconds: 'whatever', + generateName: 'whatever', + }, + spec: { versions: {} } + }; + + await steve.save(); + + const opt = { + data: expectation, + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + method: 'post', + url: undefined, + }; + + // Data sent should have been cleaned + expect(dispatch).toHaveBeenCalledWith('request', { opt, type: customResource.type }); + + // Original workload model should remain unchanged + expect({ ...steve }).toStrictEqual(customResource); + }); + }); + }); +}); diff --git a/shell/plugins/steve/__tests__/utils/steve-mocks.ts b/shell/plugins/steve/__tests__/utils/steve-mocks.ts new file mode 100644 index 00000000000..0b3a05765c6 --- /dev/null +++ b/shell/plugins/steve/__tests__/utils/steve-mocks.ts @@ -0,0 +1,31 @@ +import { resourceClassJunkObject } from '@shell/plugins/dashboard-store/__tests__/utils/store-mocks'; + +const customType = 'asdasd'; + +export const steveClassJunkObject = { + ...resourceClassJunkObject, + type: customType, + __clone: 'whatever', + metadata: { + clusterName: 'whatever', + creationTimestamp: 'whatever', + deletionGracePeriodSeconds: 'whatever', + deletionTimestamp: 'whatever', + fields: 'whatever', + finalizers: 'whatever', + generateName: 'whatever', + generation: 'whatever', + initializers: 'whatever', + managedFields: 'whatever', + ownerReferences: 'whatever', + relationships: 'whatever', + selfLink: 'whatever', + state: 'whatever', + uid: 'whatever', + resourceVersion: 'whatever', + }, + spec: { versions: { schema: 'whatever' } }, + links: 'whatever', + status: 'whatever', + stringData: 'whatever', +}; diff --git a/shell/plugins/steve/steve-class.js b/shell/plugins/steve/steve-class.js index c0e0a0029fe..71bb47049d0 100644 --- a/shell/plugins/steve/steve-class.js +++ b/shell/plugins/steve/steve-class.js @@ -1,5 +1,17 @@ import { DESCRIPTION } from '@shell/config/labels-annotations'; import HybridModel from './hybrid-class'; +import { NEVER_ADD } from '@shell/utils/create-yaml'; +import { deleteProperty } from '@shell/utils/object'; + +// Some fields that are removed for YAML (NEVER_ADD) are required via API +const STEVE_ADD = [ + 'metadata.resourceVersion', + 'metadata.fields', + 'metadata.clusterName', + 'metadata.deletionGracePeriodSeconds', + 'metadata.generateName', +]; +const STEVE_NEVER_SAVE = NEVER_ADD.filter((na) => !STEVE_ADD.includes(na)); export default class SteveModel extends HybridModel { get name() { @@ -28,4 +40,14 @@ export default class SteveModel extends HybridModel { this._description = value; } + + cleanForSave(data, forNew) { + const val = super.cleanForSave(data); + + for (const field of STEVE_NEVER_SAVE) { + deleteProperty(val, field); + } + + return val; + } } diff --git a/shell/utils/create-yaml.js b/shell/utils/create-yaml.js index bdc813c551a..031f22d04bc 100644 --- a/shell/utils/create-yaml.js +++ b/shell/utils/create-yaml.js @@ -32,7 +32,6 @@ const ALWAYS_ADD = [ ]; export const NEVER_ADD = [ - 'metadata.clusterName', 'metadata.clusterName', 'metadata.creationTimestamp', 'metadata.deletionGracePeriodSeconds', @@ -46,11 +45,16 @@ export const NEVER_ADD = [ 'metadata.resourceVersion', 'metadata.relationships', 'metadata.selfLink', + 'metadata.state', 'metadata.uid', // CRD -> Schema describes the schema used for validation, pruning, and defaulting of this version of the custom resource. If we allow processing we fall into inf loop on openAPIV3Schema.allOf which contains a cyclical ref of allOf props. 'spec.versions.schema', 'status', 'stringData', + 'links', + '_name', + '_labels', + '_annotations', ]; export const ACTIVELY_REMOVE = [ diff --git a/shell/utils/object.js b/shell/utils/object.js index 7058d6b6ffb..5922cd6e5ee 100644 --- a/shell/utils/object.js +++ b/shell/utils/object.js @@ -110,6 +110,20 @@ export function remove(obj, path) { return obj; } +/** + * `delete` a property at the given path. + * + * This is similar to `remove` but doesn't need any fancy kube obj path splitting + * and doesn't use `Vue.set` (avoids reactivity) + */ +export function deleteProperty(obj, path) { + const pathAr = path.split('.'); + const propToDelete = pathAr.pop(); + + // Walk down path until final prop, then delete final prop + delete pathAr.reduce((o, k) => o[k] || {}, obj)[propToDelete]; +} + export function getter(path) { return function(obj) { return get(obj, path);