diff --git a/.pnp.cjs b/.pnp.cjs index 12ad833e90..181b3f095d 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -1202,6 +1202,15 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["@dmsnell/diff-match-patch", [\ + ["npm:1.1.0", {\ + "packageLocation": "./.yarn/cache/@dmsnell-diff-match-patch-npm-1.1.0-021308d314-8547bf4a62.zip/node_modules/@dmsnell/diff-match-patch/",\ + "packageDependencies": [\ + ["@dmsnell/diff-match-patch", "npm:1.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@emnapi/core", [\ ["npm:1.4.4", {\ "packageLocation": "./.yarn/cache/@emnapi-core-npm-1.4.4-22a18e1831-a3a87b384d.zip/node_modules/@emnapi/core/",\ @@ -1675,9 +1684,11 @@ const RAW_RUNTIME_STATE = ["@rollup/plugin-commonjs", "virtual:faee47847dc7127a4fda44fca2035ae541a9af6260b1926ad890f5f677339c049ea62d6b398ffa233a226c0b5c370a517802e428d53b03a3356e9a04d51e8e42#npm:28.0.6"],\ ["@rollup/plugin-json", "virtual:faee47847dc7127a4fda44fca2035ae541a9af6260b1926ad890f5f677339c049ea62d6b398ffa233a226c0b5c370a517802e428d53b03a3356e9a04d51e8e42#npm:6.1.0"],\ ["@rollup/plugin-node-resolve", "virtual:faee47847dc7127a4fda44fca2035ae541a9af6260b1926ad890f5f677339c049ea62d6b398ffa233a226c0b5c370a517802e428d53b03a3356e9a04d51e8e42#npm:16.0.1"],\ + ["@vitest/coverage-v8", "virtual:91da830b29af2704bfc9679729fb85d00ca0b8eeb24a837747a5bc0b5aec0e922594580a901a48c34c06a65b9b376f23fa3cbb88dc9fd35cddc5076ab48a067f#npm:3.2.4"],\ ["abort-controller", "npm:3.0.0"],\ ["base64url", "npm:3.0.1"],\ ["body-parser", "npm:2.2.2"],\ + ["canonicalize", "npm:2.1.0"],\ ["compression", "npm:1.8.1"],\ ["cookie-parser", "npm:1.4.7"],\ ["cross-env", "npm:10.0.0"],\ @@ -1697,6 +1708,7 @@ const RAW_RUNTIME_STATE = ["jest", "virtual:f3f18773c1f2811e8d448670abfc3fed18cdffc11b444f7cbc3548ae5868e74f3c4ee449327c1fc9c24ce0732ee02505411a07539789bec8257188d17bbada1f#npm:30.1.3"],\ ["jose", "npm:5.10.0"],\ ["js-yaml", "npm:4.1.1"],\ + ["jsondiffpatch", "npm:0.7.3"],\ ["jsonwebtoken", "npm:9.0.3"],\ ["lodash", "npm:4.17.23"],\ ["lodash-es", "npm:4.17.23"],\ @@ -1728,7 +1740,8 @@ const RAW_RUNTIME_STATE = ["supertest", "npm:7.1.4"],\ ["undici", "npm:7.19.1"],\ ["unified", "npm:11.0.5"],\ - ["uuid", "npm:11.1.0"]\ + ["uuid", "npm:11.1.0"],\ + ["vitest", "virtual:91da830b29af2704bfc9679729fb85d00ca0b8eeb24a837747a5bc0b5aec0e922594580a901a48c34c06a65b9b376f23fa3cbb88dc9fd35cddc5076ab48a067f#npm:3.2.4"]\ ],\ "linkType": "SOFT"\ }]\ @@ -1787,6 +1800,7 @@ const RAW_RUNTIME_STATE = ["@xterm/addon-web-links", "virtual:8d919ffb8fd728f827df3f6a566e8e923223ffcec68f7450d83bbbc2dc25d6b8c987e111cbab484b209f253bdf2f2e00663b01a986262c44511128466462a76f#npm:0.11.0"],\ ["@xterm/xterm", "npm:5.5.0"],\ ["ansi-html", "npm:0.0.9"],\ + ["canonicalize", "npm:2.1.0"],\ ["dayjs", "npm:1.11.19"],\ ["downloadjs", "npm:1.4.7"],\ ["eslint", "virtual:f3f18773c1f2811e8d448670abfc3fed18cdffc11b444f7cbc3548ae5868e74f3c4ee449327c1fc9c24ce0732ee02505411a07539789bec8257188d17bbada1f#npm:9.32.0"],\ @@ -1804,6 +1818,7 @@ const RAW_RUNTIME_STATE = ["js-cookie", "npm:3.0.5"],\ ["js-yaml", "npm:4.1.1"],\ ["jsdom", "virtual:8d919ffb8fd728f827df3f6a566e8e923223ffcec68f7450d83bbbc2dc25d6b8c987e111cbab484b209f253bdf2f2e00663b01a986262c44511128466462a76f#npm:26.1.0"],\ + ["jsondiffpatch", "npm:0.7.3"],\ ["jwt-decode", "npm:4.0.0"],\ ["lodash", "npm:4.17.23"],\ ["md5", "npm:2.3.0"],\ @@ -4789,6 +4804,36 @@ const RAW_RUNTIME_STATE = "vitest"\ ],\ "linkType": "HARD"\ + }],\ + ["virtual:91da830b29af2704bfc9679729fb85d00ca0b8eeb24a837747a5bc0b5aec0e922594580a901a48c34c06a65b9b376f23fa3cbb88dc9fd35cddc5076ab48a067f#npm:3.2.4", {\ + "packageLocation": "./.yarn/__virtual__/@vitest-coverage-v8-virtual-ee5b494ee2/0/cache/@vitest-coverage-v8-npm-3.2.4-11f6061269-cae3e58d81.zip/node_modules/@vitest/coverage-v8/",\ + "packageDependencies": [\ + ["@ampproject/remapping", "npm:2.3.0"],\ + ["@bcoe/v8-coverage", "npm:1.0.2"],\ + ["@types/vitest", null],\ + ["@types/vitest__browser", null],\ + ["@vitest/browser", null],\ + ["@vitest/coverage-v8", "virtual:91da830b29af2704bfc9679729fb85d00ca0b8eeb24a837747a5bc0b5aec0e922594580a901a48c34c06a65b9b376f23fa3cbb88dc9fd35cddc5076ab48a067f#npm:3.2.4"],\ + ["ast-v8-to-istanbul", "npm:0.3.3"],\ + ["debug", "virtual:1ff4b5f90832ba0a9c93ba1223af226e44ba70c1126a3740d93562b97bc36544e896a5e95908196f7458713e6a6089a34bfc67362fc6df7fa093bd06c878be47#npm:4.4.3"],\ + ["istanbul-lib-coverage", "npm:3.2.2"],\ + ["istanbul-lib-report", "npm:3.0.1"],\ + ["istanbul-lib-source-maps", "npm:5.0.6"],\ + ["istanbul-reports", "npm:3.1.7"],\ + ["magic-string", "npm:0.30.21"],\ + ["magicast", "npm:0.3.5"],\ + ["std-env", "npm:3.9.0"],\ + ["test-exclude", "npm:7.0.1"],\ + ["tinyrainbow", "npm:2.0.0"],\ + ["vitest", "virtual:91da830b29af2704bfc9679729fb85d00ca0b8eeb24a837747a5bc0b5aec0e922594580a901a48c34c06a65b9b376f23fa3cbb88dc9fd35cddc5076ab48a067f#npm:3.2.4"]\ + ],\ + "packagePeers": [\ + "@types/vitest",\ + "@types/vitest__browser",\ + "@vitest/browser",\ + "vitest"\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["@vitest/eslint-plugin", [\ @@ -4844,12 +4889,12 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ - ["virtual:00436c151d7e3305179901b3b7f686975c03bf6c6c426766c13cdc0a3f89c7cde17adceef3ee32621999a6b4723d3cb91307fe2343c7d91dd0440d07bdfdfebb#npm:3.2.4", {\ - "packageLocation": "./.yarn/__virtual__/@vitest-mocker-virtual-d145b8e0ab/0/cache/@vitest-mocker-npm-3.2.4-48badb1f19-f7a4aea19b.zip/node_modules/@vitest/mocker/",\ + ["virtual:f9c32119b211f090054f37eeee42376bca7b7ff28803b804f84b70e8ac25752aeb453c59cdb6f0171f0da0b71011ed9cdcf8bad2e6b2995b8533e1c6b2609bcb#npm:3.2.4", {\ + "packageLocation": "./.yarn/__virtual__/@vitest-mocker-virtual-267fc82893/0/cache/@vitest-mocker-npm-3.2.4-48badb1f19-f7a4aea19b.zip/node_modules/@vitest/mocker/",\ "packageDependencies": [\ ["@types/msw", null],\ ["@types/vite", null],\ - ["@vitest/mocker", "virtual:00436c151d7e3305179901b3b7f686975c03bf6c6c426766c13cdc0a3f89c7cde17adceef3ee32621999a6b4723d3cb91307fe2343c7d91dd0440d07bdfdfebb#npm:3.2.4"],\ + ["@vitest/mocker", "virtual:f9c32119b211f090054f37eeee42376bca7b7ff28803b804f84b70e8ac25752aeb453c59cdb6f0171f0da0b71011ed9cdcf8bad2e6b2995b8533e1c6b2609bcb#npm:3.2.4"],\ ["@vitest/spy", "npm:3.2.4"],\ ["estree-walker", "npm:3.0.3"],\ ["magic-string", "npm:0.30.21"],\ @@ -6299,6 +6344,15 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["canonicalize", [\ + ["npm:2.1.0", {\ + "packageLocation": "./.yarn/cache/canonicalize-npm-2.1.0-8ea3fe50f0-3b1ec61276.zip/node_modules/canonicalize/",\ + "packageDependencies": [\ + ["canonicalize", "npm:2.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["ccount", [\ ["npm:2.0.1", {\ "packageLocation": "./.yarn/cache/ccount-npm-2.0.1-f4b7827860-3939b16643.zip/node_modules/ccount/",\ @@ -11056,6 +11110,16 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["jsondiffpatch", [\ + ["npm:0.7.3", {\ + "packageLocation": "./.yarn/cache/jsondiffpatch-npm-0.7.3-5579555f56-fae49073e5.zip/node_modules/jsondiffpatch/",\ + "packageDependencies": [\ + ["@dmsnell/diff-match-patch", "npm:1.1.0"],\ + ["jsondiffpatch", "npm:0.7.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["jsonfile", [\ ["npm:4.0.0", {\ "packageLocation": "./.yarn/cache/jsonfile-npm-4.0.0-10ce3aea15-7dc94b628d.zip/node_modules/jsonfile/",\ @@ -15673,7 +15737,7 @@ const RAW_RUNTIME_STATE = ["@types/vitest__ui", null],\ ["@vitest/browser", null],\ ["@vitest/expect", "npm:3.2.4"],\ - ["@vitest/mocker", "virtual:00436c151d7e3305179901b3b7f686975c03bf6c6c426766c13cdc0a3f89c7cde17adceef3ee32621999a6b4723d3cb91307fe2343c7d91dd0440d07bdfdfebb#npm:3.2.4"],\ + ["@vitest/mocker", "virtual:f9c32119b211f090054f37eeee42376bca7b7ff28803b804f84b70e8ac25752aeb453c59cdb6f0171f0da0b71011ed9cdcf8bad2e6b2995b8533e1c6b2609bcb#npm:3.2.4"],\ ["@vitest/pretty-format", "npm:3.2.4"],\ ["@vitest/runner", "npm:3.2.4"],\ ["@vitest/snapshot", "npm:3.2.4"],\ @@ -15714,6 +15778,62 @@ const RAW_RUNTIME_STATE = "jsdom"\ ],\ "linkType": "HARD"\ + }],\ + ["virtual:91da830b29af2704bfc9679729fb85d00ca0b8eeb24a837747a5bc0b5aec0e922594580a901a48c34c06a65b9b376f23fa3cbb88dc9fd35cddc5076ab48a067f#npm:3.2.4", {\ + "packageLocation": "./.yarn/__virtual__/vitest-virtual-f9c32119b2/0/cache/vitest-npm-3.2.4-7a07f931b1-5bf53ede3a.zip/node_modules/vitest/",\ + "packageDependencies": [\ + ["@edge-runtime/vm", null],\ + ["@types/chai", "npm:5.2.2"],\ + ["@types/debug", null],\ + ["@types/edge-runtime__vm", null],\ + ["@types/happy-dom", null],\ + ["@types/jsdom", null],\ + ["@types/node", null],\ + ["@types/vitest__browser", null],\ + ["@types/vitest__ui", null],\ + ["@vitest/browser", null],\ + ["@vitest/expect", "npm:3.2.4"],\ + ["@vitest/mocker", "virtual:f9c32119b211f090054f37eeee42376bca7b7ff28803b804f84b70e8ac25752aeb453c59cdb6f0171f0da0b71011ed9cdcf8bad2e6b2995b8533e1c6b2609bcb#npm:3.2.4"],\ + ["@vitest/pretty-format", "npm:3.2.4"],\ + ["@vitest/runner", "npm:3.2.4"],\ + ["@vitest/snapshot", "npm:3.2.4"],\ + ["@vitest/spy", "npm:3.2.4"],\ + ["@vitest/ui", null],\ + ["@vitest/utils", "npm:3.2.4"],\ + ["chai", "npm:5.2.0"],\ + ["debug", "virtual:1ff4b5f90832ba0a9c93ba1223af226e44ba70c1126a3740d93562b97bc36544e896a5e95908196f7458713e6a6089a34bfc67362fc6df7fa093bd06c878be47#npm:4.4.3"],\ + ["expect-type", "npm:1.2.1"],\ + ["happy-dom", null],\ + ["jsdom", null],\ + ["magic-string", "npm:0.30.21"],\ + ["pathe", "npm:2.0.3"],\ + ["picomatch", "npm:4.0.3"],\ + ["std-env", "npm:3.9.0"],\ + ["tinybench", "npm:2.9.0"],\ + ["tinyexec", "npm:0.3.2"],\ + ["tinyglobby", "npm:0.2.15"],\ + ["tinypool", "npm:1.1.1"],\ + ["tinyrainbow", "npm:2.0.0"],\ + ["vite", "virtual:cb1d79df3b4901790c8808db427c397bd3f613c8181bce1e1c99f654fcf8f1484eb3efeaaa7477306f7c95ff2d882d1e762cb59fa3743be7fbfd628566f4b6c1#npm:7.1.11"],\ + ["vite-node", "npm:3.2.4"],\ + ["vitest", "virtual:91da830b29af2704bfc9679729fb85d00ca0b8eeb24a837747a5bc0b5aec0e922594580a901a48c34c06a65b9b376f23fa3cbb88dc9fd35cddc5076ab48a067f#npm:3.2.4"],\ + ["why-is-node-running", "npm:2.3.0"]\ + ],\ + "packagePeers": [\ + "@edge-runtime/vm",\ + "@types/debug",\ + "@types/edge-runtime__vm",\ + "@types/happy-dom",\ + "@types/jsdom",\ + "@types/node",\ + "@types/vitest__browser",\ + "@types/vitest__ui",\ + "@vitest/browser",\ + "@vitest/ui",\ + "happy-dom",\ + "jsdom"\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["vitest-fetch-mock", [\ diff --git a/.yarn/cache/@dmsnell-diff-match-patch-npm-1.1.0-021308d314-8547bf4a62.zip b/.yarn/cache/@dmsnell-diff-match-patch-npm-1.1.0-021308d314-8547bf4a62.zip new file mode 100644 index 0000000000..3eb3c572d5 Binary files /dev/null and b/.yarn/cache/@dmsnell-diff-match-patch-npm-1.1.0-021308d314-8547bf4a62.zip differ diff --git a/.yarn/cache/canonicalize-npm-2.1.0-8ea3fe50f0-3b1ec61276.zip b/.yarn/cache/canonicalize-npm-2.1.0-8ea3fe50f0-3b1ec61276.zip new file mode 100644 index 0000000000..ba1b52efd5 Binary files /dev/null and b/.yarn/cache/canonicalize-npm-2.1.0-8ea3fe50f0-3b1ec61276.zip differ diff --git a/.yarn/cache/jsondiffpatch-npm-0.7.3-5579555f56-fae49073e5.zip b/.yarn/cache/jsondiffpatch-npm-0.7.3-5579555f56-fae49073e5.zip new file mode 100644 index 0000000000..7e32d69f86 Binary files /dev/null and b/.yarn/cache/jsondiffpatch-npm-0.7.3-5579555f56-fae49073e5.zip differ diff --git a/backend/__fixtures__/cloudprofiles.cjs b/backend/__fixtures__/cloudprofiles.cjs index 51aca9f118..b91368b7a6 100644 --- a/backend/__fixtures__/cloudprofiles.cjs +++ b/backend/__fixtures__/cloudprofiles.cjs @@ -13,6 +13,7 @@ function getCloudProfile ({ uid, name, kind, seedSelector = {} }) { metadata: { name, uid, + resourceVersion: String(uid * 100), }, spec: { type: kind, diff --git a/backend/__fixtures__/index.cjs b/backend/__fixtures__/index.cjs index 06c3b355c3..7e1afaa7fb 100644 --- a/backend/__fixtures__/index.cjs +++ b/backend/__fixtures__/index.cjs @@ -25,6 +25,7 @@ const resourcequotas = require('./resourcequotas') const projects = require('./projects') const serviceaccounts = require('./serviceaccounts') const cloudprofiles = require('./cloudprofiles') +const namespacedcloudprofiles = require('./namespacedCloudprofiles.cjs') const nodes = require('./nodes') const terminals = require('./terminals') const github = require('./github') @@ -58,6 +59,7 @@ const fixtures = { projects, serviceaccounts, cloudprofiles, + namespacedcloudprofiles, quotas, controllerregistrations, resourcequotas, diff --git a/backend/__fixtures__/namespacedCloudprofiles.cjs b/backend/__fixtures__/namespacedCloudprofiles.cjs new file mode 100644 index 0000000000..166b372e93 --- /dev/null +++ b/backend/__fixtures__/namespacedCloudprofiles.cjs @@ -0,0 +1,144 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +'use strict' + +const { cloneDeep, find } = require('lodash') + +function getNamespacedCloudProfile ({ uid, name, namespace, parentName, kind, seedSelector = {} }) { + return { + metadata: { + name, + namespace, + uid, + }, + spec: { + parent: { + kind: 'CloudProfile', + name: parentName, + }, + kubernetes: { + versions: [ + { + version: '1.31.1', + expirationDate: '2025-02-28T23:59:59Z', + }, + ], + }, + machineTypes: [ + { + name: `${kind}-large`, + cpu: '4', + gpu: '0', + memory: '16Gi', + usable: true, + }, + ], + }, + status: { + cloudProfileSpec: { + type: kind, + seedSelector, + kubernetes: { + versions: [ + { + version: '1.31.1', + expirationDate: '2025-02-28T23:59:59Z', + }, + { + version: '1.30.8', + }, + { + version: '1.29.10', + }, + ], + }, + machineTypes: [ + { + name: `${kind}-large`, + cpu: '4', + gpu: '0', + memory: '16Gi', + usable: true, + }, + { + name: `${kind}-medium`, + cpu: '2', + gpu: '0', + memory: '8Gi', + usable: true, + }, + ], + machineImages: [ + { + name: 'gardenlinux', + versions: [ + { + version: '15.4.20220818', + }, + ], + }, + ], + regions: [ + { + name: 'europe-central-1', + zones: [ + { + name: 'europe-central-1a', + }, + ], + }, + ], + }, + }, + } +} + +const namespacedCloudProfileList = [ + getNamespacedCloudProfile({ + uid: 1001, + name: 'custom-cloudprofile-1', + namespace: 'garden-local', + parentName: 'local', + kind: 'local', + }), + getNamespacedCloudProfile({ + uid: 1002, + name: 'custom-cloudprofile-2', + namespace: 'garden-local', + parentName: 'infra1-profileName', + kind: 'infra1', + seedSelector: { + matchLabels: { env: 'dev' }, + }, + }), + getNamespacedCloudProfile({ + uid: 1003, + name: 'custom-cloudprofile-3', + namespace: 'garden-dev', + parentName: 'infra2-profileName', + kind: 'infra2', + }), +] + +const namespacedCloudprofiles = { + create (...args) { + return getNamespacedCloudProfile(...args) + }, + get (namespace, name) { + return find(this.list(namespace), ['metadata.name', name]) + }, + list (namespace) { + const items = cloneDeep(namespacedCloudProfileList) + if (!namespace || namespace === '_all') { + return items + } + return items.filter(item => item.metadata.namespace === namespace) + }, + reset () {}, +} + +module.exports = namespacedCloudprofiles diff --git a/backend/__mocks__/jsondiffpatch.cjs b/backend/__mocks__/jsondiffpatch.cjs new file mode 100644 index 0000000000..d3bd0789d4 --- /dev/null +++ b/backend/__mocks__/jsondiffpatch.cjs @@ -0,0 +1,28 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +// CJS wrapper for the ESM-only jsondiffpatch package. +// Only the `diff` export is used by the backend. + +// jsondiffpatch ships only ESM. This wrapper is needed so that Jest (which +// loads the compiled CJS dist) can resolve the package without triggering +// "Must use import to load ES Module". +// +// We expose a lazy async-loader via a sync facade. Because the actual tests +// for namespacedCloudProfiles run under Vitest (which handles ESM natively), +// the real jsondiffpatch is never called through this mock in production tests. +// For Jest-based tests the service module is imported as a side-effect of +// loading dist/lib/services/index.cjs; those tests mock the service layer and +// never exercise the diff call directly, so a no-op implementation is fine. + +function diff (left, right) { + // Minimal no-op diff sufficient for Jest test suite isolation. + // Real diff logic is exercised in Vitest tests that import ESM directly. + if (left === right) return undefined + return { _placeholder: true } +} + +module.exports = { diff } diff --git a/backend/__tests__/acceptance/__snapshots__/api.cloudprofiles.spec.cjs.snap b/backend/__tests__/acceptance/__snapshots__/api.cloudprofiles.spec.cjs.snap index ceb1252950..fb299a6d25 100644 --- a/backend/__tests__/acceptance/__snapshots__/api.cloudprofiles.spec.cjs.snap +++ b/backend/__tests__/acceptance/__snapshots__/api.cloudprofiles.spec.cjs.snap @@ -31,6 +31,7 @@ exports[`api cloudprofiles should return all cloudprofiles 2`] = ` { "metadata": { "name": "infra1-profileName", + "resourceVersion": "100", "uid": 1, }, "spec": { @@ -51,6 +52,7 @@ exports[`api cloudprofiles should return all cloudprofiles 2`] = ` { "metadata": { "name": "infra1-profileName2", + "resourceVersion": "200", "uid": 2, }, "spec": { @@ -76,6 +78,7 @@ exports[`api cloudprofiles should return all cloudprofiles 2`] = ` { "metadata": { "name": "infra2-profileName", + "resourceVersion": "300", "uid": 3, }, "spec": { @@ -96,6 +99,7 @@ exports[`api cloudprofiles should return all cloudprofiles 2`] = ` { "metadata": { "name": "infra3-profileName", + "resourceVersion": "400", "uid": 4, }, "spec": { @@ -120,6 +124,7 @@ exports[`api cloudprofiles should return all cloudprofiles 2`] = ` { "metadata": { "name": "infra3-profileName2", + "resourceVersion": "500", "uid": 5, }, "spec": { @@ -140,6 +145,7 @@ exports[`api cloudprofiles should return all cloudprofiles 2`] = ` { "metadata": { "name": "infra4-profileName", + "resourceVersion": "600", "uid": 6, }, "spec": { diff --git a/backend/__tests__/acceptance/__snapshots__/api.namespacedCloudprofiles.spec.cjs.snap b/backend/__tests__/acceptance/__snapshots__/api.namespacedCloudprofiles.spec.cjs.snap new file mode 100644 index 0000000000..5693efb3d8 --- /dev/null +++ b/backend/__tests__/acceptance/__snapshots__/api.namespacedCloudprofiles.spec.cjs.snap @@ -0,0 +1,209 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`api namespaced cloudprofiles should return all namespaced cloudprofiles for a namespace 1`] = ` +[ + [ + { + ":authority": "kubernetes:6443", + ":method": "post", + ":path": "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews", + ":scheme": "https", + "authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImpvaG4uZG9lQGV4YW1wbGUub3JnIiwiaWF0IjoxNTc3ODM2ODAwLCJhdWQiOlsiZ2FyZGVuZXIiXSwiZXhwIjozMTU1NzE2ODAwLCJqdGkiOiJqdGkifQ.LkQ9PEN893UNTsZZn2Ux_CAYNOoQ2ISboWuHiAc5HHU", + }, + { + "apiVersion": "authorization.k8s.io/v1", + "kind": "SelfSubjectAccessReview", + "spec": { + "nonResourceAttributes": undefined, + "resourceAttributes": { + "group": "core.gardener.cloud", + "namespace": "garden-local", + "resource": "namespacedcloudprofiles", + "verb": "list", + }, + }, + }, + ], +] +`; + +exports[`api namespaced cloudprofiles should return all namespaced cloudprofiles for a namespace 2`] = ` +[ + { + "metadata": { + "name": "custom-cloudprofile-1", + "namespace": "garden-local", + "uid": 1001, + }, + "spec": { + "kubernetes": { + "versions": [ + { + "expirationDate": "2025-02-28T23:59:59Z", + "version": "1.31.1", + }, + ], + }, + "machineTypes": [ + { + "cpu": "4", + "gpu": "0", + "memory": "16Gi", + "name": "local-large", + "usable": true, + }, + ], + "parent": { + "kind": "CloudProfile", + "name": "local", + }, + }, + "status": { + "cloudProfileSpec": { + "kubernetes": { + "versions": [ + { + "expirationDate": "2025-02-28T23:59:59Z", + "version": "1.31.1", + }, + { + "version": "1.30.8", + }, + { + "version": "1.29.10", + }, + ], + }, + "machineImages": [ + { + "name": "gardenlinux", + "versions": [ + { + "version": "15.4.20220818", + }, + ], + }, + ], + "machineTypes": [ + { + "cpu": "4", + "gpu": "0", + "memory": "16Gi", + "name": "local-large", + "usable": true, + }, + { + "cpu": "2", + "gpu": "0", + "memory": "8Gi", + "name": "local-medium", + "usable": true, + }, + ], + "regions": [ + { + "name": "europe-central-1", + "zones": [ + { + "name": "europe-central-1a", + }, + ], + }, + ], + "seedSelector": {}, + "type": "local", + }, + }, + }, + { + "metadata": { + "name": "custom-cloudprofile-2", + "namespace": "garden-local", + "uid": 1002, + }, + "spec": { + "kubernetes": { + "versions": [ + { + "expirationDate": "2025-02-28T23:59:59Z", + "version": "1.31.1", + }, + ], + }, + "machineTypes": [ + { + "cpu": "4", + "gpu": "0", + "memory": "16Gi", + "name": "infra1-large", + "usable": true, + }, + ], + "parent": { + "kind": "CloudProfile", + "name": "infra1-profileName", + }, + }, + "status": { + "cloudProfileSpec": { + "kubernetes": { + "versions": [ + { + "expirationDate": "2025-02-28T23:59:59Z", + "version": "1.31.1", + }, + { + "version": "1.30.8", + }, + { + "version": "1.29.10", + }, + ], + }, + "machineImages": [ + { + "name": "gardenlinux", + "versions": [ + { + "version": "15.4.20220818", + }, + ], + }, + ], + "machineTypes": [ + { + "cpu": "4", + "gpu": "0", + "memory": "16Gi", + "name": "infra1-large", + "usable": true, + }, + { + "cpu": "2", + "gpu": "0", + "memory": "8Gi", + "name": "infra1-medium", + "usable": true, + }, + ], + "regions": [ + { + "name": "europe-central-1", + "zones": [ + { + "name": "europe-central-1a", + }, + ], + }, + ], + "seedSelector": { + "matchLabels": { + "env": "dev", + }, + }, + "type": "infra1", + }, + }, + }, +] +`; diff --git a/backend/__tests__/acceptance/api.cloudprofiles.spec.cjs b/backend/__tests__/acceptance/api.cloudprofiles.spec.cjs index aed4f7f69a..b8c170ed19 100644 --- a/backend/__tests__/acceptance/api.cloudprofiles.spec.cjs +++ b/backend/__tests__/acceptance/api.cloudprofiles.spec.cjs @@ -39,5 +39,60 @@ describe('api', function () { expect(res.body).toMatchSnapshot() }) + + it('should return 403 when user is not allowed to list cloudprofiles', async function () { + mockRequest.mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess({ + allowed: false, + })) + + const res = await agent + .get('/api/cloudprofiles') + .set('cookie', await user.cookie) + .expect(403) + + expect(mockRequest).toHaveBeenCalledTimes(1) + expect(res.body.code).toBe(403) + expect(res.body.message).toMatch(/You are not allowed to list cloudprofiles/) + }) + + describe('GET /api/cloudprofiles/:name', () => { + it('should return a single cloud profile', async function () { + mockRequest.mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + + const profileName = 'infra1-profileName' + const res = await agent + .get(`/api/cloudprofiles/${profileName}`) + .set('cookie', await user.cookie) + .expect('content-type', /json/) + .expect(200) + + expect(mockRequest).toHaveBeenCalledTimes(1) + expect(res.body.metadata.name).toBe(profileName) + expect(res.body.spec).toBeDefined() + }) + + it('should return 404 for unknown cloud profile', async function () { + mockRequest.mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + + await agent + .get('/api/cloudprofiles/nonexistent') + .set('cookie', await user.cookie) + .expect(404) + }) + + it('should return 403 when user is not allowed to get cloudprofile', async function () { + mockRequest.mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess({ + allowed: false, + })) + + const res = await agent + .get('/api/cloudprofiles/infra1-profileName') + .set('cookie', await user.cookie) + .expect(403) + + expect(res.body.code).toBe(403) + expect(res.body.message).toMatch(/not allowed to get cloudprofile/) + }) + }) }) }) diff --git a/backend/__tests__/acceptance/api.namespacedCloudprofiles.spec.cjs b/backend/__tests__/acceptance/api.namespacedCloudprofiles.spec.cjs new file mode 100644 index 0000000000..397a6ee112 --- /dev/null +++ b/backend/__tests__/acceptance/api.namespacedCloudprofiles.spec.cjs @@ -0,0 +1,59 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +'use strict' + +const { mockRequest } = require('@gardener-dashboard/request') + +describe('api', function () { + let agent + + beforeAll(() => { + agent = createAgent() + }) + + afterAll(() => { + return agent.close() + }) + + beforeEach(() => { + mockRequest.mockReset() + }) + + describe('namespaced cloudprofiles', function () { + const user = fixtures.user.create({ id: 'john.doe@example.org' }) + const namespace = 'garden-local' + + it('should return all namespaced cloudprofiles for a namespace', async function () { + mockRequest.mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess()) + + const res = await agent + .get(`/api/namespaces/${namespace}/namespacedcloudprofiles`) + .set('cookie', await user.cookie) + .expect('content-type', /json/) + + expect(mockRequest).toHaveBeenCalledTimes(1) + expect(mockRequest.mock.calls).toMatchSnapshot() + + expect(res.body).toMatchSnapshot() + }) + + it('should return 403 when user is not allowed to list namespaced cloudprofiles', async function () { + mockRequest.mockImplementationOnce(fixtures.auth.mocks.reviewSelfSubjectAccess({ + allowed: false, + })) + + const res = await agent + .get(`/api/namespaces/${namespace}/namespacedcloudprofiles`) + .set('cookie', await user.cookie) + .expect(403) + + expect(mockRequest).toHaveBeenCalledTimes(1) + expect(res.body.code).toBe(403) + expect(res.body.message).toMatch(/not allowed to list namespaced cloudprofiles/) + }) + }) +}) diff --git a/backend/__tests__/cache.spec.cjs b/backend/__tests__/cache.spec.cjs index 29138f469e..9ad83d9e27 100644 --- a/backend/__tests__/cache.spec.cjs +++ b/backend/__tests__/cache.spec.cjs @@ -31,7 +31,7 @@ describe('cache', function () { it('should dispatch "getCloudProfiles" to internal cache', function () { const list = [] const stub = jest.spyOn(internalCache, 'getCloudProfiles').mockReturnValue(list) - expect(cache.getCloudProfiles()).toBe(list) + expect(cache.getCloudProfiles()).toStrictEqual(list) expect(stub).toHaveBeenCalledTimes(1) }) diff --git a/backend/__tests__/services.namespacedCloudProfiles.spec.js b/backend/__tests__/services.namespacedCloudProfiles.spec.js new file mode 100644 index 0000000000..f83ff09789 --- /dev/null +++ b/backend/__tests__/services.namespacedCloudProfiles.spec.js @@ -0,0 +1,460 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, +} from 'vitest' +import { cloneDeep } from 'lodash-es' +import * as jsondiffpatch from 'jsondiffpatch' + +vi.mock('../lib/config/index.js', () => ({ + default: {}, +})) + +const cloudProfileList = [ + { + metadata: { + name: 'local', + uid: 1, + resourceVersion: '100', + }, + spec: { + type: 'local', + kubernetes: { + versions: [ + { version: '1.30.0' }, + { version: '1.29.0' }, + ], + }, + machineImages: [ + { name: 'ubuntu', versions: [{ version: '20.04' }] }, + ], + providerConfig: { + someProviderSpecificField: 'value', + }, + }, + }, + { + metadata: { + name: 'infra1-profileName', + uid: 2, + resourceVersion: '200', + }, + spec: { + type: 'infra1', + kubernetes: { + versions: [ + { version: '1.30.0' }, + ], + }, + machineTypes: [ + { name: 'm5.large', cpu: '2', memory: '8Gi' }, + ], + providerConfig: { + anotherProviderField: 'other-value', + }, + }, + }, + { + metadata: { + name: 'infra2-profileName', + uid: 3, + resourceVersion: '300', + }, + spec: { + type: 'infra2', + kubernetes: { + versions: [ + { version: '1.28.0' }, + ], + }, + }, + }, +] + +const namespacedCloudProfileList = [ + { + kind: 'NamespacedCloudProfile', + metadata: { + name: 'custom-cloudprofile-1', + namespace: 'garden-local', + uid: 1001, + }, + spec: { + parent: { + kind: 'CloudProfile', + name: 'local', + }, + }, + status: { + cloudProfileSpec: { + type: 'local', + kubernetes: { + versions: [ + { version: '1.31.1' }, + { version: '1.30.0' }, + { version: '1.29.0' }, + ], + }, + machineImages: [ + { name: 'ubuntu', versions: [{ version: '20.04' }] }, + ], + providerConfig: { + someProviderSpecificField: 'value', + }, + }, + }, + }, + { + kind: 'NamespacedCloudProfile', + metadata: { + name: 'custom-cloudprofile-2', + namespace: 'garden-local', + uid: 1002, + }, + spec: { + parent: { + kind: 'CloudProfile', + name: 'infra1-profileName', + }, + }, + status: { + cloudProfileSpec: { + type: 'infra1', + kubernetes: { + versions: [ + { version: '1.31.1' }, + ], + }, + machineTypes: [ + { name: 'm5.large', cpu: '2', memory: '8Gi' }, + { name: 'm5.xlarge', cpu: '4', memory: '16Gi' }, + ], + providerConfig: { + anotherProviderField: 'other-value', + }, + }, + }, + }, + { + kind: 'NamespacedCloudProfile', + metadata: { + name: 'custom-cloudprofile-3', + namespace: 'garden-dev', + uid: 1003, + }, + spec: { + parent: { + kind: 'CloudProfile', + name: 'infra2-profileName', + }, + }, + status: { + cloudProfileSpec: { + type: 'infra2', + kubernetes: { + versions: [ + { version: '1.28.0' }, + ], + }, + }, + }, + }, +] + +vi.mock('../lib/cache/index.js', () => ({ + default: { + getNamespacedCloudProfiles: vi.fn((namespace) => { + const items = cloneDeep(namespacedCloudProfileList) + if (namespace) { + return items.filter(item => item.metadata.namespace === namespace) + } + return items + }), + getCloudProfile: vi.fn((name) => { + const profile = cloudProfileList.find(p => p.metadata.name === name) + return profile ? cloneDeep(profile) : undefined + }), + }, +})) + +vi.mock('../lib/services/authorization.js', () => ({ + canListNamespacedCloudProfiles: vi.fn(), +})) + +describe('services/namespacedCloudProfiles', () => { + let namespacedCloudProfiles + let authorization + let cache + let Forbidden + + beforeEach(async () => { + const httpErrors = await import('http-errors') + Forbidden = httpErrors.default.Forbidden + + const authModule = await import('../lib/services/authorization.js') + authorization = authModule + + const cacheModule = await import('../lib/cache/index.js') + cache = cacheModule.default + + const namespacedCloudProfilesModule = await import('../lib/services/namespacedCloudProfiles.js') + namespacedCloudProfiles = namespacedCloudProfilesModule + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + const createUser = (username) => { + return { + id: username, + type: 'email', + } + } + + describe('#listForNamespace', () => { + it('should return namespaced cloudprofiles for a namespace when user is authorized', async () => { + const user = createUser('foo@example.org') + const namespace = 'garden-local' + + authorization.canListNamespacedCloudProfiles.mockResolvedValueOnce(true) + + const result = await namespacedCloudProfiles.listForNamespace({ user, namespace }) + + expect(authorization.canListNamespacedCloudProfiles).toHaveBeenCalledWith(user, namespace) + expect(result).toHaveLength(2) + expect(result[0].metadata.name).toBe('custom-cloudprofile-1') + expect(result[1].metadata.name).toBe('custom-cloudprofile-2') + }) + + it('should throw Forbidden error when user is not authorized', async () => { + const user = createUser('foo@example.org') + const namespace = 'garden-local' + + authorization.canListNamespacedCloudProfiles.mockResolvedValueOnce(false) + + const promise = namespacedCloudProfiles.listForNamespace({ user, namespace }) + + await expect(promise).rejects.toThrow(Forbidden) + await expect(promise).rejects.toThrow(`You are not allowed to list namespaced cloudprofiles in namespace ${namespace}`) + + expect(authorization.canListNamespacedCloudProfiles).toHaveBeenCalledWith(user, namespace) + }) + }) + + describe('#diff functionality', () => { + it('should return full cloudProfileSpec when diff=false', async () => { + const user = createUser('foo@example.org') + const namespace = 'garden-local' + + authorization.canListNamespacedCloudProfiles.mockResolvedValueOnce(true) + + const result = await namespacedCloudProfiles.listForNamespace({ user, namespace, diff: false }) + + expect(result).toHaveLength(2) + expect(result[0].status.cloudProfileSpec).toBeDefined() + expect(result[0].status.cloudProfileSpecDiff).toBeUndefined() + }) + + it('should return cloudProfileSpecDiff when diff=true', async () => { + const user = createUser('foo@example.org') + const namespace = 'garden-local' + + authorization.canListNamespacedCloudProfiles.mockResolvedValueOnce(true) + + const result = await namespacedCloudProfiles.listForNamespace({ user, namespace, diff: true }) + + expect(result).toHaveLength(2) + expect(result[0].status.cloudProfileSpec).toBeUndefined() + expect(result[0].status.cloudProfileSpecDiff).toBeDefined() + }) + + it('should be able to reconstruct original status by applying diff to parent spec', async () => { + const user = createUser('foo@example.org') + const namespace = 'garden-local' + + const { simplifyCloudProfile } = await import('../lib/utils/index.js') + + authorization.canListNamespacedCloudProfiles.mockResolvedValueOnce(true) + const fullResult = await namespacedCloudProfiles.listForNamespace({ user, namespace, diff: false }) + + authorization.canListNamespacedCloudProfiles.mockResolvedValueOnce(true) + const diffResult = await namespacedCloudProfiles.listForNamespace({ user, namespace, diff: true }) + + for (let i = 0; i < fullResult.length; i++) { + const fullProfile = fullResult[i] + const diffProfile = diffResult[i] + + const parentName = fullProfile.spec.parent.name + const parentCloudProfile = cache.getCloudProfile(parentName) + + const simplifiedParent = simplifyCloudProfile(cloneDeep(parentCloudProfile)) + const parentSpec = simplifiedParent.spec + + const reconstructedSpec = jsondiffpatch.patch(cloneDeep(parentSpec), diffProfile.status.cloudProfileSpecDiff) + const expectedSpec = fullProfile.status.cloudProfileSpec + + expect(reconstructedSpec).toEqual(expectedSpec) + } + }) + + it('should return null diff when parent profile is identical to namespaced spec', async () => { + const user = createUser('foo@example.org') + const namespace = 'garden-dev' + + authorization.canListNamespacedCloudProfiles.mockResolvedValueOnce(true) + const diffResult = await namespacedCloudProfiles.listForNamespace({ user, namespace, diff: true }) + + expect(diffResult).toHaveLength(1) + expect(diffResult[0].status.cloudProfileSpecDiff).toBeNull() // null means no diff + }) + + it('should handle providerConfig according to simplifyCloudProfile rules', async () => { + const user = createUser('foo@example.org') + const namespace = 'garden-local' + + authorization.canListNamespacedCloudProfiles.mockResolvedValueOnce(true) + const diffResult = await namespacedCloudProfiles.listForNamespace({ user, namespace, diff: true }) + + const diff = diffResult[0].status.cloudProfileSpecDiff + + expect(diff).toBeDefined() + if (diff) { + expect(diff.providerConfig).toBeUndefined() + } + }) + + it('should correctly diff kubernetes versions array changes', async () => { + const user = createUser('foo@example.org') + const namespace = 'garden-local' + + const { simplifyCloudProfile } = await import('../lib/utils/index.js') + + authorization.canListNamespacedCloudProfiles.mockResolvedValueOnce(true) + const fullResult = await namespacedCloudProfiles.listForNamespace({ user, namespace, diff: false }) + + authorization.canListNamespacedCloudProfiles.mockResolvedValueOnce(true) + const diffResult = await namespacedCloudProfiles.listForNamespace({ user, namespace, diff: true }) + + const profile1Diff = diffResult[0].status.cloudProfileSpecDiff + expect(profile1Diff).toBeDefined() + expect(profile1Diff.kubernetes).toBeDefined() + expect(profile1Diff.kubernetes.versions).toBeDefined() + + const parentName = fullResult[0].spec.parent.name + const parentCloudProfile = cache.getCloudProfile(parentName) + const simplifiedParent = simplifyCloudProfile(cloneDeep(parentCloudProfile)) + const parentSpec = simplifiedParent.spec + + const reconstructed = jsondiffpatch.patch(cloneDeep(parentSpec), profile1Diff) + const expected = fullResult[0].status.cloudProfileSpec + + expect(reconstructed).toEqual(expected) + }) + + it('should include parentCloudProfileResourceVersion in diff response', async () => { + const user = createUser('foo@example.org') + const namespace = 'garden-local' + + authorization.canListNamespacedCloudProfiles.mockResolvedValueOnce(true) + const result = await namespacedCloudProfiles.listForNamespace({ user, namespace, diff: true }) + + expect(result).toHaveLength(2) + for (const profile of result) { + expect(profile.status.parentCloudProfileResourceVersion).toBeDefined() + expect(typeof profile.status.parentCloudProfileResourceVersion).toBe('string') + } + }) + + it('should return the resourceVersion of the parent used for diff computation', async () => { + const user = createUser('foo@example.org') + const namespace = 'garden-local' + + authorization.canListNamespacedCloudProfiles.mockResolvedValueOnce(true) + const result = await namespacedCloudProfiles.listForNamespace({ user, namespace, diff: true }) + + const profile1 = result[0] + expect(profile1.spec.parent.name).toBe('local') + expect(profile1.status.parentCloudProfileResourceVersion).toBe('100') + + const profile2 = result[1] + expect(profile2.spec.parent.name).toBe('infra1-profileName') + expect(profile2.status.parentCloudProfileResourceVersion).toBe('200') + }) + + it('should include cloudProfileSpecHash in diff response', async () => { + const user = createUser('foo@example.org') + const namespace = 'garden-local' + + authorization.canListNamespacedCloudProfiles.mockResolvedValueOnce(true) + const result = await namespacedCloudProfiles.listForNamespace({ user, namespace, diff: true }) + + expect(result).toHaveLength(2) + for (const profile of result) { + expect(profile.status.cloudProfileSpecHash).toBeDefined() + expect(typeof profile.status.cloudProfileSpecHash).toBe('string') + expect(profile.status.cloudProfileSpecHash).toMatch(/^[0-9a-f]{64}$/) + } + }) + + it('should return a hash that matches the full NSCP spec', async () => { + const user = createUser('foo@example.org') + const namespace = 'garden-local' + + const { computeSpecHash } = await import('../lib/utils/index.js') + + authorization.canListNamespacedCloudProfiles.mockResolvedValueOnce(true) + const fullResult = await namespacedCloudProfiles.listForNamespace({ user, namespace, diff: false }) + + authorization.canListNamespacedCloudProfiles.mockResolvedValueOnce(true) + const diffResult = await namespacedCloudProfiles.listForNamespace({ user, namespace, diff: true }) + + for (let i = 0; i < fullResult.length; i++) { + const fullSpec = fullResult[i].status.cloudProfileSpec + const hash = diffResult[i].status.cloudProfileSpecHash + expect(hash).toBe(computeSpecHash(fullSpec)) + } + }) + + it('should not include parentCloudProfileResourceVersion when diff=false', async () => { + const user = createUser('foo@example.org') + const namespace = 'garden-local' + + authorization.canListNamespacedCloudProfiles.mockResolvedValueOnce(true) + const result = await namespacedCloudProfiles.listForNamespace({ user, namespace, diff: false }) + + for (const profile of result) { + expect(profile.status.parentCloudProfileResourceVersion).toBeUndefined() + expect(profile.status.cloudProfileSpecHash).toBeUndefined() + } + }) + + it('should return null parentCloudProfileResourceVersion and cloudProfileSpecHash when parent is not found', async () => { + const user = createUser('foo@example.org') + const namespace = 'garden-local' + + cache.getNamespacedCloudProfiles.mockReturnValueOnce([ + cloneDeep({ + kind: 'NamespacedCloudProfile', + metadata: { name: 'orphan', namespace: 'garden-local', uid: 9999 }, + spec: { parent: { kind: 'CloudProfile', name: 'nonexistent' } }, + status: { cloudProfileSpec: { type: 'test' } }, + }), + ]) + authorization.canListNamespacedCloudProfiles.mockResolvedValueOnce(true) + const result = await namespacedCloudProfiles.listForNamespace({ user, namespace, diff: true }) + + expect(result[0].status.parentCloudProfileResourceVersion).toBeNull() + expect(result[0].status.cloudProfileSpecHash).toBeNull() + }) + }) +}) diff --git a/backend/jest.config.cjs b/backend/jest.config.cjs index e986b80cdc..586d636681 100644 --- a/backend/jest.config.cjs +++ b/backend/jest.config.cjs @@ -12,9 +12,6 @@ module.exports = { 'dist/**/*.cjs', ], testEnvironment: 'node', - testPathIgnorePatterns: [ - '/test-ignore/', - ], transformIgnorePatterns: [ '/node_modules/', '\\.pnp\\.[^\\/]+$', @@ -32,12 +29,11 @@ module.exports = { '/jest.setup.cjs', ], testMatch: [ - '**/__tests__/**/*.js', '**/__tests__/**/*.cjs', - '**/?(*.)+(spec|test).js', '**/?(*.)+(spec|test).cjs', ], moduleNameMapper: { '^\\.{2}/markdown(\\.cjs)?$': '/__mocks__/@gardener-dashboard/markdown.cjs', + '^jsondiffpatch$': '/__mocks__/jsondiffpatch.cjs', }, } diff --git a/backend/jest.setup.cjs b/backend/jest.setup.cjs index dbe1c88501..010946bd4a 100644 --- a/backend/jest.setup.cjs +++ b/backend/jest.setup.cjs @@ -122,6 +122,7 @@ jest.mock('./dist/lib/cache/index.cjs', () => { const { cache } = originalCache const keys = [ 'cloudprofiles', + 'namespacedcloudprofiles', 'seeds', 'quotas', 'projects', diff --git a/backend/lib/cache/index.js b/backend/lib/cache/index.js index f4d25502c3..953aa51ab2 100644 --- a/backend/lib/cache/index.js +++ b/backend/lib/cache/index.js @@ -29,6 +29,10 @@ class Cache extends Map { return this.get('cloudprofiles').list() } + getNamespacedCloudProfiles () { + return this.get('namespacedcloudprofiles').list() + } + getQuotas () { return this.get('quotas').list() } @@ -68,7 +72,24 @@ export default { } }, getCloudProfiles () { - return cache.getCloudProfiles() + return cache.getCloudProfiles().map(item => _.cloneDeep(item)) + }, + getCloudProfile (name) { + return _.cloneDeep( + _ + .chain(cache.getCloudProfiles()) + .find(['metadata.name', name]) + .value(), + ) + }, + getNamespacedCloudProfiles (namespace) { + const items = cache.getNamespacedCloudProfiles() + if (namespace) { + return items + .filter(item => item.metadata.namespace === namespace) + .map(item => _.cloneDeep(item)) + } + return items.map(item => _.cloneDeep(item)) }, getQuotas () { return cache.getQuotas() diff --git a/backend/lib/hooks.js b/backend/lib/hooks.js index 054be2eccf..db063ba160 100644 --- a/backend/lib/hooks.js +++ b/backend/lib/hooks.js @@ -70,6 +70,7 @@ class LifecycleHooks { const informers = { // core.gardener cloudprofiles: client['core.gardener.cloud'].cloudprofiles.informer(), + namespacedcloudprofiles: client['core.gardener.cloud'].namespacedcloudprofiles.informerAllNamespaces(), controllerregistrations: client['core.gardener.cloud'].controllerregistrations.informer(), projects: client['core.gardener.cloud'].projects.informer(), quotas: client['core.gardener.cloud'].quotas.informerAllNamespaces(), diff --git a/backend/lib/routes/cloudprofiles.js b/backend/lib/routes/cloudprofiles.js index 66e7e3f2ab..278984c792 100644 --- a/backend/lib/routes/cloudprofiles.js +++ b/backend/lib/routes/cloudprofiles.js @@ -24,4 +24,16 @@ router.route('/') } }) +router.route('/:name') + .all(metricsMiddleware) + .get(async (req, res, next) => { + try { + const user = req.user + const name = req.params.name + res.send(await cloudprofiles.read({ user, name })) + } catch (err) { + next(err) + } + }) + export default router diff --git a/backend/lib/routes/index.js b/backend/lib/routes/index.js index 363a88ce3d..bd0b8f1dcf 100644 --- a/backend/lib/routes/index.js +++ b/backend/lib/routes/index.js @@ -11,6 +11,7 @@ import infoRoute from './info.js' import openapiRoute from '../openapi/index.js' import userRoute from './user.js' import cloudprofilesRoute from './cloudprofiles.js' +import namespacedCloudProfilesRoute from './namespacedCloudProfiles.js' import seedsRoute from './seeds.js' import gardenerExtensionsRoute from './gardenerExtensions.js' import projectsRoute from './projects.js' @@ -32,6 +33,7 @@ const routes = { '/projects': projectsRoute, '/namespaces/:namespace/shoots': shootsRoute, '/namespaces/:namespace/tickets': ticketsRoute, + '/namespaces/:namespace/namespacedcloudprofiles': namespacedCloudProfilesRoute, '/cloudprovidercredentials': cloudProviderCredentialsRoute, '/namespaces/:namespace/members': membersRoute, '/namespaces/:namespace/resourcequotas': resourceQuotasRoute, diff --git a/backend/lib/routes/namespacedCloudProfiles.js b/backend/lib/routes/namespacedCloudProfiles.js new file mode 100644 index 0000000000..0ece1b3bfa --- /dev/null +++ b/backend/lib/routes/namespacedCloudProfiles.js @@ -0,0 +1,29 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import express from 'express' +import services from '../services/index.js' +import { metricsRoute } from '../middleware.js' +const { namespacedCloudProfiles } = services + +const router = express.Router({ mergeParams: true }) + +const metricsMiddleware = metricsRoute('namespacedcloudprofiles') + +router.route('/') + .all(metricsMiddleware) + .get(async (req, res, next) => { + try { + const user = req.user + const namespace = req.params.namespace + const diff = req.query.diff === 'true' + res.send(await namespacedCloudProfiles.listForNamespace({ user, namespace, diff })) + } catch (err) { + next(err) + } + }) + +export default router diff --git a/backend/lib/services/authorization.js b/backend/lib/services/authorization.js index 8cc88f54b9..5054280701 100644 --- a/backend/lib/services/authorization.js +++ b/backend/lib/services/authorization.js @@ -113,6 +113,17 @@ export function canGetCloudProfiles (user, name) { }) } +export function canListNamespacedCloudProfiles (user, namespace) { + return hasAuthorization(user, { + resourceAttributes: { + verb: 'list', + group: 'core.gardener.cloud', + resource: 'namespacedcloudprofiles', + namespace, + }, + }) +} + export function canListResourceQuotas (user, namespace) { return hasAuthorization(user, { resourceAttributes: { diff --git a/backend/lib/services/cloudprofiles.js b/backend/lib/services/cloudprofiles.js index 0bfa0627ee..481a7f8e5a 100644 --- a/backend/lib/services/cloudprofiles.js +++ b/backend/lib/services/cloudprofiles.js @@ -6,18 +6,20 @@ import httpErrors from 'http-errors' import * as authorization from './authorization.js' -import _ from 'lodash-es' import cache from '../cache/index.js' -const { NotFound, Forbidden } = httpErrors -const { getCloudProfiles } = cache +import { simplifyCloudProfile } from '../utils/index.js' +const { Forbidden, NotFound } = httpErrors +const { getCloudProfiles, getCloudProfile } = cache export async function list ({ user }) { const allowed = await authorization.canListCloudProfiles(user) if (!allowed) { throw new Forbidden('You are not allowed to list cloudprofiles') } + const allItems = getCloudProfiles() - return getCloudProfiles() + return allItems + .map(simplifyCloudProfile) } export async function read ({ user, name }) { @@ -25,12 +27,9 @@ export async function read ({ user, name }) { if (!allowed) { throw new Forbidden(`You are not allowed to get cloudprofile ${name}`) } - - const cloudProfiles = getCloudProfiles() - const cloudProfileResource = _.find(cloudProfiles, ['metadata.name', name]) - if (!cloudProfileResource) { - throw new NotFound(`Cloud profile with name ${name} not found`) + const cloudProfile = getCloudProfile(name) + if (!cloudProfile) { + throw new NotFound(`CloudProfile '${name}' not found`) } - - return cloudProfileResource + return simplifyCloudProfile(cloudProfile) } diff --git a/backend/lib/services/index.js b/backend/lib/services/index.js index 86f71a3dd4..a8b7aa26aa 100644 --- a/backend/lib/services/index.js +++ b/backend/lib/services/index.js @@ -5,6 +5,7 @@ // import * as cloudprofiles from './cloudprofiles.js' +import * as namespacedCloudProfiles from './namespacedCloudProfiles.js' import * as seeds from './seeds.js' import * as projects from './projects.js' import * as shoots from './shoots.js' @@ -21,6 +22,7 @@ const terminals = terminalsModule export default { cloudprofiles, + namespacedCloudProfiles, seeds, projects, shoots, diff --git a/backend/lib/services/namespacedCloudProfiles.js b/backend/lib/services/namespacedCloudProfiles.js new file mode 100644 index 0000000000..1abfda026c --- /dev/null +++ b/backend/lib/services/namespacedCloudProfiles.js @@ -0,0 +1,84 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import _ from 'lodash-es' +import httpErrors from 'http-errors' +import * as jsondiffpatch from 'jsondiffpatch' +import * as authorization from './authorization.js' +import cache from '../cache/index.js' +import { + simplifyCloudProfile, + computeSpecHash, +} from '../utils/index.js' +const { Forbidden } = httpErrors +const { getNamespacedCloudProfiles, getCloudProfile } = cache + +/** + * Computes the diff between the parent CloudProfile spec and the NamespacedCloudProfile status.cloudProfileSpec. + * Both specs are simplified using simplifyCloudProfile before comparison, which filters providerConfig + * according to the cloud provider type (keeping relevant fields for ironcore/openstack, removing for others). + * + * @param {Object} namespacedCloudProfile - The namespaced cloud profile (already simplified) + * @returns {Object} The diff delta + */ +function computeDiff (namespacedCloudProfile) { + const parentName = _.get(namespacedCloudProfile, ['spec', 'parent', 'name']) + const parentCloudProfile = getCloudProfile(parentName) + + if (!parentCloudProfile) { + return { diff: null, parentResourceVersion: null, cloudProfileSpecHash: null } + } + + const parentResourceVersion = _.get(parentCloudProfile, ['metadata', 'resourceVersion']) + const simplifiedParent = simplifyCloudProfile(parentCloudProfile) + + const parentSpec = _.get(simplifiedParent, ['spec'], {}) + const namespacedSpec = _.get(namespacedCloudProfile, ['status', 'cloudProfileSpec'], {}) + + return { + diff: jsondiffpatch.diff(parentSpec, namespacedSpec), + parentResourceVersion, + cloudProfileSpecHash: computeSpecHash(namespacedSpec), + } +} + +/** + * Transforms a namespaced cloud profile to include only the diff instead of the full status. + * The diff represents the changes from the parent CloudProfile spec to the NamespacedCloudProfile status.cloudProfileSpec. + * + * @param {Object} profile - The namespaced cloud profile (already simplified) + * @returns {Object} The profile with diff instead of full cloudProfileSpec + */ +function transformToDiff (profile) { + const { diff, parentResourceVersion, cloudProfileSpecHash } = computeDiff(profile) + + const result = _.cloneDeep(profile) + result.status = { + ...result.status, + cloudProfileSpecDiff: diff || null, + parentCloudProfileResourceVersion: parentResourceVersion, + cloudProfileSpecHash, + } + delete result.status.cloudProfileSpec + + return result +} + +export async function listForNamespace ({ user, namespace, diff = false }) { + const allowed = await authorization.canListNamespacedCloudProfiles(user, namespace) + if (!allowed) { + throw new Forbidden(`You are not allowed to list namespaced cloudprofiles in namespace ${namespace}`) + } + + const items = getNamespacedCloudProfiles(namespace) + const simplifiedItems = items.map(simplifyCloudProfile) + + if (diff) { + return simplifiedItems.map(transformToDiff) + } + + return simplifiedItems +} diff --git a/backend/lib/utils/index.js b/backend/lib/utils/index.js index e5b83414e4..3a50c8c178 100644 --- a/backend/lib/utils/index.js +++ b/backend/lib/utils/index.js @@ -4,7 +4,9 @@ // SPDX-License-Identifier: Apache-2.0 // +import { createHash } from 'node:crypto' import _ from 'lodash-es' +import canonicalize from 'canonicalize' import config from '../config/index.js' import assert from 'assert/strict' @@ -117,6 +119,73 @@ function simplifySeed (seed) { return simplifyObjectMetadata(seed) } +function stripProviderConfig (providerConfig, allowlist = []) { + if (!providerConfig) { + return undefined + } + + if (allowlist.length === 0) { + return undefined + } + + const filteredProviderConfig = {} + for (const path of allowlist) { + const value = _.get(providerConfig, path) + if (value !== undefined) { + _.set(filteredProviderConfig, path, value) + } + } + + return Object.keys(filteredProviderConfig).length === 0 ? undefined : filteredProviderConfig +} + +/** + * Simplifies a CloudProfile or NamespacedCloudProfile object by removing unnecessary metadata + * and filtering the providerConfig based on the cloud provider type. + * + * This function can be used with both CloudProfile and NamespacedCloudProfile resources. + * It automatically detects the resource type and applies the appropriate simplification logic. + * + * @param {Object} profile - A CloudProfile or NamespacedCloudProfile object + * @returns {Object} The simplified profile object + */ +function simplifyCloudProfile (profile) { + const kind = _.get(profile, ['kind']) + + let entrypath = ['spec'] + if (kind === 'NamespacedCloudProfile') { + entrypath = ['status', 'cloudProfileSpec'] + } + const allowedPathsIroncore = [ + ['firewallImages'], + ['firewallNetworks'], + ] + + const allowedPathsOpenstack = [ + ['constraints', 'floatingPools'], + ['constraints', 'loadBalancerConfig', 'classes'], + ['constraints', 'loadBalancerProviders'], + ] + + profile = simplifyObjectMetadata(profile) + + const type = _.get(profile, [...entrypath, 'type']) + const providerConfig = _.get(profile, [...entrypath, 'providerConfig']) + + let allowlist = [] + if (type === 'ironcore' || type === 'metal') { + allowlist = allowedPathsIroncore + } else if (type === 'openstack') { + allowlist = allowedPathsOpenstack + } + + const strippedProviderConfig = stripProviderConfig(providerConfig, allowlist) + + _.set(profile, [...entrypath, 'providerConfig'], strippedProviderConfig) + + return profile +} + function parseSelector (selector = '') { let notOperator let key @@ -245,6 +314,13 @@ function isSeedUnreachable (seed) { return _.isMatch(seed, { metadata: { labels: matchLabels } }) } +function computeSpecHash (spec) { + if (!spec) { + return null + } + return createHash('sha256').update(canonicalize(spec)).digest('hex') +} + export { constants, decodeBase64, @@ -255,6 +331,8 @@ export { simplifyObjectMetadata, simplifyProject, simplifySeed, + simplifyCloudProfile, + stripProviderConfig, parseSelector, parseSelectors, filterBySelectors, @@ -263,4 +341,5 @@ export { shootHasIssue, getSeedIngressDomain, isSeedUnreachable, + computeSpecHash, } diff --git a/backend/package.json b/backend/package.json index ca161e6402..256e7ea353 100644 --- a/backend/package.json +++ b/backend/package.json @@ -29,7 +29,8 @@ "build-packages-for-test-target": "yarn workspaces foreach --all --topological --no-private --include 'packages/*' run build", "build-test-target": "rollup -c rollup.config.js", "test": "NODE_PATH=./dist cross-env NODE_ENV=test node --experimental-vm-modules $(yarn bin jest)", - "test-coverage": "yarn test --coverage" + "test-coverage": "yarn test --coverage", + "test-vitest": "vitest run" }, "dependencies": { "@apidevtools/swagger-parser": "^12.0.0", @@ -47,6 +48,7 @@ "@octokit/plugin-rest-endpoint-methods": "^10.4.1", "base64url": "^3.0.1", "body-parser": "^2.0.0", + "canonicalize": "^2.0.0", "compression": "^1.7.4", "cookie-parser": "^1.4.6", "express": "^5.0.0", @@ -56,6 +58,7 @@ "http-errors": "^2.0.0", "jose": "^5.2.3", "js-yaml": "^4.1.0", + "jsondiffpatch": "^0.7.3", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", "lodash-es": "^4.17.21", @@ -87,6 +90,7 @@ "@rollup/plugin-commonjs": "^28.0.3", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.1", + "@vitest/coverage-v8": "^3.0.0", "abort-controller": "^3.0.0", "cross-env": "^10.0.0", "dockerfile-ast": "^0.7.0", @@ -105,7 +109,8 @@ "rollup-plugin-copy": "^3.5.0", "set-cookie-parser": "^2.6.0", "socket.io-client": "^4.7.5", - "supertest": "^7.0.0" + "supertest": "^7.0.0", + "vitest": "^3.0.0" }, "packageManager": "yarn@4.12.0", "engines": { diff --git a/backend/vitest.config.js b/backend/vitest.config.js new file mode 100644 index 0000000000..2c61b13818 --- /dev/null +++ b/backend/vitest.config.js @@ -0,0 +1,21 @@ +// +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +// eslint-disable-next-line import/no-unresolved +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + include: ['**/__tests__/**/*.spec.js'], + exclude: [ + '**/__tests__/**/*.spec.cjs', // Exclude Jest tests + '**/node_modules/**', + '**/dist/**', + ], + }, +}) diff --git a/charts/__tests__/gardener-dashboard/application/dashboard/__snapshots__/clusterrole.spec.js.snap b/charts/__tests__/gardener-dashboard/application/dashboard/__snapshots__/clusterrole.spec.js.snap index cb572e0da2..ccf1b0243c 100644 --- a/charts/__tests__/gardener-dashboard/application/dashboard/__snapshots__/clusterrole.spec.js.snap +++ b/charts/__tests__/gardener-dashboard/application/dashboard/__snapshots__/clusterrole.spec.js.snap @@ -85,6 +85,18 @@ exports[`gardener-dashboard clusterrole should render the template with default "watch", ], }, + { + "apiGroups": [ + "core.gardener.cloud", + ], + "resources": [ + "namespacedcloudprofiles", + ], + "verbs": [ + "list", + "watch", + ], + }, { "apiGroups": [ "", diff --git a/charts/gardener-dashboard/charts/application/templates/dashboard/clusterrole.yaml b/charts/gardener-dashboard/charts/application/templates/dashboard/clusterrole.yaml index 45eb9db142..3866a6e2bb 100644 --- a/charts/gardener-dashboard/charts/application/templates/dashboard/clusterrole.yaml +++ b/charts/gardener-dashboard/charts/application/templates/dashboard/clusterrole.yaml @@ -50,6 +50,13 @@ rules: verbs: - list - watch +- apiGroups: + - core.gardener.cloud + resources: + - namespacedcloudprofiles + verbs: + - list + - watch - apiGroups: - "" resources: diff --git a/frontend/__tests__/composables/useCloudProfile/useMachineTypes.spec.js b/frontend/__tests__/composables/useCloudProfile/useMachineTypes.spec.js index b02ebc7eb6..9525308e5f 100644 --- a/frontend/__tests__/composables/useCloudProfile/useMachineTypes.spec.js +++ b/frontend/__tests__/composables/useCloudProfile/useMachineTypes.spec.js @@ -124,6 +124,20 @@ describe('composables', () => { expect(type2.architecture).toBe('amd64') expect(type3.architecture).toBe('amd64') }) + + it('should not mutate cloud profile machine types when defaulting architecture', () => { + const { useZones } = useRegions(cloudProfile) + const { useFilteredMachineTypes } = useMachineTypes(cloudProfile, useZones) + + const region = ref('region2') + const architecture = ref(undefined) + useFilteredMachineTypes(region, architecture) + + const type2InProfile = find(cloudProfile.value.spec.machineTypes, { name: 'machineType2' }) + const type3InProfile = find(cloudProfile.value.spec.machineTypes, { name: 'machineType3' }) + expect(type2InProfile.architecture).toBeUndefined() + expect(type3InProfile.architecture).toBeUndefined() + }) }) describe('#useMachineArchitectures', () => { diff --git a/frontend/__tests__/stores/cloudProfile.spec.js b/frontend/__tests__/stores/cloudProfile.spec.js index 5178733940..52fdfb7669 100644 --- a/frontend/__tests__/stores/cloudProfile.spec.js +++ b/frontend/__tests__/stores/cloudProfile.spec.js @@ -8,13 +8,19 @@ import { setActivePinia, createPinia, } from 'pinia' +import { diff as jsondiffpatchDiff } from 'jsondiffpatch' import { useAuthzStore } from '@/store/authz' import { useConfigStore } from '@/store/config' import { useCloudProfileStore } from '@/store/cloudProfile' +import { useAppStore } from '@/store/app' +import { useApi } from '@/composables/useApi' import { firstItemMatchingVersionClassification } from '@/composables/helper' +import { computeSpecHash } from '@/utils/crypto' +import { getCloudProfileSpec } from '@/utils' + describe('stores', () => { describe('cloudProfile', () => { const namespace = 'default' @@ -49,6 +55,721 @@ describe('stores', () => { cloudProfileStore.setCloudProfiles([]) }) + describe('cloudProfileByRef', () => { + beforeEach(async () => { + cloudProfileStore.setCloudProfiles([ + { + kind: 'CloudProfile', + metadata: { name: 'aws' }, + spec: { type: 'aws' }, + }, + { + kind: 'CloudProfile', + metadata: { name: 'gcp' }, + spec: { type: 'gcp' }, + }, + ]) + await cloudProfileStore.setNamespacedCloudProfiles([ + { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom-aws', namespace: 'garden-local' }, + spec: { parent: { kind: 'CloudProfile', name: 'aws' } }, + status: { cloudProfileSpec: { type: 'aws' } }, + }, + ], 'garden-local') + await cloudProfileStore.setNamespacedCloudProfiles([ + { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom-gcp', namespace: 'garden-dev' }, + spec: { parent: { kind: 'CloudProfile', name: 'gcp' } }, + status: { cloudProfileSpec: { type: 'gcp' } }, + }, + ], 'garden-dev') + }) + + it('should resolve a CloudProfile ref', () => { + const result = cloudProfileStore.cloudProfileByRef({ kind: 'CloudProfile', name: 'aws' }) + expect(result).toBeDefined() + expect(result.metadata.name).toBe('aws') + }) + + it('should resolve a NamespacedCloudProfile ref with namespace', () => { + const result = cloudProfileStore.cloudProfileByRef({ + kind: 'NamespacedCloudProfile', + name: 'custom-aws', + namespace: 'garden-local', + }) + expect(result).toBeDefined() + expect(result.metadata.name).toBe('custom-aws') + expect(result.metadata.namespace).toBe('garden-local') + }) + + it('should resolve a NamespacedCloudProfile ref without namespace (fallback)', () => { + const result = cloudProfileStore.cloudProfileByRef({ + kind: 'NamespacedCloudProfile', + name: 'custom-gcp', + }) + expect(result).toBeDefined() + expect(result.metadata.name).toBe('custom-gcp') + }) + + it('should return null for ambiguous NamespacedCloudProfile ref without namespace', async () => { + await cloudProfileStore.setNamespacedCloudProfiles([ + { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom-gcp', namespace: 'garden-local' }, + spec: { parent: { kind: 'CloudProfile', name: 'gcp' } }, + status: { cloudProfileSpec: { type: 'gcp' } }, + }, + ], 'garden-local') + await cloudProfileStore.setNamespacedCloudProfiles([ + { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom-gcp', namespace: 'garden-dev' }, + spec: { parent: { kind: 'CloudProfile', name: 'gcp' } }, + status: { cloudProfileSpec: { type: 'gcp' } }, + }, + ], 'garden-dev') + + const result = cloudProfileStore.cloudProfileByRef({ + kind: 'NamespacedCloudProfile', + name: 'custom-gcp', + }) + expect(result).toBeNull() + }) + + it('should return null for unknown ref', () => { + expect(cloudProfileStore.cloudProfileByRef({ kind: 'CloudProfile', name: 'unknown' })).toBeNull() + expect(cloudProfileStore.cloudProfileByRef({ kind: 'NamespacedCloudProfile', name: 'unknown' })).toBeNull() + }) + + it('should return null for null or undefined ref', () => { + expect(cloudProfileStore.cloudProfileByRef(null)).toBeNull() + expect(cloudProfileStore.cloudProfileByRef(undefined)).toBeNull() + }) + + it('should return null for unknown kind', () => { + expect(cloudProfileStore.cloudProfileByRef({ kind: 'Unknown', name: 'aws' })).toBeNull() + }) + }) + + describe('namespaced cloud profile cache', () => { + beforeEach(() => { + cloudProfileStore.setCloudProfiles([ + { + kind: 'CloudProfile', + metadata: { name: 'aws' }, + spec: { type: 'aws' }, + }, + { + kind: 'CloudProfile', + metadata: { name: 'gcp' }, + spec: { type: 'gcp' }, + }, + ]) + }) + + it('should aggregate namespaced cloud profiles across multiple namespaces', async () => { + await cloudProfileStore.setNamespacedCloudProfiles([ + { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom-aws', namespace: 'garden-local' }, + spec: { parent: { kind: 'CloudProfile', name: 'aws' } }, + status: { cloudProfileSpec: { type: 'aws' } }, + }, + ], 'garden-local') + + await cloudProfileStore.setNamespacedCloudProfiles([ + { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom-gcp', namespace: 'garden-dev' }, + spec: { parent: { kind: 'CloudProfile', name: 'gcp' } }, + status: { cloudProfileSpec: { type: 'gcp' } }, + }, + ], 'garden-dev') + + expect(cloudProfileStore.namespacedCloudProfileList).toHaveLength(2) + expect(cloudProfileStore.hasNamespacedCloudProfilesForNamespace('garden-local')).toBe(true) + expect(cloudProfileStore.hasNamespacedCloudProfilesForNamespace('garden-dev')).toBe(true) + }) + + it('should resolve namespaced cloud profile refs across multiple cached namespaces', async () => { + await cloudProfileStore.setNamespacedCloudProfiles([ + { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom-aws', namespace: 'garden-local' }, + spec: { parent: { kind: 'CloudProfile', name: 'aws' } }, + status: { cloudProfileSpec: { type: 'aws' } }, + }, + ], 'garden-local') + await cloudProfileStore.setNamespacedCloudProfiles([ + { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom-gcp', namespace: 'garden-dev' }, + spec: { parent: { kind: 'CloudProfile', name: 'gcp' } }, + status: { cloudProfileSpec: { type: 'gcp' } }, + }, + ], 'garden-dev') + + expect(cloudProfileStore.cloudProfileByRef({ + kind: 'NamespacedCloudProfile', + name: 'custom-gcp', + namespace: 'garden-dev', + })?.metadata.namespace).toBe('garden-dev') + }) + }) + + describe('cloudProfilesByProviderType', () => { + beforeEach(async () => { + cloudProfileStore.setCloudProfiles([ + { + kind: 'CloudProfile', + metadata: { name: 'aws' }, + spec: { type: 'aws' }, + }, + ]) + await cloudProfileStore.setNamespacedCloudProfiles([ + { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom-aws', namespace: 'garden-local' }, + spec: { parent: { kind: 'CloudProfile', name: 'aws' } }, + status: { cloudProfileSpec: { type: 'aws' } }, + }, + ], 'garden-local') + await cloudProfileStore.setNamespacedCloudProfiles([ + { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom-gcp', namespace: 'garden-dev' }, + spec: { parent: { kind: 'CloudProfile', name: 'gcp' } }, + status: { cloudProfileSpec: { type: 'gcp' } }, + }, + ], 'garden-dev') + }) + + it('should return both regular and namespaced profiles for a provider type', () => { + const profiles = cloudProfileStore.cloudProfilesByProviderType('aws') + expect(profiles).toHaveLength(2) + expect(profiles[0].metadata.name).toBe('aws') + expect(profiles[1].metadata.name).toBe('custom-aws') + }) + + it('should return only namespaced profiles when no regular profiles match', () => { + const profiles = cloudProfileStore.cloudProfilesByProviderType('gcp') + expect(profiles).toHaveLength(1) + expect(profiles[0].metadata.name).toBe('custom-gcp') + }) + + it('should return empty array for unknown provider type', () => { + const profiles = cloudProfileStore.cloudProfilesByProviderType('unknown') + expect(profiles).toHaveLength(0) + }) + }) + + describe('sortedInfraProviderTypeList', () => { + it('should include provider types from both regular and namespaced profiles', async () => { + cloudProfileStore.setCloudProfiles([ + { + kind: 'CloudProfile', + metadata: { name: 'aws' }, + spec: { type: 'aws' }, + }, + ]) + await cloudProfileStore.setNamespacedCloudProfiles([ + { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom-gcp', namespace: 'garden-local' }, + spec: { parent: { kind: 'CloudProfile', name: 'gcp' } }, + status: { cloudProfileSpec: { type: 'gcp' } }, + }, + ], 'garden-local') + const providerTypes = cloudProfileStore.sortedInfraProviderTypeList + expect(providerTypes).toContain('aws') + expect(providerTypes).toContain('gcp') + }) + }) + + describe('setNamespacedCloudProfiles with rehydration', () => { + it('should pass through profiles that already have cloudProfileSpec', async () => { + cloudProfileStore.setCloudProfiles([ + { + kind: 'CloudProfile', + metadata: { name: 'aws' }, + spec: { type: 'aws', kubernetes: { versions: [{ version: '1.30.0' }] } }, + }, + ]) + const namespacedProfile = { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom', namespace: 'garden-local' }, + spec: { parent: { kind: 'CloudProfile', name: 'aws' } }, + status: { + cloudProfileSpec: { type: 'aws', kubernetes: { versions: [{ version: '1.31.0' }] } }, + }, + } + await cloudProfileStore.setNamespacedCloudProfiles([namespacedProfile], 'garden-local') + const stored = cloudProfileStore.namespacedCloudProfileList[0] + expect(getCloudProfileSpec(stored).kubernetes.versions[0].version).toBe('1.31.0') + }) + + it('should rehydrate profiles with cloudProfileSpecDiff from parent', async () => { + const parentSpec = { + type: 'aws', + kubernetes: { versions: [{ version: '1.30.0' }] }, + } + cloudProfileStore.setCloudProfiles([ + { + kind: 'CloudProfile', + metadata: { name: 'aws' }, + spec: parentSpec, + }, + ]) + const namespacedProfile = { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom', namespace: 'garden-local' }, + spec: { parent: { kind: 'CloudProfile', name: 'aws' } }, + status: { + cloudProfileSpecDiff: null, + }, + } + await cloudProfileStore.setNamespacedCloudProfiles([namespacedProfile], 'garden-local') + const stored = cloudProfileStore.namespacedCloudProfileList[0] + expect(stored.status.cloudProfileSpec).toEqual(parentSpec) + expect(stored.status.cloudProfileSpecDiff).toBeUndefined() + }) + + it('should rehydrate profiles with non-null cloudProfileSpecDiff from parent', async () => { + const parentSpec = { + type: 'aws', + kubernetes: { versions: [{ version: '1.30.0' }] }, + machineTypes: [{ name: 'm5.large', cpu: '2', memory: '8Gi' }], + } + const namespacedSpec = { + type: 'aws', + kubernetes: { versions: [{ version: '1.31.0' }] }, + machineTypes: [ + { name: 'm5.large', cpu: '2', memory: '8Gi' }, + { name: 'm5.xlarge', cpu: '4', memory: '16Gi' }, + ], + } + const cloudProfileSpecDiff = jsondiffpatchDiff(parentSpec, namespacedSpec) + cloudProfileStore.setCloudProfiles([ + { + kind: 'CloudProfile', + metadata: { name: 'aws' }, + spec: parentSpec, + }, + ]) + const namespacedProfile = { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom', namespace: 'garden-local' }, + spec: { parent: { kind: 'CloudProfile', name: 'aws' } }, + status: { + cloudProfileSpecDiff, + }, + } + await cloudProfileStore.setNamespacedCloudProfiles([namespacedProfile], 'garden-local') + const stored = cloudProfileStore.namespacedCloudProfileList[0] + expect(stored.status.cloudProfileSpec).toEqual(namespacedSpec) + expect(stored.status.cloudProfileSpecDiff).toBeUndefined() + }) + + it('should skip rehydration when parent is not found', async () => { + cloudProfileStore.setCloudProfiles([]) + const namespacedProfile = { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom', namespace: 'garden-local' }, + spec: { parent: { kind: 'CloudProfile', name: 'nonexistent' } }, + status: { + cloudProfileSpecDiff: null, + }, + } + await cloudProfileStore.setNamespacedCloudProfiles([namespacedProfile], 'garden-local') + const stored = cloudProfileStore.namespacedCloudProfileList[0] + expect(stored.status.cloudProfileSpec).toBeUndefined() + }) + + it('should rehydrate when hash validates regardless of resourceVersion', async () => { + const parentSpec = { + type: 'aws', + kubernetes: { versions: [{ version: '1.30.0' }] }, + } + cloudProfileStore.setCloudProfiles([ + { + kind: 'CloudProfile', + metadata: { name: 'aws', resourceVersion: '42' }, + spec: parentSpec, + }, + ]) + const expectedHash = await computeSpecHash(parentSpec) + const namespacedProfile = { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom', namespace: 'garden-local' }, + spec: { parent: { kind: 'CloudProfile', name: 'aws' } }, + status: { + cloudProfileSpecDiff: null, + parentCloudProfileResourceVersion: '42', + cloudProfileSpecHash: expectedHash, + }, + } + await cloudProfileStore.setNamespacedCloudProfiles([namespacedProfile], 'garden-local') + const stored = cloudProfileStore.namespacedCloudProfileList[0] + expect(stored.status.cloudProfileSpec).toEqual(parentSpec) + expect(stored.status.parentCloudProfileResourceVersion).toBeUndefined() + expect(stored.status.cloudProfileSpecHash).toBeUndefined() + }) + + it('should not re-fetch parent when hash matches even if resourceVersion differs', async () => { + const parentSpec = { + type: 'aws', + kubernetes: { versions: [{ version: '1.30.0' }] }, + } + cloudProfileStore.setCloudProfiles([ + { + kind: 'CloudProfile', + metadata: { name: 'aws', resourceVersion: '41' }, + spec: parentSpec, + }, + ]) + const expectedHash = await computeSpecHash(parentSpec) + + const api = useApi() + api.getCloudProfile = vi.fn() + + const namespacedProfile = { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom', namespace: 'garden-local' }, + spec: { parent: { kind: 'CloudProfile', name: 'aws' } }, + status: { + cloudProfileSpecDiff: null, + parentCloudProfileResourceVersion: '42', + cloudProfileSpecHash: expectedHash, + }, + } + + await cloudProfileStore.setNamespacedCloudProfiles([namespacedProfile], 'garden-local') + + expect(api.getCloudProfile).not.toHaveBeenCalled() + const stored = cloudProfileStore.namespacedCloudProfileList[0] + expect(stored.status.cloudProfileSpec).toEqual(parentSpec) + }) + + it('should re-fetch parent on hash mismatch when resourceVersion differs and recover', async () => { + const staleParentSpec = { + type: 'aws', + kubernetes: { versions: [{ version: '1.29.0' }] }, + } + const freshParentSpec = { + type: 'aws', + kubernetes: { versions: [{ version: '1.30.0' }] }, + } + + cloudProfileStore.setCloudProfiles([ + { + kind: 'CloudProfile', + metadata: { name: 'aws', resourceVersion: '41' }, + spec: staleParentSpec, + }, + ]) + + const expectedHash = await computeSpecHash(freshParentSpec) + + const api = useApi() + api.getCloudProfile = vi.fn().mockResolvedValueOnce({ + data: { + kind: 'CloudProfile', + metadata: { name: 'aws', resourceVersion: '42' }, + spec: freshParentSpec, + }, + }) + + const namespacedProfile = { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom', namespace: 'garden-local' }, + spec: { parent: { kind: 'CloudProfile', name: 'aws' } }, + status: { + cloudProfileSpecDiff: null, + parentCloudProfileResourceVersion: '42', + cloudProfileSpecHash: expectedHash, + }, + } + + await cloudProfileStore.setNamespacedCloudProfiles([namespacedProfile], 'garden-local') + + expect(api.getCloudProfile).toHaveBeenCalledWith({ name: 'aws' }) + const stored = cloudProfileStore.namespacedCloudProfileList[0] + expect(stored.status.cloudProfileSpec).toEqual(freshParentSpec) + }) + + it('should drop profile when parent re-fetch fails and show notification', async () => { + const staleParentSpec = { + type: 'aws', + kubernetes: { versions: [{ version: '1.29.0' }] }, + } + cloudProfileStore.setCloudProfiles([ + { + kind: 'CloudProfile', + metadata: { name: 'aws', resourceVersion: '41' }, + spec: staleParentSpec, + }, + ]) + + const api = useApi() + api.getCloudProfile = vi.fn().mockRejectedValueOnce(new Error('Network error')) + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const appStore = useAppStore() + const setErrorSpy = vi.spyOn(appStore, 'setError') + + const namespacedProfile = { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom', namespace: 'garden-local' }, + spec: { parent: { kind: 'CloudProfile', name: 'aws' } }, + status: { + cloudProfileSpecDiff: null, + parentCloudProfileResourceVersion: '42', + cloudProfileSpecHash: 'deliberately-wrong-hash', + }, + } + + await cloudProfileStore.setNamespacedCloudProfiles([namespacedProfile], 'garden-local') + + expect(cloudProfileStore.namespacedCloudProfileList).toHaveLength(0) + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to re-fetch CloudProfile'), + expect.any(Error), + ) + expect(setErrorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Cloud Profile Sync Error', + text: expect.stringContaining('1 namespaced cloud profile(s)'), + }), + ) + + consoleErrorSpy.mockRestore() + }) + + it('should drop profile when hash still mismatches after parent re-fetch', async () => { + cloudProfileStore.setCloudProfiles([ + { + kind: 'CloudProfile', + metadata: { name: 'aws', resourceVersion: '41' }, + spec: { type: 'aws' }, + }, + ]) + + const api = useApi() + api.getCloudProfile = vi.fn().mockResolvedValueOnce({ + data: { + kind: 'CloudProfile', + metadata: { name: 'aws', resourceVersion: '42' }, + spec: { type: 'aws', kubernetes: { versions: [{ version: '1.31.0' }] } }, + }, + }) + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const appStore = useAppStore() + const setErrorSpy = vi.spyOn(appStore, 'setError') + + const namespacedProfile = { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom', namespace: 'garden-local' }, + spec: { parent: { kind: 'CloudProfile', name: 'aws' } }, + status: { + cloudProfileSpecDiff: null, + parentCloudProfileResourceVersion: '42', + cloudProfileSpecHash: 'deliberately-wrong-hash', + }, + } + + await cloudProfileStore.setNamespacedCloudProfiles([namespacedProfile], 'garden-local') + + expect(cloudProfileStore.namespacedCloudProfileList).toHaveLength(0) + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Hash mismatch'), + ) + expect(setErrorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Cloud Profile Sync Error', + }), + ) + + consoleErrorSpy.mockRestore() + }) + + it('should drop profile immediately when hash mismatches and resourceVersion matches', async () => { + const parentSpec = { + type: 'aws', + kubernetes: { versions: [{ version: '1.30.0' }] }, + } + cloudProfileStore.setCloudProfiles([ + { + kind: 'CloudProfile', + metadata: { name: 'aws', resourceVersion: '42' }, + spec: parentSpec, + }, + ]) + + const api = useApi() + api.getCloudProfile = vi.fn() + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const appStore = useAppStore() + const setErrorSpy = vi.spyOn(appStore, 'setError') + + const namespacedProfile = { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom', namespace: 'garden-local' }, + spec: { parent: { kind: 'CloudProfile', name: 'aws' } }, + status: { + cloudProfileSpecDiff: null, + parentCloudProfileResourceVersion: '42', + cloudProfileSpecHash: 'deliberately-wrong-hash', + }, + } + + await cloudProfileStore.setNamespacedCloudProfiles([namespacedProfile], 'garden-local') + + expect(api.getCloudProfile).not.toHaveBeenCalled() + expect(cloudProfileStore.namespacedCloudProfileList).toHaveLength(0) + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('ResourceVersion matches, so re-fetch will not help'), + ) + expect(setErrorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Cloud Profile Sync Error', + }), + ) + + consoleErrorSpy.mockRestore() + }) + + it('should re-fetch a stale parent only once when multiple profiles share it', async () => { + const freshParentSpec = { + type: 'aws', + kubernetes: { versions: [{ version: '1.30.0' }] }, + } + + cloudProfileStore.setCloudProfiles([ + { + kind: 'CloudProfile', + metadata: { name: 'aws', resourceVersion: '41' }, + spec: { type: 'aws', kubernetes: { versions: [{ version: '1.29.0' }] } }, + }, + ]) + + const api = useApi() + api.getCloudProfile = vi.fn().mockResolvedValueOnce({ + data: { + kind: 'CloudProfile', + metadata: { name: 'aws', resourceVersion: '42' }, + spec: freshParentSpec, + }, + }) + + const diff1 = jsondiffpatchDiff(freshParentSpec, { + ...freshParentSpec, + machineTypes: [{ name: 'm5.large' }], + }) + const diff2 = jsondiffpatchDiff(freshParentSpec, { + ...freshParentSpec, + machineTypes: [{ name: 'm5.xlarge' }], + }) + + const spec1 = { ...freshParentSpec, machineTypes: [{ name: 'm5.large' }] } + const spec2 = { ...freshParentSpec, machineTypes: [{ name: 'm5.xlarge' }] } + const hash1 = await computeSpecHash(spec1) + const hash2 = await computeSpecHash(spec2) + + const profiles = [ + { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom-1', namespace: 'garden-local' }, + spec: { parent: { kind: 'CloudProfile', name: 'aws' } }, + status: { + cloudProfileSpecDiff: diff1, + parentCloudProfileResourceVersion: '42', + cloudProfileSpecHash: hash1, + }, + }, + { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom-2', namespace: 'garden-local' }, + spec: { parent: { kind: 'CloudProfile', name: 'aws' } }, + status: { + cloudProfileSpecDiff: diff2, + parentCloudProfileResourceVersion: '42', + cloudProfileSpecHash: hash2, + }, + }, + ] + + await cloudProfileStore.setNamespacedCloudProfiles(profiles, 'garden-local') + + expect(api.getCloudProfile).toHaveBeenCalledTimes(1) + expect(cloudProfileStore.namespacedCloudProfileList).toHaveLength(2) + }) + + it('should not trigger validation for profiles without cloudProfileSpecDiff', async () => { + cloudProfileStore.setCloudProfiles([ + { + kind: 'CloudProfile', + metadata: { name: 'aws', resourceVersion: '41' }, + spec: { type: 'aws' }, + }, + ]) + + const api = useApi() + api.getCloudProfile = vi.fn() + + const namespacedProfile = { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom', namespace: 'garden-local' }, + spec: { parent: { kind: 'CloudProfile', name: 'aws' } }, + status: { + cloudProfileSpec: { type: 'aws', kubernetes: { versions: [{ version: '1.31.0' }] } }, + }, + } + + await cloudProfileStore.setNamespacedCloudProfiles([namespacedProfile], 'garden-local') + + expect(api.getCloudProfile).not.toHaveBeenCalled() + expect(cloudProfileStore.namespacedCloudProfileList).toHaveLength(1) + }) + + it('should not show notification when all profiles rehydrate successfully', async () => { + const parentSpec = { + type: 'aws', + kubernetes: { versions: [{ version: '1.30.0' }] }, + } + cloudProfileStore.setCloudProfiles([ + { + kind: 'CloudProfile', + metadata: { name: 'aws', resourceVersion: '42' }, + spec: parentSpec, + }, + ]) + + const appStore = useAppStore() + const setErrorSpy = vi.spyOn(appStore, 'setError') + + const expectedHash = await computeSpecHash(parentSpec) + const namespacedProfile = { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom', namespace: 'garden-local' }, + spec: { parent: { kind: 'CloudProfile', name: 'aws' } }, + status: { + cloudProfileSpecDiff: null, + parentCloudProfileResourceVersion: '42', + cloudProfileSpecHash: expectedHash, + }, + } + + await cloudProfileStore.setNamespacedCloudProfiles([namespacedProfile], 'garden-local') + + expect(cloudProfileStore.namespacedCloudProfileList).toHaveLength(1) + expect(setErrorSpy).not.toHaveBeenCalled() + }) + }) + describe('helper', () => { describe('#firstItemMatchingVersionClassification', () => { it('should select default item that matches version classification', () => { diff --git a/frontend/__tests__/utils/index.spec.js b/frontend/__tests__/utils/index.spec.js index 20dcadfa63..0b0ca593e0 100644 --- a/frontend/__tests__/utils/index.spec.js +++ b/frontend/__tests__/utils/index.spec.js @@ -19,6 +19,7 @@ import { isEmail, convertToGi, convertToGibibyte, + getCloudProfileSpec, } from '@/utils' import pick from 'lodash/pick' @@ -525,6 +526,51 @@ describe('utils', () => { }) }) + describe('getCloudProfileSpec', () => { + it('should return spec for a regular CloudProfile', () => { + const cloudProfile = { + kind: 'CloudProfile', + metadata: { name: 'aws' }, + spec: { type: 'aws', kubernetes: { versions: [] } }, + } + expect(getCloudProfileSpec(cloudProfile)).toEqual(cloudProfile.spec) + }) + + it('should return status.cloudProfileSpec for a NamespacedCloudProfile', () => { + const cloudProfile = { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom', namespace: 'garden-local' }, + spec: { parent: { kind: 'CloudProfile', name: 'aws' } }, + status: { + cloudProfileSpec: { type: 'aws', kubernetes: { versions: [{ version: '1.30.0' }] } }, + }, + } + expect(getCloudProfileSpec(cloudProfile)).toEqual(cloudProfile.status.cloudProfileSpec) + }) + + it('should return empty object for NamespacedCloudProfile without status', () => { + const cloudProfile = { + kind: 'NamespacedCloudProfile', + metadata: { name: 'custom' }, + spec: { parent: { kind: 'CloudProfile', name: 'aws' } }, + } + expect(getCloudProfileSpec(cloudProfile)).toEqual({}) + }) + + it('should return empty object for null or undefined input', () => { + expect(getCloudProfileSpec(null)).toEqual({}) + expect(getCloudProfileSpec(undefined)).toEqual({}) + }) + + it('should return spec for a profile without kind (defaults to CloudProfile behavior)', () => { + const cloudProfile = { + metadata: { name: 'aws' }, + spec: { type: 'aws' }, + } + expect(getCloudProfileSpec(cloudProfile)).toEqual(cloudProfile.spec) + }) + }) + describe('convertToGi', () => { const precision = 9 it('should convert binary units to Gibibyte', () => { diff --git a/frontend/package.json b/frontend/package.json index 4e096b56d6..e17c8a0655 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -52,6 +52,7 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", "ansi-html": "^0.0.9", + "canonicalize": "^2.0.0", "dayjs": "^1.11.10", "downloadjs": "^1.4.7", "eventemitter3": "^5.0.1", @@ -61,6 +62,7 @@ "js-base64": "^3.7.6", "js-cookie": "^3.0.5", "js-yaml": "^4.1.0", + "jsondiffpatch": "^0.7.3", "jwt-decode": "^4.0.0", "lodash": "^4.17.21", "md5": "^2.3.0", diff --git a/frontend/src/components/GSelectCloudProfile.vue b/frontend/src/components/GSelectCloudProfile.vue index fef482c2c2..a7b4ed9c16 100644 --- a/frontend/src/components/GSelectCloudProfile.vue +++ b/frontend/src/components/GSelectCloudProfile.vue @@ -17,7 +17,37 @@ SPDX-License-Identifier: Apache-2.0 :hint="hint" persistent-hint @blur="v$.selectedValue.$touch()" - /> + > + + +