Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [M3-6787] - Update LKE HA implementation #9489

Merged
merged 20 commits into from
Aug 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3bf5f12
feature: [M3-6787] - Change HA implementation
carrillo-erik Aug 3, 2023
f12239e
Merge branch 'develop' of https://github.com/linode/manager into feat…
carrillo-erik Aug 3, 2023
816c084
Remove comments
carrillo-erik Aug 3, 2023
c0700df
Fix radio btn state and label color
carrillo-erik Aug 3, 2023
d66a698
Merge branch 'develop' of https://github.com/linode/manager into feat…
carrillo-erik Aug 4, 2023
cbfd42f
Change var name and remove unused prop
carrillo-erik Aug 4, 2023
847b1db
Cosolidate HA states and fix unit tests
carrillo-erik Aug 4, 2023
7416e50
Add changeset
carrillo-erik Aug 4, 2023
4f50dd8
Update type and remove redundant code
carrillo-erik Aug 4, 2023
dcb54da
Merge branch 'develop' of https://github.com/linode/manager into feat…
carrillo-erik Aug 5, 2023
d884d88
Fix failing e2e tests
carrillo-erik Aug 5, 2023
4924e2e
Merge branch 'develop' of https://github.com/linode/manager into feat…
carrillo-erik Aug 7, 2023
411bde0
Merge branch 'develop' of https://github.com/linode/manager into feat…
carrillo-erik Aug 8, 2023
fddce18
Change logic and test when HA in not available
carrillo-erik Aug 8, 2023
ddd3077
Merge branch 'develop' of https://github.com/linode/manager into feat…
carrillo-erik Aug 9, 2023
1a15ffd
Merge branch 'develop' of https://github.com/linode/manager into feat…
carrillo-erik Aug 9, 2023
6fc5074
Merge branch 'develop' of https://github.com/linode/manager into feat…
carrillo-erik Aug 10, 2023
c154e9a
Change conditional logic for HA and update tests
carrillo-erik Aug 10, 2023
4d7d984
Merge branch 'develop' of https://github.com/linode/manager into feat…
carrillo-erik Aug 11, 2023
d13f0ab
Remove HA e2e test and add unit test for HA price
carrillo-erik Aug 11, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/api-v4/src/kubernetes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export interface KubernetesDashboardResponse {
}

export interface ControlPlaneOptions {
high_availability: boolean;
high_availability?: boolean;
}

