Skip to content

Commit b554d4b

Browse files
feat: Allow multi-cluster Marketplace deployments (linode#12648)
* augment cluster tags for stackscripts * hook fix * remove reduce for readability * final clean up * update object type * adjust summary to include cluster name or instance type when cluster_size is only defined * fix undef variable in forEach prop * add .env.example back * Update packages/manager/src/features/Linodes/LinodeCreate/Summary/Summary.tsx Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> * use existing react query hooks for mapping * Update packages/manager/src/features/Linodes/LinodeCreate/Summary/utilities.ts Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> * Update packages/manager/src/features/Linodes/LinodeCreate/Summary/Summary.tsx Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> * simplify code, cleanup unused vars * add test coverage for complex marketplace app clusters * Remove ClusterDataTypes and use ClusterData * small clean up and unit testing * more small clean up * simplify summary logic * fix unit test * hopefully final clean up --------- Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Co-authored-by: Banks Nussman <bnussman@akamai.com>
1 parent c5639e6 commit b554d4b

File tree

7 files changed

+234
-19
lines changed

7 files changed

+234
-19
lines changed

packages/manager/src/features/Linodes/LinodeCreate/Summary/Summary.test.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,53 @@ describe('Linode Create Summary', () => {
264264
await findByText(`5 Nodes - $10/month $2.50/hr`);
265265
});
266266

267+
it('should render correct pricing for Marketplace app cluster deployments with multiple plans involved', async () => {
268+
const types = [
269+
typeFactory.build({
270+
label: 'Dedicated 2GB',
271+
price: { hourly: 0.1, monthly: 1 },
272+
}),
273+
typeFactory.build({
274+
label: 'Dedicated 4GB',
275+
price: { hourly: 0.2, monthly: 2 },
276+
}),
277+
typeFactory.build({
278+
label: 'Dedicated 8GB',
279+
price: { hourly: 0.3, monthly: 3 },
280+
}),
281+
];
282+
283+
server.use(
284+
http.get('*/v4*/linode/types/:id', ({ params }) => {
285+
const type = types.find((type) => type.id === params.id);
286+
return HttpResponse.json(type);
287+
}),
288+
http.get('*/v4*/linode/types', () => {
289+
return HttpResponse.json(makeResourcePage(types));
290+
})
291+
);
292+
293+
const { findByText } =
294+
renderWithThemeAndHookFormContext<CreateLinodeRequest>({
295+
component: <Summary />,
296+
useFormOptions: {
297+
defaultValues: {
298+
region: 'fake-region',
299+
stackscript_data: {
300+
cluster_size: 1,
301+
elastic_cluster_size: 2,
302+
elastic_cluster_type: types[1].label,
303+
logstash_cluster_size: 2,
304+
logstash_cluster_type: types[2].label,
305+
},
306+
type: types[0].id,
307+
},
308+
},
309+
});
310+
311+
await findByText(`5 Nodes - $11/month $1.10/hr`);
312+
});
313+
267314
it('should render "Encrypted" if a distributed region is selected', async () => {
268315
const region = regionFactory.build({ site_type: 'distributed' });
269316

packages/manager/src/features/Linodes/LinodeCreate/Summary/Summary.tsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { useImageQuery, useRegionsQuery, useTypeQuery } from '@linode/queries';
1+
import {
2+
useAllTypes,
3+
useImageQuery,
4+
useRegionsQuery,
5+
useTypeQuery,
6+
} from '@linode/queries';
27
import { Divider, Paper, Stack, Typography } from '@linode/ui';
38
import { formatStorageUnits } from '@linode/utilities';
49
import { useTheme } from '@mui/material';
@@ -28,6 +33,8 @@ export const Summary = ({ isAlertsBetaMode }: SummaryProps) => {
2833

2934
const { control } = useFormContext<LinodeCreateFormValues>();
3035

36+
const { data: types } = useAllTypes();
37+
3138
const [
3239
label,
3340
regionId,
@@ -40,7 +47,8 @@ export const Summary = ({ isAlertsBetaMode }: SummaryProps) => {
4047
vlanLabel,
4148
vpcId,
4249
diskEncryption,
43-
clusterSize,
50+
stackscriptData,
51+
clusterName,
4452
linodeInterfaces,
4553
interfaceGeneration,
4654
alerts,
@@ -58,7 +66,8 @@ export const Summary = ({ isAlertsBetaMode }: SummaryProps) => {
5866
'interfaces.1.label',
5967
'interfaces.0.vpc_id',
6068
'disk_encryption',
61-
'stackscript_data.cluster_size',
69+
'stackscript_data',
70+
'stackscript_data.cluster_name',
6271
'linodeInterfaces',
6372
'interface_generation',
6473
'alerts',
@@ -83,7 +92,12 @@ export const Summary = ({ isAlertsBetaMode }: SummaryProps) => {
8392
getMonthlyBackupsPrice({ region: regionId, type })
8493
);
8594

86-
const price = getLinodePrice({ clusterSize, regionId, type });
95+
const price = getLinodePrice({
96+
regionId,
97+
types,
98+
stackscriptData,
99+
type,
100+
});
87101

88102
const hasVPC = isLinodeInterfacesEnabled
89103
? linodeInterfaces?.some((i) => i.purpose === 'vpc' && i.vpc?.subnet_id)
@@ -137,8 +151,8 @@ export const Summary = ({ isAlertsBetaMode }: SummaryProps) => {
137151
},
138152
{
139153
item: {
154+
title: clusterName || (type ? formatStorageUnits(type.label) : typeId),
140155
details: price,
141-
title: type ? formatStorageUnits(type.label) : typeId,
142156
},
143157
show: price,
144158
},

packages/manager/src/features/Linodes/LinodeCreate/Summary/utilities.test.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { linodeTypeFactory } from '@linode/utilities';
22

3-
import { getLinodePrice } from './utilities';
3+
import { getLinodePrice, getParsedMarketplaceClusterData } from './utilities';
44

55
describe('getLinodePrice', () => {
66
it('gets a price for a normal Linode', () => {
@@ -9,9 +9,10 @@ describe('getLinodePrice', () => {
99
});
1010

1111
const result = getLinodePrice({
12-
clusterSize: undefined,
12+
stackscriptData: undefined,
1313
regionId: 'fake-region-id',
1414
type,
15+
types: [],
1516
});
1617

1718
expect(result).toBe('$5/month');
@@ -23,11 +24,46 @@ describe('getLinodePrice', () => {
2324
});
2425

2526
const result = getLinodePrice({
26-
clusterSize: '3',
27+
stackscriptData: {
28+
cluster_size: '3',
29+
},
2730
regionId: 'fake-region-id',
31+
types: [],
2832
type,
2933
});
3034

3135
expect(result).toBe('3 Nodes - $15/month $0.60/hr');
3236
});
3337
});
38+
39+
describe('getParsedMarketplaceClusterData', () => {
40+
it('parses stackscript user defined fields', () => {
41+
const types = [
42+
linodeTypeFactory.build({ label: 'Linode 2GB' }),
43+
linodeTypeFactory.build({ label: 'Linode 4GB' }),
44+
];
45+
46+
const stackscriptData = {
47+
cluster_size: '1',
48+
mysql_cluster_size: '5',
49+
mysql_cluster_type: 'Linode 2GB',
50+
redis_cluster_size: '5',
51+
redis_cluster_type: 'Linode 4GB',
52+
};
53+
54+
expect(
55+
getParsedMarketplaceClusterData(stackscriptData, types)
56+
).toStrictEqual([
57+
{
58+
prefix: 'mysql',
59+
size: '5',
60+
type: types[0],
61+
},
62+
{
63+
prefix: 'redis',
64+
size: '5',
65+
type: types[1],
66+
},
67+
]);
68+
});
69+
});

packages/manager/src/features/Linodes/LinodeCreate/Summary/utilities.ts

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,33 @@ import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes';
44
import type { LinodeType } from '@linode/api-v4';
55

66
interface LinodePriceOptions {
7-
clusterSize: string | undefined;
7+
/**
8+
* The selected region for the Linode
9+
*/
810
regionId: string | undefined;
11+
/**
12+
* The stackscript_data (user defined fields)
13+
*
14+
* This is needed to calculate the Linode price because we could be dealing with
15+
* a Marketplace app that deploys a cluster (or clusters)
16+
*/
17+
stackscriptData: Record<string, string> | undefined;
18+
/**
19+
* The selected Linode type
20+
*/
921
type: LinodeType | undefined;
22+
/**
23+
* An array of all Linode types
24+
*/
25+
types: LinodeType[] | undefined;
1026
}
1127

1228
export const getLinodePrice = (options: LinodePriceOptions) => {
13-
const { clusterSize, regionId, type } = options;
29+
const { stackscriptData, regionId, type, types } = options;
30+
1431
const price = getLinodeRegionPrice(type, regionId);
1532

33+
const clusterSize = stackscriptData?.['cluster_size'];
1634
const isCluster = clusterSize !== undefined;
1735

1836
if (
@@ -25,18 +43,72 @@ export const getLinodePrice = (options: LinodePriceOptions) => {
2543
}
2644

2745
if (isCluster) {
28-
const numberOfNodes = Number(clusterSize);
46+
let totalClusterSize = Number(clusterSize);
47+
let clusterTotalMonthlyPrice = price.monthly * Number(clusterSize);
48+
let clusterTotalHourlyPrice = price.hourly * Number(clusterSize);
2949

30-
const totalMonthlyPrice = renderMonthlyPriceToCorrectDecimalPlace(
31-
price.monthly * numberOfNodes
50+
const complexClusterData = getParsedMarketplaceClusterData(
51+
stackscriptData,
52+
types
3253
);
3354

34-
const totalHourlyPrice = renderMonthlyPriceToCorrectDecimalPlace(
35-
price.hourly * numberOfNodes
36-
);
55+
for (const clusterPool of complexClusterData) {
56+
const price = getLinodeRegionPrice(clusterPool.type, regionId);
57+
const numberOfNodesInPool = parseInt(clusterPool.size ?? '0', 10);
58+
clusterTotalMonthlyPrice += (price?.monthly ?? 0) * numberOfNodesInPool;
59+
clusterTotalHourlyPrice += (price?.hourly ?? 0) * numberOfNodesInPool;
60+
totalClusterSize += numberOfNodesInPool;
61+
}
3762

38-
return `${numberOfNodes} Nodes - $${totalMonthlyPrice}/month $${totalHourlyPrice}/hr`;
63+
return `${totalClusterSize} Nodes - $${renderMonthlyPriceToCorrectDecimalPlace(clusterTotalMonthlyPrice)}/month $${renderMonthlyPriceToCorrectDecimalPlace(clusterTotalHourlyPrice)}/hr`;
3964
}
4065

4166
return `$${renderMonthlyPriceToCorrectDecimalPlace(price.monthly)}/month`;
4267
};
68+
69+
interface MarketplaceClusterData {
70+
/**
71+
* The name of the service within the complex Marketplace app cluster.
72+
*
73+
* @example mysql
74+
*/
75+
prefix: string;
76+
/**
77+
* The number of nodes just for this paticular service within the Marketplace cluster deployment.
78+
*/
79+
size?: string;
80+
/**
81+
* The Linode type that should be used for nodes in this service
82+
*
83+
* @example Linode 2GB
84+
*/
85+
type?: LinodeType;
86+
}
87+
88+
export function getParsedMarketplaceClusterData(
89+
stackscriptData: Record<string, string> = {},
90+
types: LinodeType[] | undefined
91+
): MarketplaceClusterData[] {
92+
const result: MarketplaceClusterData[] = [];
93+
94+
for (const [key, value] of Object.entries(stackscriptData)) {
95+
const match = key.match(/^(.+)_cluster_(size|type)$/);
96+
if (!match) continue;
97+
98+
const prefix = match[1];
99+
const kind = match[2];
100+
101+
let cluster = result.find((c) => c.prefix === prefix);
102+
if (!cluster) {
103+
cluster = { prefix };
104+
result.push(cluster);
105+
}
106+
107+
if (kind === 'size') {
108+
cluster.size = value as string;
109+
} else if (kind === 'type') {
110+
cluster.type = types?.find((t) => t.label === value);
111+
}
112+
}
113+
return result;
114+
}

packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/UserDefinedFields/UserDefinedFields.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { oneClickApps } from 'src/features/OneClickApps/oneClickApps';
1717

1818
import { getMarketplaceAppLabel } from '../../Marketplace/utilities';
1919
import { UserDefinedFieldInput } from './UserDefinedFieldInput';
20-
import { separateUDFsByRequiredStatus } from './utilities';
20+
import { getTotalClusterSize, separateUDFsByRequiredStatus } from './utilities';
2121

2222
import type { CreateLinodeRequest } from '@linode/api-v4';
2323

@@ -59,6 +59,8 @@ export const UserDefinedFields = ({ onOpenDetailsDrawer }: Props) => {
5959

6060
const isCluster = clusterSize !== null && clusterSize !== undefined;
6161

62+
const totalClusterSize = getTotalClusterSize(stackscriptData);
63+
6264
const marketplaceAppInfo =
6365
stackscriptId !== null && stackscriptId !== undefined
6466
? oneClickApps[stackscriptId]
@@ -102,7 +104,7 @@ export const UserDefinedFields = ({ onOpenDetailsDrawer }: Props) => {
102104
)}
103105
{isCluster && (
104106
<Notice
105-
text={`You are creating a cluster with ${clusterSize} nodes.`}
107+
text={`You are creating a cluster with ${totalClusterSize} nodes.`}
106108
variant="success"
107109
/>
108110
)}

packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/UserDefinedFields/utilities.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getIsUDFMultiSelect,
55
getIsUDFPasswordField,
66
getIsUDFSingleSelect,
7+
getTotalClusterSize,
78
separateUDFsByRequiredStatus,
89
} from './utilities';
910

@@ -155,3 +156,29 @@ describe('getIsUDFPasswordField', () => {
155156
expect(getIsUDFPasswordField(udf)).toBe(false);
156157
});
157158
});
159+
160+
describe('getTotalClusterSize', () => {
161+
it('should return 0 when there is no cluster data', () => {
162+
const stackscriptData = {};
163+
164+
expect(getTotalClusterSize(stackscriptData)).toBe(0);
165+
});
166+
167+
it('should support normal marketplace clusters', () => {
168+
const stackscriptData = {
169+
cluster_size: '5',
170+
};
171+
172+
expect(getTotalClusterSize(stackscriptData)).toBe(5);
173+
});
174+
175+
it('should support complex marketplace clusters', () => {
176+
const stackscriptData = {
177+
cluster_size: '3',
178+
mysql_cluster_size: '3',
179+
redis_cluster_size: '5',
180+
};
181+
182+
expect(getTotalClusterSize(stackscriptData)).toBe(11);
183+
});
184+
});

packages/manager/src/features/Linodes/LinodeCreate/Tabs/StackScripts/UserDefinedFields/utilities.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,20 @@ export const getIsUDFMultiSelect = (udf: UserDefinedField) => {
6868
export const getIsUDFHeader = (udf: UserDefinedField) => {
6969
return udf.header?.toLowerCase() === 'yes';
7070
};
71+
72+
/**
73+
* Gets the total number of nodes that will be created as part of a
74+
* marketplace app cluster.
75+
*
76+
* - Marketplace app clusters use the user-defined-field `cluster_size` to
77+
* define the number of nodes.
78+
* - Complex Marketplace App clusters will use `cluster_size` and other
79+
* fields like `{service}_cluster_size`
80+
*/
81+
export const getTotalClusterSize = (
82+
userDefinedFields: Record<string, string>
83+
) => {
84+
return Object.entries(userDefinedFields || {})
85+
.filter(([key]) => key.endsWith('_cluster_size') || key === 'cluster_size')
86+
.reduce((sum, [_, value]) => sum + Number(value), 0);
87+
};

0 commit comments

Comments
 (0)