diff --git a/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.test.tsx b/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.test.tsx
index 86e9efb20d..37719121eb 100644
--- a/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.test.tsx
+++ b/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.test.tsx
@@ -8,6 +8,7 @@ import { ipRangeActions } from "@/app/store/iprange";
import type { IPRange } from "@/app/store/iprange/types";
import { IPRangeType } from "@/app/store/iprange/types";
import type { RootState } from "@/app/store/root/types";
+import type { Subnet } from "@/app/store/subnet/types";
import * as factory from "@/testing/factories";
import {
userEvent,
@@ -22,18 +23,24 @@ const mockStore = configureStore();
describe("ReservedRangeForm", () => {
let state: RootState;
let ipRange: IPRange;
+ let subnet: Subnet;
beforeEach(() => {
ipRange = factory.ipRange({
comment: "what a beaut",
- start_ip: "11.1.1.1",
+ start_ip: "10.10.0.1",
+ end_ip: "10.10.0.100",
type: IPRangeType.Reserved,
user: "wombat",
});
+ subnet = factory.subnet({ cidr: "10.10.0.0/24" });
state = factory.rootState({
iprange: factory.ipRangeState({
items: [ipRange],
}),
+ subnet: factory.subnetState({
+ items: [subnet],
+ }),
});
});
@@ -48,6 +55,7 @@ describe("ReservedRangeForm", () => {
@@ -62,7 +70,10 @@ describe("ReservedRangeForm", () => {
-
+
);
@@ -79,16 +90,17 @@ describe("ReservedRangeForm", () => {
);
expect(
screen.getByRole("textbox", { name: Labels.StartIp })
- ).toHaveAttribute("value", ipRange.start_ip);
+ ).toHaveAttribute("value", ipRange.start_ip.split(".")[-1]); // value should only be the last octet of the address
expect(screen.getByRole("textbox", { name: Labels.EndIp })).toHaveAttribute(
"value",
- ipRange.end_ip
+ ipRange.end_ip.split(".")[-1] // value should only be the last octet of the address
);
expect(
screen.getByRole("textbox", { name: Labels.Comment })
@@ -107,6 +119,7 @@ describe("ReservedRangeForm", () => {
@@ -129,18 +142,18 @@ describe("ReservedRangeForm", () => {
);
await userEvent.type(
screen.getByRole("textbox", { name: Labels.StartIp }),
- "1.1.1.1"
+ "1"
);
await userEvent.type(
screen.getByRole("textbox", { name: Labels.EndIp }),
- "1.1.1.2"
+ "99"
);
await userEvent.type(
screen.getByRole("textbox", { name: Labels.Comment }),
@@ -149,9 +162,9 @@ describe("ReservedRangeForm", () => {
await userEvent.click(screen.getByRole("button", { name: "Reserve" }));
const expected = ipRangeActions.create({
comment: "reserved",
- end_ip: "1.1.1.2",
- start_ip: "1.1.1.1",
- subnet: 1,
+ start_ip: "10.10.0.1",
+ end_ip: "10.10.0.99",
+ subnet: subnet.id,
type: IPRangeType.Reserved,
});
await waitFor(() =>
@@ -171,19 +184,20 @@ describe("ReservedRangeForm", () => {
);
const startIpField = screen.getByRole("textbox", { name: Labels.StartIp });
await userEvent.clear(startIpField);
- await userEvent.type(startIpField, "1.2.3.4");
+ await userEvent.type(startIpField, "20");
await userEvent.click(screen.getByRole("button", { name: "Save" }));
const expected = ipRangeActions.update({
comment: ipRange.comment,
end_ip: ipRange.end_ip,
id: ipRange.id,
- start_ip: "1.2.3.4",
+ start_ip: "10.10.0.20",
});
await waitFor(() =>
expect(
@@ -204,19 +218,20 @@ describe("ReservedRangeForm", () => {
);
const startIpField = screen.getByRole("textbox", { name: Labels.StartIp });
await userEvent.clear(startIpField);
- await userEvent.type(startIpField, "1.2.3.4");
+ await userEvent.type(startIpField, "4");
await userEvent.click(screen.getByRole("button", { name: "Save" }));
const expected = ipRangeActions.update({
comment: ipRange.comment,
end_ip: ipRange.end_ip,
id: ipRange.id,
- start_ip: "1.2.3.4",
+ start_ip: "10.10.0.4",
});
await waitFor(() => {
const actual = store
@@ -238,6 +253,7 @@ describe("ReservedRangeForm", () => {
@@ -249,7 +265,7 @@ describe("ReservedRangeForm", () => {
it("displays an error when start and end IP addresses are not provided", async () => {
renderWithBrowserRouter(
- ,
+ ,
{
state,
route: "/machines",
@@ -267,4 +283,58 @@ describe("ReservedRangeForm", () => {
await screen.findByLabelText(Labels.EndIp)
).toHaveAccessibleErrorMessage(/End IP is required/);
});
+
+ it("displays an error when an invalid IP address is entered", async () => {
+ renderWithBrowserRouter(
+ ,
+ {
+ state,
+ route: "/machines",
+ }
+ );
+ await userEvent.type(
+ screen.getByRole("textbox", { name: Labels.StartIp }),
+ "abc"
+ );
+ await userEvent.type(
+ screen.getByRole("textbox", { name: Labels.EndIp }),
+ "abc"
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Reserve" }));
+ expect(
+ await screen.findByLabelText(Labels.StartIp)
+ ).toHaveAccessibleErrorMessage(/This is not a valid IP address/);
+ expect(
+ await screen.findByLabelText(Labels.EndIp)
+ ).toHaveAccessibleErrorMessage(/This is not a valid IP address/);
+ });
+
+ it("displays an error when an out-of-range IP address is entered", async () => {
+ renderWithBrowserRouter(
+ ,
+ {
+ state,
+ route: "/machines",
+ }
+ );
+ await userEvent.type(
+ screen.getByRole("textbox", { name: Labels.StartIp }),
+ "0"
+ );
+ await userEvent.type(
+ screen.getByRole("textbox", { name: Labels.EndIp }),
+ "255"
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Reserve" }));
+ expect(
+ await screen.findByLabelText(Labels.StartIp)
+ ).toHaveAccessibleErrorMessage(
+ /The IP address is outside of the subnet's range/
+ );
+ expect(
+ await screen.findByLabelText(Labels.EndIp)
+ ).toHaveAccessibleErrorMessage(
+ /The IP address is outside of the subnet's range/
+ );
+ });
});
diff --git a/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.tsx b/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.tsx
index 7241ca8d59..760fb943bd 100644
--- a/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.tsx
+++ b/src/app/subnets/components/ReservedRangeForm/ReservedRangeForm.tsx
@@ -1,11 +1,16 @@
import { useCallback } from "react";
import { Col, Row, Spinner } from "@canonical/react-components";
+import * as ipaddr from "ipaddr.js";
+import { isIP, isIPv4 } from "is-ip";
import { useDispatch, useSelector } from "react-redux";
import * as Yup from "yup";
import FormikField from "@/app/base/components/FormikField";
import FormikForm from "@/app/base/components/FormikForm";
+import PrefixedIpInput, {
+ formatIpAddress,
+} from "@/app/base/components/PrefixedIpInput";
import {
useSidePanel,
type SetSidePanelContent,
@@ -15,8 +20,14 @@ import ipRangeSelectors from "@/app/store/iprange/selectors";
import type { IPRange } from "@/app/store/iprange/types";
import { IPRangeType, IPRangeMeta } from "@/app/store/iprange/types";
import type { RootState } from "@/app/store/root/types";
+import subnetSelectors from "@/app/store/subnet/selectors";
import type { Subnet, SubnetMeta } from "@/app/store/subnet/types";
import { isId } from "@/app/utils";
+import {
+ getImmutableAndEditableOctets,
+ getIpRangeFromCidr,
+ isIpInSubnet,
+} from "@/app/utils/subnetIpRange";
type Props = {
createType?: IPRangeType;
@@ -39,12 +50,6 @@ export enum Labels {
StartIp = "Start IP address",
}
-const Schema = Yup.object().shape({
- comment: Yup.string(),
- start_ip: Yup.string().required("Start IP is required"),
- end_ip: Yup.string().required("End IP is required"),
-});
-
const ReservedRangeForm = ({
createType,
ipRangeId,
@@ -67,8 +72,15 @@ const ReservedRangeForm = ({
const saved = useSelector(ipRangeSelectors.saved);
const saving = useSelector(ipRangeSelectors.saving);
const errors = useSelector(ipRangeSelectors.errors);
+ const subnet = useSelector((state: RootState) =>
+ subnetSelectors.getById(state, subnetId)
+ );
+ const subnetLoading = useSelector(subnetSelectors.loading);
+
const cleanup = useCallback(() => ipRangeActions.cleanup(), []);
+
const isEditing = isId(computedIpRangeId);
+ const showDynamicComment = isEditing && ipRange?.type === IPRangeType.Dynamic;
const onClose = () => setSidePanelContent(null);
let computedCreateType = createType;
if (!createType) {
@@ -78,31 +90,141 @@ const ReservedRangeForm = ({
: undefined;
}
- if (isEditing && !ipRange) {
+ if ((isEditing && !ipRange) || subnetLoading) {
return (
);
}
- let initialComment = "";
- const showDynamicComment = isEditing && ipRange?.type === IPRangeType.Dynamic;
- if (showDynamicComment) {
- initialComment = "Dynamic";
- } else if (isEditing) {
- initialComment = ipRange?.comment ?? "";
+
+ if (!subnet && subnetId) {
+ // Return null if subnet ID is provided but subnet has not been loaded yet
+ return null;
}
+ const ReservedRangeSchema = Yup.object().shape({
+ comment: Yup.string(),
+ start_ip: Yup.string()
+ .required("Start IP is required")
+ .test({
+ name: "ip-is-valid",
+ message: "This is not a valid IP address",
+ test: (ip_address) =>
+ subnet
+ ? isIP(formatIpAddress(ip_address, subnet.cidr))
+ : isIP(`${ip_address}`),
+ })
+ .test({
+ name: "ip-is-in-subnet",
+ message: "The IP address is outside of the subnet's range.",
+ test: (ip_address) => {
+ if (subnet) {
+ const ip = formatIpAddress(ip_address, subnet.cidr);
+ const networkAddress = subnet.cidr.split("/")[0];
+ const subnetIsIpv4 = isIPv4(networkAddress);
+ if (subnetIsIpv4) {
+ return isIpInSubnet(ip, subnet.cidr as string);
+ } else {
+ try {
+ const prefixLength = parseInt(subnet.cidr.split("/")[1]);
+ const addr = ipaddr.parse(ip);
+ const netAddr = ipaddr.parse(networkAddress);
+ return addr.match(netAddr, prefixLength);
+ } catch (e) {
+ return false;
+ }
+ }
+ } else {
+ // Return "true" if there is no subnet - we only need this validation when reserving a range for a subnet
+ return true;
+ }
+ },
+ }),
+ end_ip: Yup.string()
+ .required("End IP is required")
+ .test({
+ name: "ip-is-valid",
+ message: "This is not a valid IP address",
+ test: (ip_address) =>
+ subnet
+ ? isIP(formatIpAddress(ip_address, subnet.cidr))
+ : isIP(`${ip_address}`),
+ })
+ .test({
+ name: "ip-is-in-subnet",
+ message: "The IP address is outside of the subnet's range.",
+ test: (ip_address) => {
+ if (subnet) {
+ const ip = formatIpAddress(ip_address, subnet.cidr);
+ const networkAddress = subnet.cidr.split("/")[0];
+ const subnetIsIpv4 = isIPv4(networkAddress);
+ if (subnetIsIpv4) {
+ return isIpInSubnet(ip, subnet.cidr as string);
+ } else {
+ try {
+ const prefixLength = parseInt(subnet.cidr.split("/")[1]);
+ const addr = ipaddr.parse(ip);
+ const netAddr = ipaddr.parse(networkAddress);
+ return addr.match(netAddr, prefixLength);
+ } catch (e) {
+ return false;
+ }
+ }
+ } else {
+ // Return "true" if there is no subnet - we only need this validation when reserving a range for a subnet
+ return true;
+ }
+ },
+ }),
+ });
+
+ const getInitialValues = () => {
+ let initialComment = "";
+ if (showDynamicComment) {
+ initialComment = "Dynamic";
+ } else if (isEditing) {
+ initialComment = ipRange?.comment ?? "";
+ }
+
+ let startIp = "";
+ let endIp = "";
+
+ if (isEditing && ipRange) {
+ if (subnet) {
+ const networkAddress = subnet.cidr.split("/")[0];
+ const subnetIsIpv4 = isIPv4(networkAddress);
+ const [firstIP, lastIp] = getIpRangeFromCidr(subnet.cidr);
+ const [immutableOctets, _] = getImmutableAndEditableOctets(
+ firstIP,
+ lastIp
+ );
+
+ startIp = subnetIsIpv4
+ ? ipRange?.start_ip.replace(`${immutableOctets}.`, "")
+ : ipRange?.start_ip.replace(`${networkAddress}`, "");
+ endIp = subnetIsIpv4
+ ? ipRange?.end_ip.replace(`${immutableOctets}.`, "")
+ : ipRange?.end_ip.replace(`${networkAddress}`, "");
+ } else {
+ startIp = ipRange.start_ip;
+ endIp = ipRange.end_ip;
+ }
+ }
+
+ return {
+ comment: initialComment,
+ end_ip: endIp,
+ start_ip: startIp,
+ };
+ };
+
return (
aria-label={isEditing ? Labels.EditRange : Labels.CreateRange}
cleanup={cleanup}
errors={errors}
- initialValues={{
- comment: initialComment,
- end_ip: ipRange?.end_ip ?? "",
- start_ip: ipRange?.start_ip ?? "",
- }}
+ initialValues={getInitialValues()}
onCancel={onClose}
onSaveAnalytics={{
action: "Save reserved range",
@@ -110,6 +232,14 @@ const ReservedRangeForm = ({
label: `${isEditing ? "Edit" : "Create"} reserved range form`,
}}
onSubmit={(values) => {
+ // If a subnet is provided, PrefixedIpInput fields are used and the IP addresses need to be formatted
+ const startIp = subnet
+ ? formatIpAddress(values.start_ip, subnet.cidr)
+ : values.start_ip;
+ const endIp = subnet
+ ? formatIpAddress(values.end_ip, subnet.cidr)
+ : values.end_ip;
+
// Clear the errors from the previous submission.
dispatch(cleanup());
if (!isEditing && computedCreateType) {
@@ -117,14 +247,17 @@ const ReservedRangeForm = ({
ipRangeActions.create({
subnet: subnetId,
type: computedCreateType,
- ...values,
+ start_ip: startIp,
+ end_ip: endIp,
+ comment: values.comment,
})
);
} else if (isEditing && ipRange) {
dispatch(
ipRangeActions.update({
[IPRangeMeta.PK]: ipRange[IPRangeMeta.PK],
- ...values,
+ start_ip: startIp,
+ end_ip: endIp,
// Reset the value of the comment field so that "Dynamic" isn't stored.
comment: showDynamicComment ? ipRange?.comment : values.comment,
})
@@ -136,26 +269,51 @@ const ReservedRangeForm = ({
saved={saved}
saving={saving}
submitLabel={isEditing ? "Save" : "Reserve"}
- validationSchema={Schema}
+ validationSchema={ReservedRangeSchema}
{...props}
>
-
-
-
-
-
-
+ {subnet ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+
+ >
+ )}
{isEditing || computedCreateType === IPRangeType.Reserved ? (