diff --git a/packages/api-v4/src/kubernetes/kubernetes.ts b/packages/api-v4/src/kubernetes/kubernetes.ts index 029bd0df976..faba1e5ba66 100644 --- a/packages/api-v4/src/kubernetes/kubernetes.ts +++ b/packages/api-v4/src/kubernetes/kubernetes.ts @@ -1,7 +1,4 @@ -import { - createKubeClusterSchema, - createKubeEnterpriseClusterSchema, -} from '@linode/validation/lib/kubernetes.schema'; +import { createKubeClusterSchema } from '@linode/validation/lib/kubernetes.schema'; import { API_ROOT, BETA_API_ROOT } from '../constants'; import Request, { setData, @@ -97,12 +94,7 @@ export const createKubernetesClusterBeta = (data: CreateKubeClusterPayload) => { return Request( setMethod('POST'), setURL(`${BETA_API_ROOT}/lke/clusters`), - setData( - data, - data.tier === 'enterprise' - ? createKubeEnterpriseClusterSchema - : createKubeClusterSchema - ) + setData(data, createKubeClusterSchema) ); }; diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts index 70de96eee4c..5a3b3490b3e 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -931,6 +931,223 @@ describe('LKE Cluster Creation with ACL', () => { .should('be.enabled'); }); + /** + * - Confirms create flow for LKE-E cluster with ACL enabled by default + * - Confirms at least one IP must be provided for ACL unless acknowledgement is checked + * - Confirms the cluster details page shows ACL is enabled + */ + it('creates an LKE cluster with ACL enabled by default and handles IP address validation', () => { + const clusterLabel = randomLabel(); + const mockedEnterpriseCluster = kubernetesClusterFactory.build({ + k8s_version: latestEnterpriseTierKubernetesVersion.id, + label: clusterLabel, + region: 'us-iad', + tier: 'enterprise', + }); + const mockedEnterpriseClusterPools = [nanodeMemoryPool]; + const mockACL = kubernetesControlPlaneACLFactory.build({ + acl: { + addresses: { + ipv4: [], + ipv6: [], + }, + enabled: true, + 'revision-id': '', + }, + }); + mockGetControlPlaneACL(mockedEnterpriseCluster.id, mockACL).as( + 'getControlPlaneACL' + ); + mockGetAccount( + accountFactory.build({ + capabilities: [ + 'Kubernetes Enterprise', + 'LKE HA Control Planes', + 'LKE Network Access Control List (IP ACL)', + ], + }) + ).as('getAccount'); + mockGetTieredKubernetesVersions('enterprise', [ + latestEnterpriseTierKubernetesVersion, + ]).as('getTieredKubernetesVersions'); + mockGetKubernetesVersions([latestKubernetesVersion]).as( + 'getKubernetesVersions' + ); + mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); + mockGetLKEClusterTypes(mockedLKEEnterprisePrices).as( + 'getLKEEnterpriseClusterTypes' + ); + mockGetRegions([ + regionFactory.build({ + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise'], + id: 'us-iad', + label: 'Washington, DC', + }), + ]).as('getRegions'); + mockGetCluster(mockedEnterpriseCluster).as('getCluster'); + mockCreateCluster(mockedEnterpriseCluster).as('createCluster'); + mockGetClusters([mockedEnterpriseCluster]).as('getClusters'); + mockGetClusterPools( + mockedEnterpriseCluster.id, + mockedEnterpriseClusterPools + ).as('getClusterPools'); + mockGetDashboardUrl(mockedEnterpriseCluster.id).as('getDashboardUrl'); + mockGetApiEndpoints(mockedEnterpriseCluster.id).as('getApiEndpoints'); + + cy.visitWithLogin('/kubernetes/clusters'); + cy.wait(['@getAccount']); + + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.url().should('endWith', '/kubernetes/create'); + cy.wait(['@getKubernetesVersions', '@getTieredKubernetesVersions']); + + // Select enterprise tier. + cy.get(`[data-qa-select-card-heading="LKE Enterprise"]`) + .closest('[data-qa-selection-card]') + .click(); + + cy.wait(['@getLKEEnterpriseClusterTypes', '@getRegions']); + + // Select a supported region. + ui.regionSelect.find().clear().type('Washington, DC{enter}'); + + // Select an enterprise version. + ui.autocomplete + .findByLabel('Kubernetes Version') + .should('be.visible') + .click(); + + clusterPlans.forEach((clusterPlan) => { + const nodeCount = clusterPlan.nodeCount; + const planName = clusterPlan.planName; + // Click the right tab for the plan, and add a node pool with the desired + // number of nodes. + cy.findByText(clusterPlan.tab).should('be.visible').click(); + const quantityInput = '[name="Quantity"]'; + cy.findByText(planName) + .should('be.visible') + .closest('tr') + .within(() => { + cy.get(quantityInput).should('be.visible'); + cy.get(quantityInput).click(); + cy.get(quantityInput).type(`{selectall}${nodeCount}`); + + ui.button + .findByTitle('Add') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + // Confirm ACL is enabled by default. + cy.contains('Control Plane ACL').should('be.visible'); + ui.toggle + .find() + .should('have.attr', 'data-qa-toggle', 'true') + .should('be.visible'); + cy.findByRole('checkbox', { name: /Provide an ACL later/ }).should( + 'not.be.checked' + ); + + // Try to submit the form without the ACL acknowledgement checked. + cy.get('[data-testid="kube-checkout-bar"]') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm error validation requires an ACL IP. + cy.findByText( + 'At least one IP address or CIDR range is required for LKE Enterprise.' + ).should('be.visible'); + + // Add an IP, + cy.findByLabelText('IPv4 Addresses or CIDRs ip-address-0') + .should('be.visible') + .click(); + cy.focused().clear(); + cy.focused().type('10.0.0.0/24'); + + cy.get('[data-testid="kube-checkout-bar"]') + .should('be.visible') + .within(() => { + // Try to submit the form again. + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm the validation message is gone. + cy.findByText( + 'At least one IP address or CIDR range is required for LKE Enterprise.' + ).should('not.exist'); + + // Check the acknowledgement to prevent IP validation. + cy.findByRole('checkbox', { name: /Provide an ACL later/ }).check(); + + // Clear the IP address field and check the acknowledgement to confirm the form can now submit without IP address validation. + cy.findByLabelText('IPv4 Addresses or CIDRs ip-address-0') + .should('be.visible') + .click(); + cy.focused().clear(); + cy.findByRole('checkbox', { name: /Provide an ACL later/ }).check(); + + // Finally, add a label, so the form will submit. + cy.findByLabelText('Cluster Label').should('be.visible').click(); + cy.focused().type(`${clusterLabel}{enter}`); + + cy.get('[data-testid="kube-checkout-bar"]') + .should('be.visible') + .within(() => { + // Try to submit the form. + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm the validation message is gone. + cy.findByText( + 'At least one IP address or CIDR range is required for LKE Enterprise.' + ).should('not.exist'); + + cy.wait([ + '@getCluster', + '@getClusterPools', + '@createCluster', + '@getLKEEnterpriseClusterTypes', + '@getLinodeTypes', + '@getDashboardUrl', + '@getApiEndpoints', + '@getControlPlaneACL', + ]); + + cy.url().should( + 'endWith', + `/kubernetes/clusters/${mockedEnterpriseCluster.id}/summary` + ); + + // Confirms Summary panel displays as expected + cy.contains('Control Plane ACL').should('be.visible'); + ui.button + .findByTitle('Enabled (0 IP Addresses)') + .should('be.visible') + .should('be.enabled'); + }); + /** * - Confirms IP validation error appears when a bad IP is entered * - Confirms IP validation error disappears when a valid IP is entered @@ -1089,7 +1306,7 @@ describe('LKE Cluster Creation with LKE-E', () => { * - Confirms an LKE-E supported region can be selected * - Confirms an LKE-E supported k8 version can be selected * - Confirms the APL section is disabled while it remains unsupported - * - Confirms at least one IP must be provided for ACL + * - Confirms ACL is enabled by default * - Confirms the checkout bar displays the correct LKE-E info * - Confirms an enterprise cluster can be created with the correct chip, version, and price * - Confirms that the total node count for each pool is displayed @@ -1103,7 +1320,20 @@ describe('LKE Cluster Creation with LKE-E', () => { tier: 'enterprise', }); const mockedEnterpriseClusterPools = [nanodeMemoryPool, dedicatedCpuPool]; + const mockACL = kubernetesControlPlaneACLFactory.build({ + acl: { + addresses: { + ipv4: ['10.0.0.0/24'], + ipv6: [], + }, + enabled: true, + 'revision-id': '', + }, + }); + mockGetControlPlaneACL(mockedEnterpriseCluster.id, mockACL).as( + 'getControlPlaneACL' + ); mockGetAccountBeta( accountBetaFactory.build({ id: 'apl', @@ -1287,19 +1517,14 @@ describe('LKE Cluster Creation with LKE-E', () => { cy.findByText('Linode 2 GB Plan').should('be.visible'); cy.findByText('$15.00').should('be.visible'); cy.findByText('$459.00').should('be.visible'); - - // Try to submit the form - ui.button - .findByTitle('Create Cluster') - .should('be.visible') - .should('be.enabled') - .click(); }); - // Confirm error validation requires an ACL IP - cy.findByText( - 'At least one IP address or CIDR range is required for LKE Enterprise.' - ).should('be.visible'); + // Confirms ACL is enabled by default. + cy.contains('Control Plane ACL').should('be.visible'); + ui.toggle + .find() + .should('have.attr', 'data-qa-toggle', 'true') + .should('be.visible'); // Add an IP cy.findByLabelText('IPv4 Addresses or CIDRs ip-address-0') @@ -1319,10 +1544,6 @@ describe('LKE Cluster Creation with LKE-E', () => { .click(); }); - cy.findByText( - 'At least one IP address or CIDR range is required for LKE Enterprise.' - ).should('not.exist'); - // Wait for LKE cluster to be created and confirm that we are redirected // to the cluster summary page. cy.wait([ @@ -1333,6 +1554,7 @@ describe('LKE Cluster Creation with LKE-E', () => { '@getLinodeTypes', '@getDashboardUrl', '@getApiEndpoints', + '@getControlPlaneACL', ]); cy.url().should( diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts index cdad53bb783..0f87c101f01 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -2969,24 +2969,38 @@ describe('LKE ACL updates', () => { tier: 'enterprise', }); const mockACLOptions = kubernetesControlPlaneACLOptionsFactory.build({ - addresses: { ipv4: ['127.0.0.1'], ipv6: undefined }, + addresses: { ipv4: [], ipv6: [] }, + enabled: true, + }); + const mockUpdatedOptions = kubernetesControlPlaneACLOptionsFactory.build({ + addresses: { + ipv4: [], + ipv6: ['8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'], + }, enabled: true, }); const mockControlPaneACL = kubernetesControlPlaneACLFactory.build({ acl: mockACLOptions, }); + const mockUpdatedControlPaneACL = kubernetesControlPlaneACLFactory.build({ + acl: mockUpdatedOptions, + }); mockGetCluster(mockEnterpriseCluster).as('getCluster'); mockGetControlPlaneACL(mockEnterpriseCluster.id, mockControlPaneACL).as( 'getControlPlaneACL' ); + mockUpdateControlPlaneACL( + mockEnterpriseCluster.id, + mockUpdatedControlPaneACL + ).as('updateControlPlaneACL'); cy.visitWithLogin(`/kubernetes/clusters/${mockEnterpriseCluster.id}`); cy.wait(['@getAccount', '@getCluster', '@getControlPlaneACL']); cy.contains('Control Plane ACL').should('be.visible'); ui.button - .findByTitle('Enabled (1 IP Address)') + .findByTitle('Enabled (0 IP Addresses)') .should('be.visible') .should('be.enabled') .click(); @@ -2995,11 +3009,14 @@ describe('LKE ACL updates', () => { .findByTitle(`Control Plane ACL for ${mockEnterpriseCluster.label}`) .should('be.visible') .within(() => { - // Clear the existing IP - cy.findByLabelText('IPv4 Addresses or CIDRs ip-address-0') - .should('be.visible') - .click(); + // Confirm the checkbox is not checked by default + cy.findByRole('checkbox', { name: /Provide an ACL later/ }).should( + 'not.be.checked' + ); + + cy.findByLabelText('Revision ID').click(); cy.focused().clear(); + cy.focused().type('1'); // Try to submit the form without any IPs ui.button @@ -3015,6 +3032,7 @@ describe('LKE ACL updates', () => { ).should('be.visible'); // Add at least one IP + cy.findByText('Add IPv6 Address').click(); cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-0') .should('be.visible') .click(); @@ -3034,6 +3052,41 @@ describe('LKE ACL updates', () => { 'At least one IP address or CIDR range is required for LKE Enterprise.' ).should('not.exist'); }); + + cy.wait('@updateControlPlaneACL'); + + ui.button + .findByTitle('Enabled (1 IP Address)') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle(`Control Plane ACL for ${mockEnterpriseCluster.label}`) + .should('be.visible') + .within(() => { + // Clear the existing IP + cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-0') + .should('be.visible') + .click(); + cy.focused().clear(); + + // Check the acknowledgement checkbox + cy.findByRole('checkbox', { name: /Provide an ACL later/ }).click(); + + // Confirm the form can submit without any IPs if the acknowledgement is checked + ui.button + .findByTitle('Update') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm error message disappears + cy.contains( + 'At least one IP address or CIDR range is required for LKE Enterprise.' + ).should('not.exist'); + }); }); }); }); diff --git a/packages/manager/src/components/ErrorMessage.tsx b/packages/manager/src/components/ErrorMessage.tsx index aae52c9770d..53f3c8e78af 100644 --- a/packages/manager/src/components/ErrorMessage.tsx +++ b/packages/manager/src/components/ErrorMessage.tsx @@ -39,5 +39,5 @@ export const ErrorMessage = (props: Props) => { return ; } - return {message}; + return {message}; }; diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.test.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.test.tsx index 14873198ce8..674159a5562 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.test.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.test.tsx @@ -12,15 +12,19 @@ const props: ControlPlaneACLProps = { errorText: undefined, handleIPv4Change: vi.fn(), handleIPv6Change: vi.fn(), + handleIsAcknowledgementChecked: vi.fn(), ipV4Addr: [{ address: '' }], ipV6Addr: [{ address: '' }], + isAcknowledgementChecked: false, selectedTier: 'standard', setControlPlaneACL: vi.fn(), }; describe('ControlPlaneACLPane', () => { - it('renders checkbox, fields, and correct copy for a standard cluster when enableControlPlaneACL is true', () => { - const { getByText } = renderWithTheme(); + it('renders toggle, fields, and correct copy for a standard cluster when enableControlPlaneACL is true', () => { + const { getByText, queryByRole } = renderWithTheme( + + ); expect(getByText('Control Plane ACL')).toBeVisible(); expect( @@ -33,6 +37,12 @@ describe('ControlPlaneACLPane', () => { expect(getByText('Add IPv4 Address')).toBeVisible(); expect(getByText('IPv6 Addresses or CIDRs')).toBeVisible(); expect(getByText('Add IPv6 Address')).toBeVisible(); + + // Confirm acknowledgement check is not shown for a standard tier cluster since ACL is not enforced by default. + const acknowledgementCheck = queryByRole('checkbox', { + name: /Provide an ACL later/, + }); + expect(acknowledgementCheck).toBe(null); }); it('hides IP fields when enableControlPlaneACL is false for a standard cluster', () => { @@ -53,7 +63,7 @@ describe('ControlPlaneACLPane', () => { expect(queryByText('Add IPv6 Address')).not.toBeInTheDocument(); }); - it('renders correct toggle state and copy for an enterprise cluster when enableControlPlaneACL is true', () => { + it('renders correct toggle state, copy, and acknowledgement checkbox for an enterprise cluster when enableControlPlaneACL is true', () => { const { getByRole, getByText } = renderWithTheme( ); @@ -65,7 +75,7 @@ describe('ControlPlaneACLPane', () => { ) ).toBeVisible(); - // Confirm ACL is checked by default and edits are disabled. + // Confirm ACL toggle is checked by default and edits are disabled. const toggle = getByRole('checkbox', { name: 'Enable Control Plane ACL' }); expect(toggle).toBeChecked(); expect(toggle).toBeDisabled(); @@ -74,6 +84,13 @@ describe('ControlPlaneACLPane', () => { expect(getByText('Add IPv4 Address')).toBeVisible(); expect(getByText('IPv6 Addresses or CIDRs')).toBeVisible(); expect(getByText('Add IPv6 Address')).toBeVisible(); + + // Confirm acknowledgement checkbox is shown. + const acknowledgementCheck = getByRole('checkbox', { + name: /Provide an ACL later/, + }); + expect(acknowledgementCheck).toBeEnabled(); + expect(acknowledgementCheck).not.toBeChecked(); }); it('calls setControlPlaneACL when clicking the toggle', async () => { @@ -85,6 +102,20 @@ describe('ControlPlaneACLPane', () => { expect(props.setControlPlaneACL).toHaveBeenCalled(); }); + it('calls handleIsAcknowledgementChecked when clicking the acknowledgement', async () => { + const { getByRole } = renderWithTheme( + + ); + + // Confirm acknowledgement checkbox is shown. + const acknowledgementCheck = getByRole('checkbox', { + name: /Provide an ACL later/, + }); + await userEvent.click(acknowledgementCheck); + + expect(props.handleIsAcknowledgementChecked).toHaveBeenCalled(); + }); + it('handles IP changes', async () => { const { getByLabelText } = renderWithTheme( diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.tsx index 6c3a03b6421..cc4798f1e3a 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.tsx @@ -1,5 +1,6 @@ import { Box, + Checkbox, FormControl, FormControlLabel, Notice, @@ -26,8 +27,10 @@ export interface ControlPlaneACLProps { errorText: string | undefined; handleIPv4Change: (ips: ExtendedIP[]) => void; handleIPv6Change: (ips: ExtendedIP[]) => void; + handleIsAcknowledgementChecked: (isChecked: boolean) => void; ipV4Addr: ExtendedIP[]; ipV6Addr: ExtendedIP[]; + isAcknowledgementChecked: boolean; selectedTier: KubernetesTier; setControlPlaneACL: (enabled: boolean) => void; } @@ -38,8 +41,10 @@ export const ControlPlaneACLPane = (props: ControlPlaneACLProps) => { errorText, handleIPv4Change, handleIPv6Change, + handleIsAcknowledgementChecked, ipV4Addr, ipV6Addr, + isAcknowledgementChecked, selectedTier, setControlPlaneACL, } = props; @@ -59,8 +64,8 @@ export const ControlPlaneACLPane = (props: ControlPlaneACLProps) => { )} {isEnterpriseCluster - ? CREATE_CLUSTER_STANDARD_TIER_ACL_COPY - : CREATE_CLUSTER_ENTERPRISE_TIER_ACL_COPY} + ? CREATE_CLUSTER_ENTERPRISE_TIER_ACL_COPY + : CREATE_CLUSTER_STANDARD_TIER_ACL_COPY} { /> {enableControlPlaneACL && ( - - { - const validatedIPs = validateIPs(_ips, { - allowEmptyAddress: true, - errorMessage: 'Must be a valid IPv4 address.', - }); - handleIPv4Change(validatedIPs); - }} - buttonText="Add IPv4 Address" - ips={ipV4Addr} - isLinkStyled - onChange={handleIPv4Change} - title="IPv4 Addresses or CIDRs" - /> - + + { const validatedIPs = validateIPs(_ips, { allowEmptyAddress: true, - errorMessage: 'Must be a valid IPv6 address.', + errorMessage: 'Must be a valid IPv4 address.', }); - handleIPv6Change(validatedIPs); + handleIPv4Change(validatedIPs); }} - buttonText="Add IPv6 Address" - ips={ipV6Addr} + buttonText="Add IPv4 Address" + ips={ipV4Addr} isLinkStyled - onChange={handleIPv6Change} - title="IPv6 Addresses or CIDRs" + onChange={handleIPv4Change} + title="IPv4 Addresses or CIDRs" /> + + { + const validatedIPs = validateIPs(_ips, { + allowEmptyAddress: true, + errorMessage: 'Must be a valid IPv6 address.', + }); + handleIPv6Change(validatedIPs); + }} + buttonText="Add IPv6 Address" + ips={ipV6Addr} + isLinkStyled + onChange={handleIPv6Change} + title="IPv6 Addresses or CIDRs" + /> + + {isEnterpriseCluster && ( + + handleIsAcknowledgementChecked(!isAcknowledgementChecked) + } + name="acl-acknowledgement" + /> + } + data-qa-checkbox="acl-acknowledgement" + label="Provide an ACL later. The control plane will be unreachable until an ACL is defined." + /> + )} )} diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index 45e35cfaa54..3fbb9cc1bd2 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -13,6 +13,7 @@ import { TextField, } from '@linode/ui'; import { plansNoticesUtils, scrollErrorIntoViewV2 } from '@linode/utilities'; +import { createKubeClusterWithRequiredACLSchema } from '@linode/validation'; import { Divider } from '@mui/material'; import Grid from '@mui/material/Grid2'; import { createLazyRoute } from '@tanstack/react-router'; @@ -46,8 +47,10 @@ import { getAPIErrorOrDefault, getErrorMap } from 'src/utilities/errorUtils'; import { extendType } from 'src/utilities/extendType'; import { filterCurrentTypes } from 'src/utilities/filterCurrentLinodeTypes'; import { stringToExtendedIP } from 'src/utilities/ipUtils'; -import { DOCS_LINK_LABEL_DC_PRICING } from 'src/utilities/pricing/constants'; -import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; +import { + DOCS_LINK_LABEL_DC_PRICING, + UNKNOWN_PRICE, +} from 'src/utilities/pricing/constants'; import { getDCSpecificPriceByType } from 'src/utilities/pricing/dynamicPricing'; import { reportAgreementSigningError } from 'src/utilities/reportAgreementSigningError'; @@ -107,6 +110,10 @@ export const CreateCluster = () => { const [selectedTier, setSelectedTier] = React.useState( 'standard' ); + const [ + isACLAcknowledgementChecked, + setIsACLAcknowledgementChecked, + ] = React.useState(false); const { data: kubernetesHighAvailabilityTypesData, @@ -189,7 +196,7 @@ export const CreateCluster = () => { } }, [versionData]); - const createCluster = () => { + const createCluster = async () => { if (ipV4Addr.some((ip) => ip.error) || ipV6Addr.some((ip) => ip.error)) { scrollErrorIntoViewV2(formContainerRef); return; @@ -257,6 +264,21 @@ export const CreateCluster = () => { ? createKubernetesClusterBeta : createKubernetesCluster; + // Since ACL is enabled by default for LKE-E clusters, run validation on the ACL IP Address fields if the acknowledgement is not explicitly checked. + if (selectedTier === 'enterprise' && !isACLAcknowledgementChecked) { + try { + await createKubeClusterWithRequiredACLSchema.validate(payload, { + abortEarly: false, + }); + } catch ({ errors }) { + setErrors([{ field: 'control_plane', reason: errors[0] }]); + setSubmitting(false); + scrollErrorIntoViewV2(formContainerRef); + + return; + } + } + createClusterFn(payload) .then((cluster) => { push(`/kubernetes/clusters/${cluster.id}`); @@ -490,10 +512,14 @@ export const CreateCluster = () => { handleIPv6Change={(newIpV6Addr: ExtendedIP[]) => { setIPv6Addr(newIpV6Addr); }} + handleIsAcknowledgementChecked={(isChecked: boolean) => + setIsACLAcknowledgementChecked(isChecked) + } enableControlPlaneACL={controlPlaneACL} errorText={errorMap.control_plane} ipV4Addr={ipV4Addr} ipV6Addr={ipV6Addr} + isAcknowledgementChecked={isACLAcknowledgementChecked} selectedTier={selectedTier} setControlPlaneACL={setControlPlaneACL} /> diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.tsx index 21af39bdd5a..875e16026b8 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeControlPaneACLDrawer.tsx @@ -2,6 +2,7 @@ import { yupResolver } from '@hookform/resolvers/yup'; import { ActionsPanel, Box, + Checkbox, Drawer, FormControlLabel, Notice, @@ -67,6 +68,11 @@ export const KubeControlPlaneACLDrawer = ( const isEnterpriseCluster = clusterTier === 'enterprise'; + const [ + isACLAcknowledgementChecked, + setIsACLAcknowledgementChecked, + ] = React.useState(false); + const { mutateAsync: updateKubernetesClusterControlPlaneACL, } = useKubernetesControlPlaneACLMutation(clusterId); @@ -86,7 +92,7 @@ export const KubeControlPlaneACLDrawer = ( defaultValues: aclData, mode: 'onBlur', resolver: yupResolver( - isEnterpriseCluster + isEnterpriseCluster && !isACLAcknowledgementChecked ? kubernetesEnterpriseControlPlaneACLPayloadSchema : kubernetesControlPlaneACLPayloadSchema ), @@ -104,6 +110,11 @@ export const KubeControlPlaneACLDrawer = ( const { acl } = watch(); + const shouldShowAclAcknowledgementCheck = + isEnterpriseCluster && + (acl?.addresses?.ipv4?.length === 0 || acl?.addresses?.ipv4?.[0] === '') && + (acl?.addresses?.ipv6?.length === 0 || acl?.addresses?.ipv6?.[0] === ''); + const updateCluster = async () => { // A quick note on the following code: // @@ -163,6 +174,8 @@ export const KubeControlPlaneACLDrawer = ( } scrollErrorIntoViewV2(formContainerRef); } + + setIsACLAcknowledgementChecked(false); }; const handleClose = () => { @@ -306,6 +319,21 @@ export const KubeControlPlaneACLDrawer = ( /> + {shouldShowAclAcknowledgementCheck && ( + + setIsACLAcknowledgementChecked(!isACLAcknowledgementChecked) + } + name="acl-acknowledgement" + /> + } + data-qa-checkbox="acl-acknowledgement" + label="Provide an ACL later. The control plane will be unreachable until an ACL is defined." + sx={{ marginY: 1 }} + /> + )} ({ - queryFn: ({ pageParam }) => getImages({ page: pageParam as number }, filters), + queryFn: ({ pageParam }) => + getImages({ page: pageParam as number }, filters), queryKey: [filters], }), paginated: (params: Params, filters: Filter) => ({ diff --git a/packages/manager/src/queries/kubernetes.ts b/packages/manager/src/queries/kubernetes.ts index 0e85651a8a2..07f36e4442b 100644 --- a/packages/manager/src/queries/kubernetes.ts +++ b/packages/manager/src/queries/kubernetes.ts @@ -152,7 +152,8 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { queryKey: [useBetaEndpoint ? 'v4beta' : 'v4'], }), infinite: (filter: Filter = {}) => ({ - queryFn: ({ pageParam }) => getKubernetesClusters({ page: pageParam as number }, filter), + queryFn: ({ pageParam }) => + getKubernetesClusters({ page: pageParam as number }, filter), queryKey: [filter], }), paginated: ( @@ -165,7 +166,7 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { ? getKubernetesClustersBeta(params, filter) : getKubernetesClusters(params, filter), queryKey: [params, filter, useBetaEndpoint ? 'v4beta' : 'v4'], - }) + }), }, queryKey: null, }, diff --git a/packages/validation/src/kubernetes.schema.ts b/packages/validation/src/kubernetes.schema.ts index 2f69c21c618..5aafb652d4d 100644 --- a/packages/validation/src/kubernetes.schema.ts +++ b/packages/validation/src/kubernetes.schema.ts @@ -92,31 +92,29 @@ export const createKubeClusterSchema = object({ .min(1, 'Please add at least one node pool.'), }); -export const createKubeEnterpriseClusterSchema = createKubeClusterSchema.concat( - object({ - control_plane: object({ - high_availability: boolean(), - acl: object({ - enabled: boolean(), - 'revision-id': string(), - addresses: object({ - ipv4: array().of(ipv4Address), - ipv6: array().of(ipv6Address), - }), +export const createKubeClusterWithRequiredACLSchema = object({ + control_plane: object({ + high_availability: boolean(), + acl: object({ + enabled: boolean(), + 'revision-id': string(), + addresses: object({ + ipv4: array().of(ipv4Address), + ipv6: array().of(ipv6Address), }), - }) - .test( - 'validateIPForEnterprise', - 'At least one IP address or CIDR range is required for LKE Enterprise.', - function (controlPlane) { - const { ipv4, ipv6 } = controlPlane.acl.addresses; - // Pass validation if either IP address has a value. - return (ipv4 && ipv4.length > 0) || (ipv6 && ipv6.length > 0); - } - ) - .required(), + }), }) -); + .test( + 'validateIPForEnterprise', + 'At least one IP address or CIDR range is required for LKE Enterprise.', + function (controlPlane) { + const { ipv4, ipv6 } = controlPlane.acl.addresses; + // Pass validation if either IP address has a value. + return (ipv4 && ipv4.length > 0) || (ipv6 && ipv6.length > 0); + } + ) + .required(), +}); export const kubernetesControlPlaneACLPayloadSchema = object({ acl: controlPlaneACLOptionsSchema,