diff --git a/api/src/main/java/com/cloud/network/Network.java b/api/src/main/java/com/cloud/network/Network.java index 2f0bcdd5ef9a..da3eda036e0a 100644 --- a/api/src/main/java/com/cloud/network/Network.java +++ b/api/src/main/java/com/cloud/network/Network.java @@ -461,6 +461,16 @@ public void setIp6Address(String ip6Address) { void setCidr(String cidr); + // For an isolated guest network, an optional custom DHCP range within the CIDR that guest IPs + // are allocated from; null means the whole CIDR is used, which is the default. + String getDhcpStartIp(); + + void setDhcpStartIp(String dhcpStartIp); + + String getDhcpEndIp(); + + void setDhcpEndIp(String dhcpEndIp); + // "networkcidr" is the network CIDR of the guest network which uses IP reservation. // It is the summation of "cidr" and the reservedIPrange(the address space used for non CloudStack purposes). // For networks not configured with IP reservation, "networkcidr" is always null diff --git a/api/src/main/java/com/cloud/network/NetworkProfile.java b/api/src/main/java/com/cloud/network/NetworkProfile.java index d690344a0e38..689bbc47bd90 100644 --- a/api/src/main/java/com/cloud/network/NetworkProfile.java +++ b/api/src/main/java/com/cloud/network/NetworkProfile.java @@ -44,6 +44,8 @@ public class NetworkProfile implements Network { private TrafficType trafficType; private String gateway; private String cidr; + private String dhcpStartIp; + private String dhcpEndIp; private final String networkCidr; private final String ip6Gateway; private final String ip6Cidr; @@ -79,6 +81,8 @@ public NetworkProfile(Network network) { gateway = network.getGateway(); cidr = network.getCidr(); networkCidr = network.getNetworkCidr(); + dhcpStartIp = network.getDhcpStartIp(); + dhcpEndIp = network.getDhcpEndIp(); ip6Gateway = network.getIp6Gateway(); ip6Cidr = network.getIp6Cidr(); networkOfferingId = network.getNetworkOfferingId(); @@ -228,6 +232,26 @@ public void setCidr(String cidr) { this.cidr = cidr; } + @Override + public String getDhcpStartIp() { + return dhcpStartIp; + } + + @Override + public void setDhcpStartIp(String dhcpStartIp) { + this.dhcpStartIp = dhcpStartIp; + } + + @Override + public String getDhcpEndIp() { + return dhcpEndIp; + } + + @Override + public void setDhcpEndIp(String dhcpEndIp) { + this.dhcpEndIp = dhcpEndIp; + } + @Override public String getNetworkCidr() { return networkCidr; diff --git a/api/src/main/java/org/apache/cloudstack/api/response/NetworkResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/NetworkResponse.java index aeaf333540c7..13651cb303dc 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/NetworkResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/NetworkResponse.java @@ -67,6 +67,14 @@ public class NetworkResponse extends BaseResponseWithAssociatedNetwork implement @Param(description = "CloudStack managed address space, all CloudStack managed Instances get IP address from CIDR") private String cidr; + @SerializedName(ApiConstants.START_IP) + @Param(description = "the start of the custom DHCP range for an isolated network, within its CIDR", since = "4.24.0") + private String dhcpStartIp; + + @SerializedName(ApiConstants.END_IP) + @Param(description = "the end of the custom DHCP range for an isolated network, within its CIDR", since = "4.24.0") + private String dhcpEndIp; + @SerializedName(ApiConstants.NETWORK_CIDR) @Param(description = "The Network CIDR of the guest Network configured with IP reservation. It is the summation of CIDR and RESERVED_IP_RANGE") private String networkCidr; @@ -511,6 +519,14 @@ public void setCidr(String cidr) { this.cidr = cidr; } + public void setDhcpStartIp(String dhcpStartIp) { + this.dhcpStartIp = dhcpStartIp; + } + + public void setDhcpEndIp(String dhcpEndIp) { + this.dhcpEndIp = dhcpEndIp; + } + public void setNetworkCidr(String networkCidr) { this.networkCidr = networkCidr; } diff --git a/engine/schema/src/main/java/com/cloud/network/dao/NetworkVO.java b/engine/schema/src/main/java/com/cloud/network/dao/NetworkVO.java index f2572ba91c21..c17b5c407c29 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/NetworkVO.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/NetworkVO.java @@ -82,6 +82,12 @@ public class NetworkVO implements Network { @Column(name = "network_cidr") String networkCidr; + @Column(name = "dhcp_start_ip") + String dhcpStartIp; + + @Column(name = "dhcp_end_ip") + String dhcpEndIp; + @Column(name = "network_offering_id") long networkOfferingId; @@ -470,6 +476,26 @@ public void setCidr(String cidr) { this.cidr = cidr; } + @Override + public String getDhcpStartIp() { + return dhcpStartIp; + } + + @Override + public void setDhcpStartIp(String dhcpStartIp) { + this.dhcpStartIp = dhcpStartIp; + } + + @Override + public String getDhcpEndIp() { + return dhcpEndIp; + } + + @Override + public void setDhcpEndIp(String dhcpEndIp) { + this.dhcpEndIp = dhcpEndIp; + } + // "networkcidr" is the network CIDR of the guest network which is configured with IP reservation feature // It is the summation of "cidr" and the reservedIPrange(the address space used for non cloudstack purposes.) // For networks not using IP reservation "networkcidr" is always null diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql b/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql index 7c11013a17d2..5e728ba336ad 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42300to2400.sql @@ -18,3 +18,7 @@ --; -- Schema upgrade from 4.23.0.0 to 24.0.0 --; + +-- Custom DHCP (guest IP allocation) range for isolated guest networks +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.networks', 'dhcp_start_ip', 'VARCHAR(15) DEFAULT NULL COMMENT ''start of the custom DHCP range for an isolated network, within its CIDR'' '); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.networks', 'dhcp_end_ip', 'VARCHAR(15) DEFAULT NULL COMMENT ''end of the custom DHCP range for an isolated network, within its CIDR'' '); diff --git a/server/src/main/java/com/cloud/api/ApiResponseHelper.java b/server/src/main/java/com/cloud/api/ApiResponseHelper.java index f56cda6e557a..d9a4bbf88285 100644 --- a/server/src/main/java/com/cloud/api/ApiResponseHelper.java +++ b/server/src/main/java/com/cloud/api/ApiResponseHelper.java @@ -2592,6 +2592,8 @@ public NetworkResponse createNetworkResponse(ResponseView view, Network network) // FIXME - either set netmask or cidr response.setCidr(cidr); + response.setDhcpStartIp(network.getDhcpStartIp()); + response.setDhcpEndIp(network.getDhcpEndIp()); if (network.getNetworkCidr() != null) { response.setNetworkCidr((network.getNetworkCidr())); } diff --git a/server/src/main/java/com/cloud/network/NetworkModelImpl.java b/server/src/main/java/com/cloud/network/NetworkModelImpl.java index f47046cdc434..cd9273cff6db 100644 --- a/server/src/main/java/com/cloud/network/NetworkModelImpl.java +++ b/server/src/main/java/com/cloud/network/NetworkModelImpl.java @@ -2333,6 +2333,12 @@ public Set getAvailableIps(Network network, String requestedIp) { if ((gateway != null) && (allPossibleIps.contains(NetUtils.ip2Long(gateway)))) allPossibleIps.remove(NetUtils.ip2Long(gateway)); + if (StringUtils.isNoneBlank(network.getDhcpStartIp(), network.getDhcpEndIp())) { + long start = NetUtils.ip2Long(network.getDhcpStartIp()); + long end = NetUtils.ip2Long(network.getDhcpEndIp()); + allPossibleIps.removeIf(ip -> ip < start || ip > end); + } + return allPossibleIps; } diff --git a/server/src/main/java/com/cloud/network/NetworkServiceImpl.java b/server/src/main/java/com/cloud/network/NetworkServiceImpl.java index 2853fa96330d..02db545cdde1 100644 --- a/server/src/main/java/com/cloud/network/NetworkServiceImpl.java +++ b/server/src/main/java/com/cloud/network/NetworkServiceImpl.java @@ -1838,6 +1838,12 @@ public Network createGuestNetwork(CreateNetworkCmd cmd) throws InsufficientCapac domainId, isDomainSpecific, subdomainAccess, vpcId, startIPv6, endIPv6, ip6Gateway, ip6Cidr, displayNetwork, aclId, secondaryVlanId, privateVlanType, ntwkOff, pNtwk, aclType, owner, cidr, createVlan, externalId, routerIPv4, routerIPv6, associatedNetwork, ip4Dns1, ip4Dns2, ip6Dns1, ip6Dns2, interfaceMTUs, networkCidrSize, keepMacAddressOnPublicNic); + // For an isolated network a start/end IP defines a custom DHCP range within the CIDR that + // guest IPs are allocated from; without it the whole CIDR is used, which is the default. + if (ntwkOff.getGuestType() == GuestType.Isolated && StringUtils.isNotBlank(startIP)) { + storeIsolatedNetworkDhcpRange(network.getId(), startIP, endIP); + } + // retrieve, acquire and associate the correct IP addresses checkAndSetRouterSourceNatIp(owner, cmd, network); @@ -1872,6 +1878,27 @@ private boolean isNonVpcNetworkSupportingDynamicRouting(NetworkOffering networkO return !networkOffering.isForVpc() && NetworkOffering.RoutingMode.Dynamic == networkOffering.getRoutingMode(); } + protected void storeIsolatedNetworkDhcpRange(long networkId, String startIP, String endIP) { + NetworkVO network = _networksDao.findById(networkId); + String cidr = network.getCidr(); + if (endIP == null) { + endIP = startIP; + } + if (!NetUtils.isIpWithInCidrRange(startIP, cidr) || !NetUtils.isIpWithInCidrRange(endIP, cidr)) { + throw new InvalidParameterValueException(String.format("The DHCP range %s-%s is not within the network CIDR %s", startIP, endIP, cidr)); + } + if (NetUtils.ip2Long(startIP) > NetUtils.ip2Long(endIP)) { + throw new InvalidParameterValueException(String.format("The DHCP start IP %s is greater than the end IP %s", startIP, endIP)); + } + String gateway = network.getGateway(); + if (gateway != null && NetUtils.ip2Long(gateway) >= NetUtils.ip2Long(startIP) && NetUtils.ip2Long(gateway) <= NetUtils.ip2Long(endIP)) { + throw new InvalidParameterValueException(String.format("The DHCP range %s-%s must not include the gateway %s", startIP, endIP, gateway)); + } + network.setDhcpStartIp(startIP); + network.setDhcpEndIp(endIP); + _networksDao.update(networkId, network); + } + private void validateNetworkCreationSupported(long zoneId, String zoneName, GuestType guestType) { NsxProviderVO nsxProviderVO = nsxProviderDao.findByZoneId(zoneId); if (Objects.nonNull(nsxProviderVO) && GuestType.L2.equals(guestType)) { diff --git a/server/src/test/java/com/cloud/network/NetworkModelImplTest.java b/server/src/test/java/com/cloud/network/NetworkModelImplTest.java index 7810662c617b..9f4ffe200d3f 100644 --- a/server/src/test/java/com/cloud/network/NetworkModelImplTest.java +++ b/server/src/test/java/com/cloud/network/NetworkModelImplTest.java @@ -69,6 +69,7 @@ import com.cloud.offerings.dao.NetworkOfferingServiceMapDao; import com.cloud.utils.Pair; import com.cloud.utils.net.Ip; +import com.cloud.utils.net.NetUtils; import com.cloud.vm.Nic; import com.cloud.vm.NicProfile; import com.cloud.vm.VirtualMachine; @@ -452,4 +453,43 @@ public void listSupportedNetworkServiceProvidersExcludesExtensionBackedProviders Mockito.verify(physicalNetworkServiceProviderDao, Mockito.times(1)).listAll(); Mockito.verify(physicalNetworkServiceProviderDao, Mockito.never()).listBy(Mockito.anyLong()); } + + private NetworkVO networkWithDhcpRange(String cidr, String gateway, String dhcpStart, String dhcpEnd) { + NetworkVO network = mock(NetworkVO.class); + when(network.getCidr()).thenReturn(cidr); + when(network.getGateway()).thenReturn(gateway); + when(network.getDhcpStartIp()).thenReturn(dhcpStart); + when(network.getDhcpEndIp()).thenReturn(dhcpEnd); + Mockito.doReturn(new ArrayList()).when(networkModel).getUsedIpsInNetwork(network); + return network; + } + + @Test + public void getAvailableIpsRestrictsToTheDhcpRangeWhenSet() { + NetworkVO network = networkWithDhcpRange("10.1.1.0/24", "10.1.1.1", "10.1.1.10", "10.1.1.20"); + + Set ips = networkModel.getAvailableIps(network, null); + + long start = NetUtils.ip2Long("10.1.1.10"); + long end = NetUtils.ip2Long("10.1.1.20"); + for (Long ip : ips) { + assertTrue(ip >= start && ip <= end); + } + assertEquals(11, ips.size()); + assertTrue(ips.contains(NetUtils.ip2Long("10.1.1.10"))); + assertTrue(ips.contains(NetUtils.ip2Long("10.1.1.20"))); + assertFalse(ips.contains(NetUtils.ip2Long("10.1.1.9"))); + assertFalse(ips.contains(NetUtils.ip2Long("10.1.1.21"))); + } + + @Test + public void getAvailableIpsUsesTheWholeCidrWhenNoDhcpRangeIsSet() { + NetworkVO network = networkWithDhcpRange("10.1.1.0/24", "10.1.1.1", null, null); + + Set ips = networkModel.getAvailableIps(network, null); + + assertTrue(ips.contains(NetUtils.ip2Long("10.1.1.10"))); + assertTrue(ips.contains(NetUtils.ip2Long("10.1.1.250"))); + assertFalse(ips.contains(NetUtils.ip2Long("10.1.1.1"))); + } } diff --git a/server/src/test/java/com/cloud/network/NetworkServiceImplTest.java b/server/src/test/java/com/cloud/network/NetworkServiceImplTest.java index cd7d40d68951..882f73b91c92 100644 --- a/server/src/test/java/com/cloud/network/NetworkServiceImplTest.java +++ b/server/src/test/java/com/cloud/network/NetworkServiceImplTest.java @@ -1378,4 +1378,51 @@ public void getAndValidateSupportForKeepMacAddressOnPublicNicParameterTestReturn Assert.assertFalse(service.getAndValidateSupportForKeepMacAddressOnPublicNicParameter(false, networkOfferingVO)); } + + private NetworkVO isolatedNetworkForDhcpRange() { + NetworkVO network = Mockito.mock(NetworkVO.class); + Mockito.when(network.getCidr()).thenReturn("10.1.1.0/24"); + Mockito.when(network.getGateway()).thenReturn("10.1.1.1"); + Mockito.when(networkDao.findById(5L)).thenReturn(network); + return network; + } + + @Test + public void storeIsolatedNetworkDhcpRangeStoresAValidRange() { + NetworkVO network = isolatedNetworkForDhcpRange(); + + service.storeIsolatedNetworkDhcpRange(5L, "10.1.1.10", "10.1.1.20"); + + Mockito.verify(network).setDhcpStartIp("10.1.1.10"); + Mockito.verify(network).setDhcpEndIp("10.1.1.20"); + Mockito.verify(networkDao).update(5L, network); + } + + @Test + public void storeIsolatedNetworkDhcpRangeDefaultsEndToStart() { + NetworkVO network = isolatedNetworkForDhcpRange(); + + service.storeIsolatedNetworkDhcpRange(5L, "10.1.1.10", null); + + Mockito.verify(network).setDhcpStartIp("10.1.1.10"); + Mockito.verify(network).setDhcpEndIp("10.1.1.10"); + } + + @Test(expected = InvalidParameterValueException.class) + public void storeIsolatedNetworkDhcpRangeRejectsRangeOutsideCidr() { + isolatedNetworkForDhcpRange(); + service.storeIsolatedNetworkDhcpRange(5L, "10.2.2.10", "10.2.2.20"); + } + + @Test(expected = InvalidParameterValueException.class) + public void storeIsolatedNetworkDhcpRangeRejectsStartGreaterThanEnd() { + isolatedNetworkForDhcpRange(); + service.storeIsolatedNetworkDhcpRange(5L, "10.1.1.20", "10.1.1.10"); + } + + @Test(expected = InvalidParameterValueException.class) + public void storeIsolatedNetworkDhcpRangeRejectsRangeIncludingGateway() { + isolatedNetworkForDhcpRange(); + service.storeIsolatedNetworkDhcpRange(5L, "10.1.1.1", "10.1.1.20"); + } } diff --git a/test/integration/smoke/test_isolated_network_dhcp_range.py b/test/integration/smoke/test_isolated_network_dhcp_range.py new file mode 100644 index 000000000000..48fbf585f8c4 --- /dev/null +++ b/test/integration/smoke/test_isolated_network_dhcp_range.py @@ -0,0 +1,103 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +""" Tests for a custom DHCP range on an isolated guest network +""" +from marvin.cloudstackTestCase import cloudstackTestCase +from marvin.lib.base import (Account, + Network, + NetworkOffering, + ServiceOffering, + VirtualMachine) +from marvin.lib.common import (get_domain, + get_zone, + get_template) +from marvin.lib.utils import cleanup_resources +from nose.plugins.attrib import attr + + +class TestIsolatedNetworkDhcpRange(cloudstackTestCase): + + @classmethod + def setUpClass(cls): + cls.testClient = super(TestIsolatedNetworkDhcpRange, cls).getClsTestClient() + cls.apiclient = cls.testClient.getApiClient() + cls.services = cls.testClient.getParsedTestDataConfig() + cls.domain = get_domain(cls.apiclient) + cls.zone = get_zone(cls.apiclient, cls.testClient.getZoneForTests()) + cls.template = get_template(cls.apiclient, cls.zone.id, cls.services["ostype"]) + cls.services["virtual_machine"]["zoneid"] = cls.zone.id + cls.services["virtual_machine"]["template"] = cls.template.id + + cls._cleanup = [] + cls.account = Account.create(cls.apiclient, cls.services["account"], admin=True, domainid=cls.domain.id) + cls._cleanup.append(cls.account) + cls.service_offering = ServiceOffering.create(cls.apiclient, cls.services["service_offerings"]["tiny"]) + cls._cleanup.append(cls.service_offering) + cls.network_offering = NetworkOffering.create(cls.apiclient, cls.services["isolated_network_offering"]) + cls.network_offering.update(cls.apiclient, state="Enabled") + cls._cleanup.append(cls.network_offering) + + @classmethod + def tearDownClass(cls): + cleanup_resources(cls.apiclient, cls._cleanup) + + @attr(tags=["advanced"], required_hardware="false") + def test_isolated_network_custom_dhcp_range(self): + """A VM on an isolated network with a custom DHCP range gets an IP within it""" + gateway = "10.99.99.1" + start_ip = "10.99.99.10" + end_ip = "10.99.99.20" + network_services = dict(self.services["network"]) + network_services["gateway"] = gateway + network_services["netmask"] = "255.255.255.0" + network_services["startip"] = start_ip + network_services["endip"] = end_ip + + network = Network.create( + self.apiclient, + network_services, + accountid=self.account.name, + domainid=self.account.domainid, + networkofferingid=self.network_offering.id, + zoneid=self.zone.id, + gateway=gateway, + netmask="255.255.255.0") + self.cleanup.append(network) + + listed = Network.list(self.apiclient, id=network.id)[0] + self.assertEqual(listed.startip, start_ip, "listNetworks should return the DHCP start IP") + self.assertEqual(listed.endip, end_ip, "listNetworks should return the DHCP end IP") + + vm = VirtualMachine.create( + self.apiclient, + self.services["virtual_machine"], + accountid=self.account.name, + domainid=self.account.domainid, + serviceofferingid=self.service_offering.id, + networkids=[network.id]) + self.cleanup.append(vm) + + octet = int(vm.nic[0].ipaddress.split(".")[-1]) + self.assertTrue( + 10 <= octet <= 20 and vm.nic[0].ipaddress.startswith("10.99.99."), + "VM IP %s is not within the custom DHCP range %s-%s" % (vm.nic[0].ipaddress, start_ip, end_ip)) + + def setUp(self): + self.cleanup = [] + + def tearDown(self): + cleanup_resources(self.apiclient, self.cleanup) diff --git a/ui/src/config/section/network.js b/ui/src/config/section/network.js index 1235dca8ceab..74f5c4eacc06 100644 --- a/ui/src/config/section/network.js +++ b/ui/src/config/section/network.js @@ -50,7 +50,7 @@ export default { return fields }, details: () => { - const fields = ['name', 'id', 'description', 'type', 'traffictype', 'vpcid', 'vlan', 'cidr', 'ip6cidr', 'netmask', 'gateway', 'asnumber', + const fields = ['name', 'id', 'description', 'type', 'traffictype', 'vpcid', 'vlan', 'cidr', 'ip6cidr', 'netmask', 'gateway', 'startip', 'endip', 'asnumber', 'aclname', 'ispersistent', 'restartrequired', 'reservediprange', 'redundantrouter', 'networkdomain', 'egressdefaultpolicy', 'zonename', 'account', 'domainpath', 'associatednetwork', 'associatednetworkid', 'ip4routing', 'ip6firewall', 'ip6routing', 'ip6routes', 'dns1', 'dns2', 'ip6dns1', 'ip6dns2', 'publicmtu', 'privatemtu', 'dnszone', 'dnssubdomain'] diff --git a/ui/src/views/network/CreateIsolatedNetworkForm.vue b/ui/src/views/network/CreateIsolatedNetworkForm.vue index 75ac30359e98..37c8cc0a02fa 100644 --- a/ui/src/views/network/CreateIsolatedNetworkForm.vue +++ b/ui/src/views/network/CreateIsolatedNetworkForm.vue @@ -229,7 +229,7 @@ v-model:value="form.cidrsize" :placeholder="apiParams.cidrsize.description"/> - + @@ -237,7 +237,7 @@ v-model:value="form.startip" :placeholder="apiParams.startip.description"/> - +