diff --git a/packages/api-v4/.changeset/pr-10968-added-1727966811522.md b/packages/api-v4/.changeset/pr-10968-added-1727966811522.md new file mode 100644 index 00000000000..df179fea56e --- /dev/null +++ b/packages/api-v4/.changeset/pr-10968-added-1727966811522.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +ACL related endpoints and types for LKE clusters ([#10968](https://github.com/linode/manager/pull/10968)) diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 39211183930..918c761d631 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -70,6 +70,7 @@ export type AccountCapability = | 'Kubernetes' | 'Linodes' | 'LKE HA Control Planes' + | 'LKE Network Access Control List (IP ACL)' | 'Machine Images' | 'Managed Databases' | 'Managed Databases Beta' diff --git a/packages/api-v4/src/kubernetes/kubernetes.ts b/packages/api-v4/src/kubernetes/kubernetes.ts index 6d954685a44..402d8633a92 100644 --- a/packages/api-v4/src/kubernetes/kubernetes.ts +++ b/packages/api-v4/src/kubernetes/kubernetes.ts @@ -15,6 +15,7 @@ import type { KubernetesEndpointResponse, KubernetesDashboardResponse, KubernetesVersion, + KubernetesControlPlaneACLPayload, } from './types'; /** @@ -221,3 +222,37 @@ export const getKubernetesTypes = (params?: Params) => setMethod('GET'), setParams(params) ); + +/** + * getKubernetesClusterControlPlaneACL + * + * Return control plane access list about a single Kubernetes cluster + */ +export const getKubernetesClusterControlPlaneACL = (clusterID: number) => + Request( + setMethod('GET'), + setURL( + `${API_ROOT}/lke/clusters/${encodeURIComponent( + clusterID + )}/control_plane_acl` + ) + ); + +/** + * updateKubernetesClusterControlPlaneACL + * + * Update an existing ACL from a single Kubernetes cluster. + */ +export const updateKubernetesClusterControlPlaneACL = ( + clusterID: number, + data: Partial +) => + Request( + setMethod('PUT'), + setURL( + `${API_ROOT}/lke/clusters/${encodeURIComponent( + clusterID + )}/control_plane_acl` + ), + setData(data) + ); diff --git a/packages/api-v4/src/kubernetes/types.ts b/packages/api-v4/src/kubernetes/types.ts index acdb6aa0ccd..c9ea9b25282 100644 --- a/packages/api-v4/src/kubernetes/types.ts +++ b/packages/api-v4/src/kubernetes/types.ts @@ -60,8 +60,22 @@ export interface KubernetesDashboardResponse { url: string; } +export interface KubernetesControlPlaneACLPayload { + acl: ControlPlaneACLOptions; +} + +export interface ControlPlaneACLOptions { + enabled?: boolean; + 'revision-id'?: string; + addresses?: null | { + ipv4?: null | string[]; + ipv6?: null | string[]; + }; +} + export interface ControlPlaneOptions { high_availability?: boolean; + acl?: ControlPlaneACLOptions; } export interface CreateKubeClusterPayload { diff --git a/packages/manager/.changeset/pr-10968-added-1727901904107.md b/packages/manager/.changeset/pr-10968-added-1727901904107.md new file mode 100644 index 00000000000..f4e98f52b9a --- /dev/null +++ b/packages/manager/.changeset/pr-10968-added-1727901904107.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +IP ACL integration to LKE clusters ([#10968](https://github.com/linode/manager/pull/10968)) diff --git a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts index dd95d49c7f2..4620ac0208f 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts @@ -46,8 +46,14 @@ import { Interception } from 'cypress/types/net-stubbing'; const expectedGranularityArray = ['Auto', '1 day', '1 hr', '5 min']; const timeDurationToSelect = 'Last 24 Hours'; -const { metrics, id, serviceType, dashboardName, region, resource } = - widgetDetails.linode; +const { + metrics, + id, + serviceType, + dashboardName, + region, + resource, +} = widgetDetails.linode; const dashboard = dashboardFactory.build({ label: dashboardName, diff --git a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx index 6d241a3b2df..00d0022ea4a 100644 --- a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx +++ b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx @@ -1,7 +1,5 @@ import Close from '@mui/icons-material/Close'; -import { InputBaseProps } from '@mui/material/InputBase'; import Grid from '@mui/material/Unstable_Grid2'; -import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -13,7 +11,10 @@ import { StyledLinkButtonBox } from 'src/components/SelectFirewallPanel/SelectFi import { TextField } from 'src/components/TextField'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; -import { ExtendedIP } from 'src/utilities/ipUtils'; + +import type { InputBaseProps } from '@mui/material/InputBase'; +import type { Theme } from '@mui/material/styles'; +import type { ExtendedIP } from 'src/utilities/ipUtils'; const useStyles = makeStyles()((theme: Theme) => ({ addIP: { @@ -66,6 +67,7 @@ interface Props { helperText?: string; inputProps?: InputBaseProps; ips: ExtendedIP[]; + isLinkStyled?: boolean; onBlur?: (ips: ExtendedIP[]) => void; onChange: (ips: ExtendedIP[]) => void; placeholder?: string; @@ -83,6 +85,7 @@ export const MultipleIPInput = React.memo((props: Props) => { forVPCIPv4Ranges, helperText, ips, + isLinkStyled, onBlur, onChange, placeholder, @@ -128,20 +131,21 @@ export const MultipleIPInput = React.memo((props: Props) => { return null; } - const addIPButton = forVPCIPv4Ranges ? ( - - {buttonText} - - ) : ( - - ); + const addIPButton = + forVPCIPv4Ranges || isLinkStyled ? ( + + {buttonText} + + ) : ( + + ); return (
diff --git a/packages/manager/src/factories/dashboards.ts b/packages/manager/src/factories/dashboards.ts index 6183d04101c..9506c349794 100644 --- a/packages/manager/src/factories/dashboards.ts +++ b/packages/manager/src/factories/dashboards.ts @@ -51,8 +51,8 @@ export const widgetFactory = Factory.Sync.makeFactory({ y_label: Factory.each((i) => `y_label_${i}`), }); -export const dashboardMetricFactory = - Factory.Sync.makeFactory({ +export const dashboardMetricFactory = Factory.Sync.makeFactory( + { available_aggregate_functions: ['min', 'max', 'avg', 'sum'], dimensions: [], label: Factory.each((i) => `widget_label_${i}`), @@ -62,10 +62,11 @@ export const dashboardMetricFactory = (i) => scrape_interval[i % scrape_interval.length] ), unit: 'defaultUnit', - }); + } +); -export const cloudPulseMetricsResponseDataFactory = - Factory.Sync.makeFactory({ +export const cloudPulseMetricsResponseDataFactory = Factory.Sync.makeFactory( + { result: [ { metric: {}, @@ -73,14 +74,16 @@ export const cloudPulseMetricsResponseDataFactory = }, ], result_type: 'matrix', - }); + } +); -export const cloudPulseMetricsResponseFactory = - Factory.Sync.makeFactory({ +export const cloudPulseMetricsResponseFactory = Factory.Sync.makeFactory( + { data: cloudPulseMetricsResponseDataFactory.build(), isPartial: false, stats: { series_fetched: 2, }, status: 'success', - }); + } +); diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLIPInputs.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLIPInputs.tsx new file mode 100644 index 00000000000..eb764ffafe9 --- /dev/null +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLIPInputs.tsx @@ -0,0 +1,57 @@ +import { Box } from '@mui/material'; +import * as React from 'react'; + +import { MultipleIPInput } from 'src/components/MultipleIPInput/MultipleIPInput'; + +import type { ExtendedIP } from 'src/utilities/ipUtils'; + +interface Props { + handleIPv4Blur: (ips: ExtendedIP[]) => void; + handleIPv4Change: (ips: ExtendedIP[]) => void; + handleIPv6Blur: (ips: ExtendedIP[]) => void; + handleIPv6Change: (ips: ExtendedIP[]) => void; + ipV4Addr: ExtendedIP[]; + ipV6Addr: ExtendedIP[]; + marginAfter?: boolean; +} + +export const ControlPlaneACLIPInputs = (props: Props) => { + const { + handleIPv4Blur, + handleIPv4Change, + handleIPv6Blur, + handleIPv6Change, + ipV4Addr, + ipV6Addr, + marginAfter, + } = props; + + const outerSx = marginAfter + ? { marginBottom: 3, maxWidth: 450 } + : { maxWidth: 450 }; + + return ( + + + + + + + ); +}; diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.tsx new file mode 100644 index 00000000000..fb52ba56c51 --- /dev/null +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.tsx @@ -0,0 +1,93 @@ +import { FormLabel } from '@mui/material'; +import * as React from 'react'; + +import { ErrorMessage } from 'src/components/ErrorMessage'; +import { FormControl } from 'src/components/FormControl'; +import { FormControlLabel } from 'src/components/FormControlLabel'; +import { Link } from 'src/components/Link'; +import { Notice } from 'src/components/Notice/Notice'; +import { Toggle } from 'src/components/Toggle/Toggle'; +import { Typography } from 'src/components/Typography'; +import { validateIPs } from 'src/utilities/ipUtils'; + +import { ControlPlaneACLIPInputs } from './ControlPlaneACLIPInputs'; + +import type { ExtendedIP } from 'src/utilities/ipUtils'; + +export interface ControlPlaneACLProps { + enableControlPlaneACL: boolean; + errorText: string | undefined; + handleIPv4Change: (ips: ExtendedIP[]) => void; + handleIPv6Change: (ips: ExtendedIP[]) => void; + ipV4Addr: ExtendedIP[]; + ipV6Addr: ExtendedIP[]; + setControlPlaneACL: (enabled: boolean) => void; +} + +export const ControlPlaneACLPane = (props: ControlPlaneACLProps) => { + const { + enableControlPlaneACL, + errorText, + handleIPv4Change, + handleIPv6Change, + ipV4Addr, + ipV6Addr, + setControlPlaneACL, + } = props; + + return ( + <> + + + + Control Plane Access Control (IPACL) + + + {errorText && ( + + {' '} + + )} + + This is the text for Control Plane Access Control.{' '} + + Learn more. + + + setControlPlaneACL(!enableControlPlaneACL)} + /> + } + label="Enable IPACL" + /> + + {enableControlPlaneACL && ( + { + const validatedIPs = validateIPs(_ips, { + allowEmptyAddress: false, + errorMessage: 'Must be a valid IPv4 address.', + }); + handleIPv4Change(validatedIPs); + }} + handleIPv6Blur={(_ips: ExtendedIP[]) => { + const validatedIPs = validateIPs(_ips, { + allowEmptyAddress: false, + errorMessage: 'Must be a valid IPv6 address.', + }); + handleIPv6Change(validatedIPs); + }} + handleIPv4Change={handleIPv4Change} + handleIPv6Change={handleIPv4Change} + ipV4Addr={ipV4Addr} + ipV6Addr={ipV6Addr} + marginAfter + /> + )} + + ); +}; diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index 4df1d4319b3..913e7d2a8fb 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -1,8 +1,3 @@ -import { - type CreateKubeClusterPayload, - type CreateNodePoolData, - type KubeNodePoolResponse, -} from '@linode/api-v4/lib/kubernetes'; import { Divider } from '@mui/material'; import Grid from '@mui/material/Unstable_Grid2'; import { createLazyRoute } from '@tanstack/react-router'; @@ -24,6 +19,7 @@ import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperT import { Stack } from 'src/components/Stack'; import { TextField } from 'src/components/TextField'; import { + getKubeControlPlaneACL, getKubeHighAvailability, getLatestVersion, useGetAPLAvailability, @@ -44,6 +40,7 @@ import { useAllTypes } from 'src/queries/types'; 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 { plansNoticesUtils } from 'src/utilities/planNotices'; import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { DOCS_LINK_LABEL_DC_PRICING } from 'src/utilities/pricing/constants'; @@ -52,6 +49,7 @@ import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; import KubeCheckoutBar from '../KubeCheckoutBar'; import { ApplicationPlatform } from './ApplicationPlatform'; +import { ControlPlaneACLPane } from './ControlPlaneACLPane'; import { StyledDocsLinkContainer, StyledFieldWithDocsStack, @@ -60,7 +58,13 @@ import { import { HAControlPlane } from './HAControlPlane'; import { NodePoolPanel } from './NodePoolPanel'; +import type { + CreateKubeClusterPayload, + CreateNodePoolData, + KubeNodePoolResponse, +} from '@linode/api-v4/lib/kubernetes'; import type { APIError } from '@linode/api-v4/lib/types'; +import type { ExtendedIP } from 'src/utilities/ipUtils'; export const CreateCluster = () => { const { classes } = useStyles(); @@ -76,6 +80,7 @@ export const CreateCluster = () => { const formContainerRef = React.useRef(null); const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); const [highAvailability, setHighAvailability] = React.useState(); + const [controlPlaneACL, setControlPlaneACL] = React.useState(true); const [apl_enabled, setApl_enabled] = React.useState(false); const { data, error: regionsError } = useRegionsQuery(); @@ -84,6 +89,13 @@ export const CreateCluster = () => { const { data: account } = useAccount(); const showAPL = useGetAPLAvailability(); const { showHighAvailability } = getKubeHighAvailability(account); + const { showControlPlaneACL } = getKubeControlPlaneACL(account); + const [ipV4Addr, setIPv4Addr] = React.useState([ + stringToExtendedIP(''), + ]); + const [ipV6Addr, setIPv6Addr] = React.useState([ + stringToExtendedIP(''), + ]); const { data: kubernetesHighAvailabilityTypesData, @@ -128,44 +140,85 @@ export const CreateCluster = () => { } }, [versionData]); -const createCluster = () => { - const { push } = history; - setErrors(undefined); - setSubmitting(true); + const createCluster = () => { + if (ipV4Addr.some((ip) => ip.error) || ipV6Addr.some((ip) => ip.error)) { + scrollErrorIntoViewV2(formContainerRef); + return; + } - const node_pools = nodePools.map(pick(['type', 'count'])) as CreateNodePoolData[]; + const { push } = history; + setErrors(undefined); + setSubmitting(true); - let payload: CreateKubeClusterPayload = { - control_plane: { high_availability: highAvailability ?? false }, - k8s_version: version, - label, - node_pools, - region: selectedRegionId, - }; + const node_pools = nodePools.map( + pick(['type', 'count']) + ) as CreateNodePoolData[]; - if (showAPL) { - payload = { ...payload, apl_enabled }; - } + const _ipv4 = ipV4Addr + .map((ip) => { + return ip.address; + }) + .filter((ip) => ip != ''); - const createClusterFn = showAPL ? createKubernetesClusterBeta : createKubernetesCluster; - - createClusterFn(payload) - .then((cluster) => { - push(`/kubernetes/clusters/${cluster.id}`); - if (hasAgreed) { - updateAccountAgreements({ - eu_model: true, - privacy_policy: true, - }).catch(reportAgreementSigningError); - } - }) - .catch((err) => { - setErrors(getAPIErrorOrDefault(err, 'Error creating your cluster')); - setSubmitting(false); - scrollErrorIntoViewV2(formContainerRef); - }); -}; + const _ipv6 = ipV6Addr + .map((ip) => { + return ip.address; + }) + .filter((ip) => ip != ''); + + const addressIPv4Payload = { + ...(_ipv4.length > 0 && { ipv4: _ipv4 }), + }; + const addressIPv6Payload = { + ...(_ipv6.length > 0 && { ipv6: _ipv6 }), + }; + + let payload: CreateKubeClusterPayload = { + control_plane: { + acl: { + enabled: controlPlaneACL, + 'revision-id': '', + ...(controlPlaneACL && // only send the IPs if we are enabling IPACL + (_ipv4.length > 0 || _ipv6.length > 0) && { + addresses: { + ...addressIPv4Payload, + ...addressIPv6Payload, + }, + }), + }, + high_availability: highAvailability ?? false, + }, + k8s_version: version, + label, + node_pools, + region: selectedRegionId, + }; + + if (showAPL) { + payload = { ...payload, apl_enabled }; + } + + const createClusterFn = showAPL + ? createKubernetesClusterBeta + : createKubernetesCluster; + + createClusterFn(payload) + .then((cluster) => { + push(`/kubernetes/clusters/${cluster.id}`); + if (hasAgreed) { + updateAccountAgreements({ + eu_model: true, + privacy_policy: true, + }).catch(reportAgreementSigningError); + } + }) + .catch((err) => { + setErrors(getAPIErrorOrDefault(err, 'Error creating your cluster')); + setSubmitting(false); + scrollErrorIntoViewV2(formContainerRef); + }); + }; const toggleHasAgreed = () => setAgreed((prevHasAgreed) => !prevHasAgreed); @@ -196,7 +249,14 @@ const createCluster = () => { }); const errorMap = getErrorMap( - ['region', 'node_pools', 'label', 'k8s_version', 'versionLoad'], + [ + 'region', + 'node_pools', + 'label', + 'k8s_version', + 'versionLoad', + 'control_plane', + ], errors ); @@ -293,7 +353,7 @@ const createCluster = () => { )} - {showHighAvailability ? ( + {showHighAvailability && ( { setHighAvailability={setHighAvailability} /> - ) : null} + )} + {showControlPlaneACL && ( + <> + + { + setIPv4Addr(newIpV4Addr); + }} + handleIPv6Change={(newIpV6Addr: ExtendedIP[]) => { + setIPv6Addr(newIpV6Addr); + }} + enableControlPlaneACL={controlPlaneACL} + errorText={errorMap.control_plane} + ipV4Addr={ipV4Addr} + ipV6Addr={ipV6Addr} + setControlPlaneACL={setControlPlaneACL} + /> + + )} void; + clusterId: number; + clusterLabel: string; + clusterMigrated: boolean; + open: boolean; +} + +export const KubeControlPlaneACLDrawer = (props: Props) => { + const { closeDrawer, clusterId, clusterLabel, clusterMigrated, open } = props; + + const { + data: data, + error: isErrorKubernetesACL, + isLoading: isLoadingKubernetesACL, + } = useKubernetesControlPlaneACLQuery(clusterId); + + const { + mutateAsync: updateKubernetesClusterControlPlaneACL, + } = useKubernetesControlPlaneACLMutation(clusterId); + + const { mutateAsync: updateKubernetesCluster } = useKubernetesClusterMutation( + clusterId + ); + + const ipv4 = data?.acl?.addresses?.ipv4?.map((ip) => { + return stringToExtendedIP(ip); + }) ?? [stringToExtendedIP('')]; + const ipv6 = data?.acl?.addresses?.ipv6?.map((ip) => { + return stringToExtendedIP(ip); + }) ?? [stringToExtendedIP('')]; + + const initialValues: IPACLDrawerFormState = { + acl: { + enabled: data?.acl?.enabled ?? false, + ipv4, + ipv6, + 'revision-id': data?.acl?.['revision-id'] ?? '', + }, + }; + + const { + formState: { errors, isDirty, isSubmitting }, + handleSubmit, + reset, + setError, + setValue, + watch, + } = useForm({ + defaultValues: initialValues, + values: { + ...initialValues, + }, + }); + + const values = watch(); + const { acl } = values; + + const updateCluster = async () => { + // A quick note on the following code: + // + // - A non-IPACL'd cluster (denominated 'traditional') does not have IPACLs natively. + // The customer must then install IPACL (or 'migrate') on this cluster. + // This is done through a call to the updateKubernetesCluster endpoint. + // Only after a migration will the call to the updateKubernetesClusterControlPlaneACL + // endpoint be accepted. + // + // Do note that all new clusters automatically have IPACLs installed (even if the customer + // chooses to disable it during creation). + // + // For this reason, further in this code, we check whether the cluster has migrated or not + // before choosing which endpoint to use. + // + // - The address stanza of the JSON payload is optional. If provided though, that stanza must + // contain either/or/both IPv4 and IPv6. This is why there is additional code to properly + // check whether either exists, and only if they do, do we provide the addresses stanza + // to the payload + // + // - Hopefully this explains the behavior of this code, and why one must be very careful + // before introducing any clever/streamlined code - there's a reason to the mess :) + // + if (acl.ipv4.some((ip) => ip.error) || acl.ipv6.some((ip) => ip.error)) { + return; + } + + const _ipv4 = acl.ipv4 + .map((ip) => { + return ip.address; + }) + .filter((ip) => ip != ''); + + const _ipv6 = acl.ipv6 + .map((ip) => { + return ip.address; + }) + .filter((ip) => ip != ''); + + const addressIPv4Payload = { + ...(_ipv4.length > 0 && { ipv4: _ipv4 }), + }; + + const addressIPv6Payload = { + ...(_ipv6.length > 0 && { ipv6: _ipv6 }), + }; + + const payload: KubernetesControlPlaneACLPayload = { + acl: { + enabled: acl.enabled, + 'revision-id': acl['revision-id'], + ...((_ipv4.length > 0 || _ipv6.length > 0) && { + addresses: { + ...addressIPv4Payload, + ...addressIPv6Payload, + }, + }), + }, + }; + + try { + if (clusterMigrated) { + await updateKubernetesClusterControlPlaneACL(payload); + } else { + await updateKubernetesCluster({ + control_plane: payload, + }); + } + closeDrawer(); + } catch (errors) { + for (const error of errors) { + if (error.field) { + setError(error.field, { message: error.message }); + } else { + setError('root', { message: error.message }); + } + } + } + }; + + return ( + reset()} + open={open} + title={'Control Plane Access Control List'} + wide + > + +
+ {errors.root?.message && ( + + {errors.root.message} + + )} + + + When a cluster is equipped with an ACL, the apiserver and + dashboard endpoints get mapped to a NodeBalancer address where all + traffic is protected through a Cloud Firewall. + + + Enabled + + A value of true results in a default policy of DENY. A value of + false results in a default policy of ALLOW (i.e., access controls + are disabled). When enabled, control plane access controls can + only be accessible through the defined IP CIDRs. + + + { + setValue('acl.enabled', e.target.checked, { + shouldDirty: true, + }); + }} + checked={acl.enabled ?? false} + name="ipacl-checkbox" + /> + } + label={'IPACL Enabled'} + /> + + + {clusterMigrated && ( + <> + Revision ID + + Enables clients to track events related to ACL update requests + and enforcements. Optional field. If omitted, defaults to a + randomly generated string. + + + setValue('acl.revision-id', e.target.value, { + shouldDirty: true, + }) + } + data-qa-label-input + label="Revision ID" + value={acl['revision-id']} + /> + + + )} + Addresses + + A list of individual ipv4 and ipv6 addresses or CIDRs to ALLOW + access to the control plane. + + {errors.acl?.message && clusterMigrated && ( + + {errors.acl.message} + + )} + + setValue( + 'acl.ipv4', + validateIPs(ips, { + allowEmptyAddress: false, + errorMessage: 'Must be a valid IPv4 address.', + }) + ) + } + handleIPv4Change={(ips: ExtendedIP[]) => + setValue('acl.ipv4', ips, { shouldDirty: true }) + } + handleIPv6Blur={(ips: ExtendedIP[]) => + setValue( + 'acl.ipv6', + validateIPs(ips, { + allowEmptyAddress: false, + errorMessage: 'Must be a valid IPv4 address.', + }) + ) + } + handleIPv6Change={(ips: ExtendedIP[]) => + setValue('acl.ipv6', ips, { shouldDirty: true }) + } + ipV4Addr={acl.ipv4} + ipV6Addr={acl.ipv6} + /> + + {!clusterMigrated && ( + + IPACL has not yet been installed on this cluster. During + installation, it may take up to 20 minutes before ACLs are fully + enforced for the first time. + + )} + +
+
+
+ ); +}; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.styles.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.styles.tsx new file mode 100644 index 00000000000..bee54c5e09f --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.styles.tsx @@ -0,0 +1,70 @@ +// This component was built asuming an unmodified MUI +import { styled } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; + +import { Box } from 'src/components/Box'; + +export const StyledBox = styled(Box, { label: 'StyledBox' })(({ theme }) => ({ + alignItems: 'center', + display: 'flex', + [theme.breakpoints.down('lg')]: { + minHeight: theme.spacing(3), + }, + [theme.breakpoints.up('lg')]: { + minHeight: theme.spacing(5), + padding: `0px 10px`, + }, +})); + +export const StyledActionRowGrid = styled(Grid, { + label: 'StyledActionRowGrid', +})({ + '& button': { + alignItems: 'flex-start', + }, + alignItems: 'flex-end', + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + padding: '8px 0px', +}); + +export const StyledTagGrid = styled(Grid, { label: 'StyledTagGrid' })( + ({ theme }) => ({ + // Tags Panel wrapper + '& > div:last-child': { + marginBottom: 0, + marginTop: 2, + width: '100%', + }, + '&.MuiGrid-item': { + paddingBottom: 0, + }, + alignItems: 'flex-end', + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'column', + justifyContent: 'flex-end', + [theme.breakpoints.down('lg')]: { + width: '100%', + }, + [theme.breakpoints.up('lg')]: { + '& .MuiChip-root': { + marginLeft: 4, + marginRight: 0, + }, + // Add a Tag button + '& > div:first-of-type': { + justifyContent: 'flex-end', + marginTop: theme.spacing(4), + }, + // Tags Panel wrapper + '& > div:last-child': { + display: 'flex', + justifyContent: 'flex-end', + }, + }, + width: '100%', + }) +); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx index a3579c98c27..ac38a5cd731 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx @@ -3,12 +3,13 @@ import { useTheme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { makeStyles } from 'tss-react/mui'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Box } from 'src/components/Box'; import { StyledActionButton } from 'src/components/Button/StyledActionButton'; +import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; import { Chip } from 'src/components/Chip'; +import { CircleProgress } from 'src/components/CircleProgress'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { EntityDetail } from 'src/components/EntityDetail/EntityDetail'; import { EntityHeader } from 'src/components/EntityHeader/EntityHeader'; @@ -19,80 +20,24 @@ import { KubeClusterSpecs } from 'src/features/Kubernetes/KubernetesClusterDetai import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useKubernetesClusterMutation, + useKubernetesControlPlaneACLQuery, useKubernetesDashboardQuery, useResetKubeConfigMutation, } from 'src/queries/kubernetes'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; +import { pluralize } from 'src/utilities/pluralize'; import { DeleteKubernetesClusterDialog } from './DeleteKubernetesClusterDialog'; import { KubeConfigDisplay } from './KubeConfigDisplay'; import { KubeConfigDrawer } from './KubeConfigDrawer'; +import { KubeControlPlaneACLDrawer } from './KubeControlPaneACLDrawer'; +import { + StyledActionRowGrid, + StyledBox, + StyledTagGrid, +} from './KubeSummaryPanel.styles'; import type { KubernetesCluster } from '@linode/api-v4/lib/kubernetes'; -import type { Theme } from '@mui/material/styles'; - -const useStyles = makeStyles()((theme: Theme) => ({ - actionRow: { - '& button': { - alignItems: 'flex-start', - }, - alignItems: 'flex-end', - alignSelf: 'stretch', - display: 'flex', - flexDirection: 'row', - justifyContent: 'flex-end', - padding: '8px 0px', - }, - dashboard: { - '& svg': { - height: 14, - marginLeft: 4, - }, - alignItems: 'center', - display: 'flex', - }, - deleteClusterBtn: { - [theme.breakpoints.up('md')]: { - paddingRight: '8px', - }, - }, - tags: { - // Tags Panel wrapper - '& > div:last-child': { - marginBottom: 0, - marginTop: 2, - width: '100%', - }, - '&.MuiGrid-item': { - paddingBottom: 0, - }, - alignItems: 'flex-end', - alignSelf: 'stretch', - display: 'flex', - flexDirection: 'column', - justifyContent: 'flex-end', - [theme.breakpoints.down('lg')]: { - width: '100%', - }, - [theme.breakpoints.up('lg')]: { - '& .MuiChip-root': { - marginLeft: 4, - marginRight: 0, - }, - // Add a Tag button - '& > div:first-of-type': { - justifyContent: 'flex-end', - marginTop: theme.spacing(4), - }, - // Tags Panel wrapper - '& > div:last-child': { - display: 'flex', - justifyContent: 'flex-end', - }, - }, - width: '100%', - }, -})); interface Props { cluster: KubernetesCluster; @@ -101,12 +46,15 @@ interface Props { export const KubeSummaryPanel = React.memo((props: Props) => { const { cluster } = props; - const { classes } = useStyles(); const theme = useTheme(); const { enqueueSnackbar } = useSnackbar(); const [drawerOpen, setDrawerOpen] = React.useState(false); + const [ + isControlPlaneACLDrawerOpen, + setControlPlaneACLDrawerOpen, + ] = React.useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); const { mutateAsync: updateKubernetesCluster } = useKubernetesClusterMutation( @@ -130,6 +78,21 @@ export const KubeSummaryPanel = React.memo((props: Props) => { id: cluster.id, }); + const { + data: aclData, + error: isErrorKubernetesACL, + isLoading: isLoadingKubernetesACL, + } = useKubernetesControlPlaneACLQuery(cluster.id); + + const enabledACL = aclData?.acl.enabled ?? false; + const totalIPv4 = aclData?.acl.addresses?.ipv4?.length ?? 0; + const totalIPv6 = aclData?.acl.addresses?.ipv6?.length ?? 0; + const totalNumberIPs = totalIPv4 + totalIPv6; + + const determineIPACLButtonCopy = enabledACL + ? pluralize('IP Address', 'IP Addresses', totalNumberIPs) + : 'Enable'; + const [ resetKubeConfigDialogOpen, setResetKubeConfigDialogOpen, @@ -191,7 +154,7 @@ export const KubeSummaryPanel = React.memo((props: Props) => { lg={5} xs={12} > - + {cluster.control_plane.high_availability && ( { variant="outlined" /> )} - - + + { updateTags={handleUpdateTags} view="inline" /> - + } + footer={ + + + + Control Plane ACL: + + {isLoadingKubernetesACL ? ( + + + + ) : ( + setControlPlaneACLDrawerOpen(true)} + > + {determineIPACLButtonCopy} + + )} + + + } header={ { onClick={() => { window.open(dashboard?.url, '_blank'); }} - className={classes.dashboard} + sx={{ + '& svg': { + height: '14px', + marginLeft: '4px', + }, + alignItems: 'center', + display: 'flex', + }} disabled={Boolean(dashboardError) || !dashboard} > Kubernetes Dashboard setIsDeleteDialogOpen(true)} > Delete Cluster @@ -253,6 +261,13 @@ export const KubeSummaryPanel = React.memo((props: Props) => { clusterLabel={cluster.label} open={drawerOpen} /> + setControlPlaneACLDrawerOpen(false)} + clusterId={cluster.id} + clusterLabel={cluster.label} + clusterMigrated={!isErrorKubernetesACL} + open={isControlPlaneACLDrawerOpen} + /> { return Boolean(flags.apl); }; +export const getKubeControlPlaneACL = ( + account: Account | undefined, + cluster?: KubernetesCluster | null +) => { + const showControlPlaneACL = account?.capabilities.includes( + 'LKE Network Access Control List (IP ACL)' + ); + + const isClusterControlPlaneACLd = Boolean( + showControlPlaneACL && cluster?.control_plane.acl + ); + + return { + isClusterControlPlaneACLd, + showControlPlaneACL, + }; +}; + /** * Retrieves the latest version from an array of version objects. * diff --git a/packages/manager/src/queries/kubernetes.ts b/packages/manager/src/queries/kubernetes.ts index 3a8b98ece3b..35983f34c6c 100644 --- a/packages/manager/src/queries/kubernetes.ts +++ b/packages/manager/src/queries/kubernetes.ts @@ -7,6 +7,7 @@ import { getKubeConfig, getKubernetesCluster, getKubernetesClusterBeta, + getKubernetesClusterControlPlaneACL, getKubernetesClusterDashboard, getKubernetesClusterEndpoints, getKubernetesClusters, @@ -18,6 +19,7 @@ import { recycleNode, resetKubeConfig, updateKubernetesCluster, + updateKubernetesClusterControlPlaneACL, updateNodePool, } from '@linode/api-v4'; import { createQueryKeys } from '@lukemorales/query-key-factory'; @@ -38,6 +40,7 @@ import type { CreateNodePoolData, KubeNodePoolResponse, KubernetesCluster, + KubernetesControlPlaneACLPayload, KubernetesDashboardResponse, KubernetesEndpointResponse, KubernetesVersion, @@ -54,6 +57,10 @@ import type { export const kubernetesQueries = createQueryKeys('kubernetes', { cluster: (id: number) => ({ contextQueries: { + acl: { + queryFn: () => getKubernetesClusterControlPlaneACL(id), + queryKey: [id], + }, beta: { queryFn: () => getKubernetesClusterBeta(id), queryKey: [id], @@ -139,6 +146,9 @@ export const useKubernetesClusterMutation = (id: number) => { queryClient.invalidateQueries({ queryKey: kubernetesQueries.lists.queryKey, }); + queryClient.invalidateQueries({ + queryKey: kubernetesQueries.cluster(id)._ctx.acl.queryKey, + }); queryClient.setQueryData(kubernetesQueries.cluster(id).queryKey, data); }, } @@ -349,6 +359,32 @@ export const useAllKubernetesClustersQuery = (enabled = false) => { }); }; +export const useKubernetesControlPlaneACLQuery = (clusterId: number) => { + return useQuery( + kubernetesQueries.cluster(clusterId)._ctx.acl + ); +}; + +export const useKubernetesControlPlaneACLMutation = (id: number) => { + const queryClient = useQueryClient(); + return useMutation< + KubernetesControlPlaneACLPayload, + APIError[], + Partial + >({ + mutationFn: (data) => updateKubernetesClusterControlPlaneACL(id, data), + onSuccess(data) { + queryClient.invalidateQueries({ + queryKey: kubernetesQueries.cluster(id)._ctx.acl.queryKey, + }); + queryClient.setQueryData( + kubernetesQueries.cluster(id)._ctx.acl.queryKey, + data + ); + }, + }); +}; + const getAllNodePoolsForCluster = (clusterId: number) => getAll((params, filters) => getNodePools(clusterId, params, filters)