Skip to content

Commit 6d52a64

Browse files
committed
frontend: support namespaced cloudprofiles including all-project scope
1 parent 32b2081 commit 6d52a64

21 files changed

Lines changed: 678 additions & 100 deletions

frontend/__tests__/composables/useCloudProfile/useMachineTypes.spec.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,20 @@ describe('composables', () => {
124124
expect(type2.architecture).toBe('amd64')
125125
expect(type3.architecture).toBe('amd64')
126126
})
127+
128+
it('should not mutate cloud profile machine types when defaulting architecture', () => {
129+
const { useZones } = useRegions(cloudProfile)
130+
const { useFilteredMachineTypes } = useMachineTypes(cloudProfile, useZones)
131+
132+
const region = ref('region2')
133+
const architecture = ref(undefined)
134+
useFilteredMachineTypes(region, architecture)
135+
136+
const type2InProfile = find(cloudProfile.value.spec.machineTypes, { name: 'machineType2' })
137+
const type3InProfile = find(cloudProfile.value.spec.machineTypes, { name: 'machineType3' })
138+
expect(type2InProfile.architecture).toBeUndefined()
139+
expect(type3InProfile.architecture).toBeUndefined()
140+
})
127141
})
128142

129143
describe('#useMachineArchitectures', () => {

frontend/__tests__/stores/cloudProfile.spec.js

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@ import {
88
setActivePinia,
99
createPinia,
1010
} from 'pinia'
11+
import { diff as jsondiffpatchDiff } from 'jsondiffpatch'
1112

1213
import { useAuthzStore } from '@/store/authz'
1314
import { useConfigStore } from '@/store/config'
1415
import { useCloudProfileStore } from '@/store/cloudProfile'
1516

1617
import { firstItemMatchingVersionClassification } from '@/composables/helper'
1718

19+
import { getCloudProfileSpec } from '@/utils'
20+
1821
describe('stores', () => {
1922
describe('cloudProfile', () => {
2023
const namespace = 'default'
@@ -49,6 +52,341 @@ describe('stores', () => {
4952
cloudProfileStore.setCloudProfiles([])
5053
})
5154

55+
describe('cloudProfileByRef', () => {
56+
beforeEach(() => {
57+
cloudProfileStore.setCloudProfiles([
58+
{
59+
kind: 'CloudProfile',
60+
metadata: { name: 'aws' },
61+
spec: { type: 'aws' },
62+
},
63+
{
64+
kind: 'CloudProfile',
65+
metadata: { name: 'gcp' },
66+
spec: { type: 'gcp' },
67+
},
68+
])
69+
cloudProfileStore.setNamespacedCloudProfiles([
70+
{
71+
kind: 'NamespacedCloudProfile',
72+
metadata: { name: 'custom-aws', namespace: 'garden-local' },
73+
spec: { parent: { kind: 'CloudProfile', name: 'aws' } },
74+
status: { cloudProfileSpec: { type: 'aws' } },
75+
},
76+
], 'garden-local')
77+
cloudProfileStore.setNamespacedCloudProfiles([
78+
{
79+
kind: 'NamespacedCloudProfile',
80+
metadata: { name: 'custom-gcp', namespace: 'garden-dev' },
81+
spec: { parent: { kind: 'CloudProfile', name: 'gcp' } },
82+
status: { cloudProfileSpec: { type: 'gcp' } },
83+
},
84+
], 'garden-dev')
85+
})
86+
87+
it('should resolve a CloudProfile ref', () => {
88+
const result = cloudProfileStore.cloudProfileByRef({ kind: 'CloudProfile', name: 'aws' })
89+
expect(result).toBeDefined()
90+
expect(result.metadata.name).toBe('aws')
91+
})
92+
93+
it('should resolve a NamespacedCloudProfile ref with namespace', () => {
94+
const result = cloudProfileStore.cloudProfileByRef({
95+
kind: 'NamespacedCloudProfile',
96+
name: 'custom-aws',
97+
namespace: 'garden-local',
98+
})
99+
expect(result).toBeDefined()
100+
expect(result.metadata.name).toBe('custom-aws')
101+
expect(result.metadata.namespace).toBe('garden-local')
102+
})
103+
104+
it('should resolve a NamespacedCloudProfile ref without namespace (fallback)', () => {
105+
const result = cloudProfileStore.cloudProfileByRef({
106+
kind: 'NamespacedCloudProfile',
107+
name: 'custom-gcp',
108+
})
109+
expect(result).toBeDefined()
110+
expect(result.metadata.name).toBe('custom-gcp')
111+
})
112+
113+
it('should return null for ambiguous NamespacedCloudProfile ref without namespace', () => {
114+
cloudProfileStore.setNamespacedCloudProfiles([
115+
{
116+
kind: 'NamespacedCloudProfile',
117+
metadata: { name: 'custom-gcp', namespace: 'garden-local' },
118+
spec: { parent: { kind: 'CloudProfile', name: 'gcp' } },
119+
status: { cloudProfileSpec: { type: 'gcp' } },
120+
},
121+
], 'garden-local')
122+
cloudProfileStore.setNamespacedCloudProfiles([
123+
{
124+
kind: 'NamespacedCloudProfile',
125+
metadata: { name: 'custom-gcp', namespace: 'garden-dev' },
126+
spec: { parent: { kind: 'CloudProfile', name: 'gcp' } },
127+
status: { cloudProfileSpec: { type: 'gcp' } },
128+
},
129+
], 'garden-dev')
130+
131+
const result = cloudProfileStore.cloudProfileByRef({
132+
kind: 'NamespacedCloudProfile',
133+
name: 'custom-gcp',
134+
})
135+
expect(result).toBeNull()
136+
})
137+
138+
it('should return null for unknown ref', () => {
139+
expect(cloudProfileStore.cloudProfileByRef({ kind: 'CloudProfile', name: 'unknown' })).toBeNull()
140+
expect(cloudProfileStore.cloudProfileByRef({ kind: 'NamespacedCloudProfile', name: 'unknown' })).toBeNull()
141+
})
142+
143+
it('should return null for null or undefined ref', () => {
144+
expect(cloudProfileStore.cloudProfileByRef(null)).toBeNull()
145+
expect(cloudProfileStore.cloudProfileByRef(undefined)).toBeNull()
146+
})
147+
148+
it('should return null for unknown kind', () => {
149+
expect(cloudProfileStore.cloudProfileByRef({ kind: 'Unknown', name: 'aws' })).toBeNull()
150+
})
151+
})
152+
153+
describe('namespaced cloud profile cache', () => {
154+
beforeEach(() => {
155+
cloudProfileStore.setCloudProfiles([
156+
{
157+
kind: 'CloudProfile',
158+
metadata: { name: 'aws' },
159+
spec: { type: 'aws' },
160+
},
161+
{
162+
kind: 'CloudProfile',
163+
metadata: { name: 'gcp' },
164+
spec: { type: 'gcp' },
165+
},
166+
])
167+
})
168+
169+
it('should aggregate namespaced cloud profiles across multiple namespaces', () => {
170+
cloudProfileStore.setNamespacedCloudProfiles([
171+
{
172+
kind: 'NamespacedCloudProfile',
173+
metadata: { name: 'custom-aws', namespace: 'garden-local' },
174+
spec: { parent: { kind: 'CloudProfile', name: 'aws' } },
175+
status: { cloudProfileSpec: { type: 'aws' } },
176+
},
177+
], 'garden-local')
178+
179+
cloudProfileStore.setNamespacedCloudProfiles([
180+
{
181+
kind: 'NamespacedCloudProfile',
182+
metadata: { name: 'custom-gcp', namespace: 'garden-dev' },
183+
spec: { parent: { kind: 'CloudProfile', name: 'gcp' } },
184+
status: { cloudProfileSpec: { type: 'gcp' } },
185+
},
186+
], 'garden-dev')
187+
188+
expect(cloudProfileStore.namespacedCloudProfileList).toHaveLength(2)
189+
expect(cloudProfileStore.hasNamespacedCloudProfilesForNamespace('garden-local')).toBe(true)
190+
expect(cloudProfileStore.hasNamespacedCloudProfilesForNamespace('garden-dev')).toBe(true)
191+
})
192+
193+
it('should resolve namespaced cloud profile refs across multiple cached namespaces', () => {
194+
cloudProfileStore.setNamespacedCloudProfiles([
195+
{
196+
kind: 'NamespacedCloudProfile',
197+
metadata: { name: 'custom-aws', namespace: 'garden-local' },
198+
spec: { parent: { kind: 'CloudProfile', name: 'aws' } },
199+
status: { cloudProfileSpec: { type: 'aws' } },
200+
},
201+
], 'garden-local')
202+
cloudProfileStore.setNamespacedCloudProfiles([
203+
{
204+
kind: 'NamespacedCloudProfile',
205+
metadata: { name: 'custom-gcp', namespace: 'garden-dev' },
206+
spec: { parent: { kind: 'CloudProfile', name: 'gcp' } },
207+
status: { cloudProfileSpec: { type: 'gcp' } },
208+
},
209+
], 'garden-dev')
210+
211+
expect(cloudProfileStore.cloudProfileByRef({
212+
kind: 'NamespacedCloudProfile',
213+
name: 'custom-gcp',
214+
namespace: 'garden-dev',
215+
})?.metadata.namespace).toBe('garden-dev')
216+
})
217+
})
218+
219+
describe('cloudProfilesByProviderType', () => {
220+
beforeEach(() => {
221+
cloudProfileStore.setCloudProfiles([
222+
{
223+
kind: 'CloudProfile',
224+
metadata: { name: 'aws' },
225+
spec: { type: 'aws' },
226+
},
227+
])
228+
cloudProfileStore.setNamespacedCloudProfiles([
229+
{
230+
kind: 'NamespacedCloudProfile',
231+
metadata: { name: 'custom-aws', namespace: 'garden-local' },
232+
spec: { parent: { kind: 'CloudProfile', name: 'aws' } },
233+
status: { cloudProfileSpec: { type: 'aws' } },
234+
},
235+
], 'garden-local')
236+
cloudProfileStore.setNamespacedCloudProfiles([
237+
{
238+
kind: 'NamespacedCloudProfile',
239+
metadata: { name: 'custom-gcp', namespace: 'garden-dev' },
240+
spec: { parent: { kind: 'CloudProfile', name: 'gcp' } },
241+
status: { cloudProfileSpec: { type: 'gcp' } },
242+
},
243+
], 'garden-dev')
244+
})
245+
246+
it('should return both regular and namespaced profiles for a provider type', () => {
247+
const profiles = cloudProfileStore.cloudProfilesByProviderType('aws')
248+
expect(profiles).toHaveLength(2)
249+
expect(profiles[0].metadata.name).toBe('aws')
250+
expect(profiles[1].metadata.name).toBe('custom-aws')
251+
})
252+
253+
it('should return only namespaced profiles when no regular profiles match', () => {
254+
const profiles = cloudProfileStore.cloudProfilesByProviderType('gcp')
255+
expect(profiles).toHaveLength(1)
256+
expect(profiles[0].metadata.name).toBe('custom-gcp')
257+
})
258+
259+
it('should return empty array for unknown provider type', () => {
260+
const profiles = cloudProfileStore.cloudProfilesByProviderType('unknown')
261+
expect(profiles).toHaveLength(0)
262+
})
263+
})
264+
265+
describe('sortedInfraProviderTypeList', () => {
266+
it('should include provider types from both regular and namespaced profiles', () => {
267+
cloudProfileStore.setCloudProfiles([
268+
{
269+
kind: 'CloudProfile',
270+
metadata: { name: 'aws' },
271+
spec: { type: 'aws' },
272+
},
273+
])
274+
cloudProfileStore.setNamespacedCloudProfiles([
275+
{
276+
kind: 'NamespacedCloudProfile',
277+
metadata: { name: 'custom-gcp', namespace: 'garden-local' },
278+
spec: { parent: { kind: 'CloudProfile', name: 'gcp' } },
279+
status: { cloudProfileSpec: { type: 'gcp' } },
280+
},
281+
], 'garden-local')
282+
const providerTypes = cloudProfileStore.sortedInfraProviderTypeList
283+
expect(providerTypes).toContain('aws')
284+
expect(providerTypes).toContain('gcp')
285+
})
286+
})
287+
288+
describe('setNamespacedCloudProfiles with rehydration', () => {
289+
it('should pass through profiles that already have cloudProfileSpec', () => {
290+
cloudProfileStore.setCloudProfiles([
291+
{
292+
kind: 'CloudProfile',
293+
metadata: { name: 'aws' },
294+
spec: { type: 'aws', kubernetes: { versions: [{ version: '1.30.0' }] } },
295+
},
296+
])
297+
const namespacedProfile = {
298+
kind: 'NamespacedCloudProfile',
299+
metadata: { name: 'custom', namespace: 'garden-local' },
300+
spec: { parent: { kind: 'CloudProfile', name: 'aws' } },
301+
status: {
302+
cloudProfileSpec: { type: 'aws', kubernetes: { versions: [{ version: '1.31.0' }] } },
303+
},
304+
}
305+
cloudProfileStore.setNamespacedCloudProfiles([namespacedProfile], 'garden-local')
306+
const stored = cloudProfileStore.namespacedCloudProfileList[0]
307+
expect(getCloudProfileSpec(stored).kubernetes.versions[0].version).toBe('1.31.0')
308+
})
309+
310+
it('should rehydrate profiles with cloudProfileSpecDiff from parent', () => {
311+
const parentSpec = {
312+
type: 'aws',
313+
kubernetes: { versions: [{ version: '1.30.0' }] },
314+
}
315+
cloudProfileStore.setCloudProfiles([
316+
{
317+
kind: 'CloudProfile',
318+
metadata: { name: 'aws' },
319+
spec: parentSpec,
320+
},
321+
])
322+
// null diff means spec is identical to parent
323+
const namespacedProfile = {
324+
kind: 'NamespacedCloudProfile',
325+
metadata: { name: 'custom', namespace: 'garden-local' },
326+
spec: { parent: { kind: 'CloudProfile', name: 'aws' } },
327+
status: {
328+
cloudProfileSpecDiff: null,
329+
},
330+
}
331+
cloudProfileStore.setNamespacedCloudProfiles([namespacedProfile], 'garden-local')
332+
const stored = cloudProfileStore.namespacedCloudProfileList[0]
333+
expect(stored.status.cloudProfileSpec).toEqual(parentSpec)
334+
expect(stored.status.cloudProfileSpecDiff).toBeUndefined()
335+
})
336+
337+
it('should rehydrate profiles with non-null cloudProfileSpecDiff from parent', () => {
338+
const parentSpec = {
339+
type: 'aws',
340+
kubernetes: { versions: [{ version: '1.30.0' }] },
341+
machineTypes: [{ name: 'm5.large', cpu: '2', memory: '8Gi' }],
342+
}
343+
const namespacedSpec = {
344+
type: 'aws',
345+
kubernetes: { versions: [{ version: '1.31.0' }] },
346+
machineTypes: [
347+
{ name: 'm5.large', cpu: '2', memory: '8Gi' },
348+
{ name: 'm5.xlarge', cpu: '4', memory: '16Gi' },
349+
],
350+
}
351+
const cloudProfileSpecDiff = jsondiffpatchDiff(parentSpec, namespacedSpec)
352+
cloudProfileStore.setCloudProfiles([
353+
{
354+
kind: 'CloudProfile',
355+
metadata: { name: 'aws' },
356+
spec: parentSpec,
357+
},
358+
])
359+
const namespacedProfile = {
360+
kind: 'NamespacedCloudProfile',
361+
metadata: { name: 'custom', namespace: 'garden-local' },
362+
spec: { parent: { kind: 'CloudProfile', name: 'aws' } },
363+
status: {
364+
cloudProfileSpecDiff,
365+
},
366+
}
367+
cloudProfileStore.setNamespacedCloudProfiles([namespacedProfile], 'garden-local')
368+
const stored = cloudProfileStore.namespacedCloudProfileList[0]
369+
expect(stored.status.cloudProfileSpec).toEqual(namespacedSpec)
370+
expect(stored.status.cloudProfileSpecDiff).toBeUndefined()
371+
})
372+
373+
it('should skip rehydration when parent is not found', () => {
374+
cloudProfileStore.setCloudProfiles([])
375+
const namespacedProfile = {
376+
kind: 'NamespacedCloudProfile',
377+
metadata: { name: 'custom', namespace: 'garden-local' },
378+
spec: { parent: { kind: 'CloudProfile', name: 'nonexistent' } },
379+
status: {
380+
cloudProfileSpecDiff: null,
381+
},
382+
}
383+
cloudProfileStore.setNamespacedCloudProfiles([namespacedProfile], 'garden-local')
384+
const stored = cloudProfileStore.namespacedCloudProfileList[0]
385+
// Profile should be passed through unchanged (no cloudProfileSpec added)
386+
expect(stored.status.cloudProfileSpec).toBeUndefined()
387+
})
388+
})
389+
52390
describe('helper', () => {
53391
describe('#firstItemMatchingVersionClassification', () => {
54392
it('should select default item that matches version classification', () => {

0 commit comments

Comments
 (0)