diff --git a/packages/manager/.changeset/pr-11534-changed-1737137202046.md b/packages/manager/.changeset/pr-11534-changed-1737137202046.md new file mode 100644 index 00000000000..26db8acbdbc --- /dev/null +++ b/packages/manager/.changeset/pr-11534-changed-1737137202046.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Don't allow "HTTP Cookie" session stickiness when NodeBalancer config protocol is TCP ([#11534](https://github.com/linode/manager/pull/11534)) diff --git a/packages/manager/.changeset/pr-11534-upcoming-features-1737137247205.md b/packages/manager/.changeset/pr-11534-upcoming-features-1737137247205.md new file mode 100644 index 00000000000..a4264cfef70 --- /dev/null +++ b/packages/manager/.changeset/pr-11534-upcoming-features-1737137247205.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add support for NodeBalancer UDP Health Check Port ([#11534](https://github.com/linode/manager/pull/11534)) diff --git a/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts index 63c1e8080cf..313c24510de 100644 --- a/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts +++ b/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts @@ -17,8 +17,10 @@ const deployNodeBalancer = () => { cy.get('[data-qa-deploy-nodebalancer]').click(); }; -import { nodeBalancerFactory } from 'src/factories'; +import { linodeFactory, nodeBalancerFactory, regionFactory } from 'src/factories'; import { interceptCreateNodeBalancer } from 'support/intercepts/nodebalancers'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { mockGetLinodes } from 'support/intercepts/linodes'; const createNodeBalancerWithUI = ( nodeBal: NodeBalancer, @@ -115,48 +117,57 @@ describe('create NodeBalancer', () => { * - Confirms session stickiness field displays error if protocol is not HTTP or HTTPS. */ it('displays API errors for NodeBalancer Create form fields', () => { - const region = chooseRegion(); - const linodePayload = { - region: region.id, - // NodeBalancers require Linodes with private IPs. - private_ip: true, - }; - cy.defer(() => createTestLinode(linodePayload)).then((linode) => { - const nodeBal = nodeBalancerFactory.build({ - label: `${randomLabel()}-^`, - ipv4: linode.ipv4[1], - region: region.id, - }); + const region = regionFactory.build({ capabilities: ['NodeBalancers'] }); + const linode = linodeFactory.build({ ipv4: ['192.168.1.213'] }); - // catch request - interceptCreateNodeBalancer().as('createNodeBalancer'); + mockGetRegions([region]); + mockGetLinodes([linode]); + interceptCreateNodeBalancer().as('createNodeBalancer') - createNodeBalancerWithUI(nodeBal); - cy.findByText(`Label can't contain special characters or spaces.`).should( - 'be.visible' - ); - cy.get('[id="nodebalancer-label"]') - .should('be.visible') - .click() - .clear() - .type(randomLabel()); - - cy.get('[data-qa-protocol-select="true"]').click().type('TCP{enter}'); - - cy.get('[data-qa-session-stickiness-select]') - .click() - .type('HTTP Cookie{enter}'); - - deployNodeBalancer(); - const errMessage = `Stickiness http_cookie requires protocol 'http' or 'https'`; - cy.wait('@createNodeBalancer') - .its('response.body') - .should('deep.equal', { - errors: [{ field: 'configs[0].stickiness', reason: errMessage }], - }); + cy.visitWithLogin('/nodebalancers/create'); - cy.findByText(errMessage).should('be.visible'); - }); + cy.findByLabelText('NodeBalancer Label') + .should('be.visible') + .type('my-nodebalancer-1'); + + ui.autocomplete.findByLabel('Region') + .should('be.visible') + .click(); + + ui.autocompletePopper.findByTitle(region.id, { exact: false }) + .should('be.visible') + .should('be.enabled') + .click(); + + cy.findByLabelText('Label') + .type("my-node-1"); + + cy.findByLabelText('IP Address') + .click() + .type(linode.ipv4[0]); + + ui.autocompletePopper.findByTitle(linode.label) + .click(); + + ui.button.findByTitle('Create NodeBalancer') + .scrollIntoView() + .should('be.enabled') + .should('be.visible') + .click(); + + const expectedError = 'Address Restricted: IP must not be within 192.168.0.0/17'; + + cy.wait('@createNodeBalancer') + .its('response.body') + .should('deep.equal', { + errors: [ + { field: 'region', reason: 'region is not valid' }, + { field: 'configs[0].nodes[0].address', reason: expectedError } + ], + }); + + cy.findByText(expectedError) + .should('be.visible'); }); /* diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx index 63e7b8615da..090351c8a90 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx @@ -13,6 +13,8 @@ import { import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; +import { useFlags } from 'src/hooks/useFlags'; + import { setErrorMap } from './utils'; import type { NodeBalancerConfigPanelProps } from './types'; @@ -32,6 +34,7 @@ const displayProtocolText = (p: string) => { }; export const ActiveCheck = (props: ActiveCheckProps) => { + const flags = useFlags(); const { checkBody, checkPath, @@ -44,6 +47,7 @@ export const ActiveCheck = (props: ActiveCheckProps) => { healthCheckTimeout, healthCheckType, protocol, + udpCheckPort, } = props; const errorMap = setErrorMap(errors || []); @@ -94,7 +98,7 @@ export const ActiveCheck = (props: ActiveCheckProps) => { return ( - + Active Health Checks @@ -129,7 +133,50 @@ export const ActiveCheck = (props: ActiveCheckProps) => { {healthCheckType !== 'none' && ( - + {['http', 'http_body'].includes(healthCheckType) && ( + + + + )} + {healthCheckType === 'http_body' && ( + + + + )} + {flags.udp && protocol === 'udp' && ( + + props.onUdpCheckPortChange(+e.target.value)} + type="number" + value={udpCheckPort} + /> + + )} + { Seconds between health check probes - + { 1-30 - {['http', 'http_body'].includes(healthCheckType) && ( - - - - )} - {healthCheckType === 'http_body' && ( - - - - )} )} diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx index 23cd0b5d357..1cb1f152b85 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx @@ -57,6 +57,7 @@ export const nbConfigPanelMockPropsForTest: NodeBalancerConfigPanelProps = { onSave: vi.fn(), onSessionStickinessChange: vi.fn(), onSslCertificateChange: vi.fn(), + onUdpCheckPortChange: vi.fn(), port: 80, privateKey: '', protocol: 'http', @@ -64,6 +65,7 @@ export const nbConfigPanelMockPropsForTest: NodeBalancerConfigPanelProps = { removeNode: vi.fn(), sessionStickiness: 'table', sslCertificate: '', + udpCheckPort: 80, }; const activeHealthChecksFormInputs = ['Interval', 'Timeout', 'Attempts']; @@ -368,4 +370,26 @@ describe('NodeBalancerConfigPanel', () => { expect(getByText(algorithm)).toBeVisible(); } }); + + it('shows a "Health Check Port" field when health checks are enabled', async () => { + const onChange = vi.fn(); + + const { getByLabelText } = renderWithTheme( + , + { flags: { udp: true } } + ); + + const checkPortField = getByLabelText('Health Check Port'); + + expect(checkPortField).toBeVisible(); + + await userEvent.type(checkPortField, '8080'); + + expect(onChange).toHaveBeenCalledWith(8080); + }); }); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx index 1359c2324ab..b1e4e535903 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx @@ -249,77 +249,7 @@ export const NodeBalancerConfigPanel = ( /> - {protocol === 'https' && ( - - - - - - - - - )} - - {tcpSelected && ( - - { - props.onProxyProtocolChange(selected.value); - }} - textFieldProps={{ - dataAttrs: { - 'data-qa-proxy-protocol-select': true, - }, - errorGroup: forEdit ? `${configIdx}` : undefined, - }} - autoHighlight - disableClearable - disabled={disabled} - errorText={errorMap.proxy_protocol} - id={`proxy-protocol-${configIdx}`} - label="Proxy Protocol" - noMarginTop - options={proxyProtocolOptions} - size="small" - value={selectedProxyProtocol || proxyProtocolOptions[0]} - /> - - Proxy Protocol preserves initial TCP connection information. - Please consult{' '} - - our Proxy Protocol guide - - {` `} - for information on the differences between each option. - - - )} - - + { props.onAlgorithmChange(selected.value); @@ -370,6 +300,79 @@ export const NodeBalancerConfigPanel = ( Route subsequent requests from the client to the same backend. + + {tcpSelected && ( + + { + props.onProxyProtocolChange(selected.value); + }} + textFieldProps={{ + dataAttrs: { + 'data-qa-proxy-protocol-select': true, + }, + errorGroup: forEdit ? `${configIdx}` : undefined, + }} + autoHighlight + disableClearable + disabled={disabled} + errorText={errorMap.proxy_protocol} + id={`proxy-protocol-${configIdx}`} + label="Proxy Protocol" + noMarginTop + options={proxyProtocolOptions} + size="small" + value={selectedProxyProtocol || proxyProtocolOptions[0]} + /> + + Proxy Protocol preserves initial TCP connection information. + Please consult{' '} + + our Proxy Protocol guide + + {` `} + for information on the differences between each option. + + + )} + + {protocol === 'https' && ( + + + + + + + + + )} + diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx index 4f084643a4b..29c6e2ce3af 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx @@ -654,6 +654,7 @@ const NodeBalancerCreate = () => { onProxyProtocolChange={onChange('proxy_protocol')} onSessionStickinessChange={onChange('stickiness')} onSslCertificateChange={onChange('ssl_cert')} + onUdpCheckPortChange={(value) => onChange('udp_check_port')(value)} port={nodeBalancerFields.configs[idx].port!} privateKey={nodeBalancerFields.configs[idx].ssl_key!} protocol={nodeBalancerFields.configs[idx].protocol!} @@ -661,6 +662,7 @@ const NodeBalancerCreate = () => { removeNode={removeNodeBalancerConfigNode(idx)} sessionStickiness={nodeBalancerFields.configs[idx].stickiness!} sslCertificate={nodeBalancerFields.configs[idx].ssl_cert!} + udpCheckPort={nodeBalancerFields.configs[idx].udp_check_port!} /> ); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx index a108f579ee5..bec324e7bc7 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx @@ -606,6 +606,7 @@ class NodeBalancerConfigurations extends React.Component< proxyProtocolLens: lensTo(['proxy_protocol']), sessionStickinessLens: lensTo(['stickiness']), sslCertificateLens: lensTo(['ssl_cert']), + udpCheckPortLens: lensTo(['udp_check_port']), }; return ( @@ -676,6 +677,7 @@ class NodeBalancerConfigurations extends React.Component< onSave={this.onSaveConfig(idx)} onSessionStickinessChange={this.updateState(L.sessionStickinessLens)} onSslCertificateChange={this.updateState(L.sslCertificateLens)} + onUdpCheckPortChange={this.updateState(L.udpCheckPortLens, L)} port={view(L.portLens, this.state)} privateKey={view(L.privateKeyLens, this.state)} protocol={view(L.protocolLens, this.state)} @@ -684,6 +686,7 @@ class NodeBalancerConfigurations extends React.Component< sessionStickiness={view(L.sessionStickinessLens, this.state)} sslCertificate={view(L.sslCertificateLens, this.state)} submitting={configSubmitting[idx]} + udpCheckPort={view(L.udpCheckPortLens, this.state)} /> ); diff --git a/packages/manager/src/features/NodeBalancers/types.ts b/packages/manager/src/features/NodeBalancers/types.ts index 82c6caeb608..2f2c7f183cb 100644 --- a/packages/manager/src/features/NodeBalancers/types.ts +++ b/packages/manager/src/features/NodeBalancers/types.ts @@ -41,6 +41,7 @@ export interface NodeBalancerConfigPanelProps { algorithm: Algorithm; checkBody: string; checkPassive: boolean; + udpCheckPort: number; checkPath: string; configIdx: number; @@ -65,6 +66,8 @@ export interface NodeBalancerConfigPanelProps { onCheckPassiveChange: (v: boolean) => void; onCheckPathChange: (v: string) => void; + onUdpCheckPortChange: (v: number) => void; + onDelete?: any; onHealthCheckAttemptsChange: (v: number | string) => void; diff --git a/packages/manager/src/features/NodeBalancers/utils.ts b/packages/manager/src/features/NodeBalancers/utils.ts index 7dfd27a05d7..2e1ea4177c7 100644 --- a/packages/manager/src/features/NodeBalancers/utils.ts +++ b/packages/manager/src/features/NodeBalancers/utils.ts @@ -105,9 +105,10 @@ export const transformConfigsForRequest = ( check_interval: !isNil(config.check_interval) ? +config.check_interval : undefined, - check_passive: shouldIncludePassiveCheck(config) - ? config.check_passive - : undefined, + // Passive checks must be false for UDP + check_passive: config.protocol === 'udp' + ? false + : config.check_passive, check_path: shouldIncludeCheckPath(config) ? config.check_path : undefined, @@ -146,6 +147,7 @@ export const transformConfigsForRequest = ( ? undefined : config.ssl_key || undefined, stickiness: config.stickiness || undefined, + udp_check_port: config.udp_check_port, } ) as unknown) as NodeBalancerConfigFields; }); @@ -162,11 +164,6 @@ export const shouldIncludeCheckPath = (config: NodeBalancerConfigFields) => { ); }; -const shouldIncludePassiveCheck = (config: NodeBalancerConfigFields) => { - // UDP does not support passive checks - return config.protocol !== 'udp'; -}; - export const shouldIncludeCheckBody = (config: NodeBalancerConfigFields) => { return config.check === 'http_body' && config.check_body; }; @@ -198,6 +195,7 @@ export const setErrorMap = (errors: APIError[]) => 'ssl_key', 'stickiness', 'nodes', + 'udp_check_port', ], filteredErrors(errors) ); @@ -237,6 +235,12 @@ export const getStickinessOptions = ( { label: 'Source IP', value: 'source_ip' }, ]; } + if (protocol === 'tcp') { + return [ + { label: 'None', value: 'none' }, + { label: 'Table', value: 'table' }, + ]; + } return [ { label: 'None', value: 'none' }, { label: 'Table', value: 'table' },