From 6a53517d37b8372df070cd3e12688d1c2a677cf8 Mon Sep 17 00:00:00 2001 From: Wei Zhou Date: Thu, 17 Mar 2022 18:35:40 +0100 Subject: [PATCH] New feature: Reserve and release Public IPs (#6046) * Reserve and release a public IP * Update #6046: show orange color for Reserved public ip * Update #6046 reserve IP: fix ui conflicts * Update #6046: fix resource count * Update #6046: associate Reserved public IP to network * Update #6046: fix unit tests * Update #6046: fix ui bugs * Update #6046: make api/ui available for domain admin and users --- .../main/java/com/cloud/event/EventTypes.java | 1 + .../java/com/cloud/network/IpAddress.java | 1 + .../com/cloud/network/NetworkService.java | 4 + .../user/address/ReleaseIPAddrCmd.java | 104 ++++++ .../user/address/ReserveIPAddrCmd.java | 164 +++++++++ .../com/cloud/network/dao/IPAddressDao.java | 2 + .../cloud/network/dao/IPAddressDaoImpl.java | 20 ++ .../cloud/network/IpAddressManagerImpl.java | 10 +- .../com/cloud/network/NetworkServiceImpl.java | 112 ++++++ .../cloud/server/ManagementServerImpl.java | 16 +- .../com/cloud/vpc/MockNetworkManagerImpl.java | 10 + ui/public/locales/en.json | 5 + ui/src/components/widgets/Status.vue | 2 + ui/src/config/section/network.js | 25 +- ui/src/views/AutogenView.vue | 8 +- ui/src/views/network/IpAddressesTab.vue | 9 +- ui/src/views/network/PublicIpResource.vue | 5 + ui/src/views/network/ReservePublicIP.vue | 332 ++++++++++++++++++ ui/tests/mockData/Status.mock.json | 3 +- 19 files changed, 819 insertions(+), 14 deletions(-) create mode 100644 api/src/main/java/org/apache/cloudstack/api/command/user/address/ReleaseIPAddrCmd.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/command/user/address/ReserveIPAddrCmd.java create mode 100644 ui/src/views/network/ReservePublicIP.vue diff --git a/api/src/main/java/com/cloud/event/EventTypes.java b/api/src/main/java/com/cloud/event/EventTypes.java index 84514493e94..3e6bb1a579c 100644 --- a/api/src/main/java/com/cloud/event/EventTypes.java +++ b/api/src/main/java/com/cloud/event/EventTypes.java @@ -133,6 +133,7 @@ public class EventTypes { // Network Events public static final String EVENT_NET_IP_ASSIGN = "NET.IPASSIGN"; public static final String EVENT_NET_IP_RELEASE = "NET.IPRELEASE"; + public static final String EVENT_NET_IP_RESERVE = "NET.IPRESERVE"; public static final String EVENT_NET_IP_UPDATE = "NET.IPUPDATE"; public static final String EVENT_PORTABLE_IP_ASSIGN = "PORTABLE.IPASSIGN"; public static final String EVENT_PORTABLE_IP_RELEASE = "PORTABLE.IPRELEASE"; diff --git a/api/src/main/java/com/cloud/network/IpAddress.java b/api/src/main/java/com/cloud/network/IpAddress.java index 2447809d66f..cf2e2f82db9 100644 --- a/api/src/main/java/com/cloud/network/IpAddress.java +++ b/api/src/main/java/com/cloud/network/IpAddress.java @@ -41,6 +41,7 @@ public interface IpAddress extends ControlledEntity, Identity, InternalIdentity, Allocating, // The IP Address is being propagated to other network elements and is not ready for use yet. Allocated, // The IP address is in used. Releasing, // The IP address is being released for other network elements and is not ready for allocation. + Reserved, // The IP address is reserved and is not ready for allocation. Free // The IP address is ready to be allocated. } diff --git a/api/src/main/java/com/cloud/network/NetworkService.java b/api/src/main/java/com/cloud/network/NetworkService.java index 297bcbdc8a8..fe89cb5e640 100644 --- a/api/src/main/java/com/cloud/network/NetworkService.java +++ b/api/src/main/java/com/cloud/network/NetworkService.java @@ -59,6 +59,10 @@ public interface NetworkService { IpAddress allocateIP(Account ipOwner, long zoneId, Long networkId, Boolean displayIp, String ipaddress) throws ResourceAllocationException, InsufficientAddressCapacityException, ConcurrentOperationException; + IpAddress reserveIpAddress(Account account, Boolean displayIp, Long ipAddressId) throws ResourceAllocationException; + + boolean releaseReservedIpAddress(long ipAddressId) throws InsufficientAddressCapacityException; + boolean releaseIpAddress(long ipAddressId) throws InsufficientAddressCapacityException; IpAddress allocatePortableIP(Account ipOwner, int regionId, Long zoneId, Long networkId, Long vpcId) throws ResourceAllocationException, diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/address/ReleaseIPAddrCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/address/ReleaseIPAddrCmd.java new file mode 100644 index 00000000000..ba8a512b183 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/address/ReleaseIPAddrCmd.java @@ -0,0 +1,104 @@ +// 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. +package org.apache.cloudstack.api.command.user.address; + +import org.apache.log4j.Logger; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.IPAddressResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.context.CallContext; + +import com.cloud.exception.InsufficientAddressCapacityException; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.network.IpAddress; + +@APICommand(name = "releaseIpAddress", + description = "Releases an IP address from the account.", + since = "4.17", + responseObject = SuccessResponse.class, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class ReleaseIPAddrCmd extends BaseCmd { + public static final Logger s_logger = Logger.getLogger(ReleaseIPAddrCmd.class.getName()); + + private static final String s_name = "releaseipaddressresponse"; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = IPAddressResponse.class, required = true, description = "the ID of the public IP address" + + " to release") + private Long id; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getIpAddressId() { + return id; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public String getCommandName() { + return s_name; + } + + @Override + public long getEntityOwnerId() { + IpAddress ip = getIpAddress(id); + if (ip == null) { + throw new InvalidParameterValueException("Unable to find IP address by ID=" + id); + } + return ip.getAccountId(); + } + + @Override + public void execute() throws InsufficientAddressCapacityException { + CallContext.current().setEventDetails("IP ID: " + getIpAddressId()); + boolean result = _networkService.releaseReservedIpAddress(getIpAddressId()); + if (result) { + SuccessResponse response = new SuccessResponse(getCommandName()); + this.setResponseObject(response); + } else { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to release IP address"); + } + } + + private IpAddress getIpAddress(long id) { + IpAddress ip = _entityMgr.findById(IpAddress.class, id); + + if (ip == null) { + throw new InvalidParameterValueException("Unable to find IP address by ID=" + id); + } else { + return ip; + } + } + +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/address/ReserveIPAddrCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/address/ReserveIPAddrCmd.java new file mode 100644 index 00000000000..7280e785093 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/address/ReserveIPAddrCmd.java @@ -0,0 +1,164 @@ +// 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. +package org.apache.cloudstack.api.command.user.address; + +import org.apache.log4j.Logger; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ResponseObject.ResponseView; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.command.user.UserCmd; +import org.apache.cloudstack.api.response.DomainResponse; +import org.apache.cloudstack.api.response.IPAddressResponse; +import org.apache.cloudstack.api.response.ProjectResponse; +import org.apache.cloudstack.context.CallContext; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.PermissionDeniedException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.network.IpAddress; +import com.cloud.projects.Project; +import com.cloud.user.Account; + +@APICommand(name = "reserveIpAddress", + description = "Reserve a public IP to an account.", + since = "4.17", + responseObject = IPAddressResponse.class, + responseView = ResponseView.Restricted, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class ReserveIPAddrCmd extends BaseCmd implements UserCmd { + public static final Logger s_logger = Logger.getLogger(ReserveIPAddrCmd.class.getName()); + private static final String s_name = "reserveipaddressresponse"; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.ACCOUNT, + type = CommandType.STRING, + description = "the account to reserve with this IP address") + private String accountName; + + @Parameter(name = ApiConstants.DOMAIN_ID, + type = CommandType.UUID, + entityType = DomainResponse.class, + description = "the ID of the domain to reserve with this IP address") + private Long domainId; + + @Parameter(name = ApiConstants.PROJECT_ID, + type = CommandType.UUID, + entityType = ProjectResponse.class, + description = "the ID of the project to reserve with this IP address") + private Long projectId; + + @Parameter(name = ApiConstants.FOR_DISPLAY, + type = CommandType.BOOLEAN, + description = "an optional field, whether to the display the IP to the end user or not", + authorized = {RoleType.Admin}) + private Boolean display; + + @Parameter(name = ApiConstants.ID, + type = CommandType.UUID, + entityType = IPAddressResponse.class, + required = true, + description = "the ID of the public IP address to reserve") + private Long id; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public String getAccountName() { + if (accountName != null) { + return accountName; + } + return CallContext.current().getCallingAccount().getAccountName(); + } + + public long getDomainId() { + if (domainId != null) { + return domainId; + } + return CallContext.current().getCallingAccount().getDomainId(); + } + + public Long getIpAddressId() { + return id; + } + + @Override + public boolean isDisplay() { + if (display == null) + return true; + else + return display; + } + + @Override + public long getEntityOwnerId() { + Account caller = CallContext.current().getCallingAccount(); + if (accountName != null && domainId != null) { + Account account = _accountService.finalizeOwner(caller, accountName, domainId, projectId); + return account.getId(); + } else if (projectId != null) { + Project project = _projectService.getProject(projectId); + if (project != null) { + if (project.getState() == Project.State.Active) { + return project.getProjectAccountId(); + } else { + throw new PermissionDeniedException("Can't add resources to the project with specified projectId in state=" + project.getState() + + " as it's no longer active"); + } + } else { + throw new InvalidParameterValueException("Unable to find project by ID"); + } + } + + return caller.getAccountId(); + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public String getCommandName() { + return s_name; + } + + @Override + public void execute() throws ResourceUnavailableException, ResourceAllocationException, ConcurrentOperationException { + IpAddress result = _networkService.reserveIpAddress(_accountService.getAccount(getEntityOwnerId()), isDisplay(), getIpAddressId()); + if (result != null) { + IPAddressResponse ipResponse = _responseGenerator.createIPAddressResponse(getResponseView(), result); + ipResponse.setResponseName(getCommandName()); + setResponseObject(ipResponse); + } else { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to reserve IP address"); + } + } + +} diff --git a/engine/schema/src/main/java/com/cloud/network/dao/IPAddressDao.java b/engine/schema/src/main/java/com/cloud/network/dao/IPAddressDao.java index 8ee23008d71..5e71d543333 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/IPAddressDao.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/IPAddressDao.java @@ -92,4 +92,6 @@ public interface IPAddressDao extends GenericDao { List listByAssociatedVmId(long vmId); IPAddressVO findByVmIdAndNetworkId(long networkId, long vmId); + + IPAddressVO findByAccountIdAndZoneIdAndStateAndIpAddress(long accountId, long dcId, State state, String ipAddress); } diff --git a/engine/schema/src/main/java/com/cloud/network/dao/IPAddressDaoImpl.java b/engine/schema/src/main/java/com/cloud/network/dao/IPAddressDaoImpl.java index 589f10a15f3..42359d06047 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/IPAddressDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/IPAddressDaoImpl.java @@ -62,6 +62,7 @@ public class IPAddressDaoImpl extends GenericDaoBase implemen @Inject protected VlanDao _vlanDao; protected GenericSearchBuilder CountFreePublicIps; + protected SearchBuilder PublicIpSearchByAccountAndState; @Inject ResourceTagDao _tagsDao; @Inject @@ -138,6 +139,7 @@ public class IPAddressDaoImpl extends GenericDaoBase implemen AllocatedIpCountForAccount.and("allocated", AllocatedIpCountForAccount.entity().getAllocatedTime(), Op.NNULL); AllocatedIpCountForAccount.and().op("network", AllocatedIpCountForAccount.entity().getAssociatedWithNetworkId(), Op.NNULL); AllocatedIpCountForAccount.or("vpc", AllocatedIpCountForAccount.entity().getVpcId(), Op.NNULL); + AllocatedIpCountForAccount.or("state", AllocatedIpCountForAccount.entity().getState(), Op.EQ); AllocatedIpCountForAccount.cp();AllocatedIpCountForAccount.done(); CountFreePublicIps = createSearchBuilder(Long.class); @@ -152,6 +154,13 @@ public class IPAddressDaoImpl extends GenericDaoBase implemen DeleteAllExceptGivenIp = createSearchBuilder(); DeleteAllExceptGivenIp.and("vlanDbId", DeleteAllExceptGivenIp.entity().getVlanId(), Op.EQ); DeleteAllExceptGivenIp.and("ip", DeleteAllExceptGivenIp.entity().getAddress(), Op.NEQ); + + PublicIpSearchByAccountAndState = createSearchBuilder(); + PublicIpSearchByAccountAndState.and("accountId", PublicIpSearchByAccountAndState.entity().getAllocatedToAccountId(), Op.EQ); + PublicIpSearchByAccountAndState.and("dcId", PublicIpSearchByAccountAndState.entity().getDataCenterId(), Op.EQ); + PublicIpSearchByAccountAndState.and("state", PublicIpSearchByAccountAndState.entity().getState(), Op.EQ); + PublicIpSearchByAccountAndState.and("allocated", PublicIpSearchByAccountAndState.entity().getAllocatedTime(), Op.NNULL); + PublicIpSearchByAccountAndState.and("ipAddress", PublicIpSearchByAccountAndState.entity().getAddress(), Op.EQ); } @Override @@ -368,6 +377,7 @@ public class IPAddressDaoImpl extends GenericDaoBase implemen public long countAllocatedIPsForAccount(long accountId) { SearchCriteria sc = AllocatedIpCountForAccount.create(); sc.setParameters("account", accountId); + sc.setParameters("state", State.Reserved); return customSearch(sc, null).get(0); } @@ -480,4 +490,14 @@ public class IPAddressDaoImpl extends GenericDaoBase implemen sc.setParameters("state", state); return listBy(sc); } + + @Override + public IPAddressVO findByAccountIdAndZoneIdAndStateAndIpAddress(long accountId, long dcId, State state, String ipAddress) { + SearchCriteria sc = PublicIpSearchByAccountAndState.create(); + sc.setParameters("accountId", accountId); + sc.setParameters("dcId", dcId); + sc.setParameters("state", state); + sc.setParameters("ipAddress", ipAddress); + return findOneBy(sc); + } } diff --git a/server/src/main/java/com/cloud/network/IpAddressManagerImpl.java b/server/src/main/java/com/cloud/network/IpAddressManagerImpl.java index 97ef0503b69..b1186148578 100644 --- a/server/src/main/java/com/cloud/network/IpAddressManagerImpl.java +++ b/server/src/main/java/com/cloud/network/IpAddressManagerImpl.java @@ -1239,6 +1239,13 @@ public class IpAddressManagerImpl extends ManagerBase implements IpAddressManage s_logger.debug("Associate IP address lock acquired"); } + if (ipaddress != null) { + IPAddressVO ipAddr = _ipAddressDao.findByAccountIdAndZoneIdAndStateAndIpAddress(ipOwner.getId(), zone.getId(), State.Reserved, ipaddress); + if (ipAddr != null) { + return PublicIp.createFromAddrAndVlan(ipAddr, _vlanDao.findById(ipAddr.getVlanId())); + } + } + ip = Transaction.execute(new TransactionCallbackWithException() { @Override public PublicIp doInTransaction(TransactionStatus status) throws InsufficientAddressCapacityException { @@ -1401,7 +1408,7 @@ public class IpAddressManagerImpl extends ManagerBase implements IpAddressManage } if (ipToAssoc.getAssociatedWithNetworkId() != null) { - s_logger.debug("IP " + ipToAssoc + " is already associated with network id" + networkId); + s_logger.debug("IP " + ipToAssoc + " is already associated with network id=" + networkId); return ipToAssoc; } @@ -1459,6 +1466,7 @@ public class IpAddressManagerImpl extends ManagerBase implements IpAddressManage IPAddressVO ip = _ipAddressDao.findById(ipId); //update ip address with networkId + ip.setState(State.Allocated); ip.setAssociatedWithNetworkId(networkId); ip.setSourceNat(isSourceNat); _ipAddressDao.update(ipId, ip); diff --git a/server/src/main/java/com/cloud/network/NetworkServiceImpl.java b/server/src/main/java/com/cloud/network/NetworkServiceImpl.java index c68bcbbc521..a4675792d48 100644 --- a/server/src/main/java/com/cloud/network/NetworkServiceImpl.java +++ b/server/src/main/java/com/cloud/network/NetworkServiceImpl.java @@ -28,6 +28,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Comparator; +import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -61,6 +62,7 @@ import org.apache.cloudstack.framework.config.dao.ConfigurationDao; import org.apache.cloudstack.framework.messagebus.MessageBus; import org.apache.cloudstack.framework.messagebus.PublishScope; import org.apache.cloudstack.network.element.InternalLoadBalancerElementService; +import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.apache.log4j.Logger; @@ -68,14 +70,18 @@ import com.cloud.api.ApiDBUtils; import com.cloud.configuration.Config; import com.cloud.configuration.ConfigurationManager; import com.cloud.configuration.Resource; +import com.cloud.dc.AccountVlanMapVO; import com.cloud.dc.DataCenter; import com.cloud.dc.DataCenter.NetworkType; import com.cloud.dc.DataCenterVO; import com.cloud.dc.DataCenterVnetVO; +import com.cloud.dc.DomainVlanMapVO; import com.cloud.dc.Vlan.VlanType; import com.cloud.dc.VlanVO; +import com.cloud.dc.dao.AccountVlanMapDao; import com.cloud.dc.dao.DataCenterDao; import com.cloud.dc.dao.DataCenterVnetDao; +import com.cloud.dc.dao.DomainVlanMapDao; import com.cloud.dc.dao.VlanDao; import com.cloud.deploy.DeployDestination; import com.cloud.domain.Domain; @@ -84,6 +90,7 @@ import com.cloud.domain.dao.DomainDao; import com.cloud.event.ActionEvent; import com.cloud.event.EventTypes; import com.cloud.event.UsageEventUtils; +import com.cloud.exception.AccountLimitException; import com.cloud.exception.ConcurrentOperationException; import com.cloud.exception.InsufficientAddressCapacityException; import com.cloud.exception.InsufficientCapacityException; @@ -303,6 +310,10 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C @Inject AccountGuestVlanMapDao _accountGuestVlanMapDao; @Inject + AccountVlanMapDao _accountVlanMapDao; + @Inject + DomainVlanMapDao _domainVlanMapDao; + @Inject VpcDao _vpcDao; @Inject NetworkACLDao _networkACLDao; @@ -915,6 +926,98 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C return nicSecIp; } + @Override + @ActionEvent(eventType = EventTypes.EVENT_NET_IP_RESERVE, eventDescription = "reserving Ip", async = false) + public IpAddress reserveIpAddress(Account account, Boolean displayIp, Long ipAddressId) throws ResourceAllocationException { + IPAddressVO ipVO = _ipAddressDao.findById(ipAddressId); + if (ipVO == null) { + throw new InvalidParameterValueException("Unable to find IP address by ID=" + ipAddressId); + } + // verify permissions + Account caller = CallContext.current().getCallingAccount(); + _accountMgr.checkAccess(caller, null, true, account); + + VlanVO vlan = _vlanDao.findById(ipVO.getVlanId()); + if (!vlan.getVlanType().equals(VlanType.VirtualNetwork)) { + throw new IllegalArgumentException("only ip addresses that belong to a virtual network may be reserved."); + } + if (ipVO.isPortable()) { + throw new InvalidParameterValueException("Unable to reserve a portable IP."); + } + if (State.Reserved.equals(ipVO.getState())) { + if (account.getId() == ipVO.getAccountId()) { + s_logger.info(String.format("IP address %s has already been reserved for account %s", ipVO.getAddress(), account)); + return ipVO; + } + throw new InvalidParameterValueException("Unable to reserve a IP because it has already been reserved for another account."); + } + if (!State.Free.equals(ipVO.getState())) { + throw new InvalidParameterValueException("Unable to reserve a IP in " + ipVO.getState() + " state."); + } + Long ipDedicatedDomainId = getIpDedicatedDomainId(ipVO.getVlanId()); + if (ipDedicatedDomainId != null && !ipDedicatedDomainId.equals(account.getDomainId())) { + throw new InvalidParameterValueException("Unable to reserve a IP because it is dedicated to another domain."); + } + Long ipDedicatedAccountId = getIpDedicatedAccountId(ipVO.getVlanId()); + if (ipDedicatedAccountId != null && !ipDedicatedAccountId.equals(account.getAccountId())) { + throw new InvalidParameterValueException("Unable to reserve a IP because it is dedicated to another account."); + } + if (ipDedicatedAccountId == null) { + // Check that the maximum number of public IPs for the given accountId will not be exceeded + try { + _resourceLimitMgr.checkResourceLimit(account, Resource.ResourceType.public_ip); + } catch (ResourceAllocationException ex) { + s_logger.warn("Failed to allocate resource of type " + ex.getResourceType() + " for account " + account); + throw new AccountLimitException("Maximum number of public IP addresses for account: " + account.getAccountName() + " has been exceeded."); + } + } + List maps = _accountVlanMapDao.listAccountVlanMapsByVlan(ipVO.getVlanId()); + ipVO.setAllocatedTime(new Date()); + ipVO.setAllocatedToAccountId(account.getAccountId()); + ipVO.setAllocatedInDomainId(account.getDomainId()); + ipVO.setState(State.Reserved); + if (displayIp != null) { + ipVO.setDisplay(displayIp); + } + ipVO = _ipAddressDao.persist(ipVO); + if (ipDedicatedAccountId == null) { + _resourceLimitMgr.incrementResourceCount(account.getId(), Resource.ResourceType.public_ip); + } + return ipVO; + } + + private Long getIpDedicatedAccountId(Long vlanId) { + List accountVlanMaps = _accountVlanMapDao.listAccountVlanMapsByVlan(vlanId); + if (CollectionUtils.isNotEmpty(accountVlanMaps)) { + return accountVlanMaps.get(0).getAccountId(); + } + return null; + } + + private Long getIpDedicatedDomainId(Long vlanId) { + List domainVlanMaps = _domainVlanMapDao.listDomainVlanMapsByVlan(vlanId); + if (CollectionUtils.isNotEmpty(domainVlanMaps)) { + return domainVlanMaps.get(0).getDomainId(); + } + return null; + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_NET_IP_RELEASE, eventDescription = "releasing Reserved Ip", async = false) + public boolean releaseReservedIpAddress(long ipAddressId) throws InsufficientAddressCapacityException { + IPAddressVO ipVO = _ipAddressDao.findById(ipAddressId); + if (ipVO == null) { + throw new InvalidParameterValueException("Unable to find IP address by ID=" + ipAddressId); + } + if (ipVO.isPortable()) { + throw new InvalidParameterValueException("Unable to release a portable IP, please use disassociateIpAddress instead"); + } + if (State.Allocated.equals(ipVO.getState())) { + throw new InvalidParameterValueException("Unable to release a public IP in Allocated state, please use disassociateIpAddress instead"); + } + return releaseIpAddressInternal(ipAddressId); + } + @Override @ActionEvent(eventType = EventTypes.EVENT_NET_IP_RELEASE, eventDescription = "disassociating Ip", async = true) public boolean releaseIpAddress(long ipAddressId) throws InsufficientAddressCapacityException { @@ -961,6 +1064,15 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C throwInvalidIdException("Can't release system IP address with specified id", ipVO.getUuid(), "systemIpAddrId"); } + if (State.Reserved.equals(ipVO.getState())) { + _ipAddressDao.unassignIpAddress(ipVO.getId()); + Long ipDedicatedAccountId = getIpDedicatedAccountId(ipVO.getVlanId()); + if (ipDedicatedAccountId == null) { + _resourceLimitMgr.decrementResourceCount(ipVO.getAccountId(), Resource.ResourceType.public_ip); + } + return true; + } + boolean success = _ipAddrMgr.disassociatePublicIpAddress(ipAddressId, userId, caller); if (success) { diff --git a/server/src/main/java/com/cloud/server/ManagementServerImpl.java b/server/src/main/java/com/cloud/server/ManagementServerImpl.java index b71bfc85fd6..2dcb9d85e83 100644 --- a/server/src/main/java/com/cloud/server/ManagementServerImpl.java +++ b/server/src/main/java/com/cloud/server/ManagementServerImpl.java @@ -316,6 +316,8 @@ import org.apache.cloudstack.api.command.user.account.ListProjectAccountsCmd; import org.apache.cloudstack.api.command.user.address.AssociateIPAddrCmd; import org.apache.cloudstack.api.command.user.address.DisassociateIPAddrCmd; import org.apache.cloudstack.api.command.user.address.ListPublicIpAddressesCmd; +import org.apache.cloudstack.api.command.user.address.ReleaseIPAddrCmd; +import org.apache.cloudstack.api.command.user.address.ReserveIPAddrCmd; import org.apache.cloudstack.api.command.user.address.UpdateIPAddrCmd; import org.apache.cloudstack.api.command.user.affinitygroup.CreateAffinityGroupCmd; import org.apache.cloudstack.api.command.user.affinitygroup.DeleteAffinityGroupCmd; @@ -2147,13 +2149,13 @@ public class ManagementServerImpl extends ManagerBase implements ManagementServe final String state = cmd.getState(); Boolean isAllocated = cmd.isAllocatedOnly(); if (isAllocated == null) { - if (state != null && state.equalsIgnoreCase(IpAddress.State.Free.name())) { + if (state != null && (state.equalsIgnoreCase(IpAddress.State.Free.name()) || state.equalsIgnoreCase(IpAddress.State.Reserved.name()))) { isAllocated = Boolean.FALSE; } else { isAllocated = Boolean.TRUE; // default } } else { - if (state != null && state.equalsIgnoreCase(IpAddress.State.Free.name())) { + if (state != null && (state.equalsIgnoreCase(IpAddress.State.Free.name()) || state.equalsIgnoreCase(IpAddress.State.Reserved.name()))) { if (isAllocated) { throw new InvalidParameterValueException("Conflict: allocatedonly is true but state is Free"); } @@ -2242,7 +2244,7 @@ public class ManagementServerImpl extends ManagerBase implements ManagementServe buildParameters(sb, cmd, vlanType == VlanType.VirtualNetwork ? true : isAllocated); SearchCriteria sc = sb.create(); - setParameters(sc, cmd, vlanType); + setParameters(sc, cmd, vlanType, isAllocated); if (isAllocated || (vlanType == VlanType.VirtualNetwork && (caller.getType() != Account.Type.ADMIN || cmd.getDomainId() != null))) { _accountMgr.buildACLSearchCriteria(sc, domainId, isRecursive, permittedAccounts, listProjectResourcesCriteria); @@ -2305,7 +2307,7 @@ public class ManagementServerImpl extends ManagerBase implements ManagementServe sb2.and("ids", sb2.entity().getId(), SearchCriteria.Op.IN); SearchCriteria sc2 = sb2.create(); - setParameters(sc2, cmd, vlanType); + setParameters(sc2, cmd, vlanType, isAllocated); sc2.setParameters("ids", freeAddrIds.toArray()); addrs.addAll(_publicIpAddressDao.search(sc2, searchFilter)); // Allocated + Free } @@ -2369,7 +2371,7 @@ public class ManagementServerImpl extends ManagerBase implements ManagementServe } } - private void setParameters(SearchCriteria sc, final ListPublicIpAddressesCmd cmd, VlanType vlanType) { + private void setParameters(SearchCriteria sc, final ListPublicIpAddressesCmd cmd, VlanType vlanType, Boolean isAllocated) { final Object keyword = cmd.getKeyword(); final Long physicalNetworkId = cmd.getPhysicalNetworkId(); final Long sourceNetworkId = cmd.getNetworkId(); @@ -2437,6 +2439,8 @@ public class ManagementServerImpl extends ManagerBase implements ManagementServe if (state != null) { sc.setParameters("state", state); + } else if (isAllocated != null && isAllocated) { + sc.setParameters("state", IpAddress.State.Allocated); } sc.setParameters( "forsystemvms", false); @@ -3199,6 +3203,8 @@ public class ManagementServerImpl extends ManagerBase implements ManagementServe cmdList.add(ListProjectAccountsCmd.class); cmdList.add(AssociateIPAddrCmd.class); cmdList.add(DisassociateIPAddrCmd.class); + cmdList.add(ReserveIPAddrCmd.class); + cmdList.add(ReleaseIPAddrCmd.class); cmdList.add(ListPublicIpAddressesCmd.class); cmdList.add(CreateAutoScalePolicyCmd.class); cmdList.add(CreateAutoScaleVmGroupCmd.class); diff --git a/server/src/test/java/com/cloud/vpc/MockNetworkManagerImpl.java b/server/src/test/java/com/cloud/vpc/MockNetworkManagerImpl.java index 728effa8b76..7301fcf2a46 100644 --- a/server/src/test/java/com/cloud/vpc/MockNetworkManagerImpl.java +++ b/server/src/test/java/com/cloud/vpc/MockNetworkManagerImpl.java @@ -166,6 +166,16 @@ public class MockNetworkManagerImpl extends ManagerBase implements NetworkOrches return null; } + @Override + public IpAddress reserveIpAddress(Account account, Boolean displayIp, Long ipAddressId) throws ResourceAllocationException { + return null; + } + + @Override + public boolean releaseReservedIpAddress(long ipAddressId) throws InsufficientAddressCapacityException { + return false; + } + @Override public IpAddress allocatePortableIP(Account ipOwner, int regionId, Long zoneId, Long networkId, Long vpcId) throws ResourceAllocationException, InsufficientAddressCapacityException, ConcurrentOperationException { diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 1150489a5af..734bfe1d5b6 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -248,6 +248,7 @@ "label.action.remove.host": "Remove Host", "label.action.remove.host.processing": "Removing Host....", "label.action.remove.vm": "Release VM", +"label.action.reserve.ip": "Reserve Public IP", "label.action.reset.password": "Reset Password", "label.action.reset.password.processing": "Resetting Password....", "label.action.resize.volume": "Resize Volume", @@ -975,6 +976,7 @@ "label.forceencap": "Force UDP Encapsulation of ESP Packets", "label.forgedtransmits": "Forged Transmits", "label.format": "Format", +"label.free": "Free", "label.french.azerty.keyboard": "French AZERTY keyboard", "label.friday": "Friday", "label.from": "from", @@ -1874,6 +1876,7 @@ "label.required": "Required", "label.requireshvm": "HVM", "label.requiresupgrade": "Requires Upgrade", +"label.reserved": "Reserved", "label.reserved.system.gateway": "Reserved system gateway", "label.reserved.system.ip": "Reserved System IP", "label.reserved.system.netmask": "Reserved system netmask", @@ -2573,6 +2576,7 @@ "message.action.recover.volume": "Please confirm that you would like to recover this volume.", "message.action.release.ip": "Please confirm that you want to release this IP.", "message.action.remove.host": "Please confirm that you want to remove this host.", +"message.action.reserve.ip": "Please confirm that you want to reserve this IP.", "message.action.reset.password.off": "Your instance currently does not support this feature.", "message.action.reset.password.warning": "Your instance must be stopped before attempting to change its current password.", "message.action.restore.instance": "Please confirm that you want to restore this instance.", @@ -3204,6 +3208,7 @@ "message.publicip.state.allocating": "The IP Address is being propagated to other network elements and is not ready for use yet.", "message.publicip.state.free": "The IP address is ready to be allocated.", "message.publicip.state.releasing": "The IP address is being released for other network elements and is not ready for allocation.", +"message.publicip.state.reserved": "The IP address is reserved and not ready for use.", "message.question.are.you.sure.you.want.to.add": "Are you sure you want to add", "message.read.accept.license.agreements": "Please read and accept the terms for the license agreements.", "message.read.admin.guide.scaling.up": "Please read the dynamic scaling section in the admin guide before scaling up.", diff --git a/ui/src/components/widgets/Status.vue b/ui/src/components/widgets/Status.vue index 9688a780b9b..62438820314 100644 --- a/ui/src/components/widgets/Status.vue +++ b/ui/src/components/widgets/Status.vue @@ -156,6 +156,8 @@ export default { switch (state.toLowerCase()) { case 'scheduled': return 'blue' + case 'reserved': + return 'orange' default: return null } diff --git a/ui/src/config/section/network.js b/ui/src/config/section/network.js index 6539e875371..2b7ff42e00a 100644 --- a/ui/src/config/section/network.js +++ b/ui/src/config/section/network.js @@ -275,6 +275,7 @@ export default { resourceType: 'PublicIpAddress', columns: ['ipaddress', 'state', 'associatednetworkname', 'virtualmachinename', 'allocated', 'account', 'zonename'], details: ['ipaddress', 'id', 'associatednetworkname', 'virtualmachinename', 'networkid', 'issourcenat', 'isstaticnat', 'virtualmachinename', 'vmipaddress', 'vlan', 'allocated', 'account', 'zonename'], + filters: ['allocated', 'reserved', 'free'], component: shallowRef(() => import('@/views/network/PublicIpResource.vue')), tabs: [{ name: 'details', @@ -311,7 +312,7 @@ export default { label: 'label.action.enable.static.nat', docHelp: 'adminguide/networking_and_traffic.html#enabling-or-disabling-static-nat', dataView: true, - show: (record) => { return !record.virtualmachineid && !record.issourcenat }, + show: (record) => { return record.state === 'Allocated' && !record.virtualmachineid && !record.issourcenat }, popup: true, component: shallowRef(defineAsyncComponent(() => import('@/views/network/EnableStaticNat.vue'))) }, @@ -337,7 +338,27 @@ export default { message: 'message.action.release.ip', docHelp: 'adminguide/networking_and_traffic.html#releasing-an-ip-address-alloted-to-a-vpc', dataView: true, - show: (record) => { return !record.issourcenat }, + show: (record) => { return record.state === 'Allocated' && !record.issourcenat }, + groupAction: true, + popup: true, + groupMap: (selection) => { return selection.map(x => { return { id: x } }) } + }, + { + api: 'reserveIpAddress', + icon: 'lock-outlined', + label: 'label.action.reserve.ip', + dataView: true, + show: (record) => { return record.state === 'Free' }, + popup: true, + component: shallowRef(defineAsyncComponent(() => import('@/views/network/ReservePublicIP.vue'))) + }, + { + api: 'releaseIpAddress', + icon: 'delete-outlined', + label: 'label.action.release.ip', + message: 'message.action.release.ip', + dataView: true, + show: (record) => { return record.state === 'Reserved' }, groupAction: true, popup: true, groupMap: (selection) => { return selection.map(x => { return { id: x } }) } diff --git a/ui/src/views/AutogenView.vue b/ui/src/views/AutogenView.vue index 86e6417bf68..4ddf9f222f4 100644 --- a/ui/src/views/AutogenView.vue +++ b/ui/src/views/AutogenView.vue @@ -55,7 +55,8 @@ :placeholder="$t('label.filterby')" :value="$route.query.filter || (projectView && $route.name === 'vm' || ['Admin', 'DomainAdmin'].includes($store.getters.userInfo.roletype) && ['vm', 'iso', 'template'].includes($route.name) - ? 'all' : ['guestnetwork'].includes($route.name) ? 'all' : 'self')" + ? 'all' : ['publicip'].includes($route.name) + ? 'allocated': ['guestnetwork'].includes($route.name) ? 'all' : 'self')" style="min-width: 100px; margin-left: 10px" @change="changeFilter" showSearch @@ -851,6 +852,9 @@ export default { delete params.id params.name = this.$route.params.id } + if (['listPublicIpAddresses'].includes(this.apiName)) { + params.allocatedonly = false + } if (this.$route.path.startsWith('/vmsnapshot/')) { params.vmsnapshotid = this.$route.params.id } else if (this.$route.path.startsWith('/ldapsetting/')) { @@ -1494,6 +1498,8 @@ export default { } else { query.type = filter } + } else if (this.$route.name === 'publicip') { + query.state = filter } else if (this.$route.name === 'vm') { if (filter === 'self') { query.account = this.$store.getters.userInfo.account diff --git a/ui/src/views/network/IpAddressesTab.vue b/ui/src/views/network/IpAddressesTab.vue index 39cf595bace..bc61d65628b 100644 --- a/ui/src/views/network/IpAddressesTab.vue +++ b/ui/src/views/network/IpAddressesTab.vue @@ -137,7 +137,7 @@ }" > {{ ip.ipaddress }} + :key="ip.ipaddress">{{ ip.ipaddress }} ({{ ip.state }})
@@ -367,7 +367,7 @@ export default { }).catch(error => { this.$notification.error({ message: `${this.$t('label.error')} ${error.response.status}`, - description: error.response.data.errorresponse.errortext, + description: error.response.data.associateipaddressresponse.errortext || error.response.data.errorresponse.errortext, duration: 0 }) }).finally(() => { @@ -447,9 +447,10 @@ export default { try { const listPublicIpAddress = await this.fetchListPublicIpAddress() listPublicIpAddress.forEach(item => { - if (item.state === 'Free') { + if (item.state === 'Free' || item.state === 'Reserved') { this.listPublicIpAddress.push({ - ipaddress: item.ipaddress + ipaddress: item.ipaddress, + state: item.state }) } }) diff --git a/ui/src/views/network/PublicIpResource.vue b/ui/src/views/network/PublicIpResource.vue index 0db6c5f0fe5..fdbd96a761a 100644 --- a/ui/src/views/network/PublicIpResource.vue +++ b/ui/src/views/network/PublicIpResource.vue @@ -113,6 +113,11 @@ export default { this.loading = false }, async filterTabs () { + // Public IPs in Free state have nothing + if (['Free', 'Reserved'].includes(this.resource.state)) { + this.tabs = this.defaultTabs + return + } // VPC IPs with source nat have only VPN if (this.resource && this.resource.vpcid && this.resource.issourcenat) { this.tabs = this.defaultTabs.concat(this.$route.meta.tabs.filter(tab => tab.name === 'vpn')) diff --git a/ui/src/views/network/ReservePublicIP.vue b/ui/src/views/network/ReservePublicIP.vue new file mode 100644 index 00000000000..dc5486e661b --- /dev/null +++ b/ui/src/views/network/ReservePublicIP.vue @@ -0,0 +1,332 @@ +// 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. + + + + + + diff --git a/ui/tests/mockData/Status.mock.json b/ui/tests/mockData/Status.mock.json index 9574652aef0..0293cd0bbd0 100644 --- a/ui/tests/mockData/Status.mock.json +++ b/ui/tests/mockData/Status.mock.json @@ -12,6 +12,7 @@ "state.error": "Error", "message.publicip.state.allocated": "Allocated", "message.publicip.state.created": "Created", + "message.publicip.state.reserved": "Reserved", "message.vmsnapshot.state.active": "Active", "message.vm.state.active": "Active", "message.volume.state.active": "Active", @@ -141,4 +142,4 @@ "component": {} } ] -} \ No newline at end of file +}