diff --git a/packages/api-v4/.changeset/pr-13209-upcoming-features-1766118917091.md b/packages/api-v4/.changeset/pr-13209-upcoming-features-1766118917091.md
new file mode 100644
index 00000000000..417c8d56f0f
--- /dev/null
+++ b/packages/api-v4/.changeset/pr-13209-upcoming-features-1766118917091.md
@@ -0,0 +1,5 @@
+---
+"@linode/api-v4": Upcoming Features
+---
+
+Change range property of IPv6SLAAC to be optional ([#13209](https://github.com/linode/manager/pull/13209))
diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts
index db79101e5bc..0d298c662a9 100644
--- a/packages/api-v4/src/linodes/types.ts
+++ b/packages/api-v4/src/linodes/types.ts
@@ -192,7 +192,7 @@ export interface ConfigInterfaceIPv4 {
export interface IPv6SLAAC {
address?: string;
- range: string;
+ range?: string;
}
// The legacy interface type - for Configuration Profile Interfaces
diff --git a/packages/manager/.changeset/pr-13209-upcoming-features-1765924066272.md b/packages/manager/.changeset/pr-13209-upcoming-features-1765924066272.md
new file mode 100644
index 00000000000..88106a0863f
--- /dev/null
+++ b/packages/manager/.changeset/pr-13209-upcoming-features-1765924066272.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Upcoming Features
+---
+
+Add VPC IPv6 support in Linode Add/Edit Config dialog ([#13209](https://github.com/linode/manager/pull/13209))
diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailRowConfigFirewall.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailRowConfigFirewall.tsx
index 808a3b927b4..a66f11dfaac 100644
--- a/packages/manager/src/features/Linodes/LinodeEntityDetailRowConfigFirewall.tsx
+++ b/packages/manager/src/features/Linodes/LinodeEntityDetailRowConfigFirewall.tsx
@@ -16,7 +16,7 @@ import {
FirewallCell,
LKEClusterCell,
} from './LinodeEntityDetailRowInterfaceFirewall';
-import { DEFAULT_UPGRADE_BUTTON_HELPER_TEXT } from './LinodesDetail/LinodeConfigs/LinodeConfigs';
+import { DEFAULT_UPGRADE_BUTTON_HELPER_TEXT } from './LinodesDetail/LinodeConfigs/constants';
import { getUnableToUpgradeTooltipText } from './LinodesDetail/LinodeConfigs/UpgradeInterfaces/utils';
import type {
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.test.tsx
index c870d9c0c3b..6710d8f776e 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.test.tsx
@@ -10,11 +10,8 @@ import {
import 'src/mocks/testServer';
import { renderWithTheme } from 'src/utilities/testHelpers';
-import {
- LinodeConfigDialog,
- padList,
- unrecommendedConfigNoticeSelector,
-} from './LinodeConfigDialog';
+import { LinodeConfigDialog } from './LinodeConfigDialog';
+import { padList, unrecommendedConfigNoticeSelector } from './utilities';
import type { MemoryLimit } from './LinodeConfigDialog';
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx
index 2fdd949f2cf..5795a49cadb 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx
@@ -46,11 +46,6 @@ import { FormLabel } from 'src/components/FormLabel';
import { Link } from 'src/components/Link';
import { DeviceSelection } from 'src/features/Linodes/LinodesDetail/LinodeRescue/DeviceSelection';
import { titlecase } from 'src/features/Linodes/presentation';
-import {
- LINODE_UNREACHABLE_HELPER_TEXT,
- NATTED_PUBLIC_IP_HELPER_TEXT,
- NOT_NATTED_HELPER_TEXT,
-} from 'src/features/VPCs/constants';
import {
handleFieldErrors,
handleGeneralErrors,
@@ -68,7 +63,11 @@ import {
StyledFormGroup,
StyledRadioGroup,
} from './LinodeConfigDialog.styles';
-import { getPrimaryInterfaceIndex, useGetDeviceLimit } from './utilities';
+import {
+ getPrimaryInterfaceIndex,
+ unrecommendedConfigNoticeSelector,
+ useGetDeviceLimit,
+} from './utilities';
import type { ExtendedInterface } from '../LinodeSettings/InterfaceSelect';
import type {
@@ -92,7 +91,7 @@ type RunLevel = 'binbash' | 'default' | 'single';
type VirtMode = 'fullvirt' | 'paravirt';
export type MemoryLimit = 'no_limit' | 'set_limit';
-interface EditableFields {
+export interface EditableFields {
comments?: string;
devices: DevicesAsStrings;
helpers: Helpers;
@@ -181,6 +180,7 @@ const interfacesToState = (interfaces?: Interface[] | null) => {
ip_ranges,
ipam_address,
ipv4,
+ ipv6,
label,
primary,
purpose,
@@ -191,6 +191,7 @@ const interfacesToState = (interfaces?: Interface[] | null) => {
ip_ranges,
ipam_address,
ipv4,
+ ipv6,
label,
primary,
purpose,
@@ -1049,7 +1050,7 @@ export const LinodeConfigDialog = (props: Props) => {
/>
>
{values.interfaces?.map((thisInterface, idx) => {
- const thisInterfaceIPRanges: ExtendedIP[] = (
+ const thisInterfaceIPv4Ranges: ExtendedIP[] = (
thisInterface.ip_ranges ?? []
).map((ip_range, index) => {
// Display a more user-friendly error to the user as opposed to, for example, "interfaces[1].ip_ranges[1] is invalid"
@@ -1069,6 +1070,26 @@ export const LinodeConfigDialog = (props: Props) => {
};
});
+ const thisInterfaceIPv6Ranges: ExtendedIP[] = (
+ thisInterface.ipv6?.ranges ?? []
+ ).map((ipv6Range, index) => {
+ // Display a more user-friendly error to the user as opposed to, for example, "interfaces[1].ipv6.ranges[1] is invalid"
+ // @ts-expect-error this form intentionally breaks formik's error type
+ const errorString: string = formik.errors[
+ `interfaces[${idx}].ipv6.ranges[${index}].range`
+ ]?.includes('is invalid')
+ ? 'Invalid IPv6 range'
+ : // @ts-expect-error this form intentionally breaks formik's error type
+ formik.errors[
+ `interfaces[${idx}].ipv6.ranges[${index}].range`
+ ];
+
+ return {
+ address: ipv6Range.range,
+ error: errorString,
+ };
+ });
+
return (
{unrecommendedConfigNoticeSelector({
@@ -1078,7 +1099,8 @@ export const LinodeConfigDialog = (props: Props) => {
values,
})}
{
publicIPv4Error:
// @ts-expect-error this form intentionally breaks formik's error type
formik.errors[`interfaces[${idx}].ipv4.nat_1_1`],
+ publicIPv6Error:
+ // @ts-expect-error this form intentionally breaks formik's error type
+ formik.errors[
+ `interfaces[${idx}].ipv6.is_public`
+ ],
subnetError:
// @ts-expect-error this form intentionally breaks formik's error type
formik.errors[`interfaces[${idx}].subnet_id`],
@@ -1104,6 +1131,11 @@ export const LinodeConfigDialog = (props: Props) => {
vpcIPv4Error:
// @ts-expect-error this form intentionally breaks formik's error type
formik.errors[`interfaces[${idx}].ipv4.vpc`],
+ vpcIPv6Error:
+ // @ts-expect-error this form intentionally breaks formik's error type
+ formik.errors[
+ `interfaces[${idx}].ipv6.slaac[0].range`
+ ],
}}
handleChange={(newInterface: ExtendedInterface) => {
handleInterfaceChange(idx, newInterface);
@@ -1122,6 +1154,12 @@ export const LinodeConfigDialog = (props: Props) => {
subnetId={thisInterface.subnet_id}
vpcId={thisInterface.vpc_id}
vpcIPv4={thisInterface.ipv4?.vpc ?? undefined}
+ vpcIPv6={
+ thisInterface.ipv6?.slaac?.[0]?.range ?? undefined
+ }
+ vpcIPv6IsPublic={
+ thisInterface.ipv6?.is_public ?? false
+ }
/>
);
@@ -1243,76 +1281,3 @@ const DialogContent = (props: ConfigFormProps) => {
const isUsingCustomRoot = (value: string) =>
pathsOptionsLabels.includes(value) === false;
-
-const noticeForScenario = (scenarioText: string) => (
-
-);
-
-/**
- * Returns a JSX warning notice if the current network interface configuration
- * is unrecommended and may lead to undesired or unsupported behavior.
- *
- * @param _interface the current config interface being passed in
- * @param primaryInterfaceIndex the index of the primary interface
- * @param thisIndex the index of the current config interface within the `interfaces` array of the `config` object
- * @param values the values held in Formik state, having a type of `EditableFields`
- * @returns JSX.Element | null
- */
-export const unrecommendedConfigNoticeSelector = ({
- _interface,
- primaryInterfaceIndex,
- thisIndex,
- values,
-}: {
- _interface: ExtendedInterface;
- primaryInterfaceIndex: null | number;
- thisIndex: number;
- values: EditableFields;
-}): JSX.Element | null => {
- const vpcInterface = _interface.purpose === 'vpc';
- const nattedIPv4Address = Boolean(_interface.ipv4?.nat_1_1);
-
- const filteredInterfaces =
- values.interfaces?.filter((_interface) => _interface.purpose !== 'none') ??
- [];
-
- // Edge case: users w/ ability to have multiple VPC interfaces. Scenario 1 & 2 notices not helpful if that's done
- const primaryInterfaceIsVPC =
- primaryInterfaceIndex !== null &&
- values.interfaces &&
- values.interfaces[primaryInterfaceIndex].purpose === 'vpc';
-
- /*
- Scenario 1:
- - the interface passed in to this function is a VPC interface
- - the index of the primary interface !== the index of the interface passed in to this function
- - nattedIPv4Address (i.e., "Assign a public IPv4 address for this Linode" checked)
-
- Scenario 2:
- - all of Scenario 1, except: !nattedIPv4Address (i.e., "Assign a public IPv4 address for this Linode" unchecked)
-
- Scenario 3:
- - only eth0 populated, and it is a VPC interface
-
- If not one of the above scenarios, do not display a warning notice re: configuration
- */
- if (
- vpcInterface &&
- primaryInterfaceIndex !== thisIndex &&
- !primaryInterfaceIsVPC
- ) {
- return nattedIPv4Address
- ? noticeForScenario(NATTED_PUBLIC_IP_HELPER_TEXT)
- : noticeForScenario(LINODE_UNREACHABLE_HELPER_TEXT);
- }
-
- if (filteredInterfaces.length === 1 && vpcInterface && !nattedIPv4Address) {
- return noticeForScenario(NOT_NATTED_HELPER_TEXT);
- }
-
- return null;
-};
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx
index b543c9048c9..9314ea4ed61 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx
@@ -3,7 +3,7 @@ import {
useGrants,
useLinodeQuery,
} from '@linode/queries';
-import { Box, Button, Typography } from '@linode/ui';
+import { Box, Button } from '@linode/ui';
import { useTheme } from '@mui/material/styles';
import { useNavigate, useParams } from '@tanstack/react-router';
import * as React from 'react';
@@ -28,24 +28,11 @@ import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes';
import { useLinodeDetailContext } from '../LinodesDetailContext';
import { BootConfigDialog } from './BootConfigDialog';
import { ConfigRow } from './ConfigRow';
+import { DEFAULT_UPGRADE_BUTTON_HELPER_TEXT } from './constants';
import { DeleteConfigDialog } from './DeleteConfigDialog';
import { LinodeConfigDialog } from './LinodeConfigDialog';
import { getUnableToUpgradeTooltipText } from './UpgradeInterfaces/utils';
-export const DEFAULT_UPGRADE_BUTTON_HELPER_TEXT = (
- <>
-
- Configuration Profile interfaces from a single profile can be upgraded to
- Linode Interfaces.
-
-
- After the upgrade, the Linode can only use Linode Interfaces and cannot
- revert to Configuration Profile interfaces. Use the dry-run feature to
- review the changes before committing.
-
- >
-);
-
const LinodeConfigs = () => {
const theme = useTheme();
const navigate = useNavigate();
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/constants.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/constants.tsx
similarity index 87%
rename from packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/constants.ts
rename to packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/constants.tsx
index d1cb140afbb..4c140df6420 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/constants.ts
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/constants.tsx
@@ -1,3 +1,6 @@
+import { Typography } from '@linode/ui';
+import * as React from 'react';
+
export const deviceSlots = [
'sda',
'sdb',
@@ -133,3 +136,17 @@ export const pathsOptions = [
];
export const pathsOptionsLabels = pathsOptions.map((path) => path.label);
+
+export const DEFAULT_UPGRADE_BUTTON_HELPER_TEXT = (
+ <>
+
+ Configuration Profile interfaces from a single profile can be upgraded to
+ Linode Interfaces.
+
+
+ After the upgrade, the Linode can only use Linode Interfaces and cannot
+ revert to Configuration Profile interfaces. Use the dry-run feature to
+ review the changes before committing.
+
+ >
+);
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.tsx
similarity index 100%
rename from packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.ts
rename to packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.tsx
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.ts
deleted file mode 100644
index faed50c71af..00000000000
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { isEmpty } from '@linode/api-v4';
-
-import { DEFAULT_DEVICE_LIMIT } from 'src/constants';
-import { useFlags } from 'src/hooks/useFlags';
-
-import type { DiskDevice, Interface, VolumeDevice } from '@linode/api-v4';
-
-/**
- * Gets the index of the primary Linode interface
- *
- * The function does more than just look for `primary: true`. It will also return the index
- * of the implicit primary interface. (The API does not enforce that a Linode config always
- * has an interface that is marked as primary)
- *
- * This is the general logic we follow in this function:
- * - If an interface is primary we know that's the primary
- * - If the API response returns an empty array "interfaces": [], under the hood, a public interface eth0 is implicit. This interface will be primary.
- * - If a config has interfaces, but none of them are marked primary: true, then the first interface in the list that’s not a VLAN will be the primary interface
- *
- * @returns the index of the primary interface or `null` if there is not a primary interface
- */
-export const getPrimaryInterfaceIndex = (interfaces: Interface[]) => {
- const indexOfPrimaryInterface = interfaces.findIndex((i) => i.primary);
-
- // If an interface has `primary: true` we know thats the primary so just return it.
- if (indexOfPrimaryInterface !== -1) {
- return indexOfPrimaryInterface;
- }
-
- // If the API response returns an empty array "interfaces": [] the Linode will by default have a public interface,
- // and it will be eth0 on the Linode. This interface will be primary.
- // This case isn't really nessesary because this form is built so that the interfaces state will be
- // populated even if the API returns an empty interfaces array, but I'm including it for completeness.
- if (isEmpty(interfaces)) {
- return null;
- }
-
- // If a config has interfaces but none of them are marked as primary,
- // then the first interface in the list that’s not a VLAN will shown as the primary interface.
- const inherentIndexOfPrimaryInterface = interfaces.findIndex(
- (i) => i.purpose !== 'vlan'
- );
-
- if (inherentIndexOfPrimaryInterface !== -1) {
- // If we're able to find the inherent primary interface, just return it.
- return inherentIndexOfPrimaryInterface;
- }
-
- // If we haven't been able to find the primary interface by this point, the Linode doesn't have one.
- // As an example, this is the case when a Linode only has a VLAN interface.
- return null;
-};
-
-/**
- * Determines the maximum available Linodes allowed for a configuration profile
- *
- * returns MAX(8, MIN(ram / 1024, 64))
- *
- * @param ram the Linode's available ram
- * @returns the device limit allowed
- */
-export const useGetDeviceLimit = (ram: number) => {
- const flags = useFlags();
- if (flags.blockStorageVolumeLimit) {
- return Math.max(DEFAULT_DEVICE_LIMIT, Math.min(ram / 1024, 64));
- }
-
- return DEFAULT_DEVICE_LIMIT;
-};
-
-export const isDiskDevice = (
- device: DiskDevice | VolumeDevice
-): device is DiskDevice => {
- return 'disk_id' in device && device.disk_id !== null;
-};
-
-export const isVolumeDevice = (
- device: DiskDevice | VolumeDevice
-): device is VolumeDevice => {
- return 'volume_id' in device && device.volume_id !== null;
-};
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.tsx
new file mode 100644
index 00000000000..02ab3260baa
--- /dev/null
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.tsx
@@ -0,0 +1,174 @@
+import { isEmpty } from '@linode/api-v4';
+import { Notice } from '@linode/ui';
+import * as React from 'react';
+import type { JSX } from 'react';
+
+import { DEFAULT_DEVICE_LIMIT } from 'src/constants';
+import {
+ LINODE_UNREACHABLE_HELPER_TEXT,
+ NATTED_PUBLIC_IP_HELPER_TEXT,
+ NOT_NATTED_HELPER_TEXT,
+} from 'src/features/VPCs/constants';
+import { useFlags } from 'src/hooks/useFlags';
+
+import type { ExtendedInterface } from '../LinodeSettings/InterfaceSelect';
+import type { EditableFields } from './LinodeConfigDialog';
+import type { DiskDevice, Interface, VolumeDevice } from '@linode/api-v4';
+
+/**
+ * Gets the index of the primary Linode interface
+ *
+ * The function does more than just look for `primary: true`. It will also return the index
+ * of the implicit primary interface. (The API does not enforce that a Linode config always
+ * has an interface that is marked as primary)
+ *
+ * This is the general logic we follow in this function:
+ * - If an interface is primary we know that's the primary
+ * - If the API response returns an empty array "interfaces": [], under the hood, a public interface eth0 is implicit. This interface will be primary.
+ * - If a config has interfaces, but none of them are marked primary: true, then the first interface in the list that’s not a VLAN will be the primary interface
+ *
+ * @returns the index of the primary interface or `null` if there is not a primary interface
+ */
+export const getPrimaryInterfaceIndex = (interfaces: Interface[]) => {
+ const indexOfPrimaryInterface = interfaces.findIndex((i) => i.primary);
+
+ // If an interface has `primary: true` we know thats the primary so just return it.
+ if (indexOfPrimaryInterface !== -1) {
+ return indexOfPrimaryInterface;
+ }
+
+ // If the API response returns an empty array "interfaces": [] the Linode will by default have a public interface,
+ // and it will be eth0 on the Linode. This interface will be primary.
+ // This case isn't really nessesary because this form is built so that the interfaces state will be
+ // populated even if the API returns an empty interfaces array, but I'm including it for completeness.
+ if (isEmpty(interfaces)) {
+ return null;
+ }
+
+ // If a config has interfaces but none of them are marked as primary,
+ // then the first interface in the list that’s not a VLAN will shown as the primary interface.
+ const inherentIndexOfPrimaryInterface = interfaces.findIndex(
+ (i) => i.purpose !== 'vlan'
+ );
+
+ if (inherentIndexOfPrimaryInterface !== -1) {
+ // If we're able to find the inherent primary interface, just return it.
+ return inherentIndexOfPrimaryInterface;
+ }
+
+ // If we haven't been able to find the primary interface by this point, the Linode doesn't have one.
+ // As an example, this is the case when a Linode only has a VLAN interface.
+ return null;
+};
+
+/**
+ * Determines the maximum available Linodes allowed for a configuration profile
+ *
+ * returns MAX(8, MIN(ram / 1024, 64))
+ *
+ * @param ram the Linode's available ram
+ * @returns the device limit allowed
+ */
+export const useGetDeviceLimit = (ram: number) => {
+ const flags = useFlags();
+ if (flags.blockStorageVolumeLimit) {
+ return Math.max(DEFAULT_DEVICE_LIMIT, Math.min(ram / 1024, 64));
+ }
+
+ return DEFAULT_DEVICE_LIMIT;
+};
+
+export const isDiskDevice = (
+ device: DiskDevice | VolumeDevice
+): device is DiskDevice => {
+ return 'disk_id' in device && device.disk_id !== null;
+};
+
+export const isVolumeDevice = (
+ device: DiskDevice | VolumeDevice
+): device is VolumeDevice => {
+ return 'volume_id' in device && device.volume_id !== null;
+};
+
+/**
+ * We want to pad the interface list in the UI with purpose.none
+ * interfaces up to the maximum (currently 3); any purpose.none
+ * interfaces will be removed from the payload before submission,
+ * they are only used as placeholders presented to the user as empty selects.
+ */
+export const padList = (list: T[], filler: T, size: number = 3): T[] => {
+ return [...list, ...Array(Math.max(0, size - list.length)).fill(filler)];
+};
+
+export const noticeForScenario = (scenarioText: string) => (
+
+);
+
+/**
+ * Returns a JSX warning notice if the current network interface configuration
+ * is unrecommended and may lead to undesired or unsupported behavior.
+ *
+ * @param _interface the current config interface being passed in
+ * @param primaryInterfaceIndex the index of the primary interface
+ * @param thisIndex the index of the current config interface within the `interfaces` array of the `config` object
+ * @param values the values held in Formik state, having a type of `EditableFields`
+ * @returns JSX.Element | null
+ */
+export const unrecommendedConfigNoticeSelector = ({
+ _interface,
+ primaryInterfaceIndex,
+ thisIndex,
+ values,
+}: {
+ _interface: ExtendedInterface;
+ primaryInterfaceIndex: null | number;
+ thisIndex: number;
+ values: EditableFields;
+}): JSX.Element | null => {
+ const vpcInterface = _interface.purpose === 'vpc';
+ const nattedIPv4Address = Boolean(_interface.ipv4?.nat_1_1);
+
+ const filteredInterfaces =
+ values.interfaces?.filter((_interface) => _interface.purpose !== 'none') ??
+ [];
+
+ // Edge case: users w/ ability to have multiple VPC interfaces. Scenario 1 & 2 notices not helpful if that's done
+ const primaryInterfaceIsVPC =
+ primaryInterfaceIndex !== null &&
+ values.interfaces &&
+ values.interfaces[primaryInterfaceIndex].purpose === 'vpc';
+
+ /*
+ Scenario 1:
+ - the interface passed in to this function is a VPC interface
+ - the index of the primary interface !== the index of the interface passed in to this function
+ - nattedIPv4Address (i.e., "Assign a public IPv4 address for this Linode" checked)
+
+ Scenario 2:
+ - all of Scenario 1, except: !nattedIPv4Address (i.e., "Assign a public IPv4 address for this Linode" unchecked)
+
+ Scenario 3:
+ - only eth0 populated, and it is a VPC interface
+
+ If not one of the above scenarios, do not display a warning notice re: configuration
+ */
+ if (
+ vpcInterface &&
+ primaryInterfaceIndex !== thisIndex &&
+ !primaryInterfaceIsVPC
+ ) {
+ return nattedIPv4Address
+ ? noticeForScenario(NATTED_PUBLIC_IP_HELPER_TEXT)
+ : noticeForScenario(LINODE_UNREACHABLE_HELPER_TEXT);
+ }
+
+ if (filteredInterfaces.length === 1 && vpcInterface && !nattedIPv4Address) {
+ return noticeForScenario(NOT_NATTED_HELPER_TEXT);
+ }
+
+ return null;
+};
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceIPs.utils.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceIPs.utils.ts
index b00e20ed925..74a0ba9105f 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceIPs.utils.ts
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceIPs.utils.ts
@@ -64,7 +64,9 @@ export function getLinodeInterfaceIPs(linodeInterface: LinodeInterface) {
for (const slaacs of linodeInterface.vpc.ipv6.slaac) {
if (slaacs.address) {
ips.push(slaacs.address);
- ips.push(slaacs.range);
+ if (slaacs.range) {
+ ips.push(slaacs.range);
+ }
}
}
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.test.tsx
index df20f1f0b54..307469b6b06 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.test.tsx
@@ -27,6 +27,7 @@ describe('InterfaceSelect', () => {
region: 'us-east',
regionHasVLANs: true,
slotNumber: 0,
+ vpcIPv6IsPublic: false,
};
it('should display helper text regarding VPCs not being available in the region in the Linode Add/Edit Config dialog if applicable', async () => {
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx
index daac7de0eef..be8d35b2281 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx
@@ -1,4 +1,4 @@
-import { useVlansQuery } from '@linode/queries';
+import { useSubnetQuery, useVlansQuery } from '@linode/queries';
import {
Autocomplete,
Divider,
@@ -14,6 +14,7 @@ import * as React from 'react';
import type { JSX } from 'react';
import { VPCPanel } from 'src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel';
+import { useVPCDualStack } from 'src/hooks/useVPCDualStack';
import { sendLinodeCreateDocsEvent } from 'src/utilities/analytics/customEventAnalytics';
import type {
@@ -27,6 +28,7 @@ interface InterfaceErrors extends VPCInterfaceErrors, OtherInterfaceErrors {}
interface InterfaceSelectProps extends VPCState {
additionalIPv4RangesForVPC?: ExtendedIP[];
+ additionalIPv6RangesForVPC?: ExtendedIP[];
errors: InterfaceErrors;
fromAddonsPanel?: boolean;
handleChange: (updatedInterface: ExtendedInterface) => void;
@@ -37,14 +39,17 @@ interface InterfaceSelectProps extends VPCState {
regionHasVLANs?: boolean;
regionHasVPCs?: boolean;
slotNumber: number;
+ vpcIPv6IsPublic: boolean;
}
interface VPCInterfaceErrors {
ipRangeError?: string;
labelError?: string;
publicIPv4Error?: string;
+ publicIPv6Error?: string;
subnetError?: string;
vpcError?: string;
vpcIPv4Error?: string;
+ vpcIPv6Error?: string;
}
interface OtherInterfaceErrors {
@@ -57,6 +62,7 @@ interface VPCState {
subnetId?: null | number;
vpcId?: null | number;
vpcIPv4?: string;
+ vpcIPv6?: string;
}
// To allow for empty slots, which the API doesn't account for
@@ -69,6 +75,7 @@ export interface ExtendedInterface
export const InterfaceSelect = (props: InterfaceSelectProps) => {
const {
additionalIPv4RangesForVPC,
+ additionalIPv6RangesForVPC,
errors,
fromAddonsPanel,
handleChange,
@@ -82,7 +89,9 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => {
slotNumber,
subnetId,
vpcIPv4,
+ vpcIPv6,
vpcId,
+ vpcIPv6IsPublic,
} = props;
const theme = useTheme();
@@ -90,8 +99,24 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => {
theme.breakpoints.down(fromAddonsPanel ? 'sm' : 1015)
);
+ const { isDualStackEnabled } = useVPCDualStack();
+
+ const { data: selectedSubnet } = useSubnetQuery(
+ vpcId ?? -1,
+ subnetId ?? -1,
+ isDualStackEnabled && Boolean(vpcId) && Boolean(subnetId)
+ );
+
+ // Show IPv6 content if Dual Stack is enabled and the VPC of the selected subnet is Dual Stack
+ const showIPv6Content =
+ isDualStackEnabled &&
+ Boolean(selectedSubnet?.ipv6?.length && selectedSubnet?.ipv6?.length > 0);
+
const [newVlan, setNewVlan] = React.useState('');
+ const [autoassignIPv6VPCAddress, setAutoassignIPv6VPCAddress] =
+ React.useState(false);
+
const purposeOptions: SelectOption[] = [
{
label: 'Public Internet',
@@ -131,6 +156,10 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => {
(ip_range) => ip_range.address
);
+ const _additionalIPv6RangesForVPC = additionalIPv6RangesForVPC?.map(
+ (ip_range) => ({ range: ip_range.address })
+ );
+
const handlePurposeChange = (selectedValue: ExtendedPurpose) => {
const purpose = selectedValue;
handleChange({
@@ -150,6 +179,12 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => {
purpose,
});
+ const slaacFieldValue = autoassignIPv6VPCAddress
+ ? [{ range: 'auto' }]
+ : vpcIPv6
+ ? [{ range: vpcIPv6 }]
+ : undefined;
+
const handleVPCLabelChange = (selectedVPCId: number) => {
// Only clear VPC related fields if VPC selection changes
if (selectedVPCId !== vpcId) {
@@ -159,6 +194,13 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => {
nat_1_1: nattedIPv4Address,
vpc: vpcIPv4,
},
+ ipv6: showIPv6Content
+ ? {
+ is_public: vpcIPv6IsPublic,
+ ranges: _additionalIPv6RangesForVPC,
+ slaac: slaacFieldValue,
+ }
+ : undefined,
purpose,
vpc_id: selectedVPCId,
});
@@ -172,6 +214,31 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => {
nat_1_1: nattedIPv4Address,
vpc: vpcIPv4,
},
+ ipv6: showIPv6Content
+ ? {
+ is_public: vpcIPv6IsPublic,
+ ranges: _additionalIPv6RangesForVPC,
+ slaac: slaacFieldValue,
+ }
+ : undefined,
+ purpose,
+ subnet_id: subnetId,
+ vpc_id: vpcId,
+ });
+ };
+
+ const handleIPv6RangeChange = (ipv6Ranges: ExtendedIP[]) => {
+ handleChange({
+ ip_ranges: _additionalIPv4RangesForVPC,
+ ipv4: {
+ nat_1_1: nattedIPv4Address,
+ vpc: vpcIPv4,
+ },
+ ipv6: {
+ is_public: vpcIPv6IsPublic,
+ ranges: ipv6Ranges.map((ip_range) => ({ range: ip_range.address })),
+ slaac: slaacFieldValue,
+ },
purpose,
subnet_id: subnetId,
vpc_id: vpcId,
@@ -185,35 +252,110 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => {
nat_1_1: nattedIPv4Address,
vpc: vpcIPv4,
},
+ ipv6: showIPv6Content
+ ? {
+ is_public: vpcIPv6IsPublic,
+ ranges: _additionalIPv6RangesForVPC,
+ slaac: slaacFieldValue,
+ }
+ : undefined,
purpose,
subnet_id: selectedSubnetId,
vpc_id: vpcId,
});
- const handleVPCIPv4Input = (vpcIPv4Input: string | undefined) =>
- handleChange({
+ const handleVPCIPv4Input = (vpcIPv4Input: string | undefined) => {
+ const obj = {
ip_ranges: _additionalIPv4RangesForVPC,
ipv4: {
nat_1_1: nattedIPv4Address,
vpc: vpcIPv4Input,
},
+ ipv6: showIPv6Content
+ ? {
+ is_public: vpcIPv6IsPublic,
+ ranges: _additionalIPv6RangesForVPC,
+ slaac: slaacFieldValue,
+ }
+ : undefined,
+ purpose,
+ subnet_id: subnetId,
+ vpc_id: vpcId,
+ };
+
+ handleChange(obj);
+ };
+
+ const handleVPCIPv6Input = (vpcIPv6Input: string | undefined) => {
+ handleChange({
+ ip_ranges: _additionalIPv4RangesForVPC,
+ ipv4: {
+ nat_1_1: nattedIPv4Address,
+ vpc: vpcIPv4,
+ },
+ ipv6: {
+ is_public: vpcIPv6IsPublic,
+ ranges: _additionalIPv6RangesForVPC,
+ slaac:
+ vpcIPv6Input !== undefined && vpcIPv6Input !== ''
+ ? [{ range: vpcIPv6Input }]
+ : undefined,
+ },
purpose,
subnet_id: subnetId,
vpc_id: vpcId,
});
+ };
- const handleIPv4Input = (IPv4Input: null | string) =>
+ const handleIPv4Input = (ipv4Input: null | string) =>
handleChange({
ip_ranges: _additionalIPv4RangesForVPC,
ipv4: {
- nat_1_1: IPv4Input,
+ nat_1_1: ipv4Input,
vpc: vpcIPv4,
},
+ ipv6: showIPv6Content
+ ? {
+ is_public: vpcIPv6IsPublic,
+ ranges: _additionalIPv6RangesForVPC,
+ slaac: slaacFieldValue,
+ }
+ : undefined,
purpose,
subnet_id: subnetId,
vpc_id: vpcId,
});
+ const handleIPv6IsPublicChange = (vpcIPv6IsPublic: boolean) => {
+ handleChange({
+ ip_ranges: _additionalIPv4RangesForVPC,
+ ipv4: {
+ nat_1_1: nattedIPv4Address,
+ vpc: vpcIPv4,
+ },
+ ipv6: {
+ is_public: !vpcIPv6IsPublic,
+ slaac: slaacFieldValue,
+ ranges: _additionalIPv6RangesForVPC,
+ },
+ purpose,
+ subnet_id: subnetId,
+ vpc_id: vpcId,
+ });
+ };
+
+ const handleToggleAutoassignIPv6WithinVPCEnabled = () => {
+ const newValue = !autoassignIPv6VPCAddress;
+
+ setAutoassignIPv6VPCAddress(newValue);
+
+ if (newValue) {
+ handleVPCIPv6Input('auto');
+ } else {
+ handleVPCIPv6Input(undefined);
+ }
+ };
+
const handleCreateOption = (_newVlan: string) => {
setNewVlan(_newVlan);
handleChange({
@@ -400,25 +542,42 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => {
+ handleIPv4Input(nattedIPv4Address === undefined ? 'any' : null)
+ }
+ toggleAssignPublicIPv6Address={() =>
+ handleIPv6IsPublicChange(vpcIPv6IsPublic)
+ }
toggleAutoassignIPv4WithinVPCEnabled={() =>
handleVPCIPv4Input(vpcIPv4 === undefined ? '' : undefined)
}
+ toggleAutoassignIPv6WithinVPCEnabled={
+ handleToggleAutoassignIPv6WithinVPCEnabled
+ }
vpcIdError={errors.vpcError}
vpcIPRangesError={errors.ipRangeError}
vpcIPv4AddressOfLinode={vpcIPv4}
vpcIPv4Error={errors.vpcIPv4Error}
+ vpcIPv6AddressOfLinode={vpcIPv6}
+ vpcIPv6Error={errors.vpcIPv6Error}
/>
)}
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx
index a9ec9ba9a1e..385a43fe9ca 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.test.tsx
@@ -13,18 +13,27 @@ beforeAll(() => mockMatchMedia());
const props = {
additionalIPv4RangesForVPC: [],
+ additionalIPv6RangesForVPC: [],
assignPublicIPv4Address: false,
+ assignPublicIPv6Address: false,
autoassignIPv4WithinVPC: true,
+ autoassignIPv6WithinVPC: false,
handleIPv4RangeChange: vi.fn(),
+ handleIPv6RangeChange: vi.fn(),
handleSelectVPC: vi.fn(),
handleSubnetChange: vi.fn(),
handleVPCIPv4Change: vi.fn(),
+ handleVPCIPv6Change: vi.fn(),
region: 'us-east',
selectedSubnetId: undefined,
selectedVPCId: undefined,
+ showIPv6Content: false,
toggleAssignPublicIPv4Address: vi.fn(),
+ toggleAssignPublicIPv6Address: vi.fn(),
toggleAutoassignIPv4WithinVPCEnabled: vi.fn(),
+ toggleAutoassignIPv6WithinVPCEnabled: vi.fn(),
vpcIPv4AddressOfLinode: undefined,
+ vpcIPv6AddressOfLinode: undefined,
};
const vpcPanelTestId = 'vpc-panel';
@@ -149,9 +158,7 @@ describe('VPCPanel', () => {
await waitFor(() => {
expect(
- wrapper.getByLabelText(
- 'Auto-assign a VPC IPv4 address for this Linode in the VPC'
- )
+ wrapper.getByLabelText('Auto-assign VPC IPv4 address')
).not.toBeChecked();
// Using regex here to account for the "(required)" indicator.
expect(wrapper.getByLabelText(/^VPC IPv4.*/)).toHaveValue('10.0.4.3');
diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.tsx
index 42e9ab02fdc..d555b64b689 100644
--- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.tsx
+++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/VPCPanel.tsx
@@ -18,10 +18,11 @@ import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import * as React from 'react';
-import { PublicIPv4Access } from 'src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/PublicIPv4Access';
+import { PublicAccess } from 'src/features/VPCs/components/PublicAccess';
import {
REGION_CAVEAT_HELPER_TEXT,
VPC_AUTO_ASSIGN_IPV4_TOOLTIP,
+ VPC_AUTO_ASSIGN_IPV6_TOOLTIP,
} from 'src/features/VPCs/constants';
import { AssignIPRanges } from 'src/features/VPCs/VPCDetail/AssignIPRanges';
import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
@@ -30,23 +31,34 @@ import type { ExtendedIP } from 'src/utilities/ipUtils';
export interface VPCPanelProps {
additionalIPv4RangesForVPC: ExtendedIP[];
+ additionalIPv6RangesForVPC: ExtendedIP[];
assignPublicIPv4Address: boolean;
+ assignPublicIPv6Address: boolean;
autoassignIPv4WithinVPC: boolean;
+ autoassignIPv6WithinVPC: boolean;
handleIPv4RangeChange: (ranges: ExtendedIP[]) => void;
+ handleIPv6RangeChange: (ranges: ExtendedIP[]) => void;
handleSelectVPC: (vpcId: number) => void;
handleSubnetChange: (subnetId: number | undefined) => void;
handleVPCIPv4Change: (IPv4: string) => void;
+ handleVPCIPv6Change: (IPv6: string) => void;
publicIPv4Error?: string;
+ publicIPv6Error?: string;
region: string | undefined;
selectedSubnetId: null | number | undefined;
selectedVPCId: null | number | undefined;
+ showIPv6Content: boolean;
subnetError?: string;
toggleAssignPublicIPv4Address: (ipv4Access: null | string) => void;
+ toggleAssignPublicIPv6Address: () => void;
toggleAutoassignIPv4WithinVPCEnabled: () => void;
+ toggleAutoassignIPv6WithinVPCEnabled: () => void;
vpcIdError?: string;
vpcIPRangesError?: string;
vpcIPv4AddressOfLinode: string | undefined;
vpcIPv4Error?: string;
+ vpcIPv6AddressOfLinode: string | undefined;
+ vpcIPv6Error?: string;
}
const ERROR_GROUP_STRING = 'vpc-errors';
@@ -54,23 +66,34 @@ const ERROR_GROUP_STRING = 'vpc-errors';
export const VPCPanel = (props: VPCPanelProps) => {
const {
additionalIPv4RangesForVPC,
+ additionalIPv6RangesForVPC,
assignPublicIPv4Address,
+ assignPublicIPv6Address,
autoassignIPv4WithinVPC,
+ autoassignIPv6WithinVPC,
handleIPv4RangeChange,
+ handleIPv6RangeChange,
handleSelectVPC,
handleSubnetChange,
handleVPCIPv4Change,
+ handleVPCIPv6Change,
publicIPv4Error,
+ publicIPv6Error,
region,
selectedSubnetId,
selectedVPCId,
+ showIPv6Content,
subnetError,
toggleAssignPublicIPv4Address,
+ toggleAssignPublicIPv6Address,
toggleAutoassignIPv4WithinVPCEnabled,
+ toggleAutoassignIPv6WithinVPCEnabled,
vpcIPRangesError,
vpcIPv4AddressOfLinode,
vpcIPv4Error,
vpcIdError,
+ vpcIPv6AddressOfLinode,
+ vpcIPv6Error,
} = props;
const theme = useTheme();
@@ -95,10 +118,10 @@ export const VPCPanel = (props: VPCPanelProps) => {
});
React.useEffect(() => {
- if (subnetError || vpcIPv4Error) {
+ if (subnetError || vpcIPv4Error || vpcIPv6Error) {
scrollErrorIntoView(ERROR_GROUP_STRING);
}
- }, [subnetError, vpcIPv4Error]);
+ }, [subnetError, vpcIPv4Error, vpcIPv6Error]);
const vpcs = vpcsData ?? [];
@@ -204,8 +227,7 @@ export const VPCPanel = (props: VPCPanelProps) => {
flexDirection="row"
>
- Auto-assign a VPC IPv4 address for this Linode in the
- VPC
+ Auto-assign VPC IPv4 address
{
errorGroup={ERROR_GROUP_STRING}
errorText={vpcIPv4Error}
label="VPC IPv4"
+ noMarginTop={showIPv6Content}
onChange={(e) => handleVPCIPv4Change(e.target.value)}
required={!autoassignIPv4WithinVPC}
value={vpcIPv4AddressOfLinode}
/>
)}
- ({
- marginLeft: '2px',
- marginTop: !autoassignIPv4WithinVPC ? theme.spacing() : 0,
- })}
- >
-
-
- {assignPublicIPv4Address && publicIPv4Error && (
- ({
- color: theme.color.red,
- })}
- >
- {publicIPv4Error}
-
+ {showIPv6Content && (
+ <>
+ ({
+ marginLeft: '2px',
+ paddingTop: theme.spacingFunction(8),
+ })}
+ >
+
+ }
+ data-testid="vpc-ipv6-checkbox"
+ label={
+
+
+ Auto-assign VPC IPv6 address
+
+
+
+ }
+ />
+
+ {!autoassignIPv6WithinVPC && (
+ handleVPCIPv6Change(e.target.value)}
+ value={vpcIPv6AddressOfLinode}
+ />
+ )}
+ >
)}
+
+ ) => void // The type conversion is not ideal, but seems to be the least disruptive option
+ }
+ handleAllowPublicIPv6AccessChange={
+ toggleAssignPublicIPv6Address
+ }
+ publicIPv4Error={publicIPv4Error}
+ publicIPv6Error={publicIPv6Error}
+ showIPv6Content={showIPv6Content}
+ sx={{ margin: `${theme.spacingFunction(16)} 0` }}
+ userCannotAssignLinodes={false}
+ />
>
)}
diff --git a/packages/manager/src/features/VPCs/components/PublicAccess.tsx b/packages/manager/src/features/VPCs/components/PublicAccess.tsx
index 3cac4a4ab50..c3727cbb20c 100644
--- a/packages/manager/src/features/VPCs/components/PublicAccess.tsx
+++ b/packages/manager/src/features/VPCs/components/PublicAccess.tsx
@@ -24,6 +24,8 @@ interface Props {
handleAllowPublicIPv6AccessChange: (
e: React.ChangeEvent
) => void;
+ publicIPv4Error?: string;
+ publicIPv6Error?: string;
showIPv6Content: boolean;
sx?: SxProps;
userCannotAssignLinodes: boolean;
@@ -35,6 +37,8 @@ export const PublicAccess = (props: Props) => {
allowPublicIPv6Access,
handleAllowPublicIPv4AccessChange,
handleAllowPublicIPv6AccessChange,
+ publicIPv4Error,
+ publicIPv6Error,
showIPv6Content,
sx,
userCannotAssignLinodes,
@@ -61,22 +65,42 @@ export const PublicAccess = (props: Props) => {
}
onChange={handleAllowPublicIPv4AccessChange}
/>
+ {allowPublicIPv4Access && publicIPv4Error && (
+ ({
+ color: theme.color.red,
+ })}
+ >
+ {publicIPv4Error}
+
+ )}
{showIPv6Content && (
- }
- disabled={userCannotAssignLinodes}
- label={
-
- Allow public IPv6 access
-
-
- }
- onChange={handleAllowPublicIPv6AccessChange}
- />
+ <>
+ }
+ disabled={userCannotAssignLinodes}
+ label={
+
+ Allow public IPv6 access
+
+
+ }
+ onChange={handleAllowPublicIPv6AccessChange}
+ />
+ {allowPublicIPv6Access && publicIPv6Error && (
+ ({
+ color: theme.color.red,
+ })}
+ >
+ {publicIPv6Error}
+
+ )}
+ >
)}
>
);
diff --git a/packages/validation/.changeset/pr-13209-changed-1765924184891.md b/packages/validation/.changeset/pr-13209-changed-1765924184891.md
new file mode 100644
index 00000000000..8f0a0aceea9
--- /dev/null
+++ b/packages/validation/.changeset/pr-13209-changed-1765924184891.md
@@ -0,0 +1,5 @@
+---
+"@linode/validation": Changed
+---
+
+Use UpdateConfigProfileInterfacesSchema in UpdateLinodeConfigSchema for interfaces property ([#13209](https://github.com/linode/manager/pull/13209))
diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts
index 4de83f20225..05e4c460a5b 100644
--- a/packages/validation/src/linodes.schema.ts
+++ b/packages/validation/src/linodes.schema.ts
@@ -141,7 +141,7 @@ const ipv4ConfigInterface = object().when('purpose', {
const slaacSchema = object().shape({
range: string()
- .required('VPC IPv6 is required.')
+ .optional()
.test({
name: 'IPv6 prefix length',
message: 'Must be a /64 IPv6 network CIDR',
@@ -321,6 +321,29 @@ export const ConfigProfileInterfacesSchema = array()
},
);
+// This was created specifically for use in UpdateLinodeConfigSchema.
+// Altering `ConfigProfileInterfaceSchema` results in issues related to the `interfaces` property
+// that bubble up to `CreateLinodeSchema` in LinodeCreate/schemas.ts
+export const UpdateConfigProfileInterfacesSchema = array()
+ .of(
+ ConfigProfileInterfaceSchema.clone().shape({
+ ipv6: ipv6Interface.notRequired().nullable(),
+ }),
+ )
+ .test(
+ 'unique-public-interface',
+ 'Only one public interface per config is allowed.',
+ (list?: any[] | null) => {
+ if (!list) {
+ return true;
+ }
+
+ return (
+ list.filter((thisSlot) => thisSlot.purpose === 'public').length <= 1
+ );
+ },
+ );
+
export const UpdateConfigInterfaceOrderSchema = object({
ids: array().of(number()).required('The list of interface IDs is required.'),
});
@@ -593,7 +616,7 @@ export const UpdateLinodeConfigSchema = object({
virt_mode: mixed().oneOf(['paravirt', 'fullvirt']),
helpers,
root_device: string(),
- interfaces: ConfigProfileInterfacesSchema,
+ interfaces: UpdateConfigProfileInterfacesSchema,
});
export const CreateLinodeDiskSchema = object({