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 1ba20f8260b..26362dcfe92 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..03e46b53dc9 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 11106e45858..b0620e4148e 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 1d60931b8a1..5c52662a742 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: { @@ -57,7 +58,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); -interface Props { +export interface MultipeIPInputProps { buttonText?: string; className?: string; disabled?: boolean; @@ -67,6 +68,7 @@ interface Props { helperText?: string; inputProps?: InputBaseProps; ips: ExtendedIP[]; + isLinkStyled?: boolean; onBlur?: (ips: ExtendedIP[]) => void; onChange: (ips: ExtendedIP[]) => void; placeholder?: string; @@ -75,7 +77,7 @@ interface Props { tooltip?: string; } -export const MultipleIPInput = React.memo((props: Props) => { +export const MultipleIPInput = React.memo((props: MultipeIPInputProps) => { const { buttonText, className, @@ -85,6 +87,7 @@ export const MultipleIPInput = React.memo((props: Props) => { forVPCIPv4Ranges, helperText, ips, + isLinkStyled, onBlur, onChange, placeholder, @@ -130,21 +133,22 @@ 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/components/MultipleIPInput/MultipleNonExtendedIPInput.tsx b/packages/manager/src/components/MultipleIPInput/MultipleNonExtendedIPInput.tsx new file mode 100644 index 00000000000..3a709de3c0c --- /dev/null +++ b/packages/manager/src/components/MultipleIPInput/MultipleNonExtendedIPInput.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; + +import { MultipleIPInput } from './MultipleIPInput'; + +import type { MultipeIPInputProps } from './MultipleIPInput'; +import type { FieldError, Merge } from 'react-hook-form'; +import type { ExtendedIP } from 'src/utilities/ipUtils'; + +interface Props extends Omit { + ipErrors?: Merge; + nonExtendedIPs: string[]; + onNonExtendedIPChange: (ips: string[]) => void; +} + +/** + * Quick wrapper for MultipleIPInput so that we do not have to directly use the type ExtendedIP (which has its own error field) + * + * I wanted to avoid touching MultipleIPInput too much, since a lot of other flows use that component. This component was + * made with 'react-hook-form' in mind, taking in 'react-hook-form' errors and mapping them to the given (non + * extended) IPs. We might eventually try to completely remove the ExtendedIP type - see + * https://github.com/linode/manager/pull/10968#discussion_r1800089369 for context + */ +export const MultipleNonExtendedIPInput = (props: Props) => { + const { ipErrors, nonExtendedIPs, onNonExtendedIPChange, ...rest } = props; + + const extendedIPs: ExtendedIP[] = + nonExtendedIPs.map((ip, idx) => { + return { + address: ip, + error: ipErrors ? ipErrors[idx]?.message : '', + }; + }) ?? []; + + return ( + { + const _ips = ips.map((ip) => { + return ip.address; + }); + onNonExtendedIPChange(_ips); + }} + ips={extendedIPs} + /> + ); +}; 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/ControlPlaneACLPane.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.tsx new file mode 100644 index 00000000000..8939e656d0c --- /dev/null +++ b/packages/manager/src/features/Kubernetes/CreateCluster/ControlPlaneACLPane.tsx @@ -0,0 +1,103 @@ +import { FormLabel } from '@mui/material'; +import * as React from 'react'; + +import { Box } from 'src/components/Box'; +import { ErrorMessage } from 'src/components/ErrorMessage'; +import { FormControl } from 'src/components/FormControl'; +import { FormControlLabel } from 'src/components/FormControlLabel'; +import { MultipleIPInput } from 'src/components/MultipleIPInput/MultipleIPInput'; +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 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 ACL + + {errorText && ( + + {' '} + + )} + + Enable an access control list (ACL) on your LKE cluster to restrict + access to your cluster’s control plane. When enabled, only the IP + addresses and ranges specified by you can connect to the control + plane. + + setControlPlaneACL(!enableControlPlaneACL)} + /> + } + label="Enable Control Plane ACL" + /> + + {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} + placeholder="0.0.0.0/0" + 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} + placeholder="::/0" + title="IPv6 Addresses or CIDRs" + /> + + + )} + + ); +}; diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index 4df1d4319b3..21a39a48c74 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 ); @@ -292,8 +352,8 @@ 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; + showControlPlaneACL: boolean; +} + +export const KubeControlPlaneACLDrawer = (props: Props) => { + const formContainerRef = React.useRef(null); + const { + closeDrawer, + clusterId, + clusterLabel, + clusterMigrated, + open, + showControlPlaneACL, + } = props; + + const { + data: data, + error: isErrorKubernetesACL, + isLoading: isLoadingKubernetesACL, + } = useKubernetesControlPlaneACLQuery(clusterId, showControlPlaneACL); + + const { + mutateAsync: updateKubernetesClusterControlPlaneACL, + } = useKubernetesControlPlaneACLMutation(clusterId); + + const { mutateAsync: updateKubernetesCluster } = useKubernetesClusterMutation( + clusterId + ); + + const { + control, + formState: { errors, isDirty, isSubmitting }, + handleSubmit, + reset, + setError, + watch, + } = useForm({ + defaultValues: data, + mode: 'onBlur', + resolver: yupResolver(kubernetesControlPlaneACLPayloadSchema), + values: { + acl: { + addresses: { + ipv4: data?.acl?.addresses?.ipv4 ?? [''], + ipv6: data?.acl?.addresses?.ipv6 ?? [''], + }, + enabled: data?.acl?.enabled ?? false, + 'revision-id': data?.acl?.['revision-id'] ?? '', + }, + }, + }); + + const { acl } = watch(); + + 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 :) + + const ipv4 = acl.addresses?.ipv4 + ? acl.addresses.ipv4.filter((ip) => ip !== '') + : []; + + const ipv6 = acl.addresses?.ipv6 + ? acl.addresses.ipv6.filter((ip) => ip !== '') + : []; + + const payload: KubernetesControlPlaneACLPayload = { + acl: { + enabled: acl.enabled, + 'revision-id': acl['revision-id'], + ...((ipv4.length > 0 || ipv6.length > 0) && { + addresses: { + ...(ipv4.length > 0 && { ipv4 }), + ...(ipv6.length > 0 && { ipv6 }), + }, + }), + }, + }; + + try { + if (clusterMigrated) { + await updateKubernetesClusterControlPlaneACL(payload); + } else { + await updateKubernetesCluster({ + control_plane: payload, + }); + } + closeDrawer(); + } catch (errors) { + for (const error of errors) { + setError(error?.field ?? 'root', { message: error.reason }); + } + scrollErrorIntoViewV2(formContainerRef); + } + }; + + return ( + reset()} + open={open} + title={'Control Plane ACL'} + wide + > + +
+ {errors.root?.message && ( + + {errors.root.message} + + )} + + + Control Plane ACL secures network access to your LKE + cluster's control plane. Use this form to enable or disable + the ACL on your LKE cluster, update the list of allowed IP + addresses, and adjust other settings. + + + Activation Status + + Enable or disable the Control Plane ACL. If the ACL is not + enabled, any public IP address can be used to access your control + plane. Once enabled, all network access is denied except for the + IP addresses and CIDR ranges defined on the ACL. + + + ( + + } + label={'Enable Control Plane ACL'} + /> + )} + control={control} + name="acl.enabled" + /> + + + {clusterMigrated && ( + <> + Revision ID + + A unique identifing string for this particular revision to the + ACL, used by clients to track events related to ACL update + requests and enforcement. This defaults to a randomly + generated string but can be edited if you prefer to specify + your own string to use for tracking this change. + + ( + + )} + control={control} + name="acl.revision-id" + /> + + + )} + Addresses + + A list of allowed IPv4 and IPv6 addresses and CIDR ranges. This + cluster's control plane will only be accessible from IP + addresses within this list. + + {errors.acl?.message && ( + + {errors.acl.message} + + )} + + ( + + )} + control={control} + name="acl.addresses.ipv4" + /> + + ( + + )} + control={control} + name="acl.addresses.ipv6" + /> + + + + {!clusterMigrated && ( + + + Control Plane ACL has not yet been installed on this cluster. + During installation, it may take up to 15 minutes for the + access control list to be fully enforced. + + + )} + +
+
+
+ ); +}; + +const StyledTypography = styled(Typography, { label: 'StyledTypography' })({ + width: '90%', +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx new file mode 100644 index 00000000000..7093d838879 --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeEntityDetailFooter.tsx @@ -0,0 +1,186 @@ +import { useTheme } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; + +import { Box } from 'src/components/Box'; +import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; +import { CircleProgress } from 'src/components/CircleProgress'; +import { TagCell } from 'src/components/TagCell/TagCell'; +import { + StyledBox, + StyledLabelBox, + StyledListItem, + sxLastListItem, + sxListItemFirstChild, +} from 'src/features/Linodes/LinodeEntityDetail.styles'; +import { useKubernetesClusterMutation } from 'src/queries/kubernetes'; +import { useProfile } from 'src/queries/profile/profile'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { formatDate } from 'src/utilities/formatDate'; +import { pluralize } from 'src/utilities/pluralize'; + +import type { KubernetesControlPlaneACLPayload } from '@linode/api-v4'; + +interface FooterProps { + aclData: KubernetesControlPlaneACLPayload | undefined; + clusterCreated: string; + clusterId: number; + clusterLabel: string; + clusterTags: string[]; + clusterUpdated: string; + isClusterReadOnly: boolean; + isLoadingKubernetesACL: boolean; + setControlPlaneACLDrawerOpen: React.Dispatch>; + showControlPlaneACL: boolean; +} + +export const KubeEntityDetailFooter = React.memo((props: FooterProps) => { + const theme = useTheme(); + + const { data: profile } = useProfile(); + const { + aclData, + clusterCreated, + clusterId, + clusterLabel, + clusterTags, + clusterUpdated, + isClusterReadOnly, + isLoadingKubernetesACL, + setControlPlaneACLDrawerOpen, + showControlPlaneACL, + } = props; + + 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 buttonCopyACL = enabledACL + ? `Enabled (${pluralize('IP Address', 'IP Addresses', totalNumberIPs)})` + : 'Enable'; + + const { mutateAsync: updateKubernetesCluster } = useKubernetesClusterMutation( + clusterId + ); + + const { enqueueSnackbar } = useSnackbar(); + + const handleUpdateTags = React.useCallback( + (newTags: string[]) => { + return updateKubernetesCluster({ + tags: newTags, + }).catch((e) => + enqueueSnackbar( + getAPIErrorOrDefault(e, 'Error updating tags')[0].reason, + { + variant: 'error', + } + ) + ); + }, + [updateKubernetesCluster, enqueueSnackbar] + ); + + return ( + + + + + Cluster ID:{' '} + {clusterId} + + {showControlPlaneACL && ( + + + Control Plane ACL:{' '} + {' '} + {isLoadingKubernetesACL ? ( + + + + ) : ( + setControlPlaneACLDrawerOpen(true)} + > + {buttonCopyACL} + + )} + + )} + + + + Created:{' '} + {formatDate(clusterCreated, { + timezone: profile?.timezone, + })} + + + Updated:{' '} + {formatDate(clusterUpdated, { + timezone: profile?.timezone, + })} + + + + + + + + ); +}); 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..3dc8e3799e9 --- /dev/null +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.styles.tsx @@ -0,0 +1,17 @@ +// This component was built asuming an unmodified MUI +import { styled } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; + +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', +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx index a3579c98c27..a4cca466c65 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx @@ -3,7 +3,6 @@ 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'; @@ -13,12 +12,13 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { EntityDetail } from 'src/components/EntityDetail/EntityDetail'; import { EntityHeader } from 'src/components/EntityHeader/EntityHeader'; import { Stack } from 'src/components/Stack'; -import { TagCell } from 'src/components/TagCell/TagCell'; import { Typography } from 'src/components/Typography'; import { KubeClusterSpecs } from 'src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs'; +import { getKubeControlPlaneACL } from 'src/features/Kubernetes/kubeUtils'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; +import { useAccount } from 'src/queries/account/account'; import { - useKubernetesClusterMutation, + useKubernetesControlPlaneACLQuery, useKubernetesDashboardQuery, useResetKubeConfigMutation, } from 'src/queries/kubernetes'; @@ -27,72 +27,11 @@ import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { DeleteKubernetesClusterDialog } from './DeleteKubernetesClusterDialog'; import { KubeConfigDisplay } from './KubeConfigDisplay'; import { KubeConfigDrawer } from './KubeConfigDrawer'; +import { KubeControlPlaneACLDrawer } from './KubeControlPaneACLDrawer'; +import { KubeEntityDetailFooter } from './KubeEntityDetailFooter'; +import { StyledActionRowGrid } 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,18 +40,20 @@ interface Props { export const KubeSummaryPanel = React.memo((props: Props) => { const { cluster } = props; - const { classes } = useStyles(); + const { data: account } = useAccount(); + const { showControlPlaneACL } = getKubeControlPlaneACL(account); + 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( - cluster.id - ); - const { data: dashboard, error: dashboardError, @@ -130,6 +71,12 @@ export const KubeSummaryPanel = React.memo((props: Props) => { id: cluster.id, }); + const { + data: aclData, + error: isErrorKubernetesACL, + isLoading: isLoadingKubernetesACL, + } = useKubernetesControlPlaneACLQuery(cluster.id, !!showControlPlaneACL); + const [ resetKubeConfigDialogOpen, setResetKubeConfigDialogOpen, @@ -148,12 +95,6 @@ export const KubeSummaryPanel = React.memo((props: Props) => { setDrawerOpen(true); }; - const handleUpdateTags = (newTags: string[]) => { - return updateKubernetesCluster({ - tags: newTags, - }); - }; - const sxSpacing = { paddingLeft: theme.spacing(3), paddingRight: theme.spacing(1), @@ -191,7 +132,7 @@ export const KubeSummaryPanel = React.memo((props: Props) => { lg={5} xs={12} > - + {cluster.control_plane.high_availability && ( { variant="outlined" /> )} - - - - + } + footer={ + + } 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 +210,14 @@ export const KubeSummaryPanel = React.memo((props: Props) => { clusterLabel={cluster.label} open={drawerOpen} /> + setControlPlaneACLDrawerOpen(false)} + clusterId={cluster.id} + clusterLabel={cluster.label} + clusterMigrated={!isErrorKubernetesACL} + open={isControlPlaneACLDrawerOpen} + showControlPlaneACL={!!showControlPlaneACL} + /> { const id = Number(clusterID); const location = useLocation(); const showAPL = useGetAPLAvailability(); - const kubernetesClusterBetaQuery = useKubernetesClusterBetaQuery(id); - const kubernetesClusterQuery = useKubernetesClusterQuery(id); - const { data: cluster, error, isLoading } = showAPL - ? kubernetesClusterBetaQuery - : kubernetesClusterQuery; + const { data: cluster, error, isLoading } = useKubernetesClusterQuery(id); const { data: regionsData } = useRegionsQuery(); const { mutateAsync: updateKubernetesCluster } = useKubernetesClusterMutation( diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.ts b/packages/manager/src/features/Kubernetes/kubeUtils.ts index f18717fc86b..0dafba32367 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.ts @@ -123,6 +123,24 @@ export const useGetAPLAvailability = (): boolean => { 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/features/Linodes/LinodeEntityDetail.styles.ts b/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts index 4c7ec07a99e..47f29f03e5b 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts +++ b/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts @@ -1,8 +1,7 @@ // This component was built asuming an unmodified MUI
+import { styled } from '@mui/material/styles'; import Table from '@mui/material/Table'; import Grid from '@mui/material/Unstable_Grid2'; -import { styled } from '@mui/material/styles'; -import { Theme } from '@mui/material/styles'; import { Link } from 'react-router-dom'; import { Box } from 'src/components/Box'; @@ -11,6 +10,8 @@ import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { Typography } from 'src/components/Typography'; +import type { Theme } from '@mui/material/styles'; + // --------------------------------------------------------------------- // Header Styles // --------------------------------------------------------------------- diff --git a/packages/manager/src/queries/kubernetes.ts b/packages/manager/src/queries/kubernetes.ts index 3a8b98ece3b..cf8e1322ff9 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'; @@ -28,6 +30,7 @@ import { useQueryClient, } from '@tanstack/react-query'; +import { useGetAPLAvailability } from 'src/features/Kubernetes/kubeUtils'; import { getAll } from 'src/utilities/getAll'; import { queryPresets } from './base'; @@ -38,6 +41,7 @@ import type { CreateNodePoolData, KubeNodePoolResponse, KubernetesCluster, + KubernetesControlPlaneACLPayload, KubernetesDashboardResponse, KubernetesEndpointResponse, KubernetesVersion, @@ -54,8 +58,8 @@ import type { export const kubernetesQueries = createQueryKeys('kubernetes', { cluster: (id: number) => ({ contextQueries: { - beta: { - queryFn: () => getKubernetesClusterBeta(id), + acl: { + queryFn: () => getKubernetesClusterControlPlaneACL(id), queryKey: [id], }, dashboard: { @@ -105,17 +109,13 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { }); export const useKubernetesClusterQuery = (id: number) => { - return useQuery(kubernetesQueries.cluster(id)); -}; - -/** - * duplicated function of useKubernetesClusterQuery - * necessary to call BETA_API_ROOT in a seperate function based on feature flag - */ -export const useKubernetesClusterBetaQuery = (id: number) => { - return useQuery( - kubernetesQueries.cluster(id)._ctx.beta - ); + const showAPL = useGetAPLAvailability(); + return useQuery({ + ...kubernetesQueries.cluster(id), + queryFn: showAPL + ? () => getKubernetesClusterBeta(id) // necessary to call BETA_API_ROOT in a seperate function based on feature flag + : () => getKubernetesCluster(id), + }); }; export const useKubernetesClustersQuery = ( @@ -139,6 +139,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 +352,34 @@ export const useAllKubernetesClustersQuery = (enabled = false) => { }); }; +export const useKubernetesControlPlaneACLQuery = ( + clusterId: number, + enabled: boolean = true +) => { + return useQuery({ + enabled, + retry: 1, + ...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.setQueryData( + kubernetesQueries.cluster(id)._ctx.acl.queryKey, + data + ); + }, + }); +}; + const getAllNodePoolsForCluster = (clusterId: number) => getAll((params, filters) => getNodePools(clusterId, params, filters) diff --git a/packages/validation/.changeset/pr-10968-added-1729020457987.md b/packages/validation/.changeset/pr-10968-added-1729020457987.md new file mode 100644 index 00000000000..dc0af59b2ff --- /dev/null +++ b/packages/validation/.changeset/pr-10968-added-1729020457987.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Added +--- + +Validation schema for LKE ACL payload ([#10968](https://github.com/linode/manager/pull/10968)) diff --git a/packages/validation/src/firewalls.schema.ts b/packages/validation/src/firewalls.schema.ts index e1a2eeb6e4f..d00e1eb6451 100644 --- a/packages/validation/src/firewalls.schema.ts +++ b/packages/validation/src/firewalls.schema.ts @@ -7,7 +7,8 @@ export const IP_ERROR_MESSAGE = 'Must be a valid IPv4 or IPv6 address or range.'; export const validateIP = (ipAddress?: string | null): boolean => { - if (!ipAddress) { + // ''is falsy, so we must specify that it is OK + if (ipAddress !== '' && !ipAddress) { return false; } // We accept plain IPs as well as ranges (i.e. CIDR notation). Ipaddr.js has separate parsing diff --git a/packages/validation/src/kubernetes.schema.ts b/packages/validation/src/kubernetes.schema.ts index 4dd7fbfd60d..8910936d005 100644 --- a/packages/validation/src/kubernetes.schema.ts +++ b/packages/validation/src/kubernetes.schema.ts @@ -1,3 +1,4 @@ +import { validateIP } from './firewalls.schema'; import { array, number, object, string, boolean } from 'yup'; export const nodePoolSchema = object().shape({ @@ -58,3 +59,28 @@ export const createKubeClusterSchema = object().shape({ .of(nodePoolSchema) .min(1, 'Please add at least one node pool.'), }); + +export const ipv4Address = string().test({ + name: 'validateIP', + message: 'Must be a valid IPv4 address.', + test: validateIP, +}); + +export const ipv6Address = string().test({ + name: 'validateIP', + message: 'Must be a valid IPv6 address.', + test: validateIP, +}); + +const controlPlaneACLOptionsSchema = object().shape({ + enabled: boolean(), + 'revision-id': string(), + addresses: object().shape({ + ipv4: array().of(ipv4Address).nullable(true), + ipv6: array().of(ipv6Address).nullable(true), + }), +}); + +export const kubernetesControlPlaneACLPayloadSchema = object().shape({ + acl: controlPlaneACLOptionsSchema, +});