export interface CreateKubeClusterPayload {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Tech Stories
---

New HA control plane to replace HA Checkbox ([#9489](https://github.com/linode/manager/pull/9489))
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,15 @@ describe('LKE Cluster Creation', () => {
.click()
.type(`${clusterRegion.label}{enter}`);

cy.findByLabelText('Kubernetes Version')
cy.findByText('Kubernetes Version')
.should('be.visible')
.click()
.type(`${clusterVersion}{enter}`);

// TODO: Circle back to add e2e tests for HA Control Plane
// once the investigation into LKE HA pricing constant has been completed.
// cy.get('[data-testid="ha-radio-button-yes"]').should('be.visible').click();

// Add a node pool for each randomly selected plan, and confirm that the
// selected node pool plan is added to the checkout bar.
clusterPlans.forEach((clusterPlan) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,24 @@ describe('LKE Create Cluster', () => {
mockCreateCluster(mockCluster).as('createCluster');
cy.visitWithLogin('/kubernetes/create');
cy.findByText('Add Node Pools').should('be.visible');

cy.findByLabelText('Cluster Label').click().type(mockCluster.label);
cy.findByLabelText('Region')

cy.findByText('Region')
.should('be.visible')
.focus()
.click()
.type(`${chooseRegion().label}{enter}`);
cy.get('[id="kubernetes-version"]').type('{enter}');
cy.findByText('Shared CPU').should('be.visible').click();

cy.findByText('Kubernetes Version')
.should('be.visible')
.click()
.type('{enter}');

// TODO: Circle back to add e2e tests for HA Control Plane
// once the investigation into LKE HA pricing constant has been completed.
// cy.get('[data-testid="ha-radio-button-yes"]').should('be.visible').click();

cy.findByText('Shared CPU').should('be.visible').click();
addNodes('Linode 2 GB');

// Confirm change is reflected in checkout bar.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,30 @@ import {
KubeNodePoolResponse,
} from '@linode/api-v4/lib/kubernetes';
import { APIError } from '@linode/api-v4/lib/types';
import { Box } from 'src/components/Box';
import Grid from '@mui/material/Unstable_Grid2';
import { Theme } from '@mui/material/styles';
import { makeStyles } from '@mui/styles';
import { pick, remove, update } from 'ramda';
import * as React from 'react';
import { useHistory } from 'react-router-dom';

import { Box } from 'src/components/Box';
import { DocumentTitleSegment } from 'src/components/DocumentTitle';
import Select, { Item } from 'src/components/EnhancedSelect/Select';
import { RegionSelect } from 'src/components/EnhancedSelect/variants/RegionSelect';
import { ErrorState } from 'src/components/ErrorState/ErrorState';
import { LandingHeader } from 'src/components/LandingHeader';
import { Notice } from 'src/components/Notice/Notice';
import { Paper } from 'src/components/Paper';
import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner';
import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperText';
import { TextField } from 'src/components/TextField';
import { Paper } from 'src/components/Paper';
import { HIGH_AVAILABILITY_PRICE } from 'src/constants';
import {
getKubeHighAvailability,
getLatestVersion,
} from 'src/features/Kubernetes/kubeUtils';
import { useAccount } from 'src/queries/account';
import {
reportAgreementSigningError,
useMutateAccountAgreements,
Expand All @@ -39,6 +45,7 @@ import { plansNoticesUtils } from 'src/utilities/planNotices';
import scrollErrorIntoView from 'src/utilities/scrollErrorIntoView';

import KubeCheckoutBar from '../KubeCheckoutBar';
import { HAControlPlane } from './HAControlPlane';
import { NodePoolPanel } from './NodePoolPanel';

const useStyles = makeStyles((theme: Theme) => ({
Expand Down Expand Up @@ -112,22 +119,35 @@ const useStyles = makeStyles((theme: Theme) => ({

export const CreateCluster = () => {
const classes = useStyles();
const [selectedRegionID, setSelectedRegionID] = React.useState<string>('');
const [nodePools, setNodePools] = React.useState<KubeNodePoolResponse[]>([]);
const [label, setLabel] = React.useState<string | undefined>();
const [version, setVersion] = React.useState<Item<string> | undefined>();
const [errors, setErrors] = React.useState<APIError[] | undefined>();
const [submitting, setSubmitting] = React.useState<boolean>(false);
const [hasAgreed, setAgreed] = React.useState<boolean>(false);
const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements();
const [highAvailability, setHighAvailability] = React.useState<boolean>();

const { data, error: regionsError } = useRegionsQuery();
const regionsData = data ?? [];
const history = useHistory();
const { data: account } = useAccount();
const { showHighAvailability } = getKubeHighAvailability(account);

const {
data: allTypes,
error: typesError,
isLoading: typesLoading,
} = useAllTypes();

// Only want to use current types here.
const typesData = filterCurrentTypes(allTypes?.map(extendType));

const {
mutateAsync: createKubernetesCluster,
} = useCreateKubernetesClusterMutation();

const { data, error: regionsError } = useRegionsQuery();
const regionsData = data ?? [];

// Only want to use current types here.
const typesData = filterCurrentTypes(allTypes?.map(extendType));

// Only include regions that have LKE capability
const filteredRegions = React.useMemo(() => {
return regionsData
Expand All @@ -137,44 +157,35 @@ export const CreateCluster = () => {
: [];
}, [regionsData]);

const [selectedRegionID, setSelectedRegionID] = React.useState<string>('');
const [nodePools, setNodePools] = React.useState<KubeNodePoolResponse[]>([]);
const [label, setLabel] = React.useState<string | undefined>();
const [highAvailability, setHighAvailability] = React.useState<boolean>(
false
);
const [version, setVersion] = React.useState<Item<string> | undefined>();
const [errors, setErrors] = React.useState<APIError[] | undefined>();
const [submitting, setSubmitting] = React.useState<boolean>(false);
const [hasAgreed, setAgreed] = React.useState<boolean>(false);
const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements();
const {
data: versionData,
isError: versionLoadError,
} = useKubernetesVersionQuery();

const versions = (versionData ?? []).map((thisVersion) => ({
label: thisVersion.id,
value: thisVersion.id,
}));
const history = useHistory();

React.useEffect(() => {
if (filteredRegions.length === 1 && !selectedRegionID) {
setSelectedRegionID(filteredRegions[0].id);
}
}, [filteredRegions, selectedRegionID]);

React.useEffect(() => {
if (versions.length > 0) {
setVersion(getLatestVersion(versions));
}
}, [versionData]);

const createCluster = () => {
const { push } = history;

setErrors(undefined);
setSubmitting(true);

const k8s_version = version ? version.value : undefined;

/**
* Only type and count to the API.
*/
// Only type and count to the API.
const node_pools = nodePools.map(
pick(['type', 'count'])
) as CreateNodePoolData[];
Expand Down Expand Up @@ -223,10 +234,7 @@ export const CreateCluster = () => {
};

const updateLabel = (newLabel: string) => {
/**
* If the new label is an empty string, use undefined.
* This allows it to pass Yup validation.
*/
// If the new label is an empty string, use undefined. This allows it to pass Yup validation.
setLabel(newLabel ? newLabel : undefined);
};

Expand All @@ -247,11 +255,7 @@ export const CreateCluster = () => {
});

if (typesError || regionsError || versionLoadError) {
/**
* This information is necessary to create a Cluster.
* Otherwise, show an error state.
*/

// This information is necessary to create a Cluster. Otherwise, show an error state.
return <ErrorState errorText="An unexpected error occurred." />;
}

Expand Down Expand Up @@ -297,16 +301,26 @@ export const CreateCluster = () => {
</Box>
<Box>
<Select
onChange={(selected: Item<string>) => {
setVersion(selected);
}}
className={classes.inputWidth}
errorText={errorMap.k8s_version}
isClearable={false}
label="Kubernetes Version"
onChange={(selected: Item<string>) => setVersion(selected)}
options={versions}
placeholder={' '}
value={version || null}
/>
</Box>
{showHighAvailability ? (
<Box data-testid="ha-control-plane">
<HAControlPlane
HIGH_AVAILABILITY_PRICE={HIGH_AVAILABILITY_PRICE}
setHighAvailability={setHighAvailability}
/>
</Box>
) : null}
</div>
<Box>
<NodePoolPanel
Expand Down Expand Up @@ -347,13 +361,14 @@ export const CreateCluster = () => {
createCluster,
classes,
]}
HIGH_AVAILABILITY_PRICE={HIGH_AVAILABILITY_PRICE}
createCluster={createCluster}
hasAgreed={hasAgreed}
highAvailability={highAvailability}
pools={nodePools}
region={selectedRegionID}
removePool={removePool}
setHighAvailability={setHighAvailability}
showHighAvailability={showHighAvailability}
submitting={submitting}
toggleHasAgreed={toggleHasAgreed}
updatePool={updatePool}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { fireEvent } from '@testing-library/react';
import * as React from 'react';

import { renderWithTheme } from 'src/utilities/testHelpers';

import { HAControlPlane, Props } from './HAControlPlane';

const props: Props = {
HIGH_AVAILABILITY_PRICE: 60,
setHighAvailability: jest.fn(),
};

describe('HAControlPlane', () => {
it('the component should render', () => {
const { getByTestId } = renderWithTheme(<HAControlPlane {...props} />);

expect(getByTestId('ha-control-plane-form')).toBeVisible();
});

it('the component should not render when HIGH_AVAILABILITY_PRICE is undefined ', () => {
const testProps: Props = {
HIGH_AVAILABILITY_PRICE: undefined,
setHighAvailability: jest.fn(),
};
const { queryByTestId } = renderWithTheme(
<HAControlPlane {...testProps} />
);

expect(queryByTestId('ha-control-plane-form')).not.toBeInTheDocument();
});

it('should call the handleChange function on change', () => {
const { getByTestId } = renderWithTheme(<HAControlPlane {...props} />);
const haRadioButton = getByTestId('ha-radio-button-yes');

fireEvent.click(haRadioButton);
expect(props.setHighAvailability).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { FormLabel } from '@mui/material';
import * as React from 'react';

import { displayPrice } from 'src/components/DisplayPrice';
import { FormControl } from 'src/components/FormControl';
import { FormControlLabel } from 'src/components/FormControlLabel';
import { Link } from 'src/components/Link';
import { Radio } from 'src/components/Radio/Radio';
import { RadioGroup } from 'src/components/RadioGroup';
import { Typography } from 'src/components/Typography';

export const HACopy = () => (
<Typography>
Recommended for production workloads, a high availability (HA) control plane
is replicated on multiple master nodes to 99.99% uptime.
<br />
<Link to="https://www.linode.com/docs/guides/enable-lke-high-availability/">
Learn more about the HA control plane
</Link>
.
</Typography>
);

export interface Props {
HIGH_AVAILABILITY_PRICE: number | undefined;
setHighAvailability: (ha: boolean | undefined) => void;
}

export const HAControlPlane = (props: Props) => {
const { HIGH_AVAILABILITY_PRICE, setHighAvailability } = props;

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setHighAvailability(e.target.value === 'yes');
};

if (HIGH_AVAILABILITY_PRICE === undefined) {
return null;
}

return (
<FormControl data-testid="ha-control-plane-form">
<FormLabel
sx={(theme) => ({
'&&.MuiFormLabel-root.Mui-focused': {
color: theme.name === 'dark' ? 'white' : theme.color.black,
},
})}
id="ha-radio-buttons-group-label"
>
<Typography variant="inherit">HA Control Plane</Typography>
</FormLabel>
<HACopy />
<RadioGroup
aria-labelledby="ha-radio-buttons-group-label"
name="ha-radio-buttons-group"
onChange={(e) => handleChange(e)}
>
<FormControlLabel
label={`Yes, enable HA control plane. (${displayPrice(
HIGH_AVAILABILITY_PRICE
)}/month)`}
control={<Radio data-testid="ha-radio-button-yes" />}
name="yes"
value="yes"
/>
<FormControlLabel control={<Radio />} label="No" name="no" value="no" />
</RadioGroup>
</FormControl>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ import KubeCheckoutBar, { Props } from './KubeCheckoutBar';
const pools = nodePoolFactory.buildList(5, { count: 3, type: 'g6-standard-1' });

const props: Props = {
HIGH_AVAILABILITY_PRICE: 60,
createCluster: jest.fn(),
hasAgreed: false,
highAvailability: false,
highAvailability: true,
pools,
region: undefined,
removePool: jest.fn(),
setHighAvailability: jest.fn(),
showHighAvailability: true,
submitting: false,
toggleHasAgreed: jest.fn(),
updatePool: jest.fn(),
Expand Down
Loading