From ca623530c86eaa19cdb7c05d8b00745706aae612 Mon Sep 17 00:00:00 2001 From: nvazquez Date: Wed, 21 Jan 2026 11:39:09 -0300 Subject: [PATCH 01/11] Fix conserve mode for VPC Source NAT and extend rules for VPC tiers --- .../java/com/cloud/network/vpc/VpcOffering.java | 2 ++ .../org/apache/cloudstack/api/ApiConstants.java | 1 + .../user/firewall/CreatePortForwardingRuleCmd.java | 2 +- .../cloudstack/api/response/VpcResponse.java | 8 ++++++++ .../network/lb/LoadBalancingRulesManager.java | 2 +- .../java/com/cloud/network/vpc/VpcOfferingVO.java | 12 ++++++++++++ .../resources/META-INF/db/schema-42100to42200.sql | 2 ++ .../META-INF/db/views/cloud.vpc_offering_view.sql | 1 + .../cloud/network/lb/LoadBalanceRuleHandler.java | 2 +- .../main/java/com/cloud/api/ApiResponseHelper.java | 1 + .../com/cloud/api/query/vo/VpcOfferingJoinVO.java | 8 ++++++++ .../com/cloud/network/IpAddressManagerImpl.java | 14 +++++++++++++- .../network/firewall/FirewallManagerImpl.java | 10 ++++++++-- .../network/lb/LoadBalancingRulesManagerImpl.java | 6 +++--- ui/src/views/network/LoadBalancing.vue | 12 ++++++------ ui/src/views/network/PortForwarding.vue | 13 +++++-------- ui/src/views/network/PublicIpResource.vue | 12 +++++------- 17 files changed, 78 insertions(+), 30 deletions(-) diff --git a/api/src/main/java/com/cloud/network/vpc/VpcOffering.java b/api/src/main/java/com/cloud/network/vpc/VpcOffering.java index 17f49bb36521..f84602232159 100644 --- a/api/src/main/java/com/cloud/network/vpc/VpcOffering.java +++ b/api/src/main/java/com/cloud/network/vpc/VpcOffering.java @@ -84,4 +84,6 @@ public enum State { NetworkOffering.RoutingMode getRoutingMode(); Boolean isSpecifyAsNumber(); + + boolean isConserveMode(); } diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java index 896031806d53..f5b3205d881b 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -980,6 +980,7 @@ public class ApiConstants { public static final String REGION_ID = "regionid"; public static final String VPC_OFF_ID = "vpcofferingid"; public static final String VPC_OFF_NAME = "vpcofferingname"; + public static final String VPC_OFFERING_CONSERVE_MODE = "vpcofferingconservemode"; public static final String NETWORK = "network"; public static final String VPC_ID = "vpcid"; public static final String VPC_NAME = "vpcname"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/firewall/CreatePortForwardingRuleCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/firewall/CreatePortForwardingRuleCmd.java index db6b788178ab..de9fa741accc 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/firewall/CreatePortForwardingRuleCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/firewall/CreatePortForwardingRuleCmd.java @@ -280,7 +280,7 @@ public long getNetworkId() { IpAddress ip = _entityMgr.findById(IpAddress.class, getIpAddressId()); Long ntwkId = null; - if (ip.getAssociatedWithNetworkId() != null) { + if (ip.getVpcId() == null && ip.getAssociatedWithNetworkId() != null) { ntwkId = ip.getAssociatedWithNetworkId(); } else { ntwkId = networkId; diff --git a/api/src/main/java/org/apache/cloudstack/api/response/VpcResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VpcResponse.java index 2648ba836785..acfabb113502 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/VpcResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/VpcResponse.java @@ -73,6 +73,10 @@ public class VpcResponse extends BaseResponseWithAnnotations implements Controll @Param(description = "VPC offering name the VPC is created from", since = "4.13.2") private String vpcOfferingName; + @SerializedName(ApiConstants.VPC_OFFERING_CONSERVE_MODE) + @Param(description = "true if VPC offering is ip conserve mode enabled", since = "4.23") + private Boolean vpcOfferingConserveMode; + @SerializedName(ApiConstants.CREATED) @Param(description = "The date this VPC was created") private Date created; @@ -197,6 +201,10 @@ public void setDisplayText(final String displayText) { this.displayText = displayText; } + public void setVpcOfferingConserveMode(Boolean vpcOfferingConserveMode) { + this.vpcOfferingConserveMode = vpcOfferingConserveMode; + } + public void setCreated(final Date created) { this.created = created; } diff --git a/engine/components-api/src/main/java/com/cloud/network/lb/LoadBalancingRulesManager.java b/engine/components-api/src/main/java/com/cloud/network/lb/LoadBalancingRulesManager.java index 669456cbdcc2..d8011e9ade12 100644 --- a/engine/components-api/src/main/java/com/cloud/network/lb/LoadBalancingRulesManager.java +++ b/engine/components-api/src/main/java/com/cloud/network/lb/LoadBalancingRulesManager.java @@ -39,7 +39,7 @@ public interface LoadBalancingRulesManager { LoadBalancer createPublicLoadBalancer(String xId, String name, String description, int srcPort, int destPort, long sourceIpId, String protocol, String algorithm, - boolean openFirewall, CallContext caller, String lbProtocol, Boolean forDisplay, String cidrList) throws NetworkRuleConflictException; + boolean openFirewall, CallContext caller, String lbProtocol, Boolean forDisplay, String cidrList, Long networkId) throws NetworkRuleConflictException; boolean removeAllLoadBalanacersForIp(long ipId, Account caller, long callerUserId); diff --git a/engine/schema/src/main/java/com/cloud/network/vpc/VpcOfferingVO.java b/engine/schema/src/main/java/com/cloud/network/vpc/VpcOfferingVO.java index 9320a37bc96e..b913468384e4 100644 --- a/engine/schema/src/main/java/com/cloud/network/vpc/VpcOfferingVO.java +++ b/engine/schema/src/main/java/com/cloud/network/vpc/VpcOfferingVO.java @@ -91,6 +91,9 @@ public class VpcOfferingVO implements VpcOffering { @Column(name = "specify_as_number") private Boolean specifyAsNumber = false; + @Column(name = "conserve_mode") + private boolean conserveMode; + public VpcOfferingVO() { this.uuid = UUID.randomUUID().toString(); } @@ -242,4 +245,13 @@ public Boolean isSpecifyAsNumber() { public void setSpecifyAsNumber(Boolean specifyAsNumber) { this.specifyAsNumber = specifyAsNumber; } + + @Override + public boolean isConserveMode() { + return conserveMode; + } + + public void setConserveMode(boolean conserveMode) { + this.conserveMode = conserveMode; + } } diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42100to42200.sql b/engine/schema/src/main/resources/META-INF/db/schema-42100to42200.sql index b523016aa3dc..4a5db5638709 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42100to42200.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42100to42200.sql @@ -87,3 +87,5 @@ CALL `cloud`.`INSERT_EXTENSION_DETAIL_IF_NOT_EXISTS`('MaaS', 'orchestratorrequir CALL `cloud`.`IDEMPOTENT_DROP_UNIQUE_KEY`('counter', 'uc_counter__provider__source__value'); CALL `cloud`.`IDEMPOTENT_ADD_UNIQUE_KEY`('cloud.counter', 'uc_counter__provider__source__value__removed', '(provider, source, value, removed)'); + +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vpc_offerings','conserve_mode', 'tinyint(1) unsigned NULL DEFAULT 1'); diff --git a/engine/schema/src/main/resources/META-INF/db/views/cloud.vpc_offering_view.sql b/engine/schema/src/main/resources/META-INF/db/views/cloud.vpc_offering_view.sql index 751d8f91a259..3669bb10122b 100644 --- a/engine/schema/src/main/resources/META-INF/db/views/cloud.vpc_offering_view.sql +++ b/engine/schema/src/main/resources/META-INF/db/views/cloud.vpc_offering_view.sql @@ -38,6 +38,7 @@ select `vpc_offerings`.`sort_key` AS `sort_key`, `vpc_offerings`.`routing_mode` AS `routing_mode`, `vpc_offerings`.`specify_as_number` AS `specify_as_number`, + `vpc_offerings`.`conserve_mode` AS `conserve_mode`, group_concat(distinct `domain`.`id` separator ',') AS `domain_id`, group_concat(distinct `domain`.`uuid` separator ',') AS `domain_uuid`, group_concat(distinct `domain`.`name` separator ',') AS `domain_name`, diff --git a/plugins/network-elements/elastic-loadbalancer/src/main/java/com/cloud/network/lb/LoadBalanceRuleHandler.java b/plugins/network-elements/elastic-loadbalancer/src/main/java/com/cloud/network/lb/LoadBalanceRuleHandler.java index 3df58470fc68..fc167b71c23d 100644 --- a/plugins/network-elements/elastic-loadbalancer/src/main/java/com/cloud/network/lb/LoadBalanceRuleHandler.java +++ b/plugins/network-elements/elastic-loadbalancer/src/main/java/com/cloud/network/lb/LoadBalanceRuleHandler.java @@ -363,7 +363,7 @@ private LoadBalancer handleCreateLoadBalancerRuleWithLock(final CreateLoadBalanc lb.setSourceIpAddressId(ipId); result = _lbMgr.createPublicLoadBalancer(lb.getXid(), lb.getName(), lb.getDescription(), lb.getSourcePortStart(), lb.getDefaultPortStart(), ipId.longValue(), - lb.getProtocol(), lb.getAlgorithm(), false, CallContext.current(), lb.getLbProtocol(), true, null); + lb.getProtocol(), lb.getAlgorithm(), false, CallContext.current(), lb.getLbProtocol(), true, null, networkId); } catch (final NetworkRuleConflictException e) { logger.warn("Failed to create LB rule, not continuing with ELB deployment"); if (newIp) { diff --git a/server/src/main/java/com/cloud/api/ApiResponseHelper.java b/server/src/main/java/com/cloud/api/ApiResponseHelper.java index ce794cf5388f..ba4a5529b03c 100644 --- a/server/src/main/java/com/cloud/api/ApiResponseHelper.java +++ b/server/src/main/java/com/cloud/api/ApiResponseHelper.java @@ -3499,6 +3499,7 @@ public VpcResponse createVpcResponse(ResponseView view, Vpc vpc) { if (voff != null) { response.setVpcOfferingId(voff.getUuid()); response.setVpcOfferingName(voff.getName()); + response.setVpcOfferingConserveMode(voff.isConserveMode()); } response.setCidr(vpc.getCidr()); response.setRestartRequired(vpc.isRestartRequired()); diff --git a/server/src/main/java/com/cloud/api/query/vo/VpcOfferingJoinVO.java b/server/src/main/java/com/cloud/api/query/vo/VpcOfferingJoinVO.java index 4e0707edf880..9d65c19479fb 100644 --- a/server/src/main/java/com/cloud/api/query/vo/VpcOfferingJoinVO.java +++ b/server/src/main/java/com/cloud/api/query/vo/VpcOfferingJoinVO.java @@ -112,6 +112,9 @@ public class VpcOfferingJoinVO implements VpcOffering { @Column(name = "specify_as_number") private Boolean specifyAsNumber = false; + @Column(name = "conserve_mode") + private boolean conserveMode; + public VpcOfferingJoinVO() { } @@ -178,6 +181,11 @@ public Boolean isSpecifyAsNumber() { return specifyAsNumber; } + @Override + public boolean isConserveMode() { + return conserveMode; + } + public void setSpecifyAsNumber(Boolean specifyAsNumber) { this.specifyAsNumber = specifyAsNumber; } diff --git a/server/src/main/java/com/cloud/network/IpAddressManagerImpl.java b/server/src/main/java/com/cloud/network/IpAddressManagerImpl.java index 8d179735a4d1..517ffb82c0d0 100644 --- a/server/src/main/java/com/cloud/network/IpAddressManagerImpl.java +++ b/server/src/main/java/com/cloud/network/IpAddressManagerImpl.java @@ -1543,6 +1543,14 @@ public IPAddressVO doInTransaction(TransactionStatus status) throws Insufficient return ipaddr; } + protected IPAddressVO getExistingSourceNatInVPC(Long vpcId) { + List ips = _ipAddressDao.listByAssociatedVpc(vpcId, true); + if (CollectionUtils.isEmpty(ips)) { + return null; + } + return ips.get(0); + } + protected IPAddressVO getExistingSourceNatInNetwork(long ownerId, Long networkId) { List addrs; Network guestNetwork = _networksDao.findById(networkId); @@ -1723,7 +1731,11 @@ protected boolean isSourceNatAvailableForNetwork(Account owner, IPAddressVO ipTo NetworkOffering offering = _networkOfferingDao.findById(network.getNetworkOfferingId()); boolean sharedSourceNat = offering.isSharedSourceNat(); boolean isSourceNat = false; - if (!sharedSourceNat) { + if (network.getVpcId() != null) { + // For VPCs: Check if the VPC Source NAT IP address is the same we are associating + IPAddressVO vpcSourceNatIpAddress = getExistingSourceNatInVPC(network.getVpcId()); + isSourceNat = vpcSourceNatIpAddress != null && vpcSourceNatIpAddress.getId() == ipToAssoc.getId(); + } else if (!sharedSourceNat) { if (getExistingSourceNatInNetwork(owner.getId(), network.getId()) == null) { if (network.getGuestType() == GuestType.Isolated && network.getVpcId() == null && !ipToAssoc.isPortable()) { isSourceNat = true; diff --git a/server/src/main/java/com/cloud/network/firewall/FirewallManagerImpl.java b/server/src/main/java/com/cloud/network/firewall/FirewallManagerImpl.java index 4aee5fef48a6..5232fbd7f668 100644 --- a/server/src/main/java/com/cloud/network/firewall/FirewallManagerImpl.java +++ b/server/src/main/java/com/cloud/network/firewall/FirewallManagerImpl.java @@ -395,6 +395,12 @@ public void detectRulesConflict(FirewallRule newRule) throws NetworkRuleConflict assert (rules.size() >= 1); } + NetworkVO newRuleNetwork = _networkDao.findById(newRule.getNetworkId()); + if (newRuleNetwork == null) { + throw new InvalidParameterValueException("Unable to create firewall rule as cannot find network by id=" + newRule.getNetworkId()); + } + boolean isNewRuleOnVpcNetwork = newRuleNetwork.getVpcId() != null; + for (FirewallRuleVO rule : rules) { if (rule.getId() == newRule.getId()) { continue; // Skips my own rule. @@ -442,8 +448,8 @@ public void detectRulesConflict(FirewallRule newRule) throws NetworkRuleConflict } } - // Checking if the rule applied is to the same network that is passed in the rule. - if (rule.getNetworkId() != newRule.getNetworkId() && rule.getState() != State.Revoke) { + // Checking if the rule applied is to the same network that is passed in the rule. (except for VPC networks) + if (!isNewRuleOnVpcNetwork && rule.getNetworkId() != newRule.getNetworkId() && rule.getState() != State.Revoke) { throw new NetworkRuleConflictException("New rule is for a different network than what's specified in rule " + rule.getXid()); } diff --git a/server/src/main/java/com/cloud/network/lb/LoadBalancingRulesManagerImpl.java b/server/src/main/java/com/cloud/network/lb/LoadBalancingRulesManagerImpl.java index c6aeeaf2db59..f6235fdc6a0a 100644 --- a/server/src/main/java/com/cloud/network/lb/LoadBalancingRulesManagerImpl.java +++ b/server/src/main/java/com/cloud/network/lb/LoadBalancingRulesManagerImpl.java @@ -1761,7 +1761,7 @@ public LoadBalancer createPublicLoadBalancerRule(String xId, String name, String } result = createPublicLoadBalancer(xId, name, description, srcPortStart, defPortStart, ipVO.getId(), protocol, algorithm, openFirewall, CallContext.current(), - lbProtocol, forDisplay, cidrString); + lbProtocol, forDisplay, cidrString, networkId); } catch (Exception ex) { logger.warn("Failed to create load balancer due to ", ex); if (ex instanceof NetworkRuleConflictException) { @@ -1824,7 +1824,7 @@ private String validateCidr(String cidr) { @Override public LoadBalancer createPublicLoadBalancer(final String xId, final String name, final String description, final int srcPort, final int destPort, final long sourceIpId, final String protocol, final String algorithm, final boolean openFirewall, final CallContext caller, final String lbProtocol, - final Boolean forDisplay, String cidrList) throws NetworkRuleConflictException { + final Boolean forDisplay, String cidrList, Long networkIdParam) throws NetworkRuleConflictException { if (!NetUtils.isValidPort(destPort)) { throw new InvalidParameterValueException("privatePort is an invalid value: " + destPort); } @@ -1853,7 +1853,7 @@ public LoadBalancer createPublicLoadBalancer(final String xId, final String name _accountMgr.checkAccess(caller.getCallingAccount(), null, true, ipAddr); - final Long networkId = ipAddr.getAssociatedWithNetworkId(); + final Long networkId = ipAddr.getVpcId() == null ? ipAddr.getAssociatedWithNetworkId() : networkIdParam; if (networkId == null) { InvalidParameterValueException ex = new InvalidParameterValueException("Unable to create load balancer rule ; specified sourceip id is not associated with any network"); diff --git a/ui/src/views/network/LoadBalancing.vue b/ui/src/views/network/LoadBalancing.vue index ad091b218a83..aac72b941849 100644 --- a/ui/src/views/network/LoadBalancing.vue +++ b/ui/src/views/network/LoadBalancing.vue @@ -97,7 +97,7 @@ {{ $t('label.add') }} -
+
{{ $t('label.select.tier') }}
{{ $t('label.add') }} @@ -487,10 +487,10 @@ >
+ v-if="'vpcid' in resource"> {{ $t('label.select.tier') }} { if (!response || !response.listnicsresponse || !response.listnicsresponse.nic[0]) return const newItem = [] @@ -1850,7 +1850,7 @@ export default { this.vmCount = 0 this.vms = [] this.addVmModalLoading = true - const networkId = ('vpcid' in this.resource && !('associatednetworkid' in this.resource)) ? this.selectedTier : this.resource.associatednetworkid + const networkId = ('vpcid' in this.resource) ? this.selectedTier : this.resource.associatednetworkid if (!networkId) { this.addVmModalLoading = false return @@ -1999,7 +1999,7 @@ export default { } const networkId = this.selectedTierForAutoScaling != null ? this.selectedTierForAutoScaling - : ('vpcid' in this.resource && !('associatednetworkid' in this.resource)) ? this.selectedTier : this.resource.associatednetworkid + : ('vpcid' in this.resource) ? this.selectedTier : this.resource.associatednetworkid postAPI('createLoadBalancerRule', { openfirewall: false, networkid: networkId, diff --git a/ui/src/views/network/PortForwarding.vue b/ui/src/views/network/PortForwarding.vue index 8ab6559b12c3..471f94d69957 100644 --- a/ui/src/views/network/PortForwarding.vue +++ b/ui/src/views/network/PortForwarding.vue @@ -216,10 +216,10 @@ @cancel="closeModal">
+ v-if="'vpcid' in resource"> {{ $t('label.select.tier') }} { if (!response.listnicsresponse.nic || response.listnicsresponse.nic.length < 1) return const nic = response.listnicsresponse.nic[0] @@ -808,7 +805,7 @@ export default { this.vmCount = 0 this.vms = [] this.addVmModalLoading = true - const networkId = ('vpcid' in this.resource && !('associatednetworkid' in this.resource)) ? this.selectedTier : this.resource.associatednetworkid + const networkId = ('vpcid' in this.resource) ? this.selectedTier : this.resource.associatednetworkid if (!networkId) { this.addVmModalLoading = false return diff --git a/ui/src/views/network/PublicIpResource.vue b/ui/src/views/network/PublicIpResource.vue index 7c25e1c32bad..82511dfadf64 100644 --- a/ui/src/views/network/PublicIpResource.vue +++ b/ui/src/views/network/PublicIpResource.vue @@ -135,12 +135,6 @@ export default { return } if (this.resource && this.resource.vpcid) { - // VPC IPs with source nat have only VPN - if (this.resource.issourcenat) { - this.tabs = this.defaultTabs.concat(this.$route.meta.tabs.filter(tab => tab.name === 'vpn')) - return - } - // VPC IPs with static nat have nothing if (this.resource.isstaticnat) { if (this.resource.virtualmachinetype === 'DomainRouter') { @@ -153,9 +147,13 @@ export default { let tabs = this.$route.meta.tabs.filter(tab => tab.name !== 'firewall') const network = await this.fetchNetwork() - if (network && network.networkofferingconservemode) { + if ((network && network.networkofferingconservemode) || !network && this.resource.issourcenat) { this.tabs = tabs return + } else if (this.resource.issourcenat) { + // VPC IPs with Source Nat have only VPN when conserve_mode = false + this.tabs = this.defaultTabs.concat(this.$route.meta.tabs.filter(tab => tab.name === 'vpn')) + return } this.portFWRuleCount = await this.fetchPortFWRule() From 5ade2b303f41e064ab56394a3ed0318d5c7b194d Mon Sep 17 00:00:00 2001 From: nvazquez Date: Wed, 21 Jan 2026 15:57:33 -0300 Subject: [PATCH 02/11] Fix unit test --- .../com/cloud/network/firewall/FirewallManagerTest.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server/src/test/java/com/cloud/network/firewall/FirewallManagerTest.java b/server/src/test/java/com/cloud/network/firewall/FirewallManagerTest.java index f94fd0c0c3cd..3c663dde92db 100644 --- a/server/src/test/java/com/cloud/network/firewall/FirewallManagerTest.java +++ b/server/src/test/java/com/cloud/network/firewall/FirewallManagerTest.java @@ -24,6 +24,8 @@ import com.cloud.network.NetworkModel; import com.cloud.network.NetworkRuleApplier; import com.cloud.network.dao.FirewallRulesDao; +import com.cloud.network.dao.NetworkDao; +import com.cloud.network.dao.NetworkVO; import com.cloud.network.element.FirewallServiceProvider; import com.cloud.network.element.VirtualRouterElement; import com.cloud.network.element.VpcVirtualRouterElement; @@ -43,6 +45,7 @@ import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.mockito.Spy; import org.mockito.junit.MockitoJUnitRunner; @@ -76,6 +79,8 @@ public class FirewallManagerTest { IpAddressManager _ipAddrMgr; @Mock FirewallRulesDao _firewallDao; + @Mock + NetworkDao _networkDao; @Spy @InjectMocks @@ -196,6 +201,10 @@ public void testDetectRulesConflict() { FirewallRule newRule4 = new FirewallRuleVO("newRule4", 3L, 15, 25, "TCP", 1, 2, 1, Purpose.Firewall, sString, dString2, null, null, null, FirewallRule.TrafficType.Egress); + NetworkVO networkVO = Mockito.mock(NetworkVO.class); + when(firewallMgr._networkDao.findById(1L)).thenReturn(networkVO); + when(networkVO.getVpcId()).thenReturn(null); + try { firewallMgr.detectRulesConflict(newRule1); firewallMgr.detectRulesConflict(newRule2); From 3fd969bc411519d5561b73f6f9db7ba3bb274bf4 Mon Sep 17 00:00:00 2001 From: nvazquez Date: Tue, 27 Jan 2026 02:00:20 -0300 Subject: [PATCH 03/11] Fixes --- .../META-INF/db/schema-42100to42200.sql | 2 -- .../META-INF/db/schema-42210to42300.sql | 3 ++ .../network/firewall/FirewallManagerImpl.java | 21 ++++++++++++-- ui/src/views/network/LoadBalancing.vue | 29 ++++++++++++++----- ui/src/views/network/PortForwarding.vue | 27 +++++++++++++---- ui/src/views/network/PublicIpResource.vue | 12 ++++---- 6 files changed, 71 insertions(+), 23 deletions(-) diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42100to42200.sql b/engine/schema/src/main/resources/META-INF/db/schema-42100to42200.sql index 4a5db5638709..b523016aa3dc 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42100to42200.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42100to42200.sql @@ -87,5 +87,3 @@ CALL `cloud`.`INSERT_EXTENSION_DETAIL_IF_NOT_EXISTS`('MaaS', 'orchestratorrequir CALL `cloud`.`IDEMPOTENT_DROP_UNIQUE_KEY`('counter', 'uc_counter__provider__source__value'); CALL `cloud`.`IDEMPOTENT_ADD_UNIQUE_KEY`('cloud.counter', 'uc_counter__provider__source__value__removed', '(provider, source, value, removed)'); - -CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vpc_offerings','conserve_mode', 'tinyint(1) unsigned NULL DEFAULT 1'); diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql index d330ecd0c0d5..67c8a3c83209 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql @@ -49,3 +49,6 @@ CREATE TABLE IF NOT EXISTS `cloud`.`webhook_filter` ( INDEX `i_webhook_filter__webhook_id`(`webhook_id`), CONSTRAINT `fk_webhook_filter__webhook_id` FOREIGN KEY(`webhook_id`) REFERENCES `webhook`(`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vpc_offerings','conserve_mode', 'tinyint(1) unsigned NULL DEFAULT 0'); +UPDATE `cloud`.`vpc_offerings` SET conserve_mode=1 WHERE name='Default VPC offering'; diff --git a/server/src/main/java/com/cloud/network/firewall/FirewallManagerImpl.java b/server/src/main/java/com/cloud/network/firewall/FirewallManagerImpl.java index 5232fbd7f668..93035157e210 100644 --- a/server/src/main/java/com/cloud/network/firewall/FirewallManagerImpl.java +++ b/server/src/main/java/com/cloud/network/firewall/FirewallManagerImpl.java @@ -30,6 +30,8 @@ import javax.inject.Inject; import javax.naming.ConfigurationException; +import com.cloud.network.vpc.VpcOfferingVO; +import com.cloud.network.vpc.dao.VpcOfferingDao; import org.apache.commons.lang3.ObjectUtils; import org.springframework.stereotype.Component; @@ -159,6 +161,8 @@ public class FirewallManagerImpl extends ManagerBase implements FirewallService, IpAddressManager _ipAddrMgr; @Inject RoutedIpv4Manager routedIpv4Manager; + @Inject + VpcOfferingDao vpcOfferingDao; private boolean _elbEnabled = false; static Boolean rulesContinueOnErrFlag = true; @@ -400,6 +404,11 @@ public void detectRulesConflict(FirewallRule newRule) throws NetworkRuleConflict throw new InvalidParameterValueException("Unable to create firewall rule as cannot find network by id=" + newRule.getNetworkId()); } boolean isNewRuleOnVpcNetwork = newRuleNetwork.getVpcId() != null; + boolean isVpcConserveModeEnabled = false; + if (isNewRuleOnVpcNetwork) { + VpcOfferingVO vpcOffering = vpcOfferingDao.findById(newRuleNetwork.getVpcId()); + isVpcConserveModeEnabled = vpcOffering != null && vpcOffering.isConserveMode(); + } for (FirewallRuleVO rule : rules) { if (rule.getId() == newRule.getId()) { @@ -448,9 +457,15 @@ public void detectRulesConflict(FirewallRule newRule) throws NetworkRuleConflict } } - // Checking if the rule applied is to the same network that is passed in the rule. (except for VPC networks) - if (!isNewRuleOnVpcNetwork && rule.getNetworkId() != newRule.getNetworkId() && rule.getState() != State.Revoke) { - throw new NetworkRuleConflictException("New rule is for a different network than what's specified in rule " + rule.getXid()); + // Checking if the rule applied is to the same network that is passed in the rule. + // (except for VPCs with conserve mode = true) + if ((!isNewRuleOnVpcNetwork || !isVpcConserveModeEnabled) + && rule.getNetworkId() != newRule.getNetworkId() && rule.getState() != State.Revoke) { + String errMsg = String.format("New rule is for a different network than what's specified in rule %s", rule.getXid()); + if (isNewRuleOnVpcNetwork) { + errMsg += String.format(" - VPC id=%s is not using conserve mode", newRuleNetwork.getVpcId()); + } + throw new NetworkRuleConflictException(errMsg); } //Check for the ICMP protocol. This has to be done separately from other protocols as we need to check the ICMP codes and ICMP type also. diff --git a/ui/src/views/network/LoadBalancing.vue b/ui/src/views/network/LoadBalancing.vue index aac72b941849..2e47f702f7bd 100644 --- a/ui/src/views/network/LoadBalancing.vue +++ b/ui/src/views/network/LoadBalancing.vue @@ -97,7 +97,7 @@ {{ $t('label.add') }}
-
+
{{ $t('label.select.tier') }}
{{ $t('label.add') }} @@ -487,10 +487,10 @@ >
+ v-if="'vpcid' in resource && (!('associatednetworkid' in resource) || this.vpcConserveMode)"> {{ $t('label.select.tier') }} { + this.vpcConserveMode = json.listvpcsresponse?.vpc?.[0].vpcofferingconservemode || false + }).catch(error => { + this.$notifyError(error) + }) + }, fetchListTiers () { this.tiers.loading = true @@ -1830,7 +1845,7 @@ export default { getAPI('listNics', { virtualmachineid: e.target.value, - networkid: ('vpcid' in this.resource) ? this.selectedTier : this.resource.associatednetworkid + networkid: ('vpcid' in this.resource && (!('associatednetworkid' in this.resource) || this.vpcConserveMode)) ? this.selectedTier : this.resource.associatednetworkid }).then(response => { if (!response || !response.listnicsresponse || !response.listnicsresponse.nic[0]) return const newItem = [] @@ -1850,7 +1865,7 @@ export default { this.vmCount = 0 this.vms = [] this.addVmModalLoading = true - const networkId = ('vpcid' in this.resource) ? this.selectedTier : this.resource.associatednetworkid + const networkId = ('vpcid' in this.resource && (!('associatednetworkid' in this.resource) || this.vpcConserveMode)) ? this.selectedTier : this.resource.associatednetworkid if (!networkId) { this.addVmModalLoading = false return @@ -1999,7 +2014,7 @@ export default { } const networkId = this.selectedTierForAutoScaling != null ? this.selectedTierForAutoScaling - : ('vpcid' in this.resource) ? this.selectedTier : this.resource.associatednetworkid + : ('vpcid' in this.resource && !('associatednetworkid' in this.resource)) ? this.selectedTier : this.resource.associatednetworkid postAPI('createLoadBalancerRule', { openfirewall: false, networkid: networkId, diff --git a/ui/src/views/network/PortForwarding.vue b/ui/src/views/network/PortForwarding.vue index 471f94d69957..d53511dc48b2 100644 --- a/ui/src/views/network/PortForwarding.vue +++ b/ui/src/views/network/PortForwarding.vue @@ -216,10 +216,10 @@ @cancel="closeModal">
+ v-if="'vpcid' in resource && (!('associatednetworkid' in resource) || this.vpcConserveMode)"> {{ $t('label.select.tier') }} { + this.vpcConserveMode = json.listvpcsresponse?.vpc?.[0].vpcofferingconservemode || false + }).catch(error => { + this.$notifyError(error) + }) + }, fetchListTiers () { this.selectedTier = null this.tiers.loading = true @@ -627,7 +642,7 @@ export default { if (this.loading) return this.loading = true this.addVmModalVisible = false - const networkId = ('vpcid' in this.resource) ? this.selectedTier : this.resource.associatednetworkid + const networkId = ('vpcid' in this.resource && (!('associatednetworkid' in this.resource || this.vpcConserveMode))) ? this.selectedTier : this.resource.associatednetworkid postAPI('createPortForwardingRule', { ...this.newRule, ipaddressid: this.resource.id, @@ -785,7 +800,7 @@ export default { this.newRule.virtualmachineid = e.target.value getAPI('listNics', { virtualmachineid: e.target.value, - networkId: ('vpcid' in this.resource) ? this.selectedTier : this.resource.associatednetworkid + networkId: ('vpcid' in this.resource && (!('associatednetworkid' in this.resource) || this.vpcConserveMode)) ? this.selectedTier : this.resource.associatednetworkid }).then(response => { if (!response.listnicsresponse.nic || response.listnicsresponse.nic.length < 1) return const nic = response.listnicsresponse.nic[0] @@ -805,7 +820,7 @@ export default { this.vmCount = 0 this.vms = [] this.addVmModalLoading = true - const networkId = ('vpcid' in this.resource) ? this.selectedTier : this.resource.associatednetworkid + const networkId = ('vpcid' in this.resource && (!('associatednetworkid' in this.resource) || this.vpcConserveMode)) ? this.selectedTier : this.resource.associatednetworkid if (!networkId) { this.addVmModalLoading = false return diff --git a/ui/src/views/network/PublicIpResource.vue b/ui/src/views/network/PublicIpResource.vue index 82511dfadf64..6ca7a2c0d475 100644 --- a/ui/src/views/network/PublicIpResource.vue +++ b/ui/src/views/network/PublicIpResource.vue @@ -147,13 +147,15 @@ export default { let tabs = this.$route.meta.tabs.filter(tab => tab.name !== 'firewall') const network = await this.fetchNetwork() - if ((network && network.networkofferingconservemode) || !network && this.resource.issourcenat) { + if (network && network.networkofferingconservemode) { this.tabs = tabs return - } else if (this.resource.issourcenat) { - // VPC IPs with Source Nat have only VPN when conserve_mode = false - this.tabs = this.defaultTabs.concat(this.$route.meta.tabs.filter(tab => tab.name === 'vpn')) - return + } else { + // VPC IPs with source nat have only VPN when conserve mode = false + if (this.resource.issourcenat) { + this.tabs = this.defaultTabs.concat(this.$route.meta.tabs.filter(tab => tab.name === 'vpn')) + return + } } this.portFWRuleCount = await this.fetchPortFWRule() From 9e3eeb7927b7724abba000024757ff36c4c33eff Mon Sep 17 00:00:00 2001 From: nvazquez Date: Tue, 27 Jan 2026 12:10:40 -0300 Subject: [PATCH 04/11] Pass conserve mode on create VPC Offering --- .../network/vpc/VpcProvisioningService.java | 2 +- .../admin/vpc/CreateVPCOfferingCmd.java | 10 ++++++++ .../api/response/VpcOfferingResponse.java | 12 ++++++++++ .../management/ContrailManagerImpl.java | 2 +- .../api/query/dao/VpcOfferingJoinDaoImpl.java | 1 + .../com/cloud/network/vpc/VpcManagerImpl.java | 24 ++++++++++--------- ui/src/config/section/offering.js | 2 +- ui/src/views/offering/AddVpcOffering.vue | 13 ++++++++-- 8 files changed, 50 insertions(+), 16 deletions(-) diff --git a/api/src/main/java/com/cloud/network/vpc/VpcProvisioningService.java b/api/src/main/java/com/cloud/network/vpc/VpcProvisioningService.java index 97b95339ecf3..fbcf4f08bcc0 100644 --- a/api/src/main/java/com/cloud/network/vpc/VpcProvisioningService.java +++ b/api/src/main/java/com/cloud/network/vpc/VpcProvisioningService.java @@ -39,7 +39,7 @@ VpcOffering createVpcOffering(String name, String displayText, List supp Map serviceCapabilitystList, NetUtils.InternetProtocol internetProtocol, Long serviceOfferingId, String externalProvider, NetworkOffering.NetworkMode networkMode, List domainIds, List zoneIds, VpcOffering.State state, - NetworkOffering.RoutingMode routingMode, boolean specifyAsNumber); + NetworkOffering.RoutingMode routingMode, boolean specifyAsNumber, boolean conserveMode); Pair,Integer> listVpcOfferings(ListVPCOfferingsCmd cmd); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vpc/CreateVPCOfferingCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vpc/CreateVPCOfferingCmd.java index 6b425bc10d21..213d721b7ae2 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/vpc/CreateVPCOfferingCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vpc/CreateVPCOfferingCmd.java @@ -161,6 +161,12 @@ public class CreateVPCOfferingCmd extends BaseAsyncCreateCmd { description = "the routing mode for the VPC offering. Supported types are: Static or Dynamic.") private String routingMode; + @Parameter(name = ApiConstants.CONSERVE_MODE, type = CommandType.BOOLEAN, + since = "4.23.0", + description = "True if the VPC offering is IP conserve mode enabled, allowing public IP services to be used across multiple VPC tiers") + private Boolean conserveMode; + + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -311,6 +317,10 @@ public String getRoutingMode() { return routingMode; } + public boolean isConserveMode() { + return BooleanUtils.toBoolean(conserveMode); + } + @Override public void create() throws ResourceAllocationException { VpcOffering vpcOff = _vpcProvSvc.createVpcOffering(this); diff --git a/api/src/main/java/org/apache/cloudstack/api/response/VpcOfferingResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VpcOfferingResponse.java index a0516e660e48..cff13519bc07 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/VpcOfferingResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/VpcOfferingResponse.java @@ -102,6 +102,10 @@ public class VpcOfferingResponse extends BaseResponse { @Param(description = "The routing mode for the network offering, supported types are Static or Dynamic.") private String routingMode; + @SerializedName(ApiConstants.CONSERVE_MODE) + @Param(description = "True if the VPC offering is IP conserve mode enabled, allowing public IP services to be used across multiple VPC tiers.") + private Boolean conserveMode; + public void setId(String id) { this.id = id; } @@ -201,4 +205,12 @@ public String getRoutingMode() { public void setRoutingMode(String routingMode) { this.routingMode = routingMode; } + + public Boolean getConserveMode() { + return conserveMode; + } + + public void setConserveMode(Boolean conserveMode) { + this.conserveMode = conserveMode; + } } diff --git a/plugins/network-elements/juniper-contrail/src/main/java/org/apache/cloudstack/network/contrail/management/ContrailManagerImpl.java b/plugins/network-elements/juniper-contrail/src/main/java/org/apache/cloudstack/network/contrail/management/ContrailManagerImpl.java index f360fab01124..8badb916eeda 100644 --- a/plugins/network-elements/juniper-contrail/src/main/java/org/apache/cloudstack/network/contrail/management/ContrailManagerImpl.java +++ b/plugins/network-elements/juniper-contrail/src/main/java/org/apache/cloudstack/network/contrail/management/ContrailManagerImpl.java @@ -293,7 +293,7 @@ private VpcOffering locateVpcOffering() { } serviceProviderMap.put(svc, providerSet); } - vpcOffer = _vpcProvSvc.createVpcOffering(juniperVPCOfferingName, juniperVPCOfferingDisplayText, services, serviceProviderMap, null, null, null, null, null, null, null, VpcOffering.State.Enabled, null, false); + vpcOffer = _vpcProvSvc.createVpcOffering(juniperVPCOfferingName, juniperVPCOfferingDisplayText, services, serviceProviderMap, null, null, null, null, null, null, null, VpcOffering.State.Enabled, null, false, false); long id = vpcOffer.getId(); _vpcOffDao.update(id, (VpcOfferingVO)vpcOffer); return _vpcOffDao.findById(id); diff --git a/server/src/main/java/com/cloud/api/query/dao/VpcOfferingJoinDaoImpl.java b/server/src/main/java/com/cloud/api/query/dao/VpcOfferingJoinDaoImpl.java index 7ea4b7d5834f..e7fe07a18c78 100644 --- a/server/src/main/java/com/cloud/api/query/dao/VpcOfferingJoinDaoImpl.java +++ b/server/src/main/java/com/cloud/api/query/dao/VpcOfferingJoinDaoImpl.java @@ -77,6 +77,7 @@ public VpcOfferingResponse newVpcOfferingResponse(VpcOffering offering) { if (offering.isSpecifyAsNumber() != null) { offeringResponse.setSpecifyAsNumber(offering.isSpecifyAsNumber()); } + offeringResponse.setConserveMode(offering.isConserveMode()); if (offering instanceof VpcOfferingJoinVO) { VpcOfferingJoinVO offeringJoinVO = (VpcOfferingJoinVO) offering; offeringResponse.setDomainId(offeringJoinVO.getDomainUuid()); diff --git a/server/src/main/java/com/cloud/network/vpc/VpcManagerImpl.java b/server/src/main/java/com/cloud/network/vpc/VpcManagerImpl.java index 60b93d409aab..b0422c4b2410 100644 --- a/server/src/main/java/com/cloud/network/vpc/VpcManagerImpl.java +++ b/server/src/main/java/com/cloud/network/vpc/VpcManagerImpl.java @@ -388,7 +388,7 @@ public void doInTransactionWithoutResult(final TransactionStatus status) { } createVpcOffering(VpcOffering.defaultVPCOfferingName, VpcOffering.defaultVPCOfferingName, svcProviderMap, true, State.Enabled, null, false, - false, false, null, null, false); + false, false, null, null, false, true); } // configure default vpc offering with Netscaler as LB Provider @@ -408,7 +408,7 @@ public void doInTransactionWithoutResult(final TransactionStatus status) { } } createVpcOffering(VpcOffering.defaultVPCNSOfferingName, VpcOffering.defaultVPCNSOfferingName, - svcProviderMap, false, State.Enabled, null, false, false, false, null, null, false); + svcProviderMap, false, State.Enabled, null, false, false, false, null, null, false, false); } @@ -429,7 +429,7 @@ public void doInTransactionWithoutResult(final TransactionStatus status) { } } createVpcOffering(VpcOffering.redundantVPCOfferingName, VpcOffering.redundantVPCOfferingName, svcProviderMap, true, State.Enabled, - null, false, false, true, null, null, false); + null, false, false, true, null, null, false, true); } // configure default vpc offering with NSX as network service provider in NAT mode @@ -446,7 +446,7 @@ public void doInTransactionWithoutResult(final TransactionStatus status) { } } createVpcOffering(VpcOffering.DEFAULT_VPC_NAT_NSX_OFFERING_NAME, VpcOffering.DEFAULT_VPC_NAT_NSX_OFFERING_NAME, svcProviderMap, false, - State.Enabled, null, false, false, false, NetworkOffering.NetworkMode.NATTED, null, false); + State.Enabled, null, false, false, false, NetworkOffering.NetworkMode.NATTED, null, false, false); } @@ -464,7 +464,7 @@ public void doInTransactionWithoutResult(final TransactionStatus status) { } } createVpcOffering(VpcOffering.DEFAULT_VPC_ROUTE_NSX_OFFERING_NAME, VpcOffering.DEFAULT_VPC_ROUTE_NSX_OFFERING_NAME, svcProviderMap, false, - State.Enabled, null, false, false, false, NetworkOffering.NetworkMode.ROUTED, null, false); + State.Enabled, null, false, false, false, NetworkOffering.NetworkMode.ROUTED, null, false, false); } @@ -482,7 +482,7 @@ public void doInTransactionWithoutResult(final TransactionStatus status) { } } createVpcOffering(VpcOffering.DEFAULT_VPC_ROUTE_NETRIS_OFFERING_NAME, VpcOffering.DEFAULT_VPC_ROUTE_NETRIS_OFFERING_NAME, svcProviderMap, false, - State.Enabled, null, false, false, false, NetworkOffering.NetworkMode.ROUTED, null, false); + State.Enabled, null, false, false, false, NetworkOffering.NetworkMode.ROUTED, null, false, false); } @@ -500,7 +500,7 @@ public void doInTransactionWithoutResult(final TransactionStatus status) { } } createVpcOffering(VpcOffering.DEFAULT_VPC_NAT_NETRIS_OFFERING_NAME, VpcOffering.DEFAULT_VPC_NAT_NETRIS_OFFERING_NAME, svcProviderMap, false, - State.Enabled, null, false, false, false, NetworkOffering.NetworkMode.NATTED, null, false); + State.Enabled, null, false, false, false, NetworkOffering.NetworkMode.NATTED, null, false, false); } } @@ -586,6 +586,7 @@ public VpcOffering createVpcOffering(CreateVPCOfferingCmd cmd) { } boolean specifyAsNumber = cmd.getSpecifyAsNumber(); String routingModeString = cmd.getRoutingMode(); + boolean conserveMode = cmd.isConserveMode(); // check if valid domain if (CollectionUtils.isNotEmpty(cmd.getDomainIds())) { @@ -624,7 +625,7 @@ public VpcOffering createVpcOffering(CreateVPCOfferingCmd cmd) { return createVpcOffering(vpcOfferingName, displayText, supportedServices, serviceProviderList, serviceCapabilityList, internetProtocol, serviceOfferingId, provider, networkMode, - domainIds, zoneIds, (enable ? State.Enabled : State.Disabled), routingMode, specifyAsNumber); + domainIds, zoneIds, (enable ? State.Enabled : State.Disabled), routingMode, specifyAsNumber, conserveMode); } @Override @@ -632,7 +633,7 @@ public VpcOffering createVpcOffering(CreateVPCOfferingCmd cmd) { public VpcOffering createVpcOffering(final String name, final String displayText, final List supportedServices, final Map> serviceProviders, final Map serviceCapabilityList, final NetUtils.InternetProtocol internetProtocol, final Long serviceOfferingId, final String externalProvider, final NetworkOffering.NetworkMode networkMode, List domainIds, List zoneIds, State state, - NetworkOffering.RoutingMode routingMode, boolean specifyAsNumber) { + NetworkOffering.RoutingMode routingMode, boolean specifyAsNumber, boolean conserveMode) { if (!Ipv6Service.Ipv6OfferingCreationEnabled.value() && !(internetProtocol == null || NetUtils.InternetProtocol.IPv4.equals(internetProtocol))) { throw new InvalidParameterValueException(String.format("Configuration %s needs to be enabled for creating IPv6 supported VPC offering", Ipv6Service.Ipv6OfferingCreationEnabled.key())); @@ -727,7 +728,7 @@ public VpcOffering createVpcOffering(final String name, final String displayText final boolean offersRegionLevelVPC = isVpcOfferingForRegionLevelVpc(serviceCapabilityList); final boolean redundantRouter = isVpcOfferingRedundantRouter(serviceCapabilityList, redundantRouterService); final VpcOfferingVO offering = createVpcOffering(name, displayText, svcProviderMap, false, state, serviceOfferingId, supportsDistributedRouter, offersRegionLevelVPC, - redundantRouter, networkMode, routingMode, specifyAsNumber); + redundantRouter, networkMode, routingMode, specifyAsNumber, conserveMode); if (offering != null) { List detailsVO = new ArrayList<>(); @@ -755,7 +756,7 @@ public VpcOffering createVpcOffering(final String name, final String displayText @DB protected VpcOfferingVO createVpcOffering(final String name, final String displayText, final Map> svcProviderMap, final boolean isDefault, final State state, final Long serviceOfferingId, final boolean supportsDistributedRouter, final boolean offersRegionLevelVPC, - final boolean redundantRouter, NetworkOffering.NetworkMode networkMode, NetworkOffering.RoutingMode routingMode, boolean specifyAsNumber) { + final boolean redundantRouter, NetworkOffering.NetworkMode networkMode, NetworkOffering.RoutingMode routingMode, boolean specifyAsNumber, boolean conserveMode) { return Transaction.execute(new TransactionCallback() { @Override @@ -771,6 +772,7 @@ public VpcOfferingVO doInTransaction(final TransactionStatus status) { if (Objects.nonNull(routingMode)) { offering.setRoutingMode(routingMode); } + offering.setConserveMode(conserveMode); logger.debug("Adding vpc offering " + offering); offering = _vpcOffDao.persist(offering); diff --git a/ui/src/config/section/offering.js b/ui/src/config/section/offering.js index bc95772d6f7a..436c0cf1b604 100644 --- a/ui/src/config/section/offering.js +++ b/ui/src/config/section/offering.js @@ -508,7 +508,7 @@ export default { searchFilters: ['name', 'zoneid', 'domainid'], resourceType: 'VpcOffering', columns: ['name', 'state', 'displaytext', 'domain', 'zone', 'order'], - details: ['name', 'id', 'displaytext', 'internetprotocol', 'distributedvpcrouter', 'tags', 'routingmode', 'specifyasnumber', 'service', 'fornsx', 'networkmode', 'domain', 'zone', 'created'], + details: ['name', 'id', 'displaytext', 'internetprotocol', 'distributedvpcrouter', 'tags', 'routingmode', 'specifyasnumber', 'service', 'fornsx', 'networkmode', 'conservemode', 'domain', 'zone', 'created'], related: [{ name: 'vpc', title: 'label.vpc', diff --git a/ui/src/views/offering/AddVpcOffering.vue b/ui/src/views/offering/AddVpcOffering.vue index 32aa3e8d3583..17939d2b19e8 100644 --- a/ui/src/views/offering/AddVpcOffering.vue +++ b/ui/src/views/offering/AddVpcOffering.vue @@ -194,6 +194,14 @@ + + + + @@ -282,7 +290,6 @@ export default { return { selectedDomains: [], selectedZones: [], - isConserveMode: true, internetProtocolValue: 'ipv4', domains: [], domainLoading: false, @@ -328,7 +335,8 @@ export default { description: 'Netris', enabled: true }, - nsxSupportedServicesMap: {} + nsxSupportedServicesMap: {}, + conservemode: false } }, beforeCreate () { @@ -719,6 +727,7 @@ export default { params.provider = 'Netris' } params.networkmode = values.networkmode + params.conservemode = values.conservemode if (!values.forVpc) { params.specifyasnumber = values.specifyasnumber } From 45eba18612650a0932e5e6d6f436bd4c39d96297 Mon Sep 17 00:00:00 2001 From: nvazquez Date: Wed, 28 Jan 2026 23:16:30 -0300 Subject: [PATCH 05/11] Revert default VPC offerings conserve mode --- .../META-INF/db/schema-42210to42300.sql | 4 +-- .../com/cloud/network/vpc/VpcManagerImpl.java | 4 +-- ui/src/views/network/PublicIpResource.vue | 34 +++++++++++++++---- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql index 67c8a3c83209..3ed97d3c2b91 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql @@ -50,5 +50,5 @@ CREATE TABLE IF NOT EXISTS `cloud`.`webhook_filter` ( CONSTRAINT `fk_webhook_filter__webhook_id` FOREIGN KEY(`webhook_id`) REFERENCES `webhook`(`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vpc_offerings','conserve_mode', 'tinyint(1) unsigned NULL DEFAULT 0'); -UPDATE `cloud`.`vpc_offerings` SET conserve_mode=1 WHERE name='Default VPC offering'; +-- Add conserve mode for VPC offerings +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vpc_offerings','conserve_mode', 'tinyint(1) unsigned NULL DEFAULT 0 COMMENT ''True if the VPC offering is IP conserve mode enabled, allowing public IP services to be used across multiple VPC tiers'' '); diff --git a/server/src/main/java/com/cloud/network/vpc/VpcManagerImpl.java b/server/src/main/java/com/cloud/network/vpc/VpcManagerImpl.java index b0422c4b2410..3f9a29556e25 100644 --- a/server/src/main/java/com/cloud/network/vpc/VpcManagerImpl.java +++ b/server/src/main/java/com/cloud/network/vpc/VpcManagerImpl.java @@ -388,7 +388,7 @@ public void doInTransactionWithoutResult(final TransactionStatus status) { } createVpcOffering(VpcOffering.defaultVPCOfferingName, VpcOffering.defaultVPCOfferingName, svcProviderMap, true, State.Enabled, null, false, - false, false, null, null, false, true); + false, false, null, null, false, false); } // configure default vpc offering with Netscaler as LB Provider @@ -429,7 +429,7 @@ public void doInTransactionWithoutResult(final TransactionStatus status) { } } createVpcOffering(VpcOffering.redundantVPCOfferingName, VpcOffering.redundantVPCOfferingName, svcProviderMap, true, State.Enabled, - null, false, false, true, null, null, false, true); + null, false, false, true, null, null, false, false); } // configure default vpc offering with NSX as network service provider in NAT mode diff --git a/ui/src/views/network/PublicIpResource.vue b/ui/src/views/network/PublicIpResource.vue index 6ca7a2c0d475..0b061ea2e0b0 100644 --- a/ui/src/views/network/PublicIpResource.vue +++ b/ui/src/views/network/PublicIpResource.vue @@ -135,6 +135,14 @@ export default { return } if (this.resource && this.resource.vpcid) { + const vpc = await this.fetchVpc() + + // VPC IPs with source nat have only VPN when VPC offering conserve mode = false + if (this.resource.issourcenat && vpc.vpcofferingconservemode === false) { + this.tabs = this.defaultTabs.concat(this.$route.meta.tabs.filter(tab => tab.name === 'vpn')) + return + } + // VPC IPs with static nat have nothing if (this.resource.isstaticnat) { if (this.resource.virtualmachinetype === 'DomainRouter') { @@ -148,14 +156,13 @@ export default { const network = await this.fetchNetwork() if (network && network.networkofferingconservemode) { - this.tabs = tabs - return - } else { - // VPC IPs with source nat have only VPN when conserve mode = false - if (this.resource.issourcenat) { + // VPC IPs with source nat have only VPN when VPC offering conserve mode = false + if (this.resource.issourcenat && vpc.vpcofferingconservemode === false) { this.tabs = this.defaultTabs.concat(this.$route.meta.tabs.filter(tab => tab.name === 'vpn')) - return + } else { + this.tabs = tabs } + return } this.portFWRuleCount = await this.fetchPortFWRule() @@ -193,6 +200,21 @@ export default { fetchAction () { this.actions = this.$route.meta.actions || [] }, + fetchVpc () { + if (!this.resource.vpcid) { + return null + } + return new Promise((resolve, reject) => { + getAPI('listVPCs', { + id: this.resource.vpcid + }).then(json => { + const vpc = json.listvpcsresponse?.vpc?.[0] || null + resolve(vpc) + }).catch(e => { + reject(e) + }) + }) + }, fetchNetwork () { if (!this.resource.associatednetworkid) { return null From 3772560a72e8b9b6861fc3e63a092570e3fda6eb Mon Sep 17 00:00:00 2001 From: nvazquez Date: Tue, 24 Feb 2026 12:50:14 -0300 Subject: [PATCH 06/11] Fix VPC search on detectRulesConflict --- .../java/com/cloud/network/firewall/FirewallManagerImpl.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/src/main/java/com/cloud/network/firewall/FirewallManagerImpl.java b/server/src/main/java/com/cloud/network/firewall/FirewallManagerImpl.java index 93035157e210..c4c358874dd5 100644 --- a/server/src/main/java/com/cloud/network/firewall/FirewallManagerImpl.java +++ b/server/src/main/java/com/cloud/network/firewall/FirewallManagerImpl.java @@ -30,6 +30,7 @@ import javax.inject.Inject; import javax.naming.ConfigurationException; +import com.cloud.network.vpc.Vpc; import com.cloud.network.vpc.VpcOfferingVO; import com.cloud.network.vpc.dao.VpcOfferingDao; import org.apache.commons.lang3.ObjectUtils; @@ -406,7 +407,8 @@ public void detectRulesConflict(FirewallRule newRule) throws NetworkRuleConflict boolean isNewRuleOnVpcNetwork = newRuleNetwork.getVpcId() != null; boolean isVpcConserveModeEnabled = false; if (isNewRuleOnVpcNetwork) { - VpcOfferingVO vpcOffering = vpcOfferingDao.findById(newRuleNetwork.getVpcId()); + Vpc vpc = _vpcMgr.getActiveVpc(newRuleNetwork.getVpcId()); + VpcOfferingVO vpcOffering = vpc != null ? vpcOfferingDao.findById(vpc.getVpcOfferingId()) : null; isVpcConserveModeEnabled = vpcOffering != null && vpcOffering.isConserveMode(); } From 980ccc98ee5da3e09175b56f1504d7feb4587dec Mon Sep 17 00:00:00 2001 From: nvazquez Date: Tue, 24 Feb 2026 13:55:20 -0300 Subject: [PATCH 07/11] Fix UI comments from Copilot review --- ui/src/views/network/LoadBalancing.vue | 4 ++-- ui/src/views/network/PortForwarding.vue | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/src/views/network/LoadBalancing.vue b/ui/src/views/network/LoadBalancing.vue index 2e47f702f7bd..55a61bae0c3b 100644 --- a/ui/src/views/network/LoadBalancing.vue +++ b/ui/src/views/network/LoadBalancing.vue @@ -487,10 +487,10 @@ >
+ v-if="'vpcid' in resource && (!('associatednetworkid' in resource) || vpcConserveMode)"> {{ $t('label.select.tier') }}
+ v-if="'vpcid' in resource && (!('associatednetworkid' in resource) || vpcConserveMode)"> {{ $t('label.select.tier') }} { if (!response.listnicsresponse.nic || response.listnicsresponse.nic.length < 1) return const nic = response.listnicsresponse.nic[0] From a0755a143eb4544644e2a576c5b8c52e5e8d6560 Mon Sep 17 00:00:00 2001 From: nvazquez Date: Tue, 24 Feb 2026 14:08:02 -0300 Subject: [PATCH 08/11] Fix UI conditions preventing for missing parameter for conserve mode --- ui/src/views/network/PublicIpResource.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/views/network/PublicIpResource.vue b/ui/src/views/network/PublicIpResource.vue index 0b061ea2e0b0..0540e7f292a8 100644 --- a/ui/src/views/network/PublicIpResource.vue +++ b/ui/src/views/network/PublicIpResource.vue @@ -138,7 +138,7 @@ export default { const vpc = await this.fetchVpc() // VPC IPs with source nat have only VPN when VPC offering conserve mode = false - if (this.resource.issourcenat && vpc.vpcofferingconservemode === false) { + if (this.resource.issourcenat && vpc?.vpcofferingconservemode === false) { this.tabs = this.defaultTabs.concat(this.$route.meta.tabs.filter(tab => tab.name === 'vpn')) return } @@ -157,7 +157,7 @@ export default { const network = await this.fetchNetwork() if (network && network.networkofferingconservemode) { // VPC IPs with source nat have only VPN when VPC offering conserve mode = false - if (this.resource.issourcenat && vpc.vpcofferingconservemode === false) { + if (this.resource.issourcenat && vpc?.vpcofferingconservemode === false) { this.tabs = this.defaultTabs.concat(this.$route.meta.tabs.filter(tab => tab.name === 'vpn')) } else { this.tabs = tabs From 6d33ef122126280bb623bccf2455dc9f82137327 Mon Sep 17 00:00:00 2001 From: nvazquez Date: Tue, 24 Feb 2026 19:39:00 -0300 Subject: [PATCH 09/11] Add unit tests for detectConflicts method and smoke tests --- .../network/firewall/FirewallManagerImpl.java | 39 ++- .../network/firewall/FirewallManagerTest.java | 99 +++++-- .../smoke/test_vpc_conserve_mode.py | 277 ++++++++++++++++++ tools/marvin/marvin/lib/base.py | 2 + 4 files changed, 381 insertions(+), 36 deletions(-) create mode 100644 test/integration/smoke/test_vpc_conserve_mode.py diff --git a/server/src/main/java/com/cloud/network/firewall/FirewallManagerImpl.java b/server/src/main/java/com/cloud/network/firewall/FirewallManagerImpl.java index c4c358874dd5..308c12ca9dca 100644 --- a/server/src/main/java/com/cloud/network/firewall/FirewallManagerImpl.java +++ b/server/src/main/java/com/cloud/network/firewall/FirewallManagerImpl.java @@ -400,17 +400,9 @@ public void detectRulesConflict(FirewallRule newRule) throws NetworkRuleConflict assert (rules.size() >= 1); } - NetworkVO newRuleNetwork = _networkDao.findById(newRule.getNetworkId()); - if (newRuleNetwork == null) { - throw new InvalidParameterValueException("Unable to create firewall rule as cannot find network by id=" + newRule.getNetworkId()); - } - boolean isNewRuleOnVpcNetwork = newRuleNetwork.getVpcId() != null; - boolean isVpcConserveModeEnabled = false; - if (isNewRuleOnVpcNetwork) { - Vpc vpc = _vpcMgr.getActiveVpc(newRuleNetwork.getVpcId()); - VpcOfferingVO vpcOffering = vpc != null ? vpcOfferingDao.findById(vpc.getVpcOfferingId()) : null; - isVpcConserveModeEnabled = vpcOffering != null && vpcOffering.isConserveMode(); - } + NetworkVO newRuleNetwork = getNewRuleNetwork(newRule); + boolean newRuleIsOnVpcNetwork = isNewRuleOnVpcNetwork(newRuleNetwork); + boolean vpcConserveModeEnabled = isVpcConserveModeEnabled(newRuleNetwork); for (FirewallRuleVO rule : rules) { if (rule.getId() == newRule.getId()) { @@ -461,10 +453,10 @@ public void detectRulesConflict(FirewallRule newRule) throws NetworkRuleConflict // Checking if the rule applied is to the same network that is passed in the rule. // (except for VPCs with conserve mode = true) - if ((!isNewRuleOnVpcNetwork || !isVpcConserveModeEnabled) + if ((!newRuleIsOnVpcNetwork || !vpcConserveModeEnabled) && rule.getNetworkId() != newRule.getNetworkId() && rule.getState() != State.Revoke) { String errMsg = String.format("New rule is for a different network than what's specified in rule %s", rule.getXid()); - if (isNewRuleOnVpcNetwork) { + if (newRuleIsOnVpcNetwork) { errMsg += String.format(" - VPC id=%s is not using conserve mode", newRuleNetwork.getVpcId()); } throw new NetworkRuleConflictException(errMsg); @@ -516,6 +508,27 @@ public void detectRulesConflict(FirewallRule newRule) throws NetworkRuleConflict } } + protected boolean isVpcConserveModeEnabled(NetworkVO newRuleNetwork) { + if (isNewRuleOnVpcNetwork(newRuleNetwork)) { + Vpc vpc = _vpcMgr.getActiveVpc(newRuleNetwork.getVpcId()); + VpcOfferingVO vpcOffering = vpc != null ? vpcOfferingDao.findById(vpc.getVpcOfferingId()) : null; + return vpcOffering != null && vpcOffering.isConserveMode(); + } + return false; + } + + protected boolean isNewRuleOnVpcNetwork(NetworkVO newRuleNetwork) { + return newRuleNetwork.getVpcId() != null; + } + + protected NetworkVO getNewRuleNetwork(FirewallRule newRule) { + NetworkVO newRuleNetwork = _networkDao.findById(newRule.getNetworkId()); + if (newRuleNetwork == null) { + throw new InvalidParameterValueException("Unable to create firewall rule as cannot find network by id=" + newRule.getNetworkId()); + } + return newRuleNetwork; + } + protected boolean checkIfRulesHaveConflictingPortRanges(FirewallRule newRule, FirewallRule rule, boolean oneOfRulesIsFirewall, boolean bothRulesFirewall, boolean bothRulesPortForwarding, boolean duplicatedCidrs) { String rulesAsString = String.format("[%s] and [%s]", rule, newRule); diff --git a/server/src/test/java/com/cloud/network/firewall/FirewallManagerTest.java b/server/src/test/java/com/cloud/network/firewall/FirewallManagerTest.java index 3c663dde92db..681cb9a62ca2 100644 --- a/server/src/test/java/com/cloud/network/firewall/FirewallManagerTest.java +++ b/server/src/test/java/com/cloud/network/firewall/FirewallManagerTest.java @@ -32,7 +32,10 @@ import com.cloud.network.rules.FirewallRule; import com.cloud.network.rules.FirewallRule.Purpose; import com.cloud.network.rules.FirewallRuleVO; +import com.cloud.network.vpc.Vpc; import com.cloud.network.vpc.VpcManager; +import com.cloud.network.vpc.VpcOfferingVO; +import com.cloud.network.vpc.dao.VpcOfferingDao; import com.cloud.user.AccountManager; import com.cloud.user.DomainManager; import com.cloud.utils.component.ComponentContext; @@ -81,6 +84,8 @@ public class FirewallManagerTest { FirewallRulesDao _firewallDao; @Mock NetworkDao _networkDao; + @Mock + VpcOfferingDao vpcOfferingDao; @Spy @InjectMocks @@ -168,54 +173,102 @@ public void testApplyFWRules() { } } - @Test - public void testDetectRulesConflict() { - List ruleList = new ArrayList(); - FirewallRuleVO rule1 = spy(new FirewallRuleVO("rule1", 3, 500, "UDP", 1, 2, 1, Purpose.Vpn, null, null, null, null)); - FirewallRuleVO rule2 = spy(new FirewallRuleVO("rule2", 3, 1701, "UDP", 1, 2, 1, Purpose.Vpn, null, null, null, null)); - FirewallRuleVO rule3 = spy(new FirewallRuleVO("rule3", 3, 4500, "UDP", 1, 2, 1, Purpose.Vpn, null, null, null, null)); + private List createExistingFirewallListRulesList(long existingNetworkId) { + List ruleList = new ArrayList<>(); + FirewallRuleVO rule1 = spy(new FirewallRuleVO("rule1", 3, 500, "UDP", existingNetworkId, 2, 1, Purpose.Vpn, null, null, null, null)); + FirewallRuleVO rule2 = spy(new FirewallRuleVO("rule2", 3, 1701, "UDP", existingNetworkId, 2, 1, Purpose.Vpn, null, null, null, null)); + FirewallRuleVO rule3 = spy(new FirewallRuleVO("rule3", 3, 4500, "UDP", existingNetworkId, 2, 1, Purpose.Vpn, null, null, null, null)); List sString = Arrays.asList("10.1.1.1/24","192.168.1.1/24"); List dString1 = Arrays.asList("10.1.1.1/25"); - List dString2 = Arrays.asList("10.1.1.128/25"); - FirewallRuleVO rule4 = spy(new FirewallRuleVO("rule4", 3L, 10, 20, "TCP", 1, 2, 1, Purpose.Firewall, sString, dString1, null, null, + FirewallRuleVO rule4 = spy(new FirewallRuleVO("rule4", 3L, 10, 20, "TCP", existingNetworkId, 2, 1, Purpose.Firewall, sString, dString1, null, null, null, FirewallRule.TrafficType.Egress)); + when(rule1.getId()).thenReturn(1L); + when(rule2.getId()).thenReturn(2L); + when(rule3.getId()).thenReturn(3L); + when(rule4.getId()).thenReturn(4L); + ruleList.add(rule1); ruleList.add(rule2); ruleList.add(rule3); ruleList.add(rule4); - FirewallManagerImpl firewallMgr = (FirewallManagerImpl)_firewallMgr; + return ruleList; + } - when(firewallMgr._firewallDao.listByIpAndPurposeAndNotRevoked(3,null)).thenReturn(ruleList); - when(rule1.getId()).thenReturn(1L); - when(rule2.getId()).thenReturn(2L); - when(rule3.getId()).thenReturn(3L); - when(rule4.getId()).thenReturn(4L); + private List createNewRuleList(long newNetworkId) { + List sString = Arrays.asList("10.1.1.1/24","192.168.1.1/24"); + List dString2 = Arrays.asList("10.1.1.128/25"); - FirewallRule newRule1 = new FirewallRuleVO("newRule1", 3, 500, "TCP", 1, 2, 1, Purpose.PortForwarding, null, null, null, null); - FirewallRule newRule2 = new FirewallRuleVO("newRule2", 3, 1701, "TCP", 1, 2, 1, Purpose.PortForwarding, null, null, null, null); - FirewallRule newRule3 = new FirewallRuleVO("newRule3", 3, 4500, "TCP", 1, 2, 1, Purpose.PortForwarding, null, null, null, null); - FirewallRule newRule4 = new FirewallRuleVO("newRule4", 3L, 15, 25, "TCP", 1, 2, 1, Purpose.Firewall, sString, dString2, null, null, + FirewallRule newRule1 = new FirewallRuleVO("newRule1", 3, 500, "TCP", newNetworkId, 2, 1, Purpose.PortForwarding, null, null, null, null); + FirewallRule newRule2 = new FirewallRuleVO("newRule2", 3, 1701, "TCP", newNetworkId, 2, 1, Purpose.PortForwarding, null, null, null, null); + FirewallRule newRule3 = new FirewallRuleVO("newRule3", 3, 4500, "TCP", newNetworkId, 2, 1, Purpose.PortForwarding, null, null, null, null); + FirewallRule newRule4 = new FirewallRuleVO("newRule4", 3L, 15, 25, "TCP", newNetworkId, 2, 1, Purpose.Firewall, sString, dString2, null, null, null, FirewallRule.TrafficType.Egress); + return Arrays.asList(newRule1, newRule2, newRule3, newRule4); + } + + @Test + public void testDetectRulesConflictIsolatedNetwork() { + List ruleList = createExistingFirewallListRulesList(1L); + when(_firewallMgr._firewallDao.listByIpAndPurposeAndNotRevoked(3,null)).thenReturn(ruleList); + + List newRuleList = createNewRuleList(1L); NetworkVO networkVO = Mockito.mock(NetworkVO.class); - when(firewallMgr._networkDao.findById(1L)).thenReturn(networkVO); + when(_firewallMgr._networkDao.findById(1L)).thenReturn(networkVO); when(networkVO.getVpcId()).thenReturn(null); try { - firewallMgr.detectRulesConflict(newRule1); - firewallMgr.detectRulesConflict(newRule2); - firewallMgr.detectRulesConflict(newRule3); - firewallMgr.detectRulesConflict(newRule4); + for (FirewallRule newRule : newRuleList) { + _firewallMgr.detectRulesConflict(newRule); + } } catch (NetworkRuleConflictException ex) { Assert.fail(); } } + private void testDetectRulesConflictVpcBase(boolean vpcConserveMode) throws NetworkRuleConflictException { + long existingNetworkId = 1L; + long newNetworkId = 2L; + long vpcId = 10L; + + List ruleList = createExistingFirewallListRulesList(existingNetworkId); + when(_firewallMgr._firewallDao.listByIpAndPurposeAndNotRevoked(3,null)).thenReturn(ruleList); + + List newRuleList = createNewRuleList(newNetworkId); + + NetworkVO newNetworkVO = Mockito.mock(NetworkVO.class); + Vpc vpc = Mockito.mock(Vpc.class); + VpcOfferingVO vpcOffering = Mockito.mock(VpcOfferingVO.class); + + when(_firewallMgr._networkDao.findById(2L)).thenReturn(newNetworkVO); + when(newNetworkVO.getVpcId()).thenReturn(vpcId); + when(_vpcMgr.getActiveVpc(vpcId)).thenReturn(vpc); + when(vpc.getVpcOfferingId()).thenReturn(1L); + when(vpcOfferingDao.findById(1L)).thenReturn(vpcOffering); + when(vpcOffering.isConserveMode()).thenReturn(vpcConserveMode); + + for (FirewallRule newRule : newRuleList) { + _firewallMgr.detectRulesConflict(newRule); + } + } + + @Test + public void testDetectRulesConflictVpcConserveMode() throws NetworkRuleConflictException { + // When VPC conserve mode is enabled, rules can be created for multiple network tiers + testDetectRulesConflictVpcBase(true); + } + + @Test(expected = NetworkRuleConflictException.class) + public void testDetectRulesConflictVpcConserveModeFalse() throws NetworkRuleConflictException { + // When VPC conserve mode is disabled, an exception should be thrown when attempting to create rules on different network tiers + testDetectRulesConflictVpcBase(false); + } + @Test public void checkIfRulesHaveConflictingPortRangesTestOnlyOneRuleIsFirewallReturnsFalse() { diff --git a/test/integration/smoke/test_vpc_conserve_mode.py b/test/integration/smoke/test_vpc_conserve_mode.py new file mode 100644 index 000000000000..35bb471ea38e --- /dev/null +++ b/test/integration/smoke/test_vpc_conserve_mode.py @@ -0,0 +1,277 @@ +# 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 VPC Conserve Mode (since 4.23.0) + +Conserve mode allows public IP services (LB, Port Forwarding, Static NAT) to be +shared across multiple VPC tiers using the same public IP address. + +When conserve mode is ON: + - A single public IP can have rules targeting VMs in different VPC tiers + - FirewallManagerImpl skips the cross-network conflict check for that VPC + +When conserve mode is OFF (default before 4.23.0): + - Rules on a given public IP must all belong to the same VPC tier (network) + - Attempting to create a rule on a different tier than an existing rule raises + a NetworkRuleConflictException +""" + +from marvin.cloudstackException import CloudstackAPIException +from marvin.cloudstackTestCase import cloudstackTestCase +from marvin.codes import FAILED +from marvin.lib.base import ( + Account, + LoadBalancerRule, + NATRule, + Network, + NetworkOffering, + PublicIPAddress, + ServiceOffering, + VirtualMachine, + VPC, + VpcOffering, +) +from marvin.lib.common import ( + get_domain, + get_test_template, + get_zone, + list_publicIP +) +from marvin.lib.utils import cleanup_resources +from nose.plugins.attrib import attr +import logging + +class TestVPCConserveModeRules(cloudstackTestCase): + """Tests that conserve mode for VPC controls whether rules on the same public IP are allowed in multiple VPC tiers. + """ + + @classmethod + def setUpClass(cls): + cls.testClient = super(TestVPCConserveModeRules, cls).getClsTestClient() + cls.apiclient = cls.testClient.getApiClient() + cls.zone = get_zone(cls.apiclient, cls.testClient.getZoneForTests()) + cls.domain = get_domain(cls.apiclient) + cls.hypervisor = cls.testClient.getHypervisorInfo() + cls.logger = logging.getLogger("TestVPCConserveModeRules") + + cls.account = Account.create( + cls.apiclient, + cls.services["account"], + admin=True, + domainid=cls.domain.id) + cls._cleanup.append(cls.account) + + cls.template = get_test_template( + cls.apiclient, + cls.zone.id, + cls.hypervisor) + if cls.template == FAILED: + assert False, "get_test_template() failed to return template" + + cls.service_offering = ServiceOffering.create( + cls.apiclient, + cls.services["service_offerings"]["tiny"] + ) + cls._cleanup.append(cls.service_offering) + + cls.vpc_offering_conserve_mode = VpcOffering.create( + cls.apiclient, + cls.services["vpc_offering"], + conservemode=True, + ) + cls._cleanup.append(cls.vpc_offering_conserve_mode) + + cls.vpc_offering_conserve_mode.update(cls.apiclient, state="Enabled") + + cls.network_offering = NetworkOffering.create( + cls.apiclient, + cls.services["network_offering"], + conservemode=True + ) + cls.network_offering.update(cls.apiclient, state="Enabled") + + cls.services["vpc"]["cidr"] = "10.10.20.0/24" + + cls.vpc = VPC.create( + cls.apiclient, + cls.services["vpc"], + vpcofferingid=cls.vpc_offering_conserve_mode.id, + zoneid=cls.zone.id, + account=cls.account.name, + domainid=cls.account.domainid, + ) + + gateway_tier1 = "10.10.20.1" + netmask_tiers = "255.255.255.240" + + cls.services["network_offering"]["name"] = "tier1-" + cls.vpc.id + cls.services["network_offering"]["displayname"] = "tier1-" + cls.vpc.id + cls.tier1 = Network.create( + cls.self.apiclient, + services=cls.services["network_offering"], + accountid=cls.account.name, + domainid=cls.account.domainid, + networkofferingid=cls.network_offering.id, + zoneid=cls.zone.id, + vpcid=cls.vpc.id, + gateway=gateway_tier1, + netmask=netmask_tiers, + ) + + gateway_tier2 = "10.10.20.17" + cls.services["network_offering"]["name"] = "tier2-" + cls.vpc.id + cls.services["network_offering"]["displayname"] = "tier2-" + cls.vpc.id + cls.tier2 = Network.create( + cls.apiclient, + services=cls.services["network_offering"], + accountid=cls.account.name, + domainid=cls.account.domainid, + networkofferingid=cls.network_offering.id, + zoneid=cls.zone.id, + vpcid=cls.vpc.id, + gateway=gateway_tier2, + netmask=netmask_tiers, + ) + + cls.services["virtual_machine"]["displayname"] = "vm1" + cls.vpc.id + cls.vm1 = VirtualMachine.create( + cls.apiclient, + services=cls.services["virtual_machine"], + templateid=cls.template.id, + zoneid=cls.zone.id, + accountid=cls.account.name, + domainid=cls.account.domainid, + serviceofferingid=cls.service_offering.id, + networkids=[cls.tier1.id], + ) + cls.services["virtual_machine"]["displayname"] = "vm2" + cls.vpc.id + cls.vm2 = VirtualMachine.create( + cls.apiclient, + services=cls.services["virtual_machine"], + templateid=cls.template.id, + zoneid=cls.zone.id, + accountid=cls.account.name, + domainid=cls.account.domainid, + serviceofferingid=cls.service_offering.id, + networkids=[cls.tier2.id], + ) + + @classmethod + def tearDownClass(cls): + super(TestVPCConserveModeRules, cls).tearDownClass() + + def setUp(self): + self.apiclient = self.testClient.getApiClient() + self.cleanup = [] + + def tearDown(self): + super(TestVPCConserveModeRules, self).tearDown() + + @attr(tags=["advanced"], required_hardware="true") + def test_01_vpc_conserve_mode_cross_tier_rules_allowed(self): + """With conserveMode=True, LB rule on VPC Tier 1 and Port Forwarding rule on VPC Tier 2 can + share the same public IP without a NetworkRuleConflictException. + """ + + public_ip = PublicIPAddress.create( + self.apiclient, + zoneid=self.zone.id, + accountid=self.account.name, + domainid=self.account.domainid, + vpcid=self.vpc.id, + ) + + self.logger.debug( + "Creating LB rule on tier-1 (networkid=%s) using public IP %s", + self.tier1.id, + public_ip.ipaddress.ipaddress, + ) + lb_rule_tier1 = LoadBalancerRule.create( + self.apiclient, + self.services["lbrule"], + ipaddressid=public_ip.ipaddress.id, + accountid=self.account.name, + vpcid=self.vpc.id, + networkid=self.tier1.id, + domainid=self.account.domainid, + ) + self.assertIsNotNone(lb_rule_tier1, "LB rule creation on tier-1 failed") + lb_rule_tier1.assign(self.apiclient, [self.vm1]) + + self.logger.debug( + "Creating Port Forwarding rule on tier-2 (networkid=%s) " + "using the same public IP %s – should succeed with conserve mode", + self.tier2.id, + public_ip.ipaddress.ipaddress, + ) + try: + nat_rule = NATRule.create( + self.apiclient, + self.vm2, + self.services["natrule"], + ipaddressid=public_ip.ipaddress.id, + vpcid=self.vpc.id, + networkid=self.tier2.id, + ) + self.assertIsNotNone( + nat_rule, + "Port Forwarding rule creation on tier-2 failed unexpectedly", + ) + except CloudstackAPIException as e: + self.fail( + "Expected cross-tier Port Forwarding rule to succeed with " + "conserveMode=True, but got exception: %s" % e + ) + + @attr(tags=["advanced"], required_hardware="true") + def test_02_vpc_conserve_mode_reuse_source_nat_ip_address(self): + """With VPC conserve mode enabled, a NAT rule can be created on a VPC tier (conserve mode enabled) + with a source NAT IP address + """ + source_nat_ip_resp = list_publicIP( + self.apiclient, + vpcid=self.vpc.id, + listall=True, + issourcenat=True + ) + + source_nat_ip = source_nat_ip_resp[0] + + self.logger.debug( + "Creating Port Forwarding rule on tier-1 (networkid=%s) " + "using the source NAT public IP %s – should succeed with conserve mode", + self.tier1.id, + source_nat_ip.ipaddress.ipaddress, + ) + try: + nat_rule = NATRule.create( + self.apiclient, + self.vm2, + self.services["natrule"], + ipaddressid=source_nat_ip.ipaddress.id, + vpcid=self.vpc.id, + networkid=self.tier2.id, + ) + self.assertIsNotNone( + nat_rule, + "Port Forwarding rule creation on tier-2 failed unexpectedly", + ) + except CloudstackAPIException as e: + self.fail( + "Expected cross-tier Port Forwarding rule to succeed with " + "conserveMode=True, but got exception: %s" % e + ) diff --git a/tools/marvin/marvin/lib/base.py b/tools/marvin/marvin/lib/base.py index bbec2027bdc1..43b44d0d3922 100755 --- a/tools/marvin/marvin/lib/base.py +++ b/tools/marvin/marvin/lib/base.py @@ -5225,6 +5225,8 @@ def create(cls, apiclient, services): cmd.networkmode = services["networkmode"] if "routingmode" in services: cmd.routingmode = services["routingmode"] + if "conservemode" in services: + cmd.conservemode = services["conservemode"] return VpcOffering(apiclient.createVPCOffering(cmd).__dict__) def update(self, apiclient, name=None, displaytext=None, state=None): From 30e916db287c38b4767401037a8b9a55778eda8c Mon Sep 17 00:00:00 2001 From: nvazquez Date: Wed, 25 Feb 2026 11:46:51 -0300 Subject: [PATCH 10/11] Fix UI condition for PF rule creation --- ui/src/views/network/PortForwarding.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/views/network/PortForwarding.vue b/ui/src/views/network/PortForwarding.vue index 9ebc447d1c39..64a670d83e7c 100644 --- a/ui/src/views/network/PortForwarding.vue +++ b/ui/src/views/network/PortForwarding.vue @@ -642,7 +642,7 @@ export default { if (this.loading) return this.loading = true this.addVmModalVisible = false - const networkId = ('vpcid' in this.resource && (!('associatednetworkid' in this.resource || this.vpcConserveMode))) ? this.selectedTier : this.resource.associatednetworkid + const networkId = ('vpcid' in this.resource && (!('associatednetworkid' in this.resource) || this.vpcConserveMode)) ? this.selectedTier : this.resource.associatednetworkid postAPI('createPortForwardingRule', { ...this.newRule, ipaddressid: this.resource.id, From 1367e436750556a6f0493b4f25c94d744c4d43c9 Mon Sep 17 00:00:00 2001 From: nvazquez Date: Wed, 25 Feb 2026 16:15:41 -0300 Subject: [PATCH 11/11] Fix marvin tests --- .../smoke/test_vpc_conserve_mode.py | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/test/integration/smoke/test_vpc_conserve_mode.py b/test/integration/smoke/test_vpc_conserve_mode.py index 35bb471ea38e..3bd63e35f0d4 100644 --- a/test/integration/smoke/test_vpc_conserve_mode.py +++ b/test/integration/smoke/test_vpc_conserve_mode.py @@ -63,10 +63,12 @@ class TestVPCConserveModeRules(cloudstackTestCase): def setUpClass(cls): cls.testClient = super(TestVPCConserveModeRules, cls).getClsTestClient() cls.apiclient = cls.testClient.getApiClient() + cls.services = cls.testClient.getParsedTestDataConfig() cls.zone = get_zone(cls.apiclient, cls.testClient.getZoneForTests()) cls.domain = get_domain(cls.apiclient) cls.hypervisor = cls.testClient.getHypervisorInfo() cls.logger = logging.getLogger("TestVPCConserveModeRules") + cls._cleanup = [] cls.account = Account.create( cls.apiclient, @@ -88,21 +90,34 @@ def setUpClass(cls): ) cls._cleanup.append(cls.service_offering) + cls.services["vpc_offering"]["supportedservices"] = 'Vpn,Dhcp,Dns,SourceNat,Lb,UserData,StaticNat,NetworkACL,PortForwarding' + cls.services["vpc_offering"]["conservemode"] = True cls.vpc_offering_conserve_mode = VpcOffering.create( cls.apiclient, - cls.services["vpc_offering"], - conservemode=True, + cls.services["vpc_offering"] ) - cls._cleanup.append(cls.vpc_offering_conserve_mode) - cls.vpc_offering_conserve_mode.update(cls.apiclient, state="Enabled") + cls._cleanup.append(cls.vpc_offering_conserve_mode) + cls.services["network_offering"]["supportedservices"] = 'Vpn,Dhcp,Dns,SourceNat,Lb,UserData,StaticNat,NetworkACL,PortForwarding' + cls.services["network_offering"]["serviceProviderList"] = { + "Vpn": 'VpcVirtualRouter', + "Dhcp": 'VpcVirtualRouter', + "Dns": 'VpcVirtualRouter', + "SourceNat": 'VpcVirtualRouter', + "Lb": 'VpcVirtualRouter', + "UserData": 'VpcVirtualRouter', + "StaticNat": 'VpcVirtualRouter', + "NetworkACL": 'VpcVirtualRouter', + "PortForwarding": 'VpcVirtualRouter' + } cls.network_offering = NetworkOffering.create( cls.apiclient, cls.services["network_offering"], conservemode=True ) cls.network_offering.update(cls.apiclient, state="Enabled") + cls._cleanup.append(cls.network_offering) cls.services["vpc"]["cidr"] = "10.10.20.0/24" @@ -114,6 +129,7 @@ def setUpClass(cls): account=cls.account.name, domainid=cls.account.domainid, ) + cls._cleanup.append(cls.vpc) gateway_tier1 = "10.10.20.1" netmask_tiers = "255.255.255.240" @@ -121,7 +137,7 @@ def setUpClass(cls): cls.services["network_offering"]["name"] = "tier1-" + cls.vpc.id cls.services["network_offering"]["displayname"] = "tier1-" + cls.vpc.id cls.tier1 = Network.create( - cls.self.apiclient, + cls.apiclient, services=cls.services["network_offering"], accountid=cls.account.name, domainid=cls.account.domainid, @@ -131,6 +147,7 @@ def setUpClass(cls): gateway=gateway_tier1, netmask=netmask_tiers, ) + cls._cleanup.append(cls.tier1) gateway_tier2 = "10.10.20.17" cls.services["network_offering"]["name"] = "tier2-" + cls.vpc.id @@ -146,6 +163,7 @@ def setUpClass(cls): gateway=gateway_tier2, netmask=netmask_tiers, ) + cls._cleanup.append(cls.tier2) cls.services["virtual_machine"]["displayname"] = "vm1" + cls.vpc.id cls.vm1 = VirtualMachine.create( @@ -169,6 +187,8 @@ def setUpClass(cls): serviceofferingid=cls.service_offering.id, networkids=[cls.tier2.id], ) + cls._cleanup.append(cls.vm1) + cls._cleanup.append(cls.vm2) @classmethod def tearDownClass(cls): @@ -181,7 +201,7 @@ def setUp(self): def tearDown(self): super(TestVPCConserveModeRules, self).tearDown() - @attr(tags=["advanced"], required_hardware="true") + @attr(tags=["advanced", "advancedns", "smoke"], required_hardware="true") def test_01_vpc_conserve_mode_cross_tier_rules_allowed(self): """With conserveMode=True, LB rule on VPC Tier 1 and Port Forwarding rule on VPC Tier 2 can share the same public IP without a NetworkRuleConflictException. @@ -237,7 +257,7 @@ def test_01_vpc_conserve_mode_cross_tier_rules_allowed(self): "conserveMode=True, but got exception: %s" % e ) - @attr(tags=["advanced"], required_hardware="true") + @attr(tags=["advanced", "advancedns", "smoke"], required_hardware="true") def test_02_vpc_conserve_mode_reuse_source_nat_ip_address(self): """With VPC conserve mode enabled, a NAT rule can be created on a VPC tier (conserve mode enabled) with a source NAT IP address @@ -255,14 +275,14 @@ def test_02_vpc_conserve_mode_reuse_source_nat_ip_address(self): "Creating Port Forwarding rule on tier-1 (networkid=%s) " "using the source NAT public IP %s – should succeed with conserve mode", self.tier1.id, - source_nat_ip.ipaddress.ipaddress, + source_nat_ip.ipaddress, ) try: nat_rule = NATRule.create( self.apiclient, self.vm2, self.services["natrule"], - ipaddressid=source_nat_ip.ipaddress.id, + ipaddressid=source_nat_ip.id, vpcid=self.vpc.id, networkid=self.tier2.id, )