diff --git a/api/src/main/java/com/cloud/network/Network.java b/api/src/main/java/com/cloud/network/Network.java index 0846306f70f9..1687702678f2 100644 --- a/api/src/main/java/com/cloud/network/Network.java +++ b/api/src/main/java/com/cloud/network/Network.java @@ -207,6 +207,7 @@ public static class Provider { public static final Provider Nsx = new Provider("Nsx", false); public static final Provider Netris = new Provider("Netris", false); + public static final Provider Ovn = new Provider("Ovn", false); private final String name; private final boolean isExternal; diff --git a/api/src/main/java/com/cloud/network/Networks.java b/api/src/main/java/com/cloud/network/Networks.java index 5f767686dc97..fb528b83e0d4 100644 --- a/api/src/main/java/com/cloud/network/Networks.java +++ b/api/src/main/java/com/cloud/network/Networks.java @@ -130,7 +130,8 @@ public URI toUri(T value) { OpenDaylight("opendaylight", String.class), TUNGSTEN("tf", String.class), NSX("nsx", String.class), - Netris("netris", String.class); + Netris("netris", String.class), + OVN("ovn", String.class); private final String scheme; private final Class type; diff --git a/api/src/main/java/com/cloud/network/ovn/OvnProvider.java b/api/src/main/java/com/cloud/network/ovn/OvnProvider.java new file mode 100644 index 000000000000..86e4fa16f7a8 --- /dev/null +++ b/api/src/main/java/com/cloud/network/ovn/OvnProvider.java @@ -0,0 +1,36 @@ +// 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 com.cloud.network.ovn; + +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; + +public interface OvnProvider extends InternalIdentity, Identity { + long getZoneId(); + Long getHostId(); + String getName(); + String getNbConnection(); + String getSbConnection(); + String getCaCertPath(); + String getClientCertPath(); + String getClientPrivateKeyPath(); + String getExternalBridge(); + String getLocalnetName(); + String getIcNbConnection(); + String getIcSbConnection(); + String getAvailabilityZoneName(); +} diff --git a/api/src/main/java/com/cloud/network/ovn/OvnService.java b/api/src/main/java/com/cloud/network/ovn/OvnService.java new file mode 100644 index 000000000000..eda51162dab5 --- /dev/null +++ b/api/src/main/java/com/cloud/network/ovn/OvnService.java @@ -0,0 +1,34 @@ +// 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 com.cloud.network.ovn; + +/** + * Service boundary for CloudStack's native OVN integration. + */ +public interface OvnService { + String getLogicalSwitchName(long networkId); + String getLogicalRouterName(long vpcId); + String getLogicalSwitchPortName(long nicId); + boolean isValidConnectionString(String connection); + + /** + * Opens a transient connection to the OVN Northbound endpoint described by the arguments, + * runs an OVSDB echo and confirms the OVN_Northbound database is advertised. Throws a + * {@link com.cloud.utils.exception.CloudRuntimeException} on any failure, leaving no resources behind. + */ + void verifyNbConnection(String nbConnection, String caCertPath, String clientCertPath, String clientPrivateKeyPath); +} 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 f84602232159..bb4ac08c2790 100644 --- a/api/src/main/java/com/cloud/network/vpc/VpcOffering.java +++ b/api/src/main/java/com/cloud/network/vpc/VpcOffering.java @@ -34,6 +34,7 @@ public enum State { public static final String DEFAULT_VPC_ROUTE_NSX_OFFERING_NAME = "VPC offering with NSX - Route Mode"; public static final String DEFAULT_VPC_ROUTE_NETRIS_OFFERING_NAME = "VPC offering with Netris - Route Mode"; public static final String DEFAULT_VPC_NAT_NETRIS_OFFERING_NAME = "VPC offering with Netris - NAT Mode"; + public static final String DEFAULT_VPC_NAT_OVN_OFFERING_NAME = "VPC offering with OVN - NAT Mode"; /** * diff --git a/api/src/main/java/com/cloud/offering/NetworkOffering.java b/api/src/main/java/com/cloud/offering/NetworkOffering.java index 5000a4f8c626..9fe44fe2d798 100644 --- a/api/src/main/java/com/cloud/offering/NetworkOffering.java +++ b/api/src/main/java/com/cloud/offering/NetworkOffering.java @@ -66,6 +66,8 @@ enum RoutingMode { public static final String DEFAULT_ROUTED_NSX_OFFERING_FOR_VPC = "DefaultRoutedNSXNetworkOfferingForVpc"; public static final String DEFAULT_ROUTED_NETRIS_OFFERING_FOR_VPC = "DefaultRoutedNetrisNetworkOfferingForVpc"; public static final String DEFAULT_NAT_NETRIS_OFFERING_FOR_VPC = "DefaultNATNetrisNetworkOfferingForVpc"; + public static final String DEFAULT_NAT_OVN_OFFERING = "DefaultNATOVNNetworkOffering"; + public static final String DEFAULT_NAT_OVN_OFFERING_FOR_VPC = "DefaultNATOVNNetworkOfferingForVpc"; public static final String DEFAULT_NAT_NSX_OFFERING = "DefaultNATNSXNetworkOffering"; public static final String DEFAULT_ROUTED_NSX_OFFERING = "DefaultRoutedNSXNetworkOffering"; public final static String QuickCloudNoServices = "QuickCloudNoServices"; 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 7eae16a2a376..91cfaa55698e 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -1036,6 +1036,16 @@ public class ApiConstants { public static final String NSX_PROVIDER_PORT = "nsxproviderport"; public static final String NSX_CONTROLLER_ID = "nsxcontrollerid"; + public static final String OVN_NB_CONNECTION = "ovnnbconnection"; + public static final String OVN_SB_CONNECTION = "ovnsbconnection"; + public static final String OVN_CA_CERT_PATH = "ovncacertpath"; + public static final String OVN_CLIENT_CERT_PATH = "ovnclientcertpath"; + public static final String OVN_CLIENT_PRIVATE_KEY_PATH = "ovnclientprivatekeypath"; + public static final String OVN_EXTERNAL_BRIDGE = "ovnexternalbridge"; + public static final String OVN_LOCALNET_NAME = "ovnlocalnetname"; + public static final String OVN_IC_NB_CONNECTION = "ovnicnbconnection"; + public static final String OVN_IC_SB_CONNECTION = "ovnicsbconnection"; + public static final String OVN_AVAILABILITY_ZONE_NAME = "ovnavailabilityzonename"; public static final String S3_ACCESS_KEY = "accesskey"; public static final String SECRET_KEY = "secretkey"; public static final String S3_END_POINT = "endpoint"; @@ -1307,6 +1317,7 @@ public class ApiConstants { public static final String HAS_RULES = "hasrules"; public static final String NSX_DETAIL_KEY = "forNsx"; public static final String NETRIS_DETAIL_KEY = "forNetris"; + public static final String OVN_DETAIL_KEY = "forOvn"; public static final String NETRIS_TAG = "netristag"; public static final String NETRIS_VXLAN_ID = "netrisvxlanid"; public static final String NETRIS_URL = "netrisurl"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/CreatePhysicalNetworkCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/CreatePhysicalNetworkCmd.java index 097b8a5b5458..cf913fd2fb46 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/CreatePhysicalNetworkCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/CreatePhysicalNetworkCmd.java @@ -75,7 +75,7 @@ public class CreatePhysicalNetworkCmd extends BaseAsyncCreateCmd { @Parameter(name = ApiConstants.ISOLATION_METHODS, type = CommandType.LIST, collectionType = CommandType.STRING, - description = "The isolation method for the physical Network[VLAN/VXLAN/GRE/STT/BCF_SEGMENT/SSP/ODL/L3VPN/VCS/NSX/NETRIS]") + description = "The isolation method for the physical Network[VLAN/VXLAN/GRE/STT/BCF_SEGMENT/SSP/ODL/L3VPN/VCS/NSX/NETRIS/OVN]") private List isolationMethods; @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, required = true, description = "The name of the physical Network") diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/NetworkOfferingBaseCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/NetworkOfferingBaseCmd.java index 1c832b7217ef..20a0ca90c916 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/NetworkOfferingBaseCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/NetworkOfferingBaseCmd.java @@ -55,6 +55,7 @@ import static org.apache.cloudstack.api.command.utils.OfferingUtils.isNsxWithoutLb; import static org.apache.cloudstack.api.command.utils.OfferingUtils.isNetrisNatted; import static org.apache.cloudstack.api.command.utils.OfferingUtils.isNetrisRouted; +import static org.apache.cloudstack.api.command.utils.OfferingUtils.isOvnProvider; public abstract class NetworkOfferingBaseCmd extends BaseCmd { @@ -249,7 +250,7 @@ public Long getServiceOfferingId() { } public boolean isExternalNetworkProvider() { - return Arrays.asList("NSX", "Netris").stream() + return Arrays.asList("NSX", "Netris", "OVN").stream() .anyMatch(s -> provider != null && s.equalsIgnoreCase(provider)); } @@ -271,16 +272,18 @@ public List getSupportedServices() { } else { List services = new ArrayList<>(List.of( Dhcp.getName(), - Dns.getName(), - UserData.getName() + Dns.getName() )); + if (!isOvnProvider(getProvider())) { + services.add(UserData.getName()); + } if (NetworkOffering.NetworkMode.NATTED.name().equalsIgnoreCase(getNetworkMode())) { services.addAll(Arrays.asList( StaticNat.getName(), SourceNat.getName(), PortForwarding.getName())); } - if (getNsxSupportsLbService() || (provider != null && isNetrisNatted(getProvider(), getNetworkMode()))) { + if (getNsxSupportsLbService() || (provider != null && (isNetrisNatted(getProvider(), getNetworkMode()) || isOvnProvider(getProvider())))) { services.add(Lb.getName()); } if (Boolean.TRUE.equals(forVpc)) { @@ -374,7 +377,7 @@ private void getServiceProviderMapForExternalProvider(Map> String routerProvider = Boolean.TRUE.equals(getForVpc()) ? VirtualRouterProvider.Type.VPCVirtualRouter.name() : VirtualRouterProvider.Type.VirtualRouter.name(); List unsupportedServices = new ArrayList<>(List.of("Vpn", "Gateway", "SecurityGroup", "Connectivity", "BaremetalPxeService")); - List routerSupported = List.of("Dhcp", "Dns", "UserData"); + List routerSupported = isOvnProvider(provider) ? List.of() : List.of("Dhcp", "Dns", "UserData"); List allServices = Network.Service.listAllServices().stream().map(Network.Service::getName).collect(Collectors.toList()); if (routerProvider.equals(VirtualRouterProvider.Type.VPCVirtualRouter.name())) { unsupportedServices.add("Firewall"); @@ -386,6 +389,9 @@ private void getServiceProviderMapForExternalProvider(Map> continue; if (routerSupported.contains(service)) serviceProviderMap.put(service, List.of(routerProvider)); + else if (isOvnProvider(provider) && (Dhcp.getName().equalsIgnoreCase(service) || Dns.getName().equalsIgnoreCase(service))) { + serviceProviderMap.put(service, List.of(provider)); + } else if (NetworkOffering.NetworkMode.NATTED.name().equalsIgnoreCase(getNetworkMode()) || NetworkACL.getName().equalsIgnoreCase(service)) { serviceProviderMap.put(service, List.of(provider)); } 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 2b934a60da7a..4b33e3a6c91c 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 @@ -64,6 +64,7 @@ import static org.apache.cloudstack.api.command.utils.OfferingUtils.isNetrisNatted; import static org.apache.cloudstack.api.command.utils.OfferingUtils.isNetrisRouted; import static org.apache.cloudstack.api.command.utils.OfferingUtils.isNsxWithoutLb; +import static org.apache.cloudstack.api.command.utils.OfferingUtils.isOvnProvider; @APICommand(name = "createVPCOffering", description = "Creates VPC offering", responseObject = VpcOfferingResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) @@ -179,7 +180,7 @@ public String getDisplayText() { } public boolean isExternalNetworkProvider() { - return Arrays.asList("NSX", "Netris").stream() + return Arrays.asList("NSX", "Netris", "OVN").stream() .anyMatch(s -> provider != null && s.equalsIgnoreCase(provider)); } @@ -189,9 +190,11 @@ public List getSupportedServices() { supportedServices = new ArrayList<>(List.of( Dhcp.getName(), Dns.getName(), - NetworkACL.getName(), - UserData.getName() + NetworkACL.getName() )); + if (!isOvnProvider(getProvider())) { + supportedServices.add(UserData.getName()); + } if (NetworkOffering.NetworkMode.NATTED.name().equalsIgnoreCase(getNetworkMode())) { supportedServices.addAll(Arrays.asList( StaticNat.getName(), @@ -201,7 +204,7 @@ public List getSupportedServices() { if (NetworkOffering.NetworkMode.ROUTED.name().equalsIgnoreCase(getNetworkMode())) { supportedServices.add(Gateway.getName()); } - if (getNsxSupportsLbService() || isNetrisNatted(getProvider(), getNetworkMode())) { + if (getNsxSupportsLbService() || isNetrisNatted(getProvider(), getNetworkMode()) || isOvnProvider(getProvider())) { supportedServices.add(Lb.getName()); } } @@ -252,13 +255,16 @@ private void getServiceProviderMapForExternalProvider(Map> if (NetworkOffering.NetworkMode.NATTED.name().equalsIgnoreCase(getNetworkMode())) { unsupportedServices.add("Gateway"); } - List routerSupported = List.of("Dhcp", "Dns", "UserData"); + List routerSupported = isOvnProvider(provider) ? List.of() : List.of("Dhcp", "Dns", "UserData"); List allServices = Network.Service.listAllServices().stream().map(Network.Service::getName).collect(Collectors.toList()); for (String service : allServices) { if (unsupportedServices.contains(service)) continue; if (routerSupported.contains(service)) serviceProviderMap.put(service, List.of(VirtualRouterProvider.Type.VPCVirtualRouter.name())); + else if (isOvnProvider(provider) && (Dhcp.getName().equalsIgnoreCase(service) || Dns.getName().equalsIgnoreCase(service))) { + serviceProviderMap.put(service, List.of(provider)); + } else if (NetworkOffering.NetworkMode.NATTED.name().equalsIgnoreCase(getNetworkMode()) || Stream.of(NetworkACL.getName(), Gateway.getName()).anyMatch(s -> s.equalsIgnoreCase(service))) { serviceProviderMap.put(service, List.of(provider)); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/utils/OfferingUtils.java b/api/src/main/java/org/apache/cloudstack/api/command/utils/OfferingUtils.java index 433a37c07cde..64e770096a3e 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/utils/OfferingUtils.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/utils/OfferingUtils.java @@ -35,4 +35,8 @@ public static boolean isNsxWithoutLb(String provider, boolean nsxSupportsLbServi public static boolean isNetrisRouted(String provider, String networkMode) { return "Netris".equalsIgnoreCase(provider) && NetworkOffering.NetworkMode.ROUTED.name().equalsIgnoreCase(networkMode); } + + public static boolean isOvnProvider(String provider) { + return "Ovn".equalsIgnoreCase(provider); + } } diff --git a/client/pom.xml b/client/pom.xml index 7118f455ab5f..a8e4ce5fa325 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -306,6 +306,11 @@ cloud-plugin-network-opendaylight ${project.version} + + org.apache.cloudstack + cloud-plugin-network-ovn + ${project.version} + org.apache.cloudstack cloud-plugin-network-vcs diff --git a/engine/api/src/main/java/com/cloud/vm/VirtualMachineGuru.java b/engine/api/src/main/java/com/cloud/vm/VirtualMachineGuru.java index 76f0830f369e..cb908348072c 100644 --- a/engine/api/src/main/java/com/cloud/vm/VirtualMachineGuru.java +++ b/engine/api/src/main/java/com/cloud/vm/VirtualMachineGuru.java @@ -88,7 +88,7 @@ public static String getEncodedString(String certificate) { return Base64.getEncoder().encodeToString(certificate.replace("\n", KeyStoreUtils.CERT_NEWLINE_ENCODER).replace(" ", KeyStoreUtils.CERT_SPACE_ENCODER).getBytes(StandardCharsets.UTF_8)); } - static void appendCertificateDetails(StringBuilder buf, Certificate certificate) { + public static void appendCertificateDetails(StringBuilder buf, Certificate certificate) { try { buf.append(" certificate=").append(getEncodedString(CertUtils.x509CertificateToPem(certificate.getClientCertificate()))); buf.append(" cacertificate=").append(getEncodedString(CertUtils.x509CertificatesToPem(certificate.getCaCertificates()))); diff --git a/engine/api/src/main/java/org/apache/cloudstack/engine/orchestration/service/NetworkOrchestrationService.java b/engine/api/src/main/java/org/apache/cloudstack/engine/orchestration/service/NetworkOrchestrationService.java index b7b548fb9407..50cd3ad31ec8 100644 --- a/engine/api/src/main/java/org/apache/cloudstack/engine/orchestration/service/NetworkOrchestrationService.java +++ b/engine/api/src/main/java/org/apache/cloudstack/engine/orchestration/service/NetworkOrchestrationService.java @@ -114,6 +114,9 @@ public interface NetworkOrchestrationService { ConfigKey NETRIS_ENABLED = new ConfigKey<>(Boolean.class, "netris.plugin.enable", "Advanced", "false", "Indicates whether to enable the Netris plugin", false, ConfigKey.Scope.Zone, null); + + ConfigKey OVN_ENABLED = new ConfigKey<>(Boolean.class, "ovn.plugin.enable", "Advanced", "false", + "Indicates whether to enable the OVN plugin", false, ConfigKey.Scope.Zone, null); ConfigKey NETWORK_LB_HAPROXY_MAX_CONN = new ConfigKey<>( "Network", Integer.class, diff --git a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestrator.java b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestrator.java index 7d455e7d6dc9..0510452023ec 100644 --- a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestrator.java +++ b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestrator.java @@ -626,7 +626,69 @@ public void doInTransactionWithoutResult(final TransactionStatus status) { true, null, true, false, null, false, null, true, false, false, false, false, null, null, null, true, null, null, false); } - //#8 - network offering with internal lb service + //#8 - OVN isolated offering with source nat enabled and no Virtual Router dependency + if (_networkOfferingDao.findByUniqueName(NetworkOffering.DEFAULT_NAT_OVN_OFFERING) == null) { + final Map> defaultOvnIsolatedOfferingProviders = new HashMap<>(); + final Set ovnProvider = new HashSet<>(); + ovnProvider.add(Network.Provider.Ovn); + final Set configDriveProvider = new HashSet<>(); + configDriveProvider.add(Network.Provider.ConfigDrive); + defaultOvnIsolatedOfferingProviders.put(Service.Dhcp, ovnProvider); + defaultOvnIsolatedOfferingProviders.put(Service.Dns, ovnProvider); + defaultOvnIsolatedOfferingProviders.put(Service.Firewall, ovnProvider); + defaultOvnIsolatedOfferingProviders.put(Service.Gateway, ovnProvider); + defaultOvnIsolatedOfferingProviders.put(Service.Lb, ovnProvider); + defaultOvnIsolatedOfferingProviders.put(Service.SourceNat, ovnProvider); + defaultOvnIsolatedOfferingProviders.put(Service.StaticNat, ovnProvider); + defaultOvnIsolatedOfferingProviders.put(Service.PortForwarding, ovnProvider); + // OVN data-plane has no metadata service of its own; UserData rides on + // ConfigDrive (ISO9660 attached to the VM at boot). The OVN provider + // does not advertise UserData in initCapabilities() so we have to bind + // it here explicitly. + defaultOvnIsolatedOfferingProviders.put(Service.UserData, configDriveProvider); + offering = _configMgr.createNetworkOffering(NetworkOffering.DEFAULT_NAT_OVN_OFFERING, + "Offering for OVN enabled networks - NAT mode", TrafficType.Guest, null, false, Availability.Optional, null, + defaultOvnIsolatedOfferingProviders, true, Network.GuestType.Isolated, false, null, true, null, false, false, null, false, null, + true, false, false, false, false, null, null, null, true, null, null, false); + offering.setPublicLb(true); + _networkOfferingDao.update(offering.getId(), offering); + } + + //#9 - OVN VPC tier offering with source nat enabled and no Virtual Router dependency + if (_networkOfferingDao.findByUniqueName(NetworkOffering.DEFAULT_NAT_OVN_OFFERING_FOR_VPC) == null) { + final Map> defaultOvnVpcOfferingProviders = new HashMap<>(); + final Set ovnProvider = new HashSet<>(); + ovnProvider.add(Network.Provider.Ovn); + final Set configDriveProvider = new HashSet<>(); + configDriveProvider.add(Network.Provider.ConfigDrive); + defaultOvnVpcOfferingProviders.put(Service.Dhcp, ovnProvider); + defaultOvnVpcOfferingProviders.put(Service.Dns, ovnProvider); + defaultOvnVpcOfferingProviders.put(Service.NetworkACL, ovnProvider); + // CloudStack #8863: VPC tiers must support firewall rules on Public IPs in + // addition to NetworkACL on the tier subnet. Bind Firewall to the OVN + // provider so applyFWRules is wired and the offering can host public-IP + // firewall rules without falling back to a VR. + defaultOvnVpcOfferingProviders.put(Service.Firewall, ovnProvider); + defaultOvnVpcOfferingProviders.put(Service.Gateway, ovnProvider); + defaultOvnVpcOfferingProviders.put(Service.Lb, ovnProvider); + defaultOvnVpcOfferingProviders.put(Service.SourceNat, ovnProvider); + defaultOvnVpcOfferingProviders.put(Service.StaticNat, ovnProvider); + defaultOvnVpcOfferingProviders.put(Service.PortForwarding, ovnProvider); + defaultOvnVpcOfferingProviders.put(Service.UserData, configDriveProvider); + offering = _configMgr.createNetworkOffering(NetworkOffering.DEFAULT_NAT_OVN_OFFERING_FOR_VPC, + "Offering for OVN enabled networks on VPCs - NAT mode", TrafficType.Guest, null, false, Availability.Optional, null, + defaultOvnVpcOfferingProviders, true, Network.GuestType.Isolated, false, null, true, null, false, false, null, false, null, + true, true, false, false, false, null, null, null, true, null, null, false); + offering.setPublicLb(true); + // Internal LB is now delivered natively by OVN (PR-5b: tier-CIDR VIP attached + // to the VPC LR + tier LS, hairpin_snat_ip on the tier gateway). Without + // this flag CloudStack's createLoadBalancer scheme=Internal API rejects the + // request with "Scheme Internal is not supported by the network offering". + offering.setInternalLb(true); + _networkOfferingDao.update(offering.getId(), offering); + } + + //#10 - network offering with internal lb service final Map> internalLbOffProviders = new HashMap<>(); final Set defaultVpcProvider = new HashSet<>(); defaultVpcProvider.add(Network.Provider.VPCVirtualRouter); diff --git a/engine/schema/src/main/java/com/cloud/network/dao/OvnProviderDao.java b/engine/schema/src/main/java/com/cloud/network/dao/OvnProviderDao.java new file mode 100644 index 000000000000..dda7c70a0551 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/network/dao/OvnProviderDao.java @@ -0,0 +1,24 @@ +// 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 com.cloud.network.dao; + +import com.cloud.network.element.OvnProviderVO; +import com.cloud.utils.db.GenericDao; + +public interface OvnProviderDao extends GenericDao { + OvnProviderVO findByZoneId(long zoneId); +} diff --git a/engine/schema/src/main/java/com/cloud/network/dao/OvnProviderDaoImpl.java b/engine/schema/src/main/java/com/cloud/network/dao/OvnProviderDaoImpl.java new file mode 100644 index 000000000000..6f97b47b6ceb --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/network/dao/OvnProviderDaoImpl.java @@ -0,0 +1,47 @@ +// 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 com.cloud.network.dao; + +import com.cloud.network.element.OvnProviderVO; +import com.cloud.utils.db.DB; +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import org.springframework.stereotype.Component; + +@Component +@DB() +public class OvnProviderDaoImpl extends GenericDaoBase implements OvnProviderDao { + final SearchBuilder allFieldsSearch; + + public OvnProviderDaoImpl() { + super(); + allFieldsSearch = createSearchBuilder(); + allFieldsSearch.and("id", allFieldsSearch.entity().getId(), SearchCriteria.Op.EQ); + allFieldsSearch.and("uuid", allFieldsSearch.entity().getUuid(), SearchCriteria.Op.EQ); + allFieldsSearch.and("zone_id", allFieldsSearch.entity().getZoneId(), SearchCriteria.Op.EQ); + allFieldsSearch.and("nb_connection", allFieldsSearch.entity().getNbConnection(), SearchCriteria.Op.EQ); + allFieldsSearch.done(); + } + + @Override + public OvnProviderVO findByZoneId(long zoneId) { + SearchCriteria sc = allFieldsSearch.create(); + sc.setParameters("zone_id", zoneId); + return findOneBy(sc); + } +} diff --git a/engine/schema/src/main/java/com/cloud/network/dao/OvnVpcPeeringDao.java b/engine/schema/src/main/java/com/cloud/network/dao/OvnVpcPeeringDao.java new file mode 100644 index 000000000000..849eba59b8d6 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/network/dao/OvnVpcPeeringDao.java @@ -0,0 +1,35 @@ +// 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 com.cloud.network.dao; + +import com.cloud.network.element.OvnVpcPeeringVO; +import com.cloud.utils.db.GenericDao; + +import java.util.List; + +public interface OvnVpcPeeringDao extends GenericDao { + List listByGroupUuid(String groupUuid); + List listByGroupUuidIncludingDisabled(String groupUuid); + List listByVpcId(long vpcId); + OvnVpcPeeringVO findByGroupUuidAndVpcId(String groupUuid, long vpcId); + List listByAccountId(long accountId); + List listByAccountIdIncludingDisabled(long accountId); + List listAllActive(); + List listAllIncludingDisabled(); + List listByAclId(long aclId); + OvnVpcPeeringVO findByUuid(String uuid); +} diff --git a/engine/schema/src/main/java/com/cloud/network/dao/OvnVpcPeeringDaoImpl.java b/engine/schema/src/main/java/com/cloud/network/dao/OvnVpcPeeringDaoImpl.java new file mode 100644 index 000000000000..d2569b64713f --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/network/dao/OvnVpcPeeringDaoImpl.java @@ -0,0 +1,128 @@ +// 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 com.cloud.network.dao; + +import com.cloud.network.element.OvnVpcPeeringVO; +import com.cloud.utils.db.DB; +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +@DB() +public class OvnVpcPeeringDaoImpl extends GenericDaoBase implements OvnVpcPeeringDao { + final SearchBuilder allFieldsSearch; + + public OvnVpcPeeringDaoImpl() { + super(); + allFieldsSearch = createSearchBuilder(); + allFieldsSearch.and("id", allFieldsSearch.entity().getId(), SearchCriteria.Op.EQ); + allFieldsSearch.and("uuid", allFieldsSearch.entity().getUuid(), SearchCriteria.Op.EQ); + allFieldsSearch.and("group_uuid", allFieldsSearch.entity().getGroupUuid(), SearchCriteria.Op.EQ); + allFieldsSearch.and("vpc_id", allFieldsSearch.entity().getVpcId(), SearchCriteria.Op.EQ); + allFieldsSearch.and("zone_id", allFieldsSearch.entity().getZoneId(), SearchCriteria.Op.EQ); + allFieldsSearch.and("account_id", allFieldsSearch.entity().getAccountId(), SearchCriteria.Op.EQ); + allFieldsSearch.and("state", allFieldsSearch.entity().getState(), SearchCriteria.Op.EQ); + allFieldsSearch.and("acl_id", allFieldsSearch.entity().getAclId(), SearchCriteria.Op.EQ); + allFieldsSearch.done(); + } + + @Override + public List listByGroupUuid(String groupUuid) { + SearchCriteria sc = allFieldsSearch.create(); + sc.setParameters("group_uuid", groupUuid); + sc.setParameters("state", "Active"); + return listBy(sc); + } + + @Override + public List listByGroupUuidIncludingDisabled(String groupUuid) { + SearchCriteria sc = allFieldsSearch.create(); + sc.setParameters("group_uuid", groupUuid); + return listBy(sc).stream() + .filter(p -> !"Removed".equals(p.getState())) + .collect(Collectors.toList()); + } + + @Override + public List listByVpcId(long vpcId) { + SearchCriteria sc = allFieldsSearch.create(); + sc.setParameters("vpc_id", vpcId); + sc.setParameters("state", "Active"); + return listBy(sc); + } + + @Override + public OvnVpcPeeringVO findByGroupUuidAndVpcId(String groupUuid, long vpcId) { + SearchCriteria sc = allFieldsSearch.create(); + sc.setParameters("group_uuid", groupUuid); + sc.setParameters("vpc_id", vpcId); + sc.setParameters("state", "Active"); + return findOneBy(sc); + } + + @Override + public List listByAccountId(long accountId) { + SearchCriteria sc = allFieldsSearch.create(); + sc.setParameters("account_id", accountId); + sc.setParameters("state", "Active"); + return listBy(sc); + } + + @Override + public List listByAclId(long aclId) { + SearchCriteria sc = allFieldsSearch.create(); + sc.setParameters("acl_id", aclId); + sc.setParameters("state", "Active"); + return listBy(sc); + } + + @Override + public List listAllActive() { + SearchCriteria sc = allFieldsSearch.create(); + sc.setParameters("state", "Active"); + return listBy(sc); + } + + @Override + public List listByAccountIdIncludingDisabled(long accountId) { + SearchCriteria sc = allFieldsSearch.create(); + sc.setParameters("account_id", accountId); + return listBy(sc).stream() + .filter(p -> !"Removed".equals(p.getState())) + .collect(Collectors.toList()); + } + + @Override + public List listAllIncludingDisabled() { + SearchCriteria sc = allFieldsSearch.create(); + return listBy(sc).stream() + .filter(p -> !"Removed".equals(p.getState())) + .collect(Collectors.toList()); + } + + @Override + public OvnVpcPeeringVO findByUuid(String uuid) { + SearchCriteria sc = allFieldsSearch.create(); + sc.setParameters("uuid", uuid); + return findOneBy(sc); + } +} diff --git a/engine/schema/src/main/java/com/cloud/network/element/OvnProviderVO.java b/engine/schema/src/main/java/com/cloud/network/element/OvnProviderVO.java new file mode 100644 index 000000000000..9a0bddeb2995 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/network/element/OvnProviderVO.java @@ -0,0 +1,339 @@ +// 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 com.cloud.network.element; + +import com.cloud.network.ovn.OvnProvider; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import java.util.Date; +import java.util.UUID; + +@Entity +@Table(name = "ovn_providers") +public class OvnProviderVO implements OvnProvider { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "zone_id") + private long zoneId; + + @Column(name = "host_id") + private Long hostId; + + @Column(name = "name") + private String name; + + @Column(name = "nb_connection") + private String nbConnection; + + @Column(name = "sb_connection") + private String sbConnection; + + @Column(name = "ca_cert_path") + private String caCertPath; + + @Column(name = "client_cert_path") + private String clientCertPath; + + @Column(name = "client_private_key_path") + private String clientPrivateKeyPath; + + @Column(name = "external_bridge") + private String externalBridge; + + @Column(name = "localnet_name") + private String localnetName; + + @Column(name = "ic_nb_connection") + private String icNbConnection; + + @Column(name = "ic_sb_connection") + private String icSbConnection; + + @Column(name = "availability_zone_name") + private String availabilityZoneName; + + @Column(name = "created") + private Date created; + + @Column(name = "removed") + private Date removed; + + public OvnProviderVO() { + uuid = UUID.randomUUID().toString(); + } + + @Override + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + @Override + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + @Override + public long getZoneId() { + return zoneId; + } + + public void setZoneId(long zoneId) { + this.zoneId = zoneId; + } + + @Override + public Long getHostId() { + return hostId; + } + + public void setHostId(Long hostId) { + this.hostId = hostId; + } + + @Override + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public String getNbConnection() { + return nbConnection; + } + + public void setNbConnection(String nbConnection) { + this.nbConnection = nbConnection; + } + + @Override + public String getSbConnection() { + return sbConnection; + } + + public void setSbConnection(String sbConnection) { + this.sbConnection = sbConnection; + } + + @Override + public String getCaCertPath() { + return caCertPath; + } + + public void setCaCertPath(String caCertPath) { + this.caCertPath = caCertPath; + } + + @Override + public String getClientCertPath() { + return clientCertPath; + } + + public void setClientCertPath(String clientCertPath) { + this.clientCertPath = clientCertPath; + } + + @Override + public String getClientPrivateKeyPath() { + return clientPrivateKeyPath; + } + + public void setClientPrivateKeyPath(String clientPrivateKeyPath) { + this.clientPrivateKeyPath = clientPrivateKeyPath; + } + + @Override + public String getExternalBridge() { + return externalBridge; + } + + public void setExternalBridge(String externalBridge) { + this.externalBridge = externalBridge; + } + + @Override + public String getLocalnetName() { + return localnetName; + } + + public void setLocalnetName(String localnetName) { + this.localnetName = localnetName; + } + + @Override + public String getIcNbConnection() { + return icNbConnection; + } + + public void setIcNbConnection(String icNbConnection) { + this.icNbConnection = icNbConnection; + } + + @Override + public String getIcSbConnection() { + return icSbConnection; + } + + public void setIcSbConnection(String icSbConnection) { + this.icSbConnection = icSbConnection; + } + + @Override + public String getAvailabilityZoneName() { + return availabilityZoneName; + } + + public void setAvailabilityZoneName(String availabilityZoneName) { + this.availabilityZoneName = availabilityZoneName; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } + + public Date getRemoved() { + return removed; + } + + public void setRemoved(Date removed) { + this.removed = removed; + } + + public static final class Builder { + private long zoneId; + private Long hostId; + private String name; + private String nbConnection; + private String sbConnection; + private String caCertPath; + private String clientCertPath; + private String clientPrivateKeyPath; + private String externalBridge; + private String localnetName; + private String icNbConnection; + private String icSbConnection; + private String availabilityZoneName; + + public Builder setZoneId(long zoneId) { + this.zoneId = zoneId; + return this; + } + + public Builder setHostId(Long hostId) { + this.hostId = hostId; + return this; + } + + public Builder setName(String name) { + this.name = name; + return this; + } + + public Builder setNbConnection(String nbConnection) { + this.nbConnection = nbConnection; + return this; + } + + public Builder setSbConnection(String sbConnection) { + this.sbConnection = sbConnection; + return this; + } + + public Builder setCaCertPath(String caCertPath) { + this.caCertPath = caCertPath; + return this; + } + + public Builder setClientCertPath(String clientCertPath) { + this.clientCertPath = clientCertPath; + return this; + } + + public Builder setClientPrivateKeyPath(String clientPrivateKeyPath) { + this.clientPrivateKeyPath = clientPrivateKeyPath; + return this; + } + + public Builder setExternalBridge(String externalBridge) { + this.externalBridge = externalBridge; + return this; + } + + public Builder setLocalnetName(String localnetName) { + this.localnetName = localnetName; + return this; + } + + public Builder setIcNbConnection(String icNbConnection) { + this.icNbConnection = icNbConnection; + return this; + } + + public Builder setIcSbConnection(String icSbConnection) { + this.icSbConnection = icSbConnection; + return this; + } + + public Builder setAvailabilityZoneName(String availabilityZoneName) { + this.availabilityZoneName = availabilityZoneName; + return this; + } + + public OvnProviderVO build() { + OvnProviderVO provider = new OvnProviderVO(); + provider.setZoneId(zoneId); + provider.setHostId(hostId); + provider.setName(name); + provider.setNbConnection(nbConnection); + provider.setSbConnection(sbConnection); + provider.setCaCertPath(caCertPath); + provider.setClientCertPath(clientCertPath); + provider.setClientPrivateKeyPath(clientPrivateKeyPath); + provider.setExternalBridge(externalBridge); + provider.setLocalnetName(localnetName); + provider.setIcNbConnection(icNbConnection); + provider.setIcSbConnection(icSbConnection); + provider.setAvailabilityZoneName(availabilityZoneName); + return provider; + } + } +} diff --git a/engine/schema/src/main/java/com/cloud/network/element/OvnVpcPeeringVO.java b/engine/schema/src/main/java/com/cloud/network/element/OvnVpcPeeringVO.java new file mode 100644 index 000000000000..96dc40f69aaf --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/network/element/OvnVpcPeeringVO.java @@ -0,0 +1,173 @@ +// 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 com.cloud.network.element; + +import org.apache.cloudstack.api.InternalIdentity; +import org.apache.cloudstack.api.Identity; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import java.util.Date; +import java.util.UUID; + +@Entity +@Table(name = "ovn_vpc_peerings") +public class OvnVpcPeeringVO implements InternalIdentity, Identity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "group_uuid") + private String groupUuid; + + @Column(name = "name") + private String name; + + @Column(name = "description") + private String description; + + @Column(name = "vpc_id") + private long vpcId; + + @Column(name = "zone_id") + private long zoneId; + + @Column(name = "account_id") + private long accountId; + + @Column(name = "domain_id") + private long domainId; + + @Column(name = "link_local_ip") + private String linkLocalIp; + + @Column(name = "acl_id") + private Long aclId; + + @Column(name = "state") + private String state; + + @Column(name = "created") + private Date created; + + @Column(name = "removed") + private Date removed; + + public OvnVpcPeeringVO() { + uuid = UUID.randomUUID().toString(); + } + + public OvnVpcPeeringVO(String groupUuid, String name, String description, long vpcId, long zoneId, long accountId, long domainId, String linkLocalIp) { + this.uuid = UUID.randomUUID().toString(); + this.groupUuid = groupUuid; + this.name = name; + this.description = description; + this.vpcId = vpcId; + this.zoneId = zoneId; + this.accountId = accountId; + this.domainId = domainId; + this.linkLocalIp = linkLocalIp; + this.state = "Active"; + this.created = new Date(); + } + + @Override + public long getId() { + return id; + } + + @Override + public String getUuid() { + return uuid; + } + + public String getGroupUuid() { + return groupUuid; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public long getVpcId() { + return vpcId; + } + + public long getZoneId() { + return zoneId; + } + + public long getAccountId() { + return accountId; + } + + public long getDomainId() { + return domainId; + } + + public String getLinkLocalIp() { + return linkLocalIp; + } + + public Long getAclId() { + return aclId; + } + + public void setAclId(Long aclId) { + this.aclId = aclId; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public Date getCreated() { + return created; + } + + public Date getRemoved() { + return removed; + } + + public void setRemoved(Date removed) { + this.removed = removed; + } +} diff --git a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml index edc14d9fa0cc..52576a59ba72 100644 --- a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml +++ b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml @@ -139,6 +139,8 @@ + + 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 4cb9eb7cb2c4..ebffb6026e77 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 @@ -117,3 +117,65 @@ CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vpc_offerings','conserve_mode', 'tin --- Disable/enable NICs CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.nics','enabled', 'TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''Indicates whether the NIC is enabled or not'' '); + +-- OVN Plugin +CREATE TABLE IF NOT EXISTS `cloud`.`ovn_providers` ( + `id` bigint unsigned NOT NULL auto_increment COMMENT 'id', + `uuid` varchar(40), + `zone_id` bigint unsigned NOT NULL COMMENT 'Zone ID', + `host_id` bigint unsigned COMMENT 'Optional resource host ID if OVN command routing is enabled', + `name` varchar(255) NOT NULL, + `nb_connection` varchar(255) NOT NULL COMMENT 'OVN Northbound database connection string', + `sb_connection` varchar(255) COMMENT 'OVN Southbound database connection string', + `ca_cert_path` varchar(1024) COMMENT 'OVN TLS CA certificate path', + `client_cert_path` varchar(1024) COMMENT 'OVN TLS client certificate path', + `client_private_key_path` varchar(1024) COMMENT 'OVN TLS client private key path', + `external_bridge` varchar(255) COMMENT 'OVN external bridge used for provider network access', + `localnet_name` varchar(255) COMMENT 'OVN localnet name used for provider network mapping', + `ic_nb_connection` varchar(255) COMMENT 'OVN-IC Northbound DB connection string for inter-zone peering', + `ic_sb_connection` varchar(255) COMMENT 'OVN-IC Southbound DB connection string for diagnostics', + `availability_zone_name` varchar(255) COMMENT 'Availability zone name registered in NB_Global for OVN-IC', + `created` datetime NOT NULL COMMENT 'created date', + `removed` datetime COMMENT 'removed date if not null', + PRIMARY KEY (`id`), + CONSTRAINT `fk_ovn_providers__zone_id` FOREIGN KEY `fk_ovn_providers__zone_id` (`zone_id`) REFERENCES `data_center`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_ovn_providers__host_id` FOREIGN KEY `fk_ovn_providers__host_id` (`host_id`) REFERENCES `host`(`id`) ON DELETE SET NULL, + UNIQUE KEY `uk_ovn_providers__zone_id` (`zone_id`), + INDEX `i_ovn_providers__zone_id`(`zone_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- OVN VPC Peering +CREATE TABLE IF NOT EXISTS `cloud`.`ovn_vpc_peerings` ( + `id` bigint unsigned NOT NULL auto_increment, + `uuid` varchar(40) NOT NULL, + `group_uuid` varchar(40) NOT NULL COMMENT 'Peering mesh group identifier', + `name` varchar(255) DEFAULT NULL COMMENT 'User-given peering group name', + `description` varchar(1024) DEFAULT NULL COMMENT 'User-given peering group description', + `vpc_id` bigint unsigned NOT NULL, + `zone_id` bigint unsigned NOT NULL, + `account_id` bigint unsigned NOT NULL, + `domain_id` bigint unsigned NOT NULL, + `link_local_ip` varchar(15) NOT NULL COMMENT 'Link-local IP on the peering switch', + `acl_id` bigint unsigned DEFAULT NULL COMMENT 'Optional Network ACL applied to this peering membership', + `state` varchar(16) NOT NULL DEFAULT 'Active', + `created` datetime NOT NULL, + `removed` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ovn_vpc_peerings_uuid` (`uuid`), + INDEX `i_ovn_vpc_peerings_group` (`group_uuid`), + INDEX `i_ovn_vpc_peerings_vpc` (`vpc_id`), + CONSTRAINT `fk_ovn_vpc_peerings_vpc` FOREIGN KEY (`vpc_id`) REFERENCES `cloud`.`vpc`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_ovn_vpc_peerings_zone` FOREIGN KEY (`zone_id`) REFERENCES `cloud`.`data_center`(`id`), + CONSTRAINT `fk_ovn_vpc_peerings_account` FOREIGN KEY (`account_id`) REFERENCES `cloud`.`account`(`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- Add Firewall service to the default OVN VPC offering so that OVN VPC tiers +-- using network offerings with Firewall/Ovn pass the service validation check. +INSERT IGNORE INTO `cloud`.`vpc_offering_service_map` (`vpc_offering_id`, `service`, `provider`) + SELECT vo.id, 'Firewall', 'Ovn' + FROM `cloud`.`vpc_offerings` vo + WHERE vo.unique_name = 'VPC offering with OVN - NAT Mode' + AND NOT EXISTS ( + SELECT 1 FROM `cloud`.`vpc_offering_service_map` sm + WHERE sm.vpc_offering_id = vo.id AND sm.service = 'Firewall' + ); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/OvsVifDriver.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/OvsVifDriver.java index 4c0482c5384f..736059457558 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/OvsVifDriver.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/OvsVifDriver.java @@ -150,6 +150,14 @@ public InterfaceDef plug(NicTO nic, String guestOsType, String nicAdapter, Map setupResult = SshHelper.sshExecute(controlIp, Integer.parseInt(LibvirtComputingResource.DEFAULTDOMRSSHPORT), "root", pemFile, null, + "if [ ! -x /usr/local/cloud/systemvm/_run.sh ] || [ ! -f /usr/local/cloud/systemvm/conf/cloud.jks ]; then /opt/cloud/bin/setup/cloud-early-config; fi && systemctl restart cloud.service", + 10000, 10000, 600000); + if (!setupResult.first()) { + String errMsg = String.format("Failed to setup systemVM after copying patch files: %s", setupResult.second()); + logger.error(errMsg); + return new StartAnswer(command, errMsg); + } + } if (!virtRouterResource.isSystemVMSetup(vmName, controlIp)) { String errMsg = "Failed to patch systemVM"; logger.error(errMsg); diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java index b96295240076..b9cf1c3dd0a6 100644 --- a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java @@ -5295,6 +5295,9 @@ public void testStartCommand() throws Exception { Mockito.anyString(), Mockito.anyInt(), Mockito.anyString(), any(File.class), nullable(String.class), Mockito.anyString(), any(String[].class), Mockito.anyString())).thenAnswer(invocation -> null); + sshHelperMockedStatic.when(() -> SshHelper.sshExecute( + Mockito.anyString(), Mockito.anyInt(), Mockito.anyString(), any(File.class), nullable(String.class), + Mockito.anyString(), Mockito.anyInt(), Mockito.anyInt(), Mockito.anyInt())).thenReturn(new Pair<>(true, "")); final LibvirtRequestWrapper wrapper = LibvirtRequestWrapper.getInstance(); assertNotNull(wrapper); @@ -5375,6 +5378,9 @@ public void testStartCommandIsolationEc2() throws Exception { Mockito.anyString(), Mockito.anyInt(), Mockito.anyString(), any(File.class), nullable(String.class), Mockito.anyString(), any(String[].class), Mockito.anyString())).thenAnswer(invocation -> null); + sshHelperMockedStatic.when(() -> SshHelper.sshExecute( + Mockito.anyString(), Mockito.anyInt(), Mockito.anyString(), any(File.class), nullable(String.class), + Mockito.anyString(), Mockito.anyInt(), Mockito.anyInt(), Mockito.anyInt())).thenReturn(new Pair<>(true, "")); final LibvirtRequestWrapper wrapper = LibvirtRequestWrapper.getInstance(); assertNotNull(wrapper); diff --git a/plugins/network-elements/ovn/docs/ovn-plugin-deep-dive.md b/plugins/network-elements/ovn/docs/ovn-plugin-deep-dive.md new file mode 100644 index 000000000000..0daec96fa6a9 --- /dev/null +++ b/plugins/network-elements/ovn/docs/ovn-plugin-deep-dive.md @@ -0,0 +1,422 @@ +--- +marp: true +theme: default +paginate: true +size: 16:9 +header: 'Apache CloudStack — OVN Plugin Deep Dive' +footer: '4.23.0-SNAPSHOT · ovn-vpc-peering branch' +style: | + section { font-size: 22px; } + h1 { color: #1f4e79; } + h2 { color: #1f4e79; border-bottom: 2px solid #1f4e79; padding-bottom: 4px; } + code { background: #f4f4f4; padding: 1px 5px; border-radius: 3px; } + table { font-size: 18px; } + pre { font-size: 16px; line-height: 1.3; } + .small { font-size: 18px; } + .tiny { font-size: 14px; } +--- + + + +# Apache CloudStack OVN Plugin +## Network virtualization without the virtual router + +A technical deep dive into how OVN replaces the legacy VR data plane, +the network services it delivers, and the new **VPC Peering** subsystem. + +--- + +## Why OVN + +CloudStack's reference data plane is the **VR (virtual router)**: one tenant-side +VM per network/VPC carrying SNAT, DHCP, DNS, LB, port-forward, ACLs in user space. + +| | Legacy VR | OVN | +|---|---|---| +| Data plane | Per-tenant VM (Linux) | Hypervisor OVS, programmed by `ovn-controller` | +| Failure domain | One VM | Distributed across all hosts | +| HA | Active/Standby pair | Built-in, distributed | +| Scale ceiling | VR CPU/NIC | OVS flow tables | +| New service | Patch & redeploy VR template | `ovsdb` schema row | +| L3 | iptables, conntrack | Logical Router + flow rules | + +**Goal of the plugin:** wire CloudStack's networking model (Networks, VPCs, NICs, +Public IPs) directly into the **OVN Northbound DB** so the data plane is OVN-native. + +--- + +## Plugin scope + +``` +plugins/network-elements/ovn/ +├── api/ # CreateOvnProvider, ListOvnProviders, *VpcPeering, ... +├── service/ # OvnElement (NetworkElement impl) + OvnNbClient +└── resources # XML beans, applicationContext + +engine/schema/com/cloud/network/ +├── element/OvnVpcPeeringVO.java +└── dao/OvnVpcPeeringDao{,Impl}.java +``` + +- One Spring-managed `NetworkElement` per zone-scoped OVN provider +- Direct **OVSDB JSON-RPC** to the zone's OVN-NB (no `ovn-nbctl` shellouts) +- Optional **OVN-IC** (interconnection) wiring for cross-zone topology + +--- + +## Provider model + +Each AZ has its own `ovn_providers` row pointing at: + +| Column | Meaning | +|---|---| +| `nb_connection` | `ssl:host:6641` — zone OVN-NB | +| `sb_connection` | `ssl:host:6642` — zone OVN-SB (used to look up chassis) | +| `ic_nb_connection` | `ssl:host:6645` — global IC NB (optional, enables cross-zone) | +| `ca_cert_path`, `client_cert_path`, `client_private_key_path` | mTLS material | + +`OvnProviderDao.findByZoneId(...)` resolves the connection set used +by every operation that touches data plane state in that zone. + +--- + +## OVN terminology cheat-sheet + +| Acronym | Full name | Role | +|---|---|---| +| **NB** | Northbound DB | High-level intent: *what* the network looks like (LS, LR, ACLs, NAT). Plugin writes here. | +| **SB** | Southbound DB | Compiled flows + chassis state. `ovn-northd` populates from NB; `ovn-controller` reads it. | +| **IC-NB / IC-SB** | Interconnection NB / SB | Global DBs shared by zones for OVN-IC; hold `Transit_Switch` rows and learned routes. | +| **LS** | Logical Switch | L2 broadcast domain. CS Network → one LS. | +| **LSP** | Logical Switch Port | A port on an LS. NIC, router-side, localnet, or peer-attachment. | +| **LR** | Logical Router | L3 router. CS VPC → one LR (`cs-vpc-`). | +| **LRP** | Logical Router Port | A router interface; pairs with an LSP on an attached LS. Carries IP, MAC, peers. | +| **DGP** | Distributed Gateway Port | Special LRP that owns external IPs and runs centralised NAT/ARP on a `gateway_chassis`. | +| **NAT** | NAT row on an LR | `snat`, `dnat`, or `dnat_and_snat`. CS public IPs / port-forwards become rows here. | +| **TS** | Transit Switch | Logical Switch in IC-NB that interconnects LRs across zones. Cross-zone peering uses one. | +| **ACL** | ACL row on an LS or LR | Stateful filter (`from-lport` / `to-lport`). Per-tier and per-peering rules live here. | +| **`localnet`** | LSP type | Bridges an LS to a physical bridge (`ovn-bridge-mappings`) — public/external uplink. | + +--- + +## What OVN delivers — services + +`OvnElement.getCapabilities()` declares: + +| Service | Capability | OVN object that implements it | +|---|---|---| +| **Connectivity** | StretchedL2 / RegionLevel | Logical Switch + Logical Router | +| **DHCP / DNS** | Server-side | LS port `dhcp_options` | +| **UserData** | ConfigDrive | hypervisor `cloud_init` ISO (zone-level) | +| **SourceNat** | redundant | LR `nat` + distributed gateway port | +| **StaticNat** | per-IP | LR `nat` | +| **PortForwarding** | TCP/UDP | LR `nat` (`dnat_and_snat`) | +| **NetworkACL** | per-tier | LS `acls` (stateful) | +| **Firewall** | egress + ingress | LR/LS `acls` (added in this branch) | + +**Not** delivered by OVN: LB, VPN, IPv6 (VR retains these in mixed offerings). + +--- + +## How CloudStack objects map to OVN + +``` +CS Network (isolated) OVN Logical Switch (cs-net-) +CS VPC OVN Logical Router (cs-vpc-) + └─ tier (Network) └─ LRP + LSP pair (lrp-cs-net-, lsp-cs-vpc-) +CS NIC OVN Logical_Switch_Port (vm--nic-) +CS Public IP OVN NAT row +CS Static Route OVN Logical_Router_Static_Route +CS Egress/ACL rule OVN ACL row on LS / LR +``` + +Naming is deterministic — `OvnElement` derives every object name from CloudStack +IDs, so reconciliation after restart-with-cleanup is idempotent. + +--- + +## Lifecycle of a tier creation + +``` +implement(network) + └─ ensureVpcLogicalRouter(vpc) # creates cs-vpc- if missing + └─ ensureTierLogicalSwitch(network) # creates cs-net- + └─ attachTierToRouter(network, vpc) # LRP+LSP, sets DHCP/DNS options + └─ ensureExternalSnat(vpc) # LR distributed-gateway-port + SNAT +``` + +``` +prepare(nic) + └─ ovnNbClient.createLogicalSwitchPort(...) # MAC + IP + dynamic_addresses + +release(nic) → ovnNbClient.deleteLogicalSwitchPort(...) +shutdown(network)/shutdownVpc → cascading cleanup +``` + +All idempotent. `restart-with-cleanup` re-runs `implement` and `prepare` +without orphaning state. + +--- + +## Distributed gateway & external connectivity + +For each VPC's LR: + +- **gateway_chassis** — picks one (or HA priority list) from chassis where + `other_config:ovn_bridge_mappings` contains the `physical` net +- **distributed_gateway_port** — egress LRP attached to a `provider` LS that + bridges to the public physical network via `localnet` +- **NAT rules** — `snat` for default outbound, `dnat_and_snat` for floating + / static NAT, `dnat_and_snat` with `external_port` for port forwarding + +ARP ownership of public IPs is the gateway-chassis. **Failure of the gateway +host is recovered by re-pinning** to the next priority — `ovn-northd` +re-publishes the flow distribution. + +--- + +## Why a peering subsystem + +VPC tiers can talk to each other inside a VPC (via the VPC's LR). +**Across VPCs**, traffic has to leave the VPC and re-enter via SNAT — which +breaks intra-tenant private addressing and per-VPC ACLs. + +CloudStack's existing answer is **Private Gateway** + static routes — but it +requires admin to set `broadcastUri`, allocates a real network, and can't model +N×N propagation in a mesh. + +**OVN VPC Peering** = a dedicated peering object that sets up an internal +peering fabric in OVN, **bypassing public IPs and SNAT**, with stateful ACLs +applied at the peering boundary. + +--- + +## Peering topology — same zone + +A peering "group" identified by `group_uuid` provisions: + +``` + ┌─────────────── peering LS ───────────────┐ + │ cs-peer- (zone-local) │ + └──┬───────────────┬───────────────┬───────┘ + │ .1/30 │ .5/30 │ .9/30 + ┌──────┴────┐ ┌──────┴────┐ ┌──────┴────┐ + │ cs-vpc-A │ │ cs-vpc-B │ │ cs-vpc-C │ logical routers + └───────────┘ └───────────┘ └───────────┘ +``` + +- /30 link-local pool **`169.254.100.0/24`** +- Per-router static routes pointing at every other peer's CIDR via its LL IP +- `Logical_Router_Policy` (priority 1000, `reroute`) **bypasses SNAT** for + destinations inside any peered CIDR + +--- + +## Peering topology — cross zone + +Same-zone peering LSes don't traverse zone boundaries. For multi-AZ peering +the plugin uses **OVN Interconnection (OVN-IC)**: + +``` + Zone Z1 NB Zone Z2 NB + ┌──────────────────┐ ┌──────────────────┐ + │ cs-vpc-A ──┐ │ │ ┌── cs-vpc-D │ + │ cs-vpc-B ──┤ │ │ │ │ + └─────────────┼────┘ └───┼──────────────┘ + │ ┌──────────────────┴──┐ + └──>│ Transit_Switch │<─┐ + │ ts-peer- │ │ IC_NB (global) + └─────────────────────┘ +``` + +- Pool **`169.254.200.0/24`** (separated from same-zone) +- `ovn-ic` daemon learns LRPs and **route-advertises** peer CIDRs zone-to-zone +- Per-router NAT bypass policies + ACLs are still local to each zone + +--- + +## Data plane elements per peering + +Per group_uuid, per member VPC, the plugin creates: + +| Element | Purpose | External_id tag | +|---|---|---| +| LRP on `cs-vpc-` | Router port into peering LS / TS | `cloudstack_peering_group=` | +| LSP on peering LS / TS | Counterpart of the LRP | `cloudstack_peering_group=` | +| Static route per peer CIDR | Forward traffic to peer's LL IP | `cloudstack_peering_group=` | +| LR Policy `reroute` priority 1000 | Skip SNAT for peered CIDRs | `cloudstack_peering_group_target=` | +| ACL row on peering LS | Apply VPC's `aclid` to its peering traffic | `cloudstack_peering_acl_vpc_=true` | + +Bulk cleanup uses `removeStaticRoutesByExternalId` / +`removeLogicalRouterPoliciesByExternalId` — DB rows aren't tracked per object. + +--- + +## Persistence model + +```sql +CREATE TABLE ovn_vpc_peerings ( + id, uuid, group_uuid, + vpc_id, zone_id, account_id, domain_id, + link_local_ip, -- pool prefix encodes same vs cross zone + acl_id, -- per-member ACL applied at peering boundary + state, -- Active | Disabled | Removed + created, removed +); +``` + +- One row **per VPC** in a peering mesh; group_uuid is the natural key +- `link_local_ip` is the source of truth for cross-zone vs same-zone + detection — survives bulk-delete iterations that shrink the Active set +- `acl_id` nullable → null means "Default Allow All" + +--- + +## API surface + +| Command | Role | Body | +|---|---|---| +| `createVpcPeering` | Add a VPC to a group | `{name, vpcid, peervpcid, [aclid]}` | +| `listVpcPeerings` | Returns aggregated **groups** with `members[]` | `{vpcid?, groupuuid?}` | +| `updateVpcPeering` | Change a member's ACL | `{id, aclid?}` | +| `deleteVpcPeering` | Remove a member, or whole group | `{id}` (peering-uuid or group-uuid) | +| `enableVpcPeering` | Re-provision OVN data plane | `{id}` | +| `disableVpcPeering` | Tear down data plane, keep DB | `{id}` | + +All authorized for **User** role (no admin gate). The mesh adds itself: a +second `createVpcPeering` with `peervpcid` already in a group joins that +existing `group_uuid` instead of starting a new one. + +--- + +## State machine + +``` + ┌──────────┐ enableVpcPeering ┌──────────┐ + │ Disabled │ ──────────────────> │ Active │ + └────┬─────┘ <───────────────────└────┬─────┘ + │ disableVpcPeering │ + │ │ + │ deleteVpcPeering │ + ▼ ▼ + ┌────────────────────────────────────────┐ + │ Removed │ + └────────────────────────────────────────┘ +``` + +- **Active** — full OVN fabric in place +- **Disabled** — DB row + LL IP reserved, OVN data plane torn down + (idempotent re-`enable` rebuilds via `provisionPeeringGroup`) +- **Removed** — terminal; LL IP slot can be reused + +--- + +## Use case 1 — multi-tier app spanning two VPCs + +``` + VPC-app (10.0.0.0/16) VPC-data (10.1.0.0/16) + ├─ web 10.0.10.0/24 ├─ db 10.1.10.0/24 + ├─ api 10.0.20.0/24 ├─ cache 10.1.20.0/24 + └─ ─────────────────────────── +``` + +- Decouples app and data lifecycles — separate VPC ownership, separate ACLs +- DB tier never gets a public IP; the only ingress path is the peering +- Per-VPC ACL on the peering LS pins L4 access (e.g. only `api → db:5432`) + +--- + +## Use case 2 — shared services VPC + +``` + ┌──────────────┐ + │ VPC-shared │ (DNS, LDAP, monitoring) + │ 172.16.0.0/16│ + └──┬──┬──┬─────┘ + │ │ │ + ┌────────────────┘ │ └────────────────┐ + ▼ ▼ ▼ + VPC-team-a VPC-team-b VPC-team-c +``` + +- One mesh group, hub-and-spoke usage by ACL on `VPC-shared` side +- Spokes don't need to know each other (ACL denies spoke-to-spoke at peering + ingress on `VPC-shared` rules — even though L2/L3 reachability is mesh) +- Adding a fourth team is a single `createVpcPeering` call + +--- + +## Use case 3 — disaster recovery / cross-zone + +``` + Zone Z1 Zone Z2 + ┌──────────────────┐ ┌──────────────────┐ + │ VPC-prod-active │ ── peer ──> │ VPC-prod-standby │ + └──────────────────┘ └──────────────────┘ +``` + +- **Same VPC name and CIDR** allowed (different VPC IDs) +- Replication traffic (DB streaming, S3 sync, K8s control plane) runs on + private link-local infra — no public Internet, no SNAT +- **`disableVpcPeering`** on the standby side caps egress without losing + topology — useful for blue/green or controlled fail-back + +--- + +## Use case 4 — dev / staging / prod isolation + +``` + ┌────────────────┐ + │ VPC-build-tools│ peer-A ──> VPC-dev + └─────┬──────────┘ peer-B ──> VPC-staging + │ peer-C ──> VPC-prod (disabled by default) +``` + +- One artifact-server VPC reachable from each environment +- The prod peering stays in **Disabled** state until a release window: + `enableVpcPeering` opens it for the deploy, `disableVpcPeering` shuts it + again — auditable through API logs + +--- + +## Operational concerns + +| Concern | Mechanism | +|---|---| +| Idempotent reconciliation | `provisionPeeringGroup` rebuilds from DB on demand | +| Bulk delete safety | external_id tags + per-pool LL IP detection | +| Restart cleanup | `OvnElement.startup()` re-runs provision for each Active group | +| Permission boundary | account ownership check + non-admin allowed | +| Quota / addressing | /30 slots: 63 same-zone, 63 cross-zone per group | +| Observability | `ovn-nbctl lr-route-list cs-vpc-`, `lr-policy-list`, `ovn-ic-nbctl ts-list` | +| Disable for ACL drift | `disableVpcPeering` lets you re-stage rules off the wire | + +--- + +## What's next + +- **Distributed firewall log shipping** — OVN sample-driven logging ⇒ Loki/ES +- **BGP egress** — replace static defaults with dynamic upstream +- **IPv6 dual-stack** in the peering pools +- **Live ACL hot-swap** without disable/enable churn + (currently re-applied on every `updateVpcPeering`) +- **UI**: per-member traffic counters from `Logical_Flow` stats + +Plus the plain-old debt: tests against a containerised OVN-NB, telemetry +hooks, and an upstream-friendly split between `cloudstack-server` and the +plugin jar. + +--- + + + +# Q & A + +Repository: +`apache/cloudstack` — branch `ovn-vpc-peering` + +Plugin entry-point: +[`OvnElement.java`](../src/main/java/org/apache/cloudstack/service/OvnElement.java) + +Schema: +[`schema-42210to42300.sql`](../../../../engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql) diff --git a/plugins/network-elements/ovn/docs/ovn-plugin-overview.md b/plugins/network-elements/ovn/docs/ovn-plugin-overview.md new file mode 100644 index 000000000000..7f0fad1439f7 --- /dev/null +++ b/plugins/network-elements/ovn/docs/ovn-plugin-overview.md @@ -0,0 +1,812 @@ +# OVN Plugin for Apache CloudStack + +> **Audience.** Cloud engineers who already understand CloudStack's networking +> primitives (Networks, VPCs, Public IPs, Network ACLs) and want to learn how +> the OVN plugin re-implements the data plane *without* the legacy virtual +> router (VR), how guest networks are wired into OVN at the object level, +> what services it currently delivers, and how the new VPC Peering subsystem +> fits in. + +> **Source of truth.** Plugin lives at +> `plugins/network-elements/ovn/` in the Apache CloudStack tree +> (branch `ovn-qos-bandwidth` at the time of writing). Schema additions are +> in `engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql`. + +--- + +## 1. Background + +### 1.1 What CloudStack already does + +CloudStack abstracts compute, storage, and **networking** into tenant-facing +objects: + +| Object | Concept | +|---|---| +| Zone | A region or data centre | +| Pod / Cluster / Host | Physical infrastructure under a Zone | +| Network | A guest L2/L3 segment (Isolated, Shared, or L2-only) | +| VPC | A tenant-private routing domain that contains one or more tier networks | +| NIC | A virtual NIC of a VM, bound to a Network | +| Public IP | An IPv4 from the public-network range, mapped to a tenant resource | +| Network ACL | Stateful filter applied to a VPC tier | +| Static Route, Site-to-Site VPN, … | VPC-scoped extras | + +Each Network is offered through a **Network Offering** (e.g. *Isolated with +Source-NAT, DHCP, DNS, ACL*). Network offerings declare which **Network +Element** implements which **Service** for that network. The VR is the +default network element. + +### 1.2 Why a new network element + +The VR is a tenant-side Linux VM that runs DHCP, DNS, SNAT, port-forwarding, +LB, ACLs, and VPN in user-space. It is well-understood, but it has known +trade-offs: + +| Concern | VR | What we want | +|---|---|---| +| Failure domain | one VM per network/VPC | hypervisor-distributed | +| HA story | active/standby pair, takes seconds | sub-second, distributed | +| New service | new image, new VR template, new package | configuration row in a database | +| Throughput | bounded by the VR VM | bounded by OVS flow-table | +| Diagnostics | tcpdump, conntrack, iptables on the VR | NB rows + Logical_Flow trace | + +OVN — Open Virtual Network — solves these by moving the L2/L3 logic into a +**logical-network database** (the Northbound DB) which is compiled into +hypervisor-local OVS flows by `ovn-controller`. The OVN plugin's job is to +translate CloudStack's user-facing actions into rows in that database, so +the VR is never required for OVN-backed networks. + +### 1.3 Plugin goals & non-goals + +**Goals.** + +- Replace the VR for the most common service set: connectivity, DHCP, DNS, + SourceNAT, StaticNAT, PortForwarding, Network ACLs, Firewall, and per-NIC + egress QoS. The QoS coverage is currently egress-only at the NIC level + and can be broadened in future revisions. +- Keep the operator's mental model intact: a Network offering still + declares which services are provided. The plugin announces itself as a + provider for those services through `OvnElement.getCapabilities()`. +- Enable scenarios the VR cannot model cleanly — most importantly, + user-driven VPC peering, including across zones via OVN + Interconnection. + +**Non-goals.** + +- Wholesale replacement of the VR for *every* offering. LB, Site-to-Site + VPN, and IPv6 dual-stack remain on the VR side; mixed offerings keep the + VR for those services. +- Re-implementing OVN itself. The plugin is a thin orchestrator on top of + the upstream OVN. + +--- + +## 2. Architectural overview + +### 2.1 The data path at a glance + +``` + MANAGEMENT PLANE ++---------------------------+ OVSDB JSON-RPC +-------------------+ +| CloudStack Management Srv | ------------------> | OVN NB DB | +| | | (per zone) | +| +---------------------+ | OVSDB JSON-RPC +---------+---------+ +| | OvnElement |--+ <------(read)------------------ | ovn-northd +| | (NetworkElement) | | v +| +---------------------+ | +-------------------+ +| ^ | | OVN SB DB | +| | | | (per zone) | ++-------------+-------------+ +---------+---------+ + | | + | v + | +-----------------+ + | | ovn-controller | <- on each + | | + OVS bridge | hypervisor + | +-----------------+ + | | + | v + | DATA PLANE + v (VM tap → OVS → NIC) + DB / Schema + (cloud schema + + ovn_providers, + ovn_vpc_peerings) +``` + +The Management Server is the only writer to the **OVN Northbound** database. +All other components are read-only consumers of NB or producers of compiled +state in SB. The plugin **never** drives `ovn-controller` directly and +**never** shells out to `ovn-nbctl` — every operation is an OVSDB JSON-RPC +transaction issued by `OvnNbClient.java`. + +### 2.2 Per-zone scope + +OVN is fundamentally per-deployment: one NB + one SB per administrative +domain. CloudStack's natural administrative domain is a **Zone**. The plugin +follows that grain: each zone has its own `ovn_providers` row that pins the +NB/SB connection strings and mTLS material. + +``` +ovn_providers ++----+---------+--------------------------+----------------------+ +| id | zone_id | nb_connection | sb_connection | ++----+---------+--------------------------+----------------------+ +| 2 | 7 | ssl:10.0.34.51:6641 | ssl:10.0.34.51:6642 | +| 3 | 8 | ssl:10.0.35.106:6641 | ssl:10.0.35.106:6642 | ++----+---------+--------------------------+----------------------+ +``` + +This means a tenant's Network or VPC always lives in *exactly one* zone- +scoped NB. The cross-zone story is handled by **OVN Interconnection** — +described in §6 — not by sharing one NB across zones. + +### 2.3 Plugin layout + +``` +plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/ +├── api/ +│ ├── command/ # AddOvnProviderCmd, ListOvnProvidersCmd, +│ │ # CreateVpcPeeringCmd, EnableVpcPeeringCmd, ... +│ └── response/ # OvnProviderResponse, VpcPeeringResponse, ... +├── service/ +│ ├── OvnElement.java # NetworkElement implementation +│ ├── OvnNbClient.java # OVSDB JSON-RPC wrapper +│ ├── OvnProviderServiceImpl.java +│ └── OvnPeeringService.java # Service interface for peering APIs +└── resources/applicationContext.xml + +engine/schema/src/main/java/com/cloud/network/ +├── element/OvnVpcPeeringVO.java +└── dao/OvnVpcPeeringDao{,Impl}.java +``` + +`OvnElement` is the public Spring bean that registers as a CloudStack +`NetworkElement`, `IpDeployer`, `DhcpServiceProvider`, `DnsServiceProvider`, +and friends. Almost every interesting hook the plugin reacts to enters +through one of those interfaces. + +--- + +## 3. OVN concepts cheat-sheet + +OVN's vocabulary is small but unfamiliar at first. Below is the subset that +appears in the plugin code and in the rest of this document. + +| Term | Full name | Role | +|---|---|---| +| **NB** | Northbound DB | High-level intent — what the network *should* look like (LS, LR, ACL, NAT). The plugin writes here. | +| **SB** | Southbound DB | Compiled flows + chassis state. `ovn-northd` translates NB → SB; `ovn-controller` reads SB and programs OVS. | +| **IC-NB / IC-SB** | Interconnection NB / SB | Global DBs shared across zones. Hold `Transit_Switch` rows and routes learned across zone boundaries. | +| **LS** | Logical_Switch | An L2 broadcast domain. CloudStack Network → one LS. | +| **LSP** | Logical_Switch_Port | A port on an LS. May represent a NIC, a router-side connector, a `localnet` uplink, or a peer attachment. | +| **LR** | Logical_Router | An L3 router. CloudStack VPC → one LR (`cs-vpc-`). For non-VPC isolated networks, one LR per network (`cs-router-`). | +| **LRP** | Logical_Router_Port | A router interface. Pairs with an LSP on an attached LS. Carries IP, MAC, and references its peer. | +| **DGP** | Distributed Gateway Port | An LRP that owns external IPs and runs centralised NAT/ARP on a `gateway_chassis`. | +| **NAT** | NAT row on an LR | One of `snat`, `dnat`, or `dnat_and_snat`. CloudStack public IPs and port-forwards become rows here. | +| **TS** | Transit_Switch | An LS in IC-NB that interconnects LRs across zones. The plugin uses one TS per cross-zone peering group. | +| **ACL** | ACL row on an LS or LR | Stateful filter (`from-lport` / `to-lport`). Per-tier and per-peering rules live here. | +| **`localnet`** | LSP type | Bridges an LS to a physical OVS bridge via `ovn-bridge-mappings`. The provider-side of public/external connectivity. | +| **Chassis** | Chassis row in SB | A hypervisor running `ovn-controller`. `gateway_chassis` references pin DGPs to specific chassis with priorities. | + +A few related terms that the document does not use but a reader might +encounter in `ovn-sbctl` output: `Logical_Flow` (a SB flow rule), +`Encap` (geneve/STT/vxlan tunnel info), and `Port_Binding` (which chassis +currently owns a logical port). + +--- + +## 4. CloudStack ↔ OVN object mapping + +The plugin uses **deterministic naming** so reconciliation is idempotent — +every OVN object name can be derived from CloudStack IDs alone. After a +restart-with-cleanup, the same names produce the same OVN topology without +any orphans. + +| CloudStack object | OVN object | Name pattern | +|---|---|---| +| Isolated Network (non-VPC) | Logical_Switch + Logical_Router | `cs-net-`, `cs-router-` | +| VPC | Logical_Router | `cs-vpc-` | +| VPC tier (Network in a VPC) | Logical_Switch | `cs-net-` (router is the VPC's LR) | +| Tier-to-router junction | LRP + LSP | `lrp-cs-net-`, `lsp-cs-vpc-` | +| NIC | Logical_Switch_Port (NIC type) | `` | +| Public IP (SourceNAT) | NAT row (`snat`) | indexed by VPC, attached to DGP | +| Public IP (StaticNAT / Floating) | NAT row (`dnat_and_snat`) | one per public IP | +| Port forward rule | NAT row (`dnat_and_snat` with `external_port`) | one per rule | +| Network ACL list | ACL rows on the tier LS | filtered by `external_ids:cloudstack_acl_id` | +| VPC peering member (same zone) | LRP on VPC LR + LSP on peering LS | `lrp-peer--vpc-`, `lsp-peer--vpc-` | +| VPC peering member (cross zone) | LRP on VPC LR + LSP on Transit_Switch | `lrp-cs-vpc--ts`, `lsp-ts-vpc-` | +| DHCP server for a tier | DHCP_Options row | indexed by network external_id | +| Per-VM egress rate-limit | LSP `options:qos_max_rate` + `qos_burst` | applied to NIC LSP | + +The plugin **does not** store these names in the CloudStack DB. The names +are reconstructed on demand from the CloudStack object IDs, which keeps the +OVN-NB the single source of truth for OVN state and the CloudStack DB the +single source of truth for tenant intent. + +--- + +## 5. Networks and tiers + +### 5.1 Isolated network (no VPC) + +The simplest case is an isolated network not attached to any VPC. The +plugin builds: + +``` + +---------------------+ + | LR cs-router- | + | (gateway IP) | + +---------+-----------+ + | LRP lrp-cs-net- + | (gateway IP, /prefix) + v + +---------+-----------+ + | LS cs-net- | + +---------+-----------+ + | | | + NIC NIC NIC (LSPs per VM NIC) +``` + +If the offering grants `SourceNat`, the LR also gets a **distributed gateway +port** to a provider LS that bridges to the public physical network via a +`localnet` LSP — see §7. + +### 5.2 VPC tier + +A VPC has one shared LR (`cs-vpc-`). Each tier (a Network whose +`vpc_id` is set) is an LS attached to that LR by an LRP/LSP pair: + +``` + +------------------------+ + | LR cs-vpc- | + | (CIDR aggregate) | + +-----+------+-----------+ + | | + | | (one LRP per tier) + v v + +-------------+ +-------------+ + | LS web-tier | | LS db-tier | + | 10.0.10/24 | | 10.0.20/24 | + +-------------+ +-------------+ + | | | | + NIC NIC NIC NIC +``` + +Tier-to-tier traffic flows through the VPC's LR. Per-tier ACLs apply at the +LS where the destination tier hangs (and conversely on the source LS for +egress). NAT for outbound Internet traffic happens at the LR's DGP. + +### 5.3 Naming determinism + +All names are derived from numeric CloudStack IDs. There is no +`getName()`-based naming because users can rename Networks. The IDs are +stable for the lifetime of the object. If a CloudStack object is deleted +and a new one created, it gets a new ID and therefore a fresh OVN object; +the old OVN object is removed by the lifecycle hooks before deletion. + +--- + +## 6. Multi-zone via OVN-IC + +OVN itself is per-deployment, but **OVN Interconnection** (the +`ovn-ic` daemon plus the IC-NB / IC-SB databases) lets independent OVN +deployments — i.e. zones — exchange routes through a global *Transit +Switch*. + +``` + Zone Z1 NB Zone Z2 NB + +------------------+ +------------------+ + | cs-vpc-A ─┐ | | ┌── cs-vpc-D | + | cs-vpc-B ─┤ | | │ | + +─────────────┼────+ +───┼─────────────+ + │ │ + ▼ ▼ + +────────────────────────────────────────+ + │ ts-peer- │ ◄── IC NB + │ (Transit Switch) │ + +────────────────────────────────────────+ + ▲ + │ ic-route-adv / ic-route-learn + │ + +─────────────────+ + │ ovn-ic daemon │ (one per zone, peers + +─────────────────+ over the IC SB) +``` + +The plugin stores `ic_nb_connection` on the `ovn_providers` row. Zones +that participate in cross-zone topologies must point at the same global +IC-NB. Cross-zone VPC peering (§9.2) is the first feature to consume this +plumbing. + +--- + +## 7. External connectivity & gateways + +For each VPC LR — and for isolated-network LRs that grant SourceNAT — +the plugin sets up an egress path that gives VMs Internet access through +provider-network IPs while still running the bulk of L3 logic +distributed. + +### 7.1 Distributed gateway port + +The DGP is a special LRP attached to the **provider LS**, an LS that has +a `localnet` LSP bridging to the physical OVS bridge via +`ovn-bridge-mappings`. + +``` + ┌──────────────────────┐ ┌────────────────────┐ + │ LR cs-vpc- │ │ LS provider-public │ + │ │ │ │ + │ (DGP) lrp-pub ───┼─────────────────┼─── lsp-lrp-pub │ + │ │ │ │ + │ NAT (snat, │ │ localnet ─── physical bridge + │ dnat_and_snat) │ (br-ex / br-eth1 / …) + └──────────────────────┘ └────────────────────┘ +``` + +ARP for the public IP set is the responsibility of the chassis the DGP is +**pinned** to. In OVN's terminology this is the `gateway_chassis` set on +the LRP — a priority list. When the highest-priority chassis is +unreachable, OVN's `northd` re-elects the next; the new owner emits a +gratuitous ARP so upstream switches re-learn. + +The plugin walks the SB to find chassis whose `other_config:ovn-bridge- +mappings` contains the relevant physical mapping (and skips remote-AZ +chassis when applicable) and sets the gateway_chassis list with explicit +priorities. + +### 7.2 NAT translations + +Public-IP types map to OVN NAT rows: + +| CloudStack public-IP type | OVN NAT type | Notes | +|---|---|---| +| Source NAT | `snat` | one row per VPC; `external_ip` is the SNAT IP, `logical_ip` is the VPC CIDR | +| Static NAT (1:1 floating) | `dnat_and_snat` | `external_ip` is the public IP, `logical_ip` is the VM private IP | +| Port forward | `dnat_and_snat` | `external_port`/`logical_port` set; one row per rule | + +`gARP-on-NAT-change` is on by default; when a NAT row is created or the +gateway pin moves, the new owner chassis announces. + +### 7.3 NAT bypass for peering + +VPC peering (§9) deliberately avoids the DGP/NAT path: traffic between +peered VPCs is supposed to keep its private addressing. The plugin +achieves this with `Logical_Router_Policy` rows at priority 1000 with +`reroute` action that match destinations inside any peered CIDR — these +policies are evaluated *before* the SNAT rule and divert traffic into +the peering port. + +--- + +## 8. Services delivered + +`OvnElement.getCapabilities()` declares which services the plugin +implements. As of `ovn-qos-bandwidth`: + +| CloudStack Service | Capability | OVN object that backs it | +|---|---|---| +| Connectivity | `StretchedL2`, `RegionLevel` | LS + LR | +| DHCP | server-side | DHCP_Options row referenced from each NIC's LSP | +| DNS | server-side | the same DHCP_Options row carries `dns_server` | +| UserData | ConfigDrive | hypervisor cloud-init ISO (zone-level), not OVN | +| SourceNat | redundant | LR `nat:snat` + DGP | +| StaticNat | per-IP | LR `nat:dnat_and_snat` | +| PortForwarding | TCP/UDP | LR `nat:dnat_and_snat` with port mapping | +| NetworkACL | per tier | LS `acls` rows scoped by `external_ids` | +| Firewall | egress + ingress | LR/LS `acls` | +| QoS | per-NIC egress | LSP `options:qos_max_rate` + `qos_burst` | + +**Not** delivered by the OVN plugin in the current branch: Load Balancer +(LB), Site-to-Site VPN, IPv6 dual-stack, multicast. Network offerings +that require those services keep the VR as the provider for them. + +### 8.1 DHCP and DNS + +Each tier LS gets a single DHCP_Options row (idempotently created on the +first NIC `prepare`) carrying: + +``` +server_id = +router = +server_mac = locally-administered MAC derived from the network ID +lease_time = 86400 +mtu = 1442 (geneve overhead headroom) +dns_server = [network.dns1, network.dns2] +``` + +Each NIC LSP is then linked to that DHCP_Options row via the +`dhcpv4_options` column. ovn-controller answers the DHCPDISCOVER on the +hypervisor that hosts the VM — there is no DHCP packet leaving the host. + +### 8.2 QoS — per-NIC egress rate-limit + +When a VM is started or migrated, `prepare(nic)` consults the VM's +**service offering** for `nw_rate` (Mbps). If positive, it writes: + +``` +Logical_Switch_Port.options:qos_max_rate = nw_rate * 1000 (kbps) +Logical_Switch_Port.options:qos_burst = max(nw_rate * 100, 12) (kbits) +``` + +OVN treats `qos_max_rate` on an LSP as the meter for **traffic ingressing +the switch from that port** — i.e. VM upstream / egress. The 100-ms burst +gives TCP slow-start enough room to ramp without spurious drops. + +The current scope is egress only and the rate is read from the service +offering's `nw_rate`. Per-tier rate, ingress shaping, DSCP marking, and a +fallback to the `vm.network.throttling.rate` global setting are candidates +for future revisions. + +--- + +## 9. VPC Peering subsystem + +CloudStack VPCs are isolated routing domains. Tier-to-tier traffic stays +inside a VPC. **Cross-VPC** traffic on the legacy stack has to leave one +VPC, traverse the public network, and re-enter the other through Static +NAT — which breaks private addressing and ACL boundaries. + +The OVN plugin introduces a dedicated **VPC Peering** subsystem that +builds a private peering fabric in OVN, bypassing public IPs and SNAT, +and applying ACLs at the peering boundary. + +### 9.1 Same-zone peering + +When all members of a peering group live in the same zone, the plugin +provisions a zone-local **peering Logical_Switch** and attaches each +VPC's LR to it via /30 link-local subnets: + +``` + ┌── peering LS ───────────────────────┐ + │ cs-peer- │ + └──┬───────────────┬──────────────┬───┘ + │ .1/30 │ .5/30 │ .9/30 + ┌───────┴───┐ ┌────────┴───┐ ┌───────┴───┐ + │ cs-vpc-A │ │ cs-vpc-B │ │ cs-vpc-C │ logical routers + └───────────┘ └────────────┘ └───────────┘ +``` + +Addressing is from `169.254.100.0/24` (the *same-zone* pool — see §9.3). +Each LR gets a static route per peer CIDR pointing at the peer's link- +local IP, plus a `Logical_Router_Policy` at priority 1000 with `reroute` +that bypasses SNAT for destinations inside any peered CIDR. + +### 9.2 Cross-zone peering + +When the group spans zones, the same logic moves into the IC-NB through +a **Transit_Switch** named `ts-peer-`: + +``` + Zone Z1 Zone Z2 + ┌──────────────────────┐ ┌──────────────────────┐ + │ cs-vpc-A ─┐ │ │ ┌── cs-vpc-D │ + │ cs-vpc-B ─┤ │ │ │ │ + └─────────────┼────────┘ └───────┼──────────────┘ + │ ┌────────────────────────────┐ │ + └──>│ ts-peer- │<───┘ + │ (Transit_Switch in IC-NB) │ + └────────────────────────────┘ +``` + +Addressing for cross-zone members comes from a **separate** /24 pool — +`169.254.200.0/24` — so the two fabrics never alias if a VPC is in a +group that grew from same-zone to cross-zone (or shrunk back). The +encoding lets the plugin tell *from the link-local IP alone* whether a +member belongs to a same-zone or cross-zone fabric, which matters for +deletion correctness (see §9.5). + +OVN-IC's `ic-route-adv` advertises each LR's local CIDR onto the TS, and +`ic-route-learn` installs the peer routes on the LRs in the other zones — +no static routes, no manual route-advertise calls. + +### 9.3 Per-member objects + +For each peering record (one VPC's slot in a group), the plugin creates: + +| Object | External_id tag | Purpose | +|---|---|---| +| LRP on `cs-vpc-` | `cloudstack_peering_group=` | Router-side port into the peering LS or TS | +| LSP on peering LS or TS | `cloudstack_peering_group=` | Counterpart of the LRP | +| Static route per peer CIDR | `cloudstack_peering_group=` | Forwards traffic to the peer's link-local IP (same-zone only; cross-zone uses learned routes) | +| LR Policy `reroute` priority 1000 | `cloudstack_peering_group_target=` | Skips SNAT for peered CIDRs | +| ACL rows on the peering LS | `cloudstack_peering_acl_vpc_=true` | Apply this VPC's `aclid` filter to its peering traffic | + +The `external_ids` strategy means **bulk cleanup never has to track +individual UUIDs in the CloudStack DB.** A delete or disable removes every +row whose `external_ids` matches a (group, vpc) pair through helper +methods like `removeStaticRoutesByExternalId` and +`removeLogicalRouterPoliciesByExternalId` on the NB client. + +### 9.4 Persistence + +```sql +CREATE TABLE ovn_vpc_peerings ( + id bigint unsigned auto_increment PRIMARY KEY, + uuid varchar(40) NOT NULL UNIQUE, + group_uuid varchar(40) NOT NULL, -- group identifier (UUID for the mesh) + vpc_id bigint NOT NULL, -- one row per VPC in the group + zone_id bigint NOT NULL, + account_id bigint NOT NULL, + domain_id bigint NOT NULL, + link_local_ip varchar(15) NOT NULL, -- pool prefix encodes same vs cross zone + acl_id bigint NULL, -- per-member ACL applied at peering boundary + name varchar(255) NULL, + description varchar(255) NULL, + state varchar(20) NOT NULL, -- Active | Disabled | Removed + created datetime NOT NULL, + removed datetime NULL +); +``` + +There is **one row per VPC** in a peering mesh. The natural grouping key +is `group_uuid`. The link-local IP is stored explicitly because (a) the +pool prefix is the cross-zone signal, and (b) the slot must remain +reserved while a member is `Disabled`, so a re-enable picks the same +address. + +### 9.5 API surface + +| Command | Role | Body | +|---|---|---| +| `createVpcPeering` | Add a VPC to a group; the very first call seeds the group | `{name, vpcid, peervpcid, [aclid], [description]}` | +| `listVpcPeerings` | Returns aggregated **groups** with `members[]` embedded | `{vpcid?, groupuuid?}` | +| `updateVpcPeering` | Change a member's ACL | `{id, aclid?}` | +| `enableVpcPeering` | Re-provision the OVN data plane for a Disabled group | `{id}` (group UUID or any member UUID) | +| `disableVpcPeering` | Tear down the OVN data plane, keep DB rows | `{id}` | +| `deleteVpcPeering` | Remove a member, or the whole group | `{id}` (peering-uuid or group-uuid) | + +All commands are authorized for the **User** role — no admin gate. Adding +a VPC to a group works through `peervpcid`: if the peer is already in a +group, the new caller joins that `group_uuid` instead of starting a new +mesh. + +The aggregated `listVpcPeerings` response is what the CloudStack UI's +AutogenView consumes: one row per group, with each row carrying the full +member list under `members[]` so the per-VPC tab can be rendered without a +second round-trip. + +### 9.6 State machine + +``` + ┌──────────┐ enableVpcPeering ┌──────────┐ + │ Disabled │ ──────────────────> │ Active │ + └────┬─────┘ <───────────────────└────┬─────┘ + │ disableVpcPeering │ + │ │ + │ deleteVpcPeering │ deleteVpcPeering + ▼ ▼ + ┌────────────────────────────────────────┐ + │ Removed │ + └────────────────────────────────────────┘ +``` + +- **Active** — full OVN fabric in place. Routes advertised, ACLs applied, + NAT bypass policies live. +- **Disabled** — DB row + link-local IP still reserved. The OVN data plane + for this group is torn down (LRPs, LSPs, peering LS or TS attachments, + routes, policies, ACLs all removed). `enableVpcPeering` is idempotent + and rebuilds via `provisionPeeringGroup`. +- **Removed** — terminal. The link-local slot is freed. A new + `createVpcPeering` may reuse the slot. + +### 9.7 Constraints enforced at create time + +`createVpcPeering` rejects the request before any OVN row is touched if any +of these hold: + +- The two VPCs are the same VPC (`vpcid == peervpcid`). +- The caller does not own both VPCs. +- The two VPCs belong to different accounts. +- Either VPC's zone has no `ovn_providers` row. +- The two VPCs already belong to *different* peering groups (a VPC may only + be in one group at a time). +- An `aclid` was supplied that doesn't belong to the VPC the caller is + operating on. +- **The two VPCs (or any existing group member) carry IPv4 CIDRs that + overlap.** The peering fabric writes one static route per peer CIDR on + every member's LR; two routes for overlapping prefixes would race and + OVN cannot disambiguate at runtime. Disabled members are included in + this check so re-enabling the group cannot produce overlap either. + +### 9.8 Operational invariants + +A few invariants worth knowing when reading the code or debugging: + +- **Cross-zone detection is by IP prefix, not by member-set inspection.** + Bulk delete iterates members and the Active set shrinks at each step; + trying to detect cross-zone from "are there still members in two + zones?" gives a wrong answer once you're processing the second-to-last + member. The link-local IP (`169.254.200.x` ⇒ cross-zone) is stable + through the whole delete. +- **Provisioning is fully idempotent.** `provisionPeeringGroup(uuid)` can + be called any number of times; it computes the desired set and calls + the NB client which short-circuits when the requested state already + matches the current state. This makes restart-with-cleanup and + enable-after-disable cheap. +- **`external_ids` are the only delete handle.** Static routes and LR + policies are not tracked individually in CloudStack DB. The delete + path removes every NB row whose external_ids match the + (`cloudstack_peering_group`, ``) tuple. Add new tagged + rows and update the cleanup queries accordingly when extending the + feature. + +--- + +## 10. Lifecycle hooks + +The plugin reacts to CloudStack lifecycle events through `NetworkElement` +interface methods. The interesting ones: + +| Hook | When | What the plugin does | +|---|---|---| +| `implement(network)` | First VM in a network is being deployed, or restart-with-cleanup runs | Ensures the LR exists, creates the tier LS, attaches LS↔LR via LRP+LSP, programs DHCP, ensures egress SNAT and DGP if the offering grants SourceNAT | +| `prepare(nic, vm)` | NIC about to plug into the data plane | Creates the NIC LSP, links DHCP options, **applies QoS** | +| `release(nic, vm)` | NIC unplugging | Removes the NIC LSP | +| `shutdown(network)` / `shutdownVpc(vpc)` | Network/VPC being torn down | Cascading cleanup of all OVN rows for the network/VPC | +| `applyIps`, `applyStaticNats`, `applyPortForwards`, `applyAcls` | User-facing API call modifies a Public IP / NAT / ACL | Reconciles the corresponding NAT or ACL rows | +| `startup()` | MS boot | Walks active VPC peering groups and re-runs `provisionPeeringGroup` to recover from any drift introduced while the MS was down | + +All hooks are written to be safe to re-run. The plugin does **not** +maintain a "what was last seen" cache; it always reads CloudStack DB + +OVN-NB and converges. + +--- + +## 11. Use cases for VPC Peering + +These are the four scenarios the peering subsystem was designed to cover. +They are also the four scenarios used to validate the design end-to-end +on a lab. + +### 11.1 Multi-tier app spanning two VPCs + +``` + VPC-app (10.0.0.0/16) VPC-data (10.1.0.0/16) + ├─ web 10.0.10.0/24 ├─ db 10.1.10.0/24 + ├─ api 10.0.20.0/24 ├─ cache 10.1.20.0/24 + └─ ─────────────────────────── +``` + +Decouples app and data lifecycles into separate VPCs (often separate +account ownership). DB tier never gets a public IP. Per-VPC ACL on the +peering LS pins L4 access (e.g. `api → db:5432` only). + +### 11.2 Shared services hub + +``` + ┌──────────────┐ + │ VPC-shared │ (DNS, LDAP, monitoring) + │ 172.16.0.0/16│ + └──┬──┬──┬─────┘ + │ │ │ + ┌────────────────┘ │ └────────────────┐ + ▼ ▼ ▼ + VPC-team-a VPC-team-b VPC-team-c +``` + +One mesh group, hub-and-spoke usage by ACL on the `VPC-shared` side. +Spokes don't need to know each other (the ACL on `VPC-shared`'s peering +boundary denies spoke-to-spoke even though L2/L3 reachability technically +exists across the mesh). + +### 11.3 Cross-zone DR + +``` + Zone Z1 Zone Z2 + ┌──────────────────┐ ┌──────────────────┐ + │ VPC-prod-active │ ── peer ──> │ VPC-prod-standby │ + └──────────────────┘ └──────────────────┘ +``` + +Replication traffic (DB streaming, S3 sync, K8s control plane) over the +private OVN-IC fabric. `disableVpcPeering` on the standby side caps egress +without losing topology — useful for blue/green or controlled fail-back. + +### 11.4 Dev / staging / prod with disabled-by-default prod peering + +``` + ┌────────────────┐ + │ VPC-build-tools│ peer-A ──> VPC-dev + └─────┬──────────┘ peer-B ──> VPC-staging + │ peer-C ──> VPC-prod (Disabled by default) +``` + +One artifact-server VPC reachable from each environment. The prod peering +stays Disabled until a release window: `enableVpcPeering` opens it for +the deploy, `disableVpcPeering` shuts it again — both auditable through +the standard CloudStack API logs. + +--- + +## 12. Operational notes + +### 12.1 Observability + +The most useful commands for poking around an OVN fabric driven by the +plugin: + +| What | Command | +|---|---| +| Inventory of LSes / LRs in a zone | `ovn-nbctl show` | +| Ports on an LR | `ovn-nbctl lrp-list cs-vpc-` | +| Static routes on an LR | `ovn-nbctl lr-route-list cs-vpc-` | +| Policies on an LR | `ovn-nbctl lr-policy-list cs-vpc-` | +| NAT rows on an LR | `ovn-nbctl lr-nat-list cs-vpc-` | +| LSP options (incl. QoS) | `ovn-nbctl --columns=name,options find logical_switch_port name=""` | +| ACLs on an LS | `ovn-nbctl acl-list ` | +| Cross-zone Transit Switches | `ovn-ic-nbctl ts-list` | +| Where is a logical port bound | `ovn-sbctl show` (Port_Binding section) | +| Trace a flow | `ovn-trace --minimal 'inport=="" && eth.src==... && ip4.src==... && ...'` | + +On the management side, structured search through the management server +log is your friend. The plugin uses logger names rooted at +`org.apache.cloudstack.service.OvnElement`; an info-level grep against +that prefix returns most of the per-operation summary (e.g. +`Applied QoS to LSP [...]`, `Provisioned peering group [...]`). + +### 12.2 Known failure modes + +| Symptom | Likely cause | Where to look | +|---|---|---| +| `/client/api` returns 404 after a UI deploy | `WEB-INF/web.xml` was wiped by an `rsync -a --delete` from `ui/dist/` | Restore WEB-INF from the most recent timestamped backup of `webapp.dir` | +| New VPC peering does not propagate cross-zone | `ovn-ic` not running, or zones don't share an IC-NB | `ovn-ic-nbctl ts-list` on each zone host; check `ovn_providers.ic_nb_connection` | +| Stale routes remain on a VPC LR after delete | `external_ids` mismatch on the cleanup query | Inspect `lr-route-list cs-vpc-` for routes whose external_ids contain the dead group_uuid; delete by external_id | +| VM cannot reach Internet | DGP gateway_chassis is on a host that lost the physical mapping, or the chassis is unreachable | `ovn-sbctl list chassis`, `ovn-nbctl get logical_router_port lrp-... gateway_chassis` | +| Peering says "removed" in DB but VMs still talk | Cross-zone delete bug fixed in `OvnElement` (use IP-prefix detection); leftover TS/LRPs may remain on a deployment that hit the bug — manual cleanup via `ovn-nbctl --if-exists lrp-del` and `ovn-ic-nbctl ts-del` is required once | + +### 12.3 Capacity + +Same-zone peering pool: `169.254.100.0/24` divided into /30 subnets gives +**63 slots** per group (252 host bits / 4). The cross-zone pool +(`169.254.200.0/24`) provides another 63. A single CloudStack +deployment can host arbitrary many groups; the limit is per-group only. + +Number of NIC LSPs per zone is bounded by OVN's flow-table scalability +(rather than CloudStack's). Real-world OVN deployments have run with +high tens of thousands of LSPs; production planning past that should +consult OVN-side scale reports. + +--- + +## 13. Roadmap + +Already in flight or considered for the next branch: + +- **Broader QoS coverage.** Per-tier rate (`QoS` table on the LS), + ingress shaping (OVN's `qos_max_rate` is one-directional on the LSP), + DSCP marking, and an explicit *unset* path for when an offering's + rate goes back to null without a stop+start. +- **Distributed firewall logging.** `ACL.log` + sampling driven into + Loki / Elastic / Grafana for tenant-visible flow logs. +- **BGP egress.** Replace the static default route with a dynamic + upstream peering for multi-homed deployments. +- **IPv6 dual-stack.** Both for tier networks and the peering pools. +- **Live ACL hot-swap** without a disable/enable churn — currently the + ACL re-apply is wrapped into `updateVpcPeering`. +- **Per-flow telemetry.** Surface OVN's `Logical_Flow` stats to the + CloudStack UI per peering or per tier. + +Plus the usual debt items: containerised integration tests against an +upstream OVN-NB image, a clean split between core `cloudstack-server` +and the plugin jar, and CI matrices that include the OVN path. + +--- + +## 14. References + +- **OVN documentation.** [www.ovn.org](https://www.ovn.org/), the + `ovn-nb(5)` and `ovn-sb(5)` man pages, and the OVN-IC tutorial in the + upstream tree. +- **Apache CloudStack docs.** Networking-and-traffic chapter of the + Admin Guide (for VPC, ACL, Public IP semantics). +- **Plugin source — entry points.** + - [OvnElement.java](../src/main/java/org/apache/cloudstack/service/OvnElement.java) + - [OvnNbClient.java](../src/main/java/org/apache/cloudstack/service/OvnNbClient.java) + - [OvnPeeringService.java](../src/main/java/org/apache/cloudstack/service/OvnPeeringService.java) +- **Schema additions.** + [`schema-42210to42300.sql`](../../../../engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql) +- **Companion deck.** + [`ovn-plugin-deep-dive.md`](./ovn-plugin-deep-dive.md) — the slide + format of the same content for ~30-minute presentations. diff --git a/plugins/network-elements/ovn/pom.xml b/plugins/network-elements/ovn/pom.xml new file mode 100644 index 000000000000..36ae54b2858b --- /dev/null +++ b/plugins/network-elements/ovn/pom.xml @@ -0,0 +1,72 @@ + + + 4.0.0 + cloud-plugin-network-ovn + Apache CloudStack Plugin - OVN + + + org.apache.cloudstack + cloudstack-plugins + 4.23.0.0-SNAPSHOT + ../../pom.xml + + + + 1.18.3 + + + + + org.opendaylight.ovsdb + library + ${cs.ovsdb.library.version} + + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.core + jackson-annotations + + + com.google.guava + guava + + + + org.osgi + org.osgi.service.component.annotations + + + org.osgi + org.osgi.service.metatype.annotations + + + + + diff --git a/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/AddOvnProviderCmd.java b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/AddOvnProviderCmd.java new file mode 100644 index 000000000000..ff8844b62966 --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/AddOvnProviderCmd.java @@ -0,0 +1,149 @@ +// 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; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.network.ovn.OvnProvider; +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.OvnProviderResponse; +import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.service.OvnProviderService; + +import javax.inject.Inject; + +@APICommand(name = AddOvnProviderCmd.APINAME, description = "Add OVN provider to CloudStack", + responseObject = OvnProviderResponse.class, requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, authorized = {RoleType.Admin}, since = "4.23.0") +public class AddOvnProviderCmd extends BaseCmd { + public static final String APINAME = "addOvnProvider"; + + @Inject + OvnProviderService ovnProviderService; + + @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = ZoneResponse.class, required = true, + description = "the ID of zone") + private Long zoneId; + + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, required = true, description = "OVN provider name") + private String name; + + @Parameter(name = ApiConstants.OVN_NB_CONNECTION, type = CommandType.STRING, required = true, + description = "OVN Northbound database connection string. Supported formats: tcp:host:6641, ssl:host:6641, unix:/path/to/ovnnb_db.sock") + private String nbConnection; + + @Parameter(name = ApiConstants.OVN_SB_CONNECTION, type = CommandType.STRING, + description = "OVN Southbound database connection string for diagnostics and binding checks") + private String sbConnection; + + @Parameter(name = ApiConstants.OVN_CA_CERT_PATH, type = CommandType.STRING, description = "OVN TLS CA certificate path") + private String caCertPath; + + @Parameter(name = ApiConstants.OVN_CLIENT_CERT_PATH, type = CommandType.STRING, description = "OVN TLS client certificate path") + private String clientCertPath; + + @Parameter(name = ApiConstants.OVN_CLIENT_PRIVATE_KEY_PATH, type = CommandType.STRING, description = "OVN TLS client private key path") + private String clientPrivateKeyPath; + + @Parameter(name = ApiConstants.OVN_EXTERNAL_BRIDGE, type = CommandType.STRING, description = "OVN external bridge used for provider network access") + private String externalBridge; + + @Parameter(name = ApiConstants.OVN_LOCALNET_NAME, type = CommandType.STRING, description = "OVN localnet name used for provider network mapping") + private String localnetName; + + @Parameter(name = ApiConstants.OVN_IC_NB_CONNECTION, type = CommandType.STRING, + description = "OVN-IC Northbound database connection string (e.g. tcp:host:6645). Required to enable cross-zone VPC peering via OVN Interconnection.") + private String icNbConnection; + + @Parameter(name = ApiConstants.OVN_IC_SB_CONNECTION, type = CommandType.STRING, + description = "OVN-IC Southbound database connection string (e.g. tcp:host:6646) for diagnostics") + private String icSbConnection; + + @Parameter(name = ApiConstants.OVN_AVAILABILITY_ZONE_NAME, type = CommandType.STRING, + description = "Availability zone name registered in NB_Global for OVN-IC. Must be unique across all peered zones.") + private String availabilityZoneName; + + public Long getZoneId() { + return zoneId; + } + + public String getName() { + return name; + } + + public String getNbConnection() { + return nbConnection; + } + + public String getSbConnection() { + return sbConnection; + } + + public String getCaCertPath() { + return caCertPath; + } + + public String getClientCertPath() { + return clientCertPath; + } + + public String getClientPrivateKeyPath() { + return clientPrivateKeyPath; + } + + public String getExternalBridge() { + return externalBridge; + } + + public String getLocalnetName() { + return localnetName; + } + + public String getIcNbConnection() { + return icNbConnection; + } + + public String getIcSbConnection() { + return icSbConnection; + } + + public String getAvailabilityZoneName() { + return availabilityZoneName; + } + + @Override + public void execute() throws ServerApiException, ConcurrentOperationException { + OvnProvider provider = ovnProviderService.addProvider(this); + OvnProviderResponse response = ovnProviderService.createOvnProviderResponse(provider); + if (response == null) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to add OVN provider"); + } + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } +} diff --git a/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/CreateVpcPeeringCmd.java b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/CreateVpcPeeringCmd.java new file mode 100644 index 000000000000..2cb323cea09f --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/CreateVpcPeeringCmd.java @@ -0,0 +1,101 @@ +// 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; + +import com.cloud.network.element.OvnVpcPeeringVO; +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.VpcPeeringResponse; +import org.apache.cloudstack.api.response.VpcResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.service.OvnPeeringService; + +import javax.inject.Inject; + +@APICommand(name = CreateVpcPeeringCmd.APINAME, + description = "Creates a peering connection between two OVN-backed VPCs. If the peer VPC already belongs to a peering group, the calling VPC joins that group (mesh topology).", + responseObject = VpcPeeringResponse.class, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}, + since = "4.23.0") +public class CreateVpcPeeringCmd extends BaseCmd { + public static final String APINAME = "createVpcPeering"; + + @Inject + OvnPeeringService ovnPeeringService; + + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, + required = true, description = "Name for the VPC peering group") + private String name; + + @Parameter(name = ApiConstants.DESCRIPTION, type = CommandType.STRING, + description = "Description for the VPC peering group") + private String description; + + @Parameter(name = ApiConstants.VPC_ID, type = CommandType.UUID, entityType = VpcResponse.class, + required = true, description = "The ID of the VPC to peer") + private Long vpcId; + + @Parameter(name = "peervpcid", type = CommandType.UUID, entityType = VpcResponse.class, + required = true, description = "The ID of the peer VPC. If it already belongs to a peering group, the calling VPC joins that group.") + private Long peerVpcId; + + @Parameter(name = "aclid", type = CommandType.UUID, entityType = org.apache.cloudstack.api.response.NetworkACLResponse.class, + description = "The ID of a VPC Network ACL list to apply to this peering membership. Controls what traffic is allowed through the peering connection.") + private Long aclId; + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public Long getVpcId() { + return vpcId; + } + + public Long getPeerVpcId() { + return peerVpcId; + } + + public Long getAclId() { + return aclId; + } + + @Override + public void execute() throws ServerApiException { + OvnVpcPeeringVO peering = ovnPeeringService.createVpcPeering(this); + if (peering == null) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to create VPC peering"); + } + VpcPeeringResponse response = ovnPeeringService.createVpcPeeringResponse(peering); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } +} diff --git a/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/DeleteOvnProviderCmd.java b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/DeleteOvnProviderCmd.java new file mode 100644 index 000000000000..b149a62f7a4b --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/DeleteOvnProviderCmd.java @@ -0,0 +1,74 @@ +// 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; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.utils.exception.CloudRuntimeException; +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.OvnProviderResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.service.OvnProviderService; + +import javax.inject.Inject; + +@APICommand(name = DeleteOvnProviderCmd.APINAME, description = "Delete OVN provider from CloudStack", + responseObject = OvnProviderResponse.class, requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, authorized = {RoleType.Admin}, since = "4.23.0") +public class DeleteOvnProviderCmd extends BaseCmd { + public static final String APINAME = "deleteOvnProvider"; + + @Inject + private OvnProviderService ovnProviderService; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = OvnProviderResponse.class, + required = true, description = "OVN provider ID") + private Long id; + + public Long getId() { + return id; + } + + @Override + public void execute() throws ServerApiException, ConcurrentOperationException { + try { + boolean deleted = ovnProviderService.deleteOvnProvider(getId()); + if (deleted) { + SuccessResponse response = new SuccessResponse(getCommandName()); + response.setResponseName(getCommandName()); + setResponseObject(response); + return; + } + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to delete OVN provider from zone"); + } catch (InvalidParameterValueException e) { + throw new ServerApiException(ApiErrorCode.PARAM_ERROR, e.getMessage()); + } catch (CloudRuntimeException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, e.getMessage()); + } + } + + @Override + public long getEntityOwnerId() { + return 0; + } +} diff --git a/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/DeleteVpcPeeringCmd.java b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/DeleteVpcPeeringCmd.java new file mode 100644 index 000000000000..7c1408cf4794 --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/DeleteVpcPeeringCmd.java @@ -0,0 +1,66 @@ +// 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; + +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.SuccessResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.service.OvnPeeringService; + +import javax.inject.Inject; + +@APICommand(name = DeleteVpcPeeringCmd.APINAME, + description = "Removes a VPC from a peering group. Routes and NAT bypass policies are cleaned up on all remaining group members.", + responseObject = SuccessResponse.class, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}, + since = "4.23.0") +public class DeleteVpcPeeringCmd extends BaseCmd { + public static final String APINAME = "deleteVpcPeering"; + + @Inject + OvnPeeringService ovnPeeringService; + + @Parameter(name = ApiConstants.ID, type = CommandType.STRING, + required = true, description = "The UUID of the VPC peering to delete") + private String id; + + public String getId() { + return id; + } + + @Override + public void execute() throws ServerApiException { + boolean result = ovnPeeringService.deleteVpcPeering(this); + if (!result) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to delete VPC peering"); + } + SuccessResponse response = new SuccessResponse(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } +} diff --git a/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/DisableVpcPeeringCmd.java b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/DisableVpcPeeringCmd.java new file mode 100644 index 000000000000..8ca9517eae6d --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/DisableVpcPeeringCmd.java @@ -0,0 +1,66 @@ +// 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; + +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.SuccessResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.service.OvnPeeringService; + +import javax.inject.Inject; + +@APICommand(name = DisableVpcPeeringCmd.APINAME, + description = "Disables a VPC peering group. Removes the OVN data-plane (routes, NAT bypass, ACLs) from every member while keeping records and topology so it can be re-enabled.", + responseObject = SuccessResponse.class, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}, + since = "4.23.0") +public class DisableVpcPeeringCmd extends BaseCmd { + public static final String APINAME = "disableVpcPeering"; + + @Inject + OvnPeeringService ovnPeeringService; + + @Parameter(name = ApiConstants.ID, type = CommandType.STRING, + required = true, description = "The UUID of the VPC peering group (or any member peering UUID)") + private String id; + + public String getId() { + return id; + } + + @Override + public void execute() throws ServerApiException { + boolean result = ovnPeeringService.disableVpcPeering(this); + if (!result) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to disable VPC peering"); + } + SuccessResponse response = new SuccessResponse(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } +} diff --git a/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/EnableVpcPeeringCmd.java b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/EnableVpcPeeringCmd.java new file mode 100644 index 000000000000..d24f281b5adb --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/EnableVpcPeeringCmd.java @@ -0,0 +1,66 @@ +// 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; + +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.SuccessResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.service.OvnPeeringService; + +import javax.inject.Inject; + +@APICommand(name = EnableVpcPeeringCmd.APINAME, + description = "Enables a VPC peering group. Re-applies the OVN data-plane (routes, NAT bypass, ACLs) for every member of the group.", + responseObject = SuccessResponse.class, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}, + since = "4.23.0") +public class EnableVpcPeeringCmd extends BaseCmd { + public static final String APINAME = "enableVpcPeering"; + + @Inject + OvnPeeringService ovnPeeringService; + + @Parameter(name = ApiConstants.ID, type = CommandType.STRING, + required = true, description = "The UUID of the VPC peering group (or any member peering UUID)") + private String id; + + public String getId() { + return id; + } + + @Override + public void execute() throws ServerApiException { + boolean result = ovnPeeringService.enableVpcPeering(this); + if (!result) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to enable VPC peering"); + } + SuccessResponse response = new SuccessResponse(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } +} diff --git a/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/ListOvnProvidersCmd.java b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/ListOvnProvidersCmd.java new file mode 100644 index 000000000000..a0f3eefff9d8 --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/ListOvnProvidersCmd.java @@ -0,0 +1,60 @@ +// 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; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.utils.StringUtils; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseListCmd; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.OvnProviderResponse; +import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.service.OvnProviderService; + +import javax.inject.Inject; +import java.util.List; + +@APICommand(name = ListOvnProvidersCmd.APINAME, description = "List all OVN providers added to CloudStack", + responseObject = OvnProviderResponse.class, requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false, since = "4.23.0") +public class ListOvnProvidersCmd extends BaseListCmd { + public static final String APINAME = "listOvnProviders"; + + @Inject + OvnProviderService ovnProviderService; + + @Parameter(name = ApiConstants.ZONE_ID, description = "ID of the zone", type = CommandType.UUID, entityType = ZoneResponse.class) + private Long zoneId; + + public Long getZoneId() { + return zoneId; + } + + @Override + public void execute() throws ServerApiException, ConcurrentOperationException { + List baseResponseList = ovnProviderService.listOvnProviders(zoneId); + List pagingList = StringUtils.applyPagination(baseResponseList, getStartIndex(), getPageSizeVal()); + ListResponse listResponse = new ListResponse<>(); + listResponse.setResponses(pagingList); + listResponse.setResponseName(getCommandName()); + setResponseObject(listResponse); + } +} diff --git a/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/ListVpcPeeringsCmd.java b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/ListVpcPeeringsCmd.java new file mode 100644 index 000000000000..ae58bbcd4cc5 --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/ListVpcPeeringsCmd.java @@ -0,0 +1,87 @@ +// 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; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseListCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.VpcPeeringResponse; +import org.apache.cloudstack.api.response.VpcResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.service.OvnPeeringService; + +import javax.inject.Inject; +import java.util.List; + +@APICommand(name = ListVpcPeeringsCmd.APINAME, + description = "Lists VPC peerings for the calling account. Optionally filter by VPC ID or peering group.", + responseObject = VpcPeeringResponse.class, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}, + since = "4.23.0") +public class ListVpcPeeringsCmd extends BaseListCmd { + public static final String APINAME = "listVpcPeerings"; + + @Inject + OvnPeeringService ovnPeeringService; + + @Parameter(name = ApiConstants.VPC_ID, type = CommandType.UUID, entityType = VpcResponse.class, + description = "The ID of the VPC to list peerings for") + private Long vpcId; + + @Parameter(name = "groupuuid", type = CommandType.STRING, + description = "The peering group UUID to filter by") + private String groupUuid; + + @Parameter(name = ApiConstants.ID, type = CommandType.STRING, + description = "The peering group ID (alias of groupuuid; used by AutogenView for the detail view)") + private String id; + + public Long getVpcId() { + return vpcId; + } + + public String getGroupUuid() { + // id is exposed as the resource identifier of a peering "group" so the standard + // AutogenView /:id/ detail flow works. We aliase it onto groupUuid since both + // refer to the same peering mesh. + if (groupUuid != null) return groupUuid; + return id; + } + + public String getId() { + return id; + } + + @Override + public void execute() throws ServerApiException { + List responses = ovnPeeringService.listVpcPeerings(this); + ListResponse listResponse = new ListResponse<>(); + listResponse.setResponses(responses, responses.size()); + listResponse.setResponseName(getCommandName()); + setResponseObject(listResponse); + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } +} diff --git a/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/UpdateVpcPeeringCmd.java b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/UpdateVpcPeeringCmd.java new file mode 100644 index 000000000000..f0052328843a --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/UpdateVpcPeeringCmd.java @@ -0,0 +1,77 @@ +// 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; + +import com.cloud.network.element.OvnVpcPeeringVO; +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.NetworkACLResponse; +import org.apache.cloudstack.api.response.VpcPeeringResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.service.OvnPeeringService; + +import javax.inject.Inject; + +@APICommand(name = UpdateVpcPeeringCmd.APINAME, + description = "Updates a VPC peering membership. Allows changing the Network ACL applied to this peering connection.", + responseObject = VpcPeeringResponse.class, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}, + since = "4.23.0") +public class UpdateVpcPeeringCmd extends BaseCmd { + public static final String APINAME = "updateVpcPeering"; + + @Inject + OvnPeeringService ovnPeeringService; + + @Parameter(name = ApiConstants.ID, type = CommandType.STRING, + required = true, description = "The UUID of the VPC peering to update") + private String id; + + @Parameter(name = "aclid", type = CommandType.UUID, entityType = NetworkACLResponse.class, + description = "The ID of a VPC Network ACL list to apply to this peering membership. Pass empty or omit to remove the ACL (allow all).") + private Long aclId; + + public String getId() { + return id; + } + + public Long getAclId() { + return aclId; + } + + @Override + public void execute() throws ServerApiException { + OvnVpcPeeringVO peering = ovnPeeringService.updateVpcPeering(this); + if (peering == null) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to update VPC peering"); + } + VpcPeeringResponse response = ovnPeeringService.createVpcPeeringResponse(peering); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } +} diff --git a/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/response/OvnProviderResponse.java b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/response/OvnProviderResponse.java new file mode 100644 index 000000000000..7f72973e685c --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/response/OvnProviderResponse.java @@ -0,0 +1,195 @@ +// 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.response; + +import com.cloud.network.ovn.OvnProvider; +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; + +@EntityReference(value = {OvnProvider.class}) +public class OvnProviderResponse extends BaseResponse { + @SerializedName(ApiConstants.NAME) + @Param(description = "OVN provider name") + private String name; + + @SerializedName(ApiConstants.UUID) + @Param(description = "OVN provider UUID") + private String uuid; + + @SerializedName(ApiConstants.ZONE_ID) + @Param(description = "Zone ID to which the OVN provider is associated") + private String zoneId; + + @SerializedName(ApiConstants.ZONE_NAME) + @Param(description = "Zone name to which the OVN provider is associated") + private String zoneName; + + @SerializedName(ApiConstants.OVN_NB_CONNECTION) + @Param(description = "OVN Northbound database connection string") + private String nbConnection; + + @SerializedName(ApiConstants.OVN_SB_CONNECTION) + @Param(description = "OVN Southbound database connection string") + private String sbConnection; + + @SerializedName(ApiConstants.OVN_CA_CERT_PATH) + @Param(description = "OVN TLS CA certificate path") + private String caCertPath; + + @SerializedName(ApiConstants.OVN_CLIENT_CERT_PATH) + @Param(description = "OVN TLS client certificate path") + private String clientCertPath; + + @SerializedName(ApiConstants.OVN_CLIENT_PRIVATE_KEY_PATH) + @Param(description = "OVN TLS client private key path") + private String clientPrivateKeyPath; + + @SerializedName(ApiConstants.OVN_EXTERNAL_BRIDGE) + @Param(description = "OVN external bridge used for provider network access") + private String externalBridge; + + @SerializedName(ApiConstants.OVN_LOCALNET_NAME) + @Param(description = "OVN localnet name used for provider network mapping") + private String localnetName; + + @SerializedName(ApiConstants.OVN_IC_NB_CONNECTION) + @Param(description = "OVN-IC Northbound database connection string") + private String icNbConnection; + + @SerializedName(ApiConstants.OVN_IC_SB_CONNECTION) + @Param(description = "OVN-IC Southbound database connection string") + private String icSbConnection; + + @SerializedName(ApiConstants.OVN_AVAILABILITY_ZONE_NAME) + @Param(description = "Availability zone name used by OVN-IC for this provider") + private String availabilityZoneName; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public String getZoneId() { + return zoneId; + } + + public void setZoneId(String zoneId) { + this.zoneId = zoneId; + } + + public String getZoneName() { + return zoneName; + } + + public void setZoneName(String zoneName) { + this.zoneName = zoneName; + } + + public String getNbConnection() { + return nbConnection; + } + + public void setNbConnection(String nbConnection) { + this.nbConnection = nbConnection; + } + + public String getSbConnection() { + return sbConnection; + } + + public void setSbConnection(String sbConnection) { + this.sbConnection = sbConnection; + } + + public String getCaCertPath() { + return caCertPath; + } + + public void setCaCertPath(String caCertPath) { + this.caCertPath = caCertPath; + } + + public String getClientCertPath() { + return clientCertPath; + } + + public void setClientCertPath(String clientCertPath) { + this.clientCertPath = clientCertPath; + } + + public String getClientPrivateKeyPath() { + return clientPrivateKeyPath; + } + + public void setClientPrivateKeyPath(String clientPrivateKeyPath) { + this.clientPrivateKeyPath = clientPrivateKeyPath; + } + + public String getExternalBridge() { + return externalBridge; + } + + public void setExternalBridge(String externalBridge) { + this.externalBridge = externalBridge; + } + + public String getLocalnetName() { + return localnetName; + } + + public void setLocalnetName(String localnetName) { + this.localnetName = localnetName; + } + + public String getIcNbConnection() { + return icNbConnection; + } + + public void setIcNbConnection(String icNbConnection) { + this.icNbConnection = icNbConnection; + } + + public String getIcSbConnection() { + return icSbConnection; + } + + public void setIcSbConnection(String icSbConnection) { + this.icSbConnection = icSbConnection; + } + + public String getAvailabilityZoneName() { + return availabilityZoneName; + } + + public void setAvailabilityZoneName(String availabilityZoneName) { + this.availabilityZoneName = availabilityZoneName; + } +} diff --git a/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/response/VpcPeeringMemberResponse.java b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/response/VpcPeeringMemberResponse.java new file mode 100644 index 000000000000..2b81624b0c4a --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/response/VpcPeeringMemberResponse.java @@ -0,0 +1,70 @@ +// 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.response; + +import com.google.gson.annotations.SerializedName; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; + +/** + * One VPC's membership in a peering group, embedded under + * {@link VpcPeeringResponse#setMembers(java.util.List)} on group-level responses. + * Carries the per-VPC peering row identity so the UI can drive add/remove actions + * without a separate listVpcPeerings round-trip. + */ +public class VpcPeeringMemberResponse extends BaseResponse { + @SerializedName(ApiConstants.ID) + private String id; + + @SerializedName(ApiConstants.VPC_ID) + private String vpcId; + + @SerializedName("vpcname") + private String vpcName; + + @SerializedName("vpccidr") + private String vpcCidr; + + @SerializedName(ApiConstants.ZONE_ID) + private String zoneId; + + @SerializedName(ApiConstants.ZONE_NAME) + private String zoneName; + + @SerializedName("linklocalip") + private String linkLocalIp; + + @SerializedName("aclid") + private String aclId; + + @SerializedName("aclname") + private String aclName; + + @SerializedName(ApiConstants.STATE) + private String state; + + public void setId(String id) { this.id = id; } + public void setVpcId(String vpcId) { this.vpcId = vpcId; } + public void setVpcName(String vpcName) { this.vpcName = vpcName; } + public void setVpcCidr(String vpcCidr) { this.vpcCidr = vpcCidr; } + public void setZoneId(String zoneId) { this.zoneId = zoneId; } + public void setZoneName(String zoneName) { this.zoneName = zoneName; } + public void setLinkLocalIp(String linkLocalIp) { this.linkLocalIp = linkLocalIp; } + public void setAclId(String aclId) { this.aclId = aclId; } + public void setAclName(String aclName) { this.aclName = aclName; } + public void setState(String state) { this.state = state; } +} diff --git a/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/response/VpcPeeringResponse.java b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/response/VpcPeeringResponse.java new file mode 100644 index 000000000000..453eaf76618d --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/response/VpcPeeringResponse.java @@ -0,0 +1,177 @@ +// 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.response; + +import com.google.gson.annotations.SerializedName; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; + +import java.util.Date; +import java.util.List; + +public class VpcPeeringResponse extends BaseResponse { + @SerializedName(ApiConstants.ID) + private String id; + + @SerializedName("groupuuid") + private String groupUuid; + + @SerializedName(ApiConstants.NAME) + private String name; + + @SerializedName(ApiConstants.DESCRIPTION) + private String description; + + @SerializedName(ApiConstants.VPC_ID) + private String vpcId; + + @SerializedName("vpcname") + private String vpcName; + + @SerializedName("vpccidr") + private String vpcCidr; + + @SerializedName("peervpcid") + private String peerVpcId; + + @SerializedName("peervpcname") + private String peerVpcName; + + @SerializedName("peervpccidr") + private String peerVpcCidr; + + @SerializedName(ApiConstants.ZONE_ID) + private String zoneId; + + @SerializedName(ApiConstants.ZONE_NAME) + private String zoneName; + + @SerializedName("linklocalip") + private String linkLocalIp; + + @SerializedName("aclid") + private String aclId; + + @SerializedName("aclname") + private String aclName; + + @SerializedName(ApiConstants.STATE) + private String state; + + @SerializedName(ApiConstants.CREATED) + private Date created; + + /** + * Number of VPCs in the peering group. Set on aggregated (group-level) responses. + */ + @SerializedName("vpccount") + private Integer vpcCount; + + /** + * Comma-separated list of VPC names in the group. Convenient for the list-view column. + */ + @SerializedName("vpcnames") + private String vpcNames; + + /** + * Per-member detail. Populated only on group-level responses (id == groupuuid). + * Each entry corresponds to one VPC's row in the peering DB and is enough to drive + * the "VPC Peers" detail tab without an extra round-trip. + */ + @SerializedName("members") + private List members; + + public void setId(String id) { + this.id = id; + } + + public void setGroupUuid(String groupUuid) { + this.groupUuid = groupUuid; + } + + public void setName(String name) { + this.name = name; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setVpcId(String vpcId) { + this.vpcId = vpcId; + } + + public void setVpcName(String vpcName) { + this.vpcName = vpcName; + } + + public void setVpcCidr(String vpcCidr) { + this.vpcCidr = vpcCidr; + } + + public void setPeerVpcId(String peerVpcId) { + this.peerVpcId = peerVpcId; + } + + public void setPeerVpcName(String peerVpcName) { + this.peerVpcName = peerVpcName; + } + + public void setPeerVpcCidr(String peerVpcCidr) { + this.peerVpcCidr = peerVpcCidr; + } + + public void setZoneId(String zoneId) { + this.zoneId = zoneId; + } + + public void setZoneName(String zoneName) { + this.zoneName = zoneName; + } + + public void setLinkLocalIp(String linkLocalIp) { + this.linkLocalIp = linkLocalIp; + } + + public void setAclId(String aclId) { + this.aclId = aclId; + } + + public void setAclName(String aclName) { + this.aclName = aclName; + } + + public void setState(String state) { + this.state = state; + } + + public void setCreated(Date created) { + this.created = created; + } + + public void setVpcCount(Integer vpcCount) { + this.vpcCount = vpcCount; + } + + public void setVpcNames(String vpcNames) { + this.vpcNames = vpcNames; + } + + public void setMembers(List members) { + this.members = members; + } +} diff --git a/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnElement.java b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnElement.java new file mode 100644 index 000000000000..cfa6810b1016 --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnElement.java @@ -0,0 +1,3337 @@ +// 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.service; + +import com.cloud.agent.api.to.LoadBalancerTO; +import com.cloud.dc.DataCenter; +import com.cloud.deploy.DeployDestination; +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.network.IpAddress; +import com.cloud.network.Network; +import com.cloud.network.Networks; +import com.cloud.network.PhysicalNetworkServiceProvider; +import com.cloud.network.PublicIpAddress; +import com.cloud.network.element.DhcpServiceProvider; +import com.cloud.network.element.DnsServiceProvider; +import com.cloud.network.element.FirewallServiceProvider; +import com.cloud.network.element.IpDeployer; +import com.cloud.network.element.LoadBalancingServiceProvider; +import com.cloud.network.element.NetworkACLServiceProvider; +import com.cloud.dc.VlanVO; +import com.cloud.dc.dao.VlanDao; +import com.cloud.network.dao.IPAddressDao; +import com.cloud.network.dao.IPAddressVO; +import com.cloud.network.dao.OvnProviderDao; +import com.cloud.network.dao.OvnVpcPeeringDao; +import com.cloud.network.element.OvnProviderVO; +import com.cloud.network.element.OvnVpcPeeringVO; +import com.cloud.network.vpc.VpcVO; +import com.cloud.user.AccountManager; +import com.cloud.vm.NicVO; +import com.cloud.vm.dao.NicDao; +import com.cloud.network.element.PortForwardingServiceProvider; +import com.cloud.network.element.StaticNatServiceProvider; +import com.cloud.network.element.VpcProvider; +import com.cloud.network.lb.LoadBalancingRule; +import com.cloud.network.rules.FirewallRule; +import com.cloud.network.rules.LoadBalancerContainer; +import com.cloud.network.rules.PortForwardingRule; +import com.cloud.network.rules.StaticNat; +import com.cloud.network.vpc.NetworkACLItem; +import com.cloud.network.vpc.NetworkACLItemDao; +import com.cloud.network.vpc.NetworkACLItemVO; +import com.cloud.network.vpc.NetworkACLVO; +import com.cloud.network.vpc.dao.NetworkACLDao; +import com.cloud.network.vpc.PrivateGateway; +import com.cloud.network.vpc.StaticRouteProfile; +import com.cloud.network.vpc.Vpc; +import com.cloud.offering.NetworkOffering; +import com.cloud.utils.component.AdapterBase; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.vm.NicProfile; +import com.cloud.vm.ReservationContext; +import com.cloud.vm.VirtualMachineProfile; + +import org.apache.cloudstack.api.command.CreateVpcPeeringCmd; +import org.apache.cloudstack.api.command.DeleteVpcPeeringCmd; +import org.apache.cloudstack.api.command.DisableVpcPeeringCmd; +import org.apache.cloudstack.api.command.EnableVpcPeeringCmd; +import org.apache.cloudstack.api.command.ListVpcPeeringsCmd; +import org.apache.cloudstack.api.command.UpdateVpcPeeringCmd; +import org.apache.cloudstack.api.response.VpcPeeringMemberResponse; +import org.apache.cloudstack.api.response.VpcPeeringResponse; +import org.apache.cloudstack.context.CallContext; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.PermissionDeniedException; +import com.cloud.dc.DataCenterVO; +import com.cloud.dc.dao.DataCenterDao; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import javax.inject.Inject; +import org.apache.commons.lang3.StringUtils; + +public class OvnElement extends AdapterBase implements DhcpServiceProvider, DnsServiceProvider, VpcProvider, + StaticNatServiceProvider, IpDeployer, PortForwardingServiceProvider, FirewallServiceProvider, + NetworkACLServiceProvider, LoadBalancingServiceProvider, OvnPeeringService { + + private final Map> capabilities = initCapabilities(); + private final OvnNbClient ovnNbClient = new OvnNbClient(); + + @Inject + OvnProviderDao ovnProviderDao; + + @Inject + IPAddressDao ipAddressDao; + + @Inject + VlanDao vlanDao; + + @Inject + NicDao nicDao; + + @Inject + com.cloud.network.vpc.dao.VpcDao vpcDao; + + @Inject + com.cloud.host.dao.HostDao hostDao; + + @Inject + OvnVpcPeeringDao ovnVpcPeeringDao; + + @Inject + AccountManager accountMgr; + + @Inject + DataCenterDao dataCenterDao; + + @Inject + NetworkACLDao networkACLDao; + + @Inject + NetworkACLItemDao networkACLItemDao; + + @Inject + com.cloud.service.dao.ServiceOfferingDao serviceOfferingDao; + + protected static Map> initCapabilities() { + Map> capabilities = new HashMap<>(); + + Map dhcpCapabilities = new HashMap<>(); + dhcpCapabilities.put(Network.Capability.DhcpAccrossMultipleSubnets, "true"); + capabilities.put(Network.Service.Dhcp, dhcpCapabilities); + + Map dnsCapabilities = new HashMap<>(); + dnsCapabilities.put(Network.Capability.AllowDnsSuffixModification, "true"); + capabilities.put(Network.Service.Dns, dnsCapabilities); + + Map sourceNatCapabilities = new HashMap<>(); + sourceNatCapabilities.put(Network.Capability.SupportedSourceNatTypes, "peraccount"); + capabilities.put(Network.Service.SourceNat, sourceNatCapabilities); + + capabilities.put(Network.Service.StaticNat, null); + capabilities.put(Network.Service.PortForwarding, null); + capabilities.put(Network.Service.NetworkACL, null); + capabilities.put(Network.Service.Gateway, null); + + Map firewallCapabilities = new HashMap<>(); + firewallCapabilities.put(Network.Capability.SupportedProtocols, "tcp,udp,icmp"); + firewallCapabilities.put(Network.Capability.SupportedEgressProtocols, "tcp,udp,icmp,all"); + firewallCapabilities.put(Network.Capability.SupportedTrafficDirection, "ingress,egress"); + capabilities.put(Network.Service.Firewall, firewallCapabilities); + + Map lbCapabilities = new HashMap<>(); + // OVN Load_Balancer is L4-only. We only advertise what we actually deliver: + // - tcp/udp (sctp omitted - rarely used in CS UI) + // - round-robin and source-IP based hashing (no leastconn: OVN has no per-backend + // connection state) + // - Public + Internal schemes. Internal LB is delivered natively by attaching the + // same Load_Balancer row to the VPC LR + the tier LS that owns the VIP, with + // options:hairpin_snat_ip pointing at the tier gateway. No appliance VM needed. + // SSL offload, HTTP-aware LB, cookie stickiness etc. are L7 features that OVN cannot do + // in the datapath - those tenants should pick a VirtualRouter offering instead. + lbCapabilities.put(Network.Capability.SupportedLBAlgorithms, "roundrobin,source"); + lbCapabilities.put(Network.Capability.SupportedLBIsolation, "dedicated"); + lbCapabilities.put(Network.Capability.SupportedProtocols, "tcp,udp"); + lbCapabilities.put(Network.Capability.LbSchemes, + LoadBalancerContainer.Scheme.Public.name() + "," + LoadBalancerContainer.Scheme.Internal.name()); + // OVN does L4 TCP probes via Load_Balancer_Health_Check. We accept HTTP/PING policies + // but degrade to TCP probe of the same port (logged in applyLBHealthCheck). + lbCapabilities.put(Network.Capability.HealthCheckPolicy, "true"); + capabilities.put(Network.Service.Lb, lbCapabilities); + + capabilities.put(Network.Service.Connectivity, null); + return capabilities; + } + + @Override + public Map> getCapabilities() { + return capabilities; + } + + @Override + public Network.Provider getProvider() { + return Network.Provider.Ovn; + } + + @Override + public boolean implement(Network network, NetworkOffering offering, DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, ResourceUnavailableException, InsufficientCapacityException { + if (network.getBroadcastDomainType() == Networks.BroadcastDomainType.OVN) { + OvnProviderVO provider = getProviderForNetwork(network); + String logicalSwitchName = getLogicalSwitchName(network); + Map externalIds = new HashMap<>(); + externalIds.put("cloudstack_network_id", String.valueOf(network.getId())); + externalIds.put("cloudstack_network_uuid", network.getUuid()); + externalIds.put("cloudstack_zone_id", String.valueOf(network.getDataCenterId())); + if (network.getVpcId() != null) { + externalIds.put("cloudstack_vpc_id", String.valueOf(network.getVpcId())); + externalIds.put("cloudstack_role", "tier"); + } + try { + ovnNbClient.createLogicalSwitch(provider.getNbConnection(), provider.getCaCertPath(), provider.getClientCertPath(), + provider.getClientPrivateKeyPath(), logicalSwitchName, externalIds); + createDhcpOptionsForNetwork(provider, network); + if (network.getVpcId() != null) { + // VPC tier: the LR (cs-vpc-{vpcId}) and the public side were already provisioned + // by implementVpc; here we only need to attach this tier to the shared LR and + // add a per-tier SNAT row so traffic from this CIDR egresses with the VPC's + // SourceNat IP. No per-network LR, no per-network public LS. + attachVpcTierToRouter(provider, network); + addVpcTierSnatRule(provider, network); + } else { + createRouterAndAttachToGuest(provider, network); + applySourceNatForNetwork(provider, network); + } + } catch (CloudRuntimeException e) { + throw new ResourceUnavailableException(e.getMessage(), DataCenter.class, network.getDataCenterId()); + } + } + return true; + } + + /** + * Attaches a VPC tier's Logical_Switch to the shared VPC Logical_Router via a tier-LRP at + * the tier's gateway IP. Mirrors {@link #createRouterAndAttachToGuest} but skips the LR + * creation (the VPC LR is owned by {@link #implementVpc}). Idempotent. + */ + protected void attachVpcTierToRouter(OvnProviderVO provider, Network network) { + if (network.getCidr() == null || network.getGateway() == null) { + return; + } + String routerName = getRouterNameForNetwork(network); + String prefix = network.getCidr().contains("/") + ? network.getCidr().substring(network.getCidr().indexOf('/')) + : "/24"; + String lrpNetwork = network.getGateway() + prefix; + ovnNbClient.attachRouterToSwitch(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, getLogicalSwitchName(network), + "lrp-" + getLogicalSwitchName(network), buildRouterMac(network.getId(), false), + java.util.Collections.singletonList(lrpNetwork)); + } + + /** + * Programs (or refreshes) the per-tier SNAT row on the VPC LR so traffic from this tier's + * CIDR is masqueraded behind the VPC's SourceNat IP. Skipped when the VPC has not yet been + * assigned a SourceNat IP (the SNAT row will be added on the next implement / IP update). + */ + protected void addVpcTierSnatRule(OvnProviderVO provider, Network network) { + if (network.getCidr() == null) { + return; + } + Vpc vpc = vpcDao.findById(network.getVpcId()); + if (vpc == null) { + return; + } + List ips = ipAddressDao.listByAssociatedVpc(vpc.getId(), true); + if (ips == null) { + return; + } + String routerName = getRouterNameForNetwork(network); + for (IPAddressVO ipVo : ips) { + if (!ipVo.isSourceNat() || ipVo.getAddress() == null) { + continue; + } + String externalIp = ipVo.getAddress().addr(); + Map ext = new HashMap<>(); + ext.put("cloudstack_network_id", String.valueOf(network.getId())); + ext.put("cloudstack_vpc_id", String.valueOf(vpc.getId())); + ext.put("cloudstack_nat_kind", "source-tier"); + ovnNbClient.addNatRule(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, "snat", externalIp, network.getCidr(), ext); + // Refresh the gARP announcement on the VPC LRP so newly-attached tiers do not have + // to wait for the next public-IP event for ovn-controller to gARP for the shared + // SourceNat IP. + applyVpcNatAddressesAnnouncement(provider, vpc); + break; + } + } + + @Override + public boolean prepare(Network network, NicProfile nic, VirtualMachineProfile vm, DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, ResourceUnavailableException, InsufficientCapacityException { + if (network.getBroadcastDomainType() == Networks.BroadcastDomainType.OVN) { + OvnProviderVO provider = getProviderForNetwork(network); + String lsName = getLogicalSwitchName(network); + String lspName = getLogicalSwitchPortName(nic); + Map externalIds = new HashMap<>(); + externalIds.put("cloudstack_nic_id", String.valueOf(nic.getId())); + externalIds.put("cloudstack_nic_uuid", nic.getUuid()); + externalIds.put("cloudstack_vm_id", String.valueOf(vm.getId())); + externalIds.put("cloudstack_vm_uuid", vm.getUuid()); + try { + ovnNbClient.createLogicalSwitchPort(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + lsName, lspName, nic.getMacAddress(), nic.getIPv4Address(), externalIds); + String dhcpUuid = createDhcpOptionsForNetwork(provider, network); + if (dhcpUuid != null && nic.getIPv4Address() != null) { + ovnNbClient.setLspDhcpv4Options(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + lspName, dhcpUuid); + } + applyNicEgressRateLimit(provider, network, nic, vm, lspName); + } catch (CloudRuntimeException e) { + throw new ResourceUnavailableException(e.getMessage(), DataCenter.class, network.getDataCenterId()); + } + } + return true; + } + + /** + * Idempotently creates the DHCP_Options row for an OVN-backed Network. Returns the UUID, or null + * when the network has no IPv4 CIDR (in which case there is nothing to serve via OVN DHCP). + */ + protected String createDhcpOptionsForNetwork(OvnProviderVO provider, Network network) { + String cidr = network.getCidr(); + if (cidr == null || cidr.isEmpty()) { + return null; + } + String gateway = network.getGateway(); + Map options = new HashMap<>(); + if (gateway != null && !gateway.isEmpty()) { + options.put("server_id", gateway); + options.put("router", gateway); + } + // server_mac just needs to be a stable, locally administered MAC unique within this LS. + options.put("server_mac", buildServerMac(network.getId())); + options.put("lease_time", "86400"); + options.put("mtu", "1442"); + StringBuilder dns = new StringBuilder("{"); + if (network.getDns1() != null && !network.getDns1().isEmpty()) { + dns.append(network.getDns1()); + } + if (network.getDns2() != null && !network.getDns2().isEmpty()) { + if (dns.length() > 1) dns.append(","); + dns.append(network.getDns2()); + } + dns.append("}"); + if (dns.length() > 2) { + options.put("dns_server", dns.toString()); + } + Map externalIds = new HashMap<>(); + externalIds.put("cloudstack_network_id", String.valueOf(network.getId())); + externalIds.put("cloudstack_network_uuid", network.getUuid()); + return ovnNbClient.createDhcpOptions(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + cidr, options, externalIds); + } + + private static String buildServerMac(long networkId) { + return String.format("fa:16:3e:%02x:%02x:%02x", + (int) ((networkId >> 16) & 0xff), + (int) ((networkId >> 8) & 0xff), + (int) (networkId & 0xff)); + } + + /** + * Applies a per-LSP egress rate-limit derived from the VM's compute (service) + * offering {@code nw_rate} (Mbps). {@code null} or non-positive ⇒ no shaping + * (LSP options stay untouched). We read directly from {@link com.cloud.service.dao.ServiceOfferingDao} + * to avoid pulling {@code NetworkModel} into the OVN plugin's Spring context — that + * dependency direction risks a cycle since {@code NetworkModel} discovers + * {@code OvnElement} as a {@link com.cloud.network.element.NetworkElement}. + * + *

OVN reads {@code Logical_Switch_Port.options:qos_max_rate} as kbps + * for traffic ingressing the switch from this port — i.e. VM upload/egress — and + * {@code qos_burst} as the bucket size in kbits. We give the burst + * 100 ms of room so TCP slow-start isn't punished, with a 12 kbit floor so a single + * MTU still fits. + * + *

Phase 1 limitation: only the service offering's {@code nw_rate} is consulted. + * The global {@code vm.network.throttling.rate} fallback applied by the legacy + * VR-based path is NOT applied here — operators that rely on the global must set + * {@code nw_rate} explicitly on the offering. Phase 2 will broaden coverage. + * Phase 1 also doesn't remove keys when the offering changes from positive to null; + * stop+start is required. + */ + protected void applyNicEgressRateLimit(OvnProviderVO provider, Network network, + NicProfile nic, VirtualMachineProfile vm, String lspName) { + Long soId = vm.getServiceOfferingId(); + if (soId == null) { + return; + } + com.cloud.service.ServiceOfferingVO so; + try { + so = serviceOfferingDao.findById(vm.getId(), soId); + } catch (RuntimeException e) { + logger.warn("Skipping QoS on LSP [{}]: ServiceOffering lookup failed: {}", lspName, e.getMessage()); + return; + } + Integer rateMbps = (so != null) ? so.getRateMbps() : null; + if (rateMbps == null || rateMbps <= 0) { + logger.debug("No nw_rate on service offering for nic [{}] on network [{}]; skipping QoS", nic.getId(), network.getId()); + return; + } + long rateKbps = rateMbps.longValue() * 1000L; + long burstKbits = Math.max(rateMbps.longValue() * 100L, 12L); + Map qos = new HashMap<>(); + qos.put("qos_max_rate", String.valueOf(rateKbps)); + qos.put("qos_burst", String.valueOf(burstKbits)); + ovnNbClient.setLspOptions(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + lspName, qos); + logger.info("Applied QoS to LSP [{}]: max-rate={} kbps, burst={} kbits ({} Mbps offering)", + lspName, rateKbps, burstKbits, rateMbps); + } + + /** + * Returns the OVN Logical_Router name owning the network's tenant routing. For an isolated + * network this is per-network ({@code cs-router-}); for a VPC tier all networks + * share the VPC's LR ({@code cs-vpc-}). PR-1 introduced this helper as the single + * resolver for both cases — call sites should never branch on {@code network.getVpcId()} + * themselves. + */ + protected String getRouterNameForNetwork(Network network) { + Long vpcId = network.getVpcId(); + return vpcId != null ? String.format("cs-vpc-%d", vpcId) : String.format("cs-router-%d", network.getId()); + } + + /** + * Returns the public-side Logical_Switch that fronts the LR's external port. Per-network for + * isolated ({@code cs-pub-}), shared across all tiers of a VPC for VPC tiers + * ({@code cs-vpc-pub-}). + */ + protected String getPublicLogicalSwitchNameForNetwork(Network network) { + Long vpcId = network.getVpcId(); + return vpcId != null ? String.format("cs-vpc-pub-%d", vpcId) : String.format("cs-pub-%d", network.getId()); + } + + /** Name of the LR-side router port attached to the public Logical_Switch. */ + protected String getPublicRouterPortNameForNetwork(Network network) { + return "lrp-" + getPublicLogicalSwitchNameForNetwork(network); + } + + /** + * Name of the LSP on the public LS that pairs with the public LRP. OVN names router-type + * Logical_Switch_Ports as {@code lsp-}; firewall ACL matches and gARP announcements + * target this LSP. + */ + protected String getPublicRouterSwitchPortNameForNetwork(Network network) { + return "lsp-" + getPublicRouterPortNameForNetwork(network); + } + + /** + * VPC-flavoured naming. The same scheme as the network helpers but keyed off a {@link Vpc} + * directly, so {@link #implementVpc} / {@link #shutdownVpc} / {@link #updateVpcSourceNatIp} + * can resolve OVN object names without manufacturing a {@link Network}. + */ + protected String getVpcRouterName(Vpc vpc) { + return String.format("cs-vpc-%d", vpc.getId()); + } + + protected String getVpcPublicLogicalSwitchName(Vpc vpc) { + return String.format("cs-vpc-pub-%d", vpc.getId()); + } + + protected String getVpcPublicRouterPortName(Vpc vpc) { + return "lrp-" + getVpcPublicLogicalSwitchName(vpc); + } + + protected String getVpcPublicRouterSwitchPortName(Vpc vpc) { + return "lsp-" + getVpcPublicRouterPortName(vpc); + } + + /** + * Creates the LR for the network and wires it to the guest Logical_Switch with an internal-only + * router port whose IP is the network gateway. External attachment / NAT rules are added later + * in {@link #applyIps(Network, java.util.List, java.util.Set)} when CloudStack provisions a + * source NAT IP for the network. + */ + protected void createRouterAndAttachToGuest(OvnProviderVO provider, Network network) { + if (network.getCidr() == null || network.getGateway() == null) { + return; + } + String routerName = getRouterNameForNetwork(network); + Map lrExternalIds = new HashMap<>(); + lrExternalIds.put("cloudstack_network_id", String.valueOf(network.getId())); + lrExternalIds.put("cloudstack_network_uuid", network.getUuid()); + lrExternalIds.put("cloudstack_zone_id", String.valueOf(network.getDataCenterId())); + ovnNbClient.createLogicalRouter(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, lrExternalIds); + String prefix = network.getCidr().contains("/") + ? network.getCidr().substring(network.getCidr().indexOf('/')) + : "/24"; + String lrpNetwork = network.getGateway() + prefix; + ovnNbClient.attachRouterToSwitch(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, getLogicalSwitchName(network), + "lrp-" + getLogicalSwitchName(network), buildRouterMac(network.getId(), false), + java.util.Collections.singletonList(lrpNetwork)); + } + + private static String buildRouterMac(long networkId, boolean external) { + return String.format("fa:16:3e:%02x:%02x:%02x", + external ? 0xfe : 0xfd, + (int) ((networkId >> 8) & 0xff), + (int) (networkId & 0xff)); + } + + /** + * MAC for a VPC-level router port. We pick a different leading octet ({@code 0xfc} external, + * {@code 0xfb} internal) than {@link #buildRouterMac}'s isolated-network scheme so that an + * isolated network and a VPC sharing the same numeric id never produce a colliding MAC on + * the same OVN deployment. + */ + private static String buildVpcRouterMac(long vpcId, boolean external) { + return String.format("fa:16:3e:%02x:%02x:%02x", + external ? 0xfc : 0xfb, + (int) ((vpcId >> 8) & 0xff), + (int) (vpcId & 0xff)); + } + + /** + * Looks up CloudStack-allocated source NAT public IPs for the network and provisions the + * full external attachment (public LS + localnet port + LR external port + snat rule) for + * each. Idempotent: re-running on an already-provisioned LR is a no-op. + */ + protected void applySourceNatForNetwork(OvnProviderVO provider, Network network) { + if (network.getCidr() == null) { + return; + } + List ips = ipAddressDao.listByAssociatedNetwork(network.getId(), true); + if (ips == null || ips.isEmpty()) { + return; + } + String routerName = getRouterNameForNetwork(network); + String publicLs = getPublicLogicalSwitchNameForNetwork(network); + String localnet = provider.getLocalnetName(); + String externalBridge = provider.getExternalBridge(); + String guestCidr = network.getCidr(); + for (IPAddressVO ipVo : ips) { + if (!ipVo.isSourceNat() || ipVo.getAddress() == null) { + continue; + } + String externalIp = ipVo.getAddress().addr(); + VlanVO vlan = vlanDao.findById(ipVo.getVlanId()); + String netmask = vlan != null && vlan.getVlanNetmask() != null ? vlan.getVlanNetmask() : "255.255.240.0"; + String externalGateway = vlan != null ? vlan.getVlanGateway() : null; + Integer vlanTag = null; + if (vlan != null && vlan.getVlanTag() != null && !"untagged".equalsIgnoreCase(vlan.getVlanTag())) { + String tagPart = vlan.getVlanTag().replaceAll("^vlan://", ""); + try { vlanTag = Integer.parseInt(tagPart); } catch (NumberFormatException ignored) { } + } + // VlanVO is fetched lazily in DAO; for now we let CloudStack stamp the localnet port + // without a vlan (admin can override via the localnet on br-ex if needed). + Map publicLsExt = new HashMap<>(); + publicLsExt.put("cloudstack_network_id", String.valueOf(network.getId())); + publicLsExt.put("cloudstack_role", "public"); + ovnNbClient.createLogicalSwitch(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + publicLs, publicLsExt); + ovnNbClient.addLocalnetPort(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + publicLs, "ln-" + publicLs, localnet != null ? localnet : externalBridge, vlanTag); + String prefix = "/" + maskToPrefix(netmask != null ? netmask : "255.255.240.0"); + String publicLrpName = getPublicRouterPortNameForNetwork(network); + ovnNbClient.attachRouterToSwitch(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, publicLs, + publicLrpName, buildRouterMac(network.getId(), true), + java.util.Collections.singletonList(externalIp + prefix)); + // Anchor the external LRP to a chassis so ovn-northd materialises lr_in_dnat / + // lr_in_unsnat / lr_out_snat for the NAT rules attached to this router. + String anchorChassis = pickAnchorChassis(provider, network); + if (anchorChassis != null) { + ovnNbClient.setLrpGatewayChassis(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + publicLrpName, anchorChassis, 10); + } + Map natExt = new HashMap<>(); + natExt.put("cloudstack_network_id", String.valueOf(network.getId())); + natExt.put("cloudstack_nat_kind", "source"); + ovnNbClient.addNatRule(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, "snat", externalIp, guestCidr, natExt); + if (externalGateway != null && !externalGateway.isEmpty()) { + ovnNbClient.addStaticRoute(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, "0.0.0.0/0", externalGateway); + } + applyNatAddressesAnnouncement(provider, network); + } + } + + /** + * Tells ovn-controller (running on the gateway chassis for this LR) to announce the public + * IPs of this network via gratuitous ARPs. Without this, the upstream switch / router only + * learns our LR's MAC when it ARPs for one of those IPs - which races against any other + * device on the public segment that may also claim the same address (legitimately or not). + * + *

The mechanism: ovn-controller, when it claims the cr-lrp Port_Binding for this LR's + * gateway, looks at {@code options:nat-addresses} on the type=router LSP that peers the + * external LRP. If it is set to the explicit {@code " ..."} format, ovn-controller + * emits gARP for each IP. The {@code router} keyword only covers {@code dnat_and_snat} + * rules with {@code logical_port} set, so it skips plain SNAT - which is why we need the + * explicit form here.

+ */ + protected void applyNatAddressesAnnouncement(OvnProviderVO provider, Network network) { + String externalLrpLsp = getPublicRouterSwitchPortNameForNetwork(network); + String routerMac = buildRouterMac(network.getId(), true); + StringBuilder addresses = new StringBuilder(routerMac); + boolean any = false; + List ips = ipAddressDao.listByAssociatedNetwork(network.getId(), true); + if (ips != null) { + for (IPAddressVO ipVo : ips) { + if (ipVo.getAddress() == null || ipVo.getState() == com.cloud.network.IpAddress.State.Releasing) { + continue; + } + addresses.append(' ').append(ipVo.getAddress().addr()); + any = true; + } + } + if (!any) { + return; + } + Map options = new HashMap<>(); + options.put("nat-addresses", addresses.toString()); + StringBuilder arpProxy = new StringBuilder(); + for (IPAddressVO ipVo : ips) { + if (ipVo.getAddress() == null || ipVo.getState() == com.cloud.network.IpAddress.State.Releasing) { + continue; + } + if (arpProxy.length() > 0) arpProxy.append(' '); + arpProxy.append(ipVo.getAddress().addr()); + } + options.put("arp_proxy", arpProxy.toString()); + ovnNbClient.setLspOptions(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + externalLrpLsp, options); + } + + /** + * VPC counterpart of {@link #applyNatAddressesAnnouncement(OvnProviderVO, Network)}. The set + * of advertised IPs is the {@code SourceNat} flag pool for the whole VPC (looked up by VPC + * id), not per tier — every tier in a VPC shares the same external IP. + */ + protected void applyVpcNatAddressesAnnouncement(OvnProviderVO provider, Vpc vpc) { + String externalLrpLsp = getVpcPublicRouterSwitchPortName(vpc); + String routerMac = buildVpcRouterMac(vpc.getId(), true); + StringBuilder addresses = new StringBuilder(routerMac); + boolean any = false; + List ips = ipAddressDao.listByAssociatedVpc(vpc.getId(), true); + if (ips != null) { + for (IPAddressVO ipVo : ips) { + if (ipVo.getAddress() == null || ipVo.getState() == com.cloud.network.IpAddress.State.Releasing) { + continue; + } + addresses.append(' ').append(ipVo.getAddress().addr()); + any = true; + } + } + if (!any) { + return; + } + Map options = new HashMap<>(); + options.put("nat-addresses", addresses.toString()); + // arp_proxy makes OVN generate ARP responder flows on the external LS + // for every IP the router owns, including port-forwarding VIPs that + // only exist as LB entries (no dnat_and_snat NAT row on the router). + StringBuilder arpProxy = new StringBuilder(); + for (IPAddressVO ipVo : ips) { + if (ipVo.getAddress() == null || ipVo.getState() == com.cloud.network.IpAddress.State.Releasing) { + continue; + } + if (arpProxy.length() > 0) arpProxy.append(' '); + arpProxy.append(ipVo.getAddress().addr()); + } + options.put("arp_proxy", arpProxy.toString()); + ovnNbClient.setLspOptions(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + externalLrpLsp, options); + } + + /** + * Wipes every OVN artifact tied to a public IP that CloudStack is releasing. Called from + * applyIps when ip.state == Releasing, regardless of SourceNat flag. This catches things our + * per-feature revoke callbacks miss: + * + *
    + *
  • Per-IP default-drop ACL ({@code cloudstack_fw_default=true cloudstack_fw_ip=<ip>}). + * Created by ensureFirewallDefaultDeny without a rule_id, so applyFWRules revoke would + * not delete it. Without explicit cleanup it stays for the next tenant of the IP.
  • + *
  • Any leftover {@code allow-related} ACL still tagged with this IP, in case a + * FirewallRule revoke arrived out of order.
  • + *
  • StaticNat dnat_and_snat NAT rows on this external IP that the StaticNat revoke + * callback may have skipped (defensive).
  • + *
  • Re-emits {@code nat-addresses} on the public LSP so the released IP stops being + * announced via gARP.
  • + *
+ */ + protected void cleanupPublicIpArtifacts(OvnProviderVO provider, Network network, String externalIp) { + String publicLs = getPublicLogicalSwitchNameForNetwork(network); + String routerName = getRouterNameForNetwork(network); + // ACLs: matches both per-rule and the default-drop, since both carry cloudstack_fw_ip. + ovnNbClient.removeAclsOnLsByExternalId(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + publicLs, "cloudstack_fw_ip", externalIp); + // dnat_and_snat NATs (StaticNat) on this IP — defensive; applyStaticNats normally clears. + ovnNbClient.removeNatRulesByExternalIp(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, "dnat_and_snat", externalIp); + // Load_Balancer rows pinned to this IP — defensive; applyLBRules revoke normally clears. + // We tag every LB row with cloudstack_lb_ip in programLBRule for exactly this lookup. + ovnNbClient.removeLoadBalancersByExternalId(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + "cloudstack_lb_ip", externalIp); + // Refresh gARP announcement so this IP is no longer claimed by us. + applyNatAddressesAnnouncement(provider, network); + } + + @Override + public boolean release(Network network, NicProfile nic, VirtualMachineProfile vm, ReservationContext context) + throws ConcurrentOperationException, ResourceUnavailableException { + if (network.getBroadcastDomainType() == Networks.BroadcastDomainType.OVN) { + OvnProviderVO provider = getProviderForNetwork(network); + String lsName = getLogicalSwitchName(network); + String lspName = getLogicalSwitchPortName(nic); + try { + ovnNbClient.deleteLogicalSwitchPort(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + lsName, lspName); + } catch (CloudRuntimeException e) { + throw new ResourceUnavailableException(e.getMessage(), DataCenter.class, network.getDataCenterId()); + } + } + return true; + } + + /** + * Returns the OVN Logical_Switch_Port name for the given NIC. Must match the value the KVM + * agent stamps as {@code external_ids:iface-id} on the OVS port — see {@code OvsVifDriver}'s + * OVN branch which uses {@link com.cloud.agent.api.to.NicTO#getUuid()} for the same purpose. + */ + protected String getLogicalSwitchPortName(NicProfile nic) { + return nic.getUuid(); + } + + @Override + public boolean shutdown(Network network, ReservationContext context, boolean cleanup) throws ConcurrentOperationException, ResourceUnavailableException { + if (cleanup && network.getBroadcastDomainType() == Networks.BroadcastDomainType.OVN) { + destroy(network, context); + } + return true; + } + + @Override + public boolean destroy(Network network, ReservationContext context) throws ConcurrentOperationException, ResourceUnavailableException { + if (network.getBroadcastDomainType() == Networks.BroadcastDomainType.OVN) { + OvnProviderVO provider = getProviderForNetwork(network); + try { + // Wipe any Load_Balancer rows owned by this network before tearing down the LR/LS + // they were attached to. If the network is destroyed without an explicit LB revoke + // (e.g. force-delete path) the LB row would otherwise remain orphaned in NB DB. + ovnNbClient.removeLoadBalancersByExternalId(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + "cloudstack_network_id", String.valueOf(network.getId())); + if (network.getVpcId() != null) { + // VPC tier: do not touch cs-vpc-{vpcId} or cs-vpc-pub-{vpcId}. Drop only the + // tier-specific SNAT row, the tier LRP on the shared VPC LR, the per-tier + // DHCP options, and the tier LS. + String vpcRouterName = getRouterNameForNetwork(network); + if (network.getCidr() != null) { + // Identify the tier SNAT row by (router, type, external_ip, logical_ip). + // We have to look up the VPC SourceNat IP now since the network's own + // associations don't carry it. + Vpc vpc = vpcDao.findById(network.getVpcId()); + if (vpc != null) { + List vpcIps = ipAddressDao.listByAssociatedVpc(vpc.getId(), true); + if (vpcIps != null) { + for (IPAddressVO ipVo : vpcIps) { + if (ipVo.isSourceNat() && ipVo.getAddress() != null) { + ovnNbClient.removeNatRule(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + vpcRouterName, "snat", ipVo.getAddress().addr(), network.getCidr()); + } + } + } + } + } + String tierLrp = "lrp-" + getLogicalSwitchName(network); + ovnNbClient.removeLogicalRouterPort(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + vpcRouterName, tierLrp); + ovnNbClient.deleteDhcpOptions(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + String.valueOf(network.getId())); + ovnNbClient.deleteLogicalSwitch(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + getLogicalSwitchName(network)); + return true; + } + ovnNbClient.deleteLogicalSwitch(provider.getNbConnection(), provider.getCaCertPath(), provider.getClientCertPath(), + provider.getClientPrivateKeyPath(), getPublicLogicalSwitchNameForNetwork(network)); + ovnNbClient.deleteLogicalRouter(provider.getNbConnection(), provider.getCaCertPath(), provider.getClientCertPath(), + provider.getClientPrivateKeyPath(), getRouterNameForNetwork(network)); + ovnNbClient.deleteDhcpOptions(provider.getNbConnection(), provider.getCaCertPath(), provider.getClientCertPath(), + provider.getClientPrivateKeyPath(), String.valueOf(network.getId())); + ovnNbClient.deleteLogicalSwitch(provider.getNbConnection(), provider.getCaCertPath(), provider.getClientCertPath(), + provider.getClientPrivateKeyPath(), getLogicalSwitchName(network)); + } catch (CloudRuntimeException e) { + throw new ResourceUnavailableException(e.getMessage(), DataCenter.class, network.getDataCenterId()); + } + } + return true; + } + + protected OvnProviderVO getProviderForNetwork(Network network) throws ResourceUnavailableException { + OvnProviderVO provider = ovnProviderDao.findByZoneId(network.getDataCenterId()); + if (provider == null) { + throw new ResourceUnavailableException(String.format("No OVN provider configured for zone %s", network.getDataCenterId()), + DataCenter.class, network.getDataCenterId()); + } + return provider; + } + + protected String getLogicalSwitchName(Network network) { + return String.format("cs-net-%d", network.getId()); + } + + @Override + public boolean isReady(PhysicalNetworkServiceProvider provider) { + return true; + } + + @Override + public boolean shutdownProviderInstances(PhysicalNetworkServiceProvider provider, ReservationContext context) + throws ConcurrentOperationException, ResourceUnavailableException { + return true; + } + + @Override + public boolean canEnableIndividualServices() { + return true; + } + + @Override + public boolean verifyServicesCombination(Set services) { + return true; + } + + @Override + public boolean addDhcpEntry(Network network, NicProfile nic, VirtualMachineProfile vm, DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, InsufficientCapacityException, ResourceUnavailableException { + return true; + } + + @Override + public boolean configDhcpSupportForSubnet(Network network, NicProfile nic, VirtualMachineProfile vm, DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, InsufficientCapacityException, ResourceUnavailableException { + return true; + } + + @Override + public boolean removeDhcpSupportForSubnet(Network network) throws ResourceUnavailableException { + return true; + } + + @Override + public boolean setExtraDhcpOptions(Network network, long nicId, Map dhcpOptions) { + return true; + } + + @Override + public boolean removeDhcpEntry(Network network, NicProfile nic, VirtualMachineProfile vmProfile) throws ResourceUnavailableException { + return true; + } + + @Override + public boolean addDnsEntry(Network network, NicProfile nic, VirtualMachineProfile vm, DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, InsufficientCapacityException, ResourceUnavailableException { + return true; + } + + @Override + public boolean configDnsSupportForSubnet(Network network, NicProfile nic, VirtualMachineProfile vm, DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, InsufficientCapacityException, ResourceUnavailableException { + return true; + } + + @Override + public boolean removeDnsSupportForSubnet(Network network) throws ResourceUnavailableException { + return true; + } + + @Override + public boolean applyIps(Network network, List ipAddress, Set services) throws ResourceUnavailableException { + if (network.getBroadcastDomainType() != Networks.BroadcastDomainType.OVN + || ipAddress == null || ipAddress.isEmpty()) { + return true; + } + OvnProviderVO provider = getProviderForNetwork(network); + String routerName = getRouterNameForNetwork(network); + String publicLs = getPublicLogicalSwitchNameForNetwork(network); + String localnet = provider.getLocalnetName(); + String guestCidr = network.getCidr(); + String externalBridge = provider.getExternalBridge(); + try { + for (PublicIpAddress ip : ipAddress) { + String externalIp = ip.getAddress() != null ? ip.getAddress().addr() : null; + if (externalIp == null) { + continue; + } + // Releasing IP: drop every artifact tagged with that IP regardless of whether it + // is SourceNat or not. CloudStack delivers fw/PF revoke through dedicated callbacks, + // but the per-IP default-drop ACL we plant via ensureFirewallDefaultDeny carries + // no rule_id - it would otherwise stay behind, blocking traffic if the same public + // IP is later reassigned. Same idea for any leftover dnat_and_snat NAT row. + if (ip.getState() == com.cloud.network.IpAddress.State.Releasing) { + cleanupPublicIpArtifacts(provider, network, externalIp); + if (ip.isSourceNat()) { + ovnNbClient.removeNatRule(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, "snat", externalIp, guestCidr); + } + continue; + } + if (ip.isSourceNat() && Boolean.TRUE.equals(services.contains(Network.Service.SourceNat)) + && network.getVpcId() == null) { + // Isolated networks only: implementVpc already provisioned the VPC's public + // side, and CloudStack does not reuse this hook to push the VPC SourceNat IP + // through tier networks. Running this block for a VPC tier would create a + // duplicate LRP with the wrong MAC scheme. + Map publicLsExt = new HashMap<>(); + publicLsExt.put("cloudstack_network_id", String.valueOf(network.getId())); + publicLsExt.put("cloudstack_role", "public"); + ovnNbClient.createLogicalSwitch(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + publicLs, publicLsExt); + Integer vlanTag = null; + try { + if (ip.getVlanTag() != null) vlanTag = Integer.valueOf(ip.getVlanTag()); + } catch (NumberFormatException ignored) { /* vlan may be 'untagged' */ } + ovnNbClient.addLocalnetPort(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + publicLs, "ln-" + publicLs, localnet != null ? localnet : externalBridge, vlanTag); + String prefix = ip.getNetmask() != null ? "/" + maskToPrefix(ip.getNetmask()) : "/20"; + ovnNbClient.attachRouterToSwitch(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, publicLs, + getPublicRouterPortNameForNetwork(network), buildRouterMac(network.getId(), true), + java.util.Collections.singletonList(externalIp + prefix)); + Map natExt = new HashMap<>(); + natExt.put("cloudstack_network_id", String.valueOf(network.getId())); + natExt.put("cloudstack_nat_kind", "source"); + ovnNbClient.addNatRule(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, "snat", externalIp, guestCidr, natExt); + } + } + // Refresh nat-addresses on the gateway-side LSP. For a VPC tier the announcement is + // VPC-scoped (one set of SourceNat IPs shared by every tier), so route through the + // VPC-flavoured helper; isolated networks keep the per-network refresh. + if (network.getVpcId() != null) { + Vpc vpc = vpcDao.findById(network.getVpcId()); + if (vpc != null) { + applyVpcNatAddressesAnnouncement(provider, vpc); + } + } else { + applyNatAddressesAnnouncement(provider, network); + } + } catch (CloudRuntimeException e) { + throw new ResourceUnavailableException(e.getMessage(), DataCenter.class, network.getDataCenterId()); + } + return true; + } + + /** + * Picks a chassis name to host the centralised gateway pipeline for this network's LR and, + * as a side-effect, prunes any {@code Gateway_Chassis} row on the network's public LRP that + * points to a chassis no longer registered in SB. This fixes a real problem we hit when a + * KVM host is destroyed and re-added: the host re-registers with a fresh OVS system-id, and + * any old Gateway_Chassis row keeps pointing to the dead system-id - ovn-northd refuses to + * claim the cr-lrp port and SNAT/DNAT silently break. + * + *

Returns the chassis system-id we want anchored, or {@code null} when SB has no live + * chassis at all (the LRP simply has no anchor in that case and the caller falls through).

+ */ + protected String pickAnchorChassis(OvnProviderVO provider, Network network) { + if (provider == null || provider.getSbConnection() == null || provider.getSbConnection().isEmpty()) { + logger.warn("No OVN SB connection configured; cannot pick a Gateway_Chassis anchor for network {}", network); + return null; + } + try { + java.util.List chassisNames = ovnNbClient.listSouthboundChassisNames( + provider.getSbConnection(), provider.getCaCertPath(), + provider.getClientCertPath(), provider.getClientPrivateKeyPath()); + if (chassisNames == null || chassisNames.isEmpty()) { + logger.warn("OVN SB reports no registered Chassis yet; deferring Gateway_Chassis anchor for network {}", network); + return null; + } + // Drop any stale Gateway_Chassis row on the public LRP whose chassis_name is not in + // the live set. This must run BEFORE we hand back a name to the caller, because + // setLrpGatewayChassis is idempotent on (lrp_name, chassis_name) and will not detect + // a name change on its own. + String publicLrpName = getPublicRouterPortNameForNetwork(network); + try { + ovnNbClient.pruneStaleGatewayChassis(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + publicLrpName, new java.util.HashSet<>(chassisNames)); + } catch (CloudRuntimeException e) { + // LRP may not exist yet on the very first implement - that is fine, no rows to prune. + logger.debug("Skipping Gateway_Chassis prune for {} ({})", publicLrpName, e.getMessage()); + } + // Deterministic pick: sort by name and rotate by network id so several LRs do not + // all pile on the same chassis. The Chassis row is keyed by the OVS system-id that + // ovn-controller registers on each hypervisor. + java.util.Collections.sort(chassisNames); + return chassisNames.get((int) (Math.abs(network.getId()) % chassisNames.size())); + } catch (Exception e) { + logger.warn("Failed to query OVN SB for Chassis names while anchoring network {}: {}", network, e.getMessage()); + return null; + } + } + + /** + * VPC variant of {@link #pickAnchorChassis(OvnProviderVO, Network)}: deterministic chassis + * pick keyed off the VPC id, with the same stale-{@code Gateway_Chassis} prune applied to + * the VPC's public LRP before we hand the name back to the caller. + */ + protected String pickAnchorChassisForVpc(OvnProviderVO provider, Vpc vpc) { + if (provider == null || provider.getSbConnection() == null || provider.getSbConnection().isEmpty()) { + logger.warn("No OVN SB connection configured; cannot pick a Gateway_Chassis anchor for VPC {}", vpc); + return null; + } + try { + java.util.List chassisNames = ovnNbClient.listSouthboundChassisNames( + provider.getSbConnection(), provider.getCaCertPath(), + provider.getClientCertPath(), provider.getClientPrivateKeyPath()); + if (chassisNames == null || chassisNames.isEmpty()) { + logger.warn("OVN SB reports no registered Chassis yet; deferring Gateway_Chassis anchor for VPC {}", vpc); + return null; + } + String publicLrpName = getVpcPublicRouterPortName(vpc); + try { + ovnNbClient.pruneStaleGatewayChassis(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + publicLrpName, new java.util.HashSet<>(chassisNames)); + } catch (CloudRuntimeException e) { + logger.debug("Skipping Gateway_Chassis prune for {} ({})", publicLrpName, e.getMessage()); + } + java.util.Collections.sort(chassisNames); + return chassisNames.get((int) (Math.abs(vpc.getId()) % chassisNames.size())); + } catch (Exception e) { + logger.warn("Failed to query OVN SB for Chassis names while anchoring VPC {}: {}", vpc, e.getMessage()); + return null; + } + } + + private static int maskToPrefix(String netmask) { + try { + String[] parts = netmask.split("\\."); + int bits = 0; + for (String p : parts) { + bits += Integer.bitCount(Integer.parseInt(p) & 0xff); + } + return bits; + } catch (Exception e) { + return 24; + } + } + + @Override + public IpDeployer getIpDeployer(Network network) { + return this; + } + + @Override + public boolean applyStaticNats(Network config, List rules) throws ResourceUnavailableException { + if (config.getBroadcastDomainType() != Networks.BroadcastDomainType.OVN || rules == null || rules.isEmpty()) { + return true; + } + OvnProviderVO provider = getProviderForNetwork(config); + String routerName = getRouterNameForNetwork(config); + boolean isVpcTier = config.getVpcId() != null; + Vpc vpc = isVpcTier ? vpcDao.findById(config.getVpcId()) : null; + try { + // Anchor the public LRP to a chassis so ovn-northd materialises the lr_in_dnat + // pipeline. Without Gateway_Chassis, dnat_and_snat NAT rows are silently ignored + // by lr_in_dnat. setLrpGatewayChassis is idempotent. For VPC tiers we route through + // the VPC-flavoured helper so every tier converges on the same chassis the VPC LR + // already anchored at implementVpc time. + String anchorChassis = (isVpcTier && vpc != null) ? pickAnchorChassisForVpc(provider, vpc) + : pickAnchorChassis(provider, config); + if (anchorChassis != null) { + ovnNbClient.setLrpGatewayChassis(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + getPublicRouterPortNameForNetwork(config), anchorChassis, 10); + } + for (StaticNat rule : rules) { + IPAddressVO ipVo = ipAddressDao.findById(rule.getSourceIpAddressId()); + if (ipVo == null || ipVo.getAddress() == null) { + continue; + } + String externalIp = ipVo.getAddress().addr(); + String logicalIp = rule.getDestIpAddress(); + if (rule.isForRevoke() || logicalIp == null || logicalIp.isEmpty()) { + ovnNbClient.removeNatRulesByExternalIp(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, "dnat_and_snat", externalIp); + continue; + } + Map ext = new HashMap<>(); + ext.put("cloudstack_network_id", String.valueOf(config.getId())); + ext.put("cloudstack_nat_kind", "static"); + ext.put("cloudstack_public_ip", externalIp); + if (isVpcTier) { + ext.put("cloudstack_vpc_id", String.valueOf(config.getVpcId())); + } + NicVO targetNic = nicDao.findByIp4AddressAndNetworkId(logicalIp, config.getId()); + String distributedLogicalPort = targetNic != null ? targetNic.getUuid() : null; + // For distributed dnat_and_snat the external_mac must match the LR's external + // LRP MAC so ovn-northd applies the rewrite locally on the chassis hosting the + // backend VM. VPC LRPs use a different MAC scheme (buildVpcRouterMac, octet + // 0xfc) than the per-network isolated LRPs (0xfe). + String distributedMac = isVpcTier + ? buildVpcRouterMac(config.getVpcId(), true) + : buildRouterMac(config.getId(), true); + ovnNbClient.addNatRule(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, "dnat_and_snat", externalIp, logicalIp, ext, + distributedMac, distributedLogicalPort); + } + } catch (CloudRuntimeException e) { + throw new ResourceUnavailableException(e.getMessage(), DataCenter.class, config.getDataCenterId()); + } + return true; + } + + /** + * Cap on the size of a single PortForwarding rule's port range. CloudStack lets the user + * declare arbitrarily large ranges; we expand each rule into one Load_Balancer.vips entry + * per port, so very large ranges would balloon the NB row. 256 is enough for the common + * case (small service ranges) without risking an unbounded transaction. + */ + private static final int MAX_PF_RANGE = 256; + + @Override + public boolean applyPFRules(Network network, List rules) throws ResourceUnavailableException { + if (network.getBroadcastDomainType() != Networks.BroadcastDomainType.OVN || rules == null || rules.isEmpty()) { + return true; + } + OvnProviderVO provider = getProviderForNetwork(network); + String routerName = getRouterNameForNetwork(network); + String guestLs = getLogicalSwitchName(network); + try { + for (PortForwardingRule rule : rules) { + programPortForwardingRule(provider, network, routerName, guestLs, rule); + } + } catch (CloudRuntimeException e) { + throw new ResourceUnavailableException(e.getMessage(), DataCenter.class, network.getDataCenterId()); + } + return true; + } + + /** + * Translates one CloudStack {@link PortForwardingRule} into an OVN {@code Load_Balancer} row. + * Naming: {@code pf--}. The LB carries one VIP entry per port in the + * source range mapped to the corresponding destination port. Backed by {@link + * OvnNbClient#createOrReplaceLoadBalancer}, then attached to the network's Logical_Router and + * its guest Logical_Switch (the LS attachment is the Neutron-recommended workaround for + * RHBZ#2043543 — VMs talking to their own FIP need the LB visible on the LS too). + * + *

NAT-based PortForwarding was removed from OVN NB 24.03 (the {@code external_port} and + * {@code protocol} columns are gone), and even where it existed it could not remap a public + * port to a different internal port — which CloudStack semantics require. Load_Balancer + * gives us both the port translation and the per-rule revoke story (delete the LB by + * external_ids tag).

+ */ + protected void programPortForwardingRule(OvnProviderVO provider, Network network, + String routerName, String guestLs, PortForwardingRule rule) { + String ruleTag = String.valueOf(rule.getId()); + if (rule.getState() == FirewallRule.State.Revoke) { + ovnNbClient.removeLoadBalancersByExternalId(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + "cloudstack_pf_rule_id", ruleTag); + // Drop the dnat_and_snat advertisement we created for this rule. We resolve + // the original (external_ip, logical_ip) pair from the rule's IDs - if either + // is missing we just skip; the IP-release path catches strays. + IPAddressVO ipForRevoke = ipAddressDao.findById(rule.getSourceIpAddressId()); + if (ipForRevoke != null && ipForRevoke.getAddress() != null + && rule.getDestinationIpAddress() != null) { + ovnNbClient.removeNatRule(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, "dnat_and_snat", + ipForRevoke.getAddress().addr(), rule.getDestinationIpAddress().addr()); + } + return; + } + + IPAddressVO ipVo = ipAddressDao.findById(rule.getSourceIpAddressId()); + if (ipVo == null || ipVo.getAddress() == null) { + logger.warn("PF rule {} references unknown source IP id {} - skipping", rule.getId(), rule.getSourceIpAddressId()); + return; + } + String externalIp = ipVo.getAddress().addr(); + String logicalIp = rule.getDestinationIpAddress() != null ? rule.getDestinationIpAddress().addr() : null; + if (logicalIp == null || logicalIp.isEmpty()) { + logger.warn("PF rule {} has no destination IP - skipping", rule.getId()); + return; + } + String protocol = rule.getProtocol() != null ? rule.getProtocol().toLowerCase() : "tcp"; + if (!"tcp".equals(protocol) && !"udp".equals(protocol) && !"sctp".equals(protocol)) { + logger.warn("PF rule {} protocol [{}] is not supported by OVN Load_Balancer - skipping", rule.getId(), protocol); + return; + } + + int extStart = rule.getSourcePortStart() != null ? rule.getSourcePortStart() : 0; + int extEnd = rule.getSourcePortEnd() != null ? rule.getSourcePortEnd() : extStart; + int destStart = rule.getDestinationPortStart(); + int destEnd = rule.getDestinationPortEnd(); + int extRange = extEnd - extStart + 1; + int destRange = destEnd - destStart + 1; + if (extRange <= 0) { + logger.warn("PF rule {} has invalid source port range [{}, {}]", rule.getId(), extStart, extEnd); + return; + } + if (extRange > MAX_PF_RANGE) { + logger.warn("PF rule {} source range size [{}] exceeds MAX_PF_RANGE [{}] - rejecting", + rule.getId(), extRange, MAX_PF_RANGE); + return; + } + // CloudStack allows the destination range to be either equal in length to the source + // range (1:1 mapping with a possible offset) or a single port (all source ports map to + // the same internal port). Anything else is ambiguous. + boolean destSinglePort = destRange == 1; + if (destRange != extRange && !destSinglePort) { + logger.warn("PF rule {} dest range [{}-{}] mismatches source range [{}-{}] - skipping", + rule.getId(), destStart, destEnd, extStart, extEnd); + return; + } + + Map vips = new HashMap<>(); + for (int i = 0; i < extRange; i++) { + int extPort = extStart + i; + int destPort = destSinglePort ? destStart : destStart + i; + vips.put(externalIp + ":" + extPort, logicalIp + ":" + destPort); + } + + Map ext = new HashMap<>(); + ext.put("cloudstack_pf_rule_id", ruleTag); + ext.put("cloudstack_network_id", String.valueOf(network.getId())); + ext.put("cloudstack_nat_kind", "portforward"); + ext.put("cloudstack_public_ip", externalIp); + if (network.getVpcId() != null) { + ext.put("cloudstack_vpc_id", String.valueOf(network.getVpcId())); + } + + // hairpin_snat_ip lets a VM behind the FIP talk to its own public IP without ovn-northd + // mis-routing the reply. Cost: a tiny extra rewrite. Neutron sets it unconditionally for + // FIP-style LBs. + Map options = new HashMap<>(); + options.put("hairpin_snat_ip", externalIp); + + String lbName = "pf-" + ruleTag + "-" + protocol; + ovnNbClient.createOrReplaceLoadBalancer(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + lbName, protocol, vips, ext, options); + ovnNbClient.attachLoadBalancerToRouter(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, lbName); + ovnNbClient.attachLoadBalancerToSwitch(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + guestLs, lbName); + + // Publish the public IP so the upstream fabric learns the gateway-chassis MAC for + // this VIP. A bare Load_Balancer row by itself does not trigger gARP nor an ARP + // responder canonical enough for some upstream routers; an LB is purely a forwarding + // table from OVN's point of view. Adding a dnat_and_snat NAT row makes the LR own + // the IP, so ovn-controller emits gARP and answers ARP with the LR MAC. + // Idempotent: addNatRule no-ops if an identical (external,logical) pair already + // exists, so subsequent applyPFRules calls (CloudStack re-applies the full ruleset + // on every change) are cheap. The first PF for a given external_ip wins the + // mapping; further PFs to the same IP rely on the LB to fan out by port. Caveat: + // dnat_and_snat is all-protocols, all-ports - the per-tier ACLs are what limit + // exposure to only the ports declared in PF rules. + Map natExt = new HashMap<>(); + natExt.put("cloudstack_pf_rule_id", ruleTag); + natExt.put("cloudstack_nat_kind", "pf_advertise"); + natExt.put("cloudstack_public_ip", externalIp); + natExt.put("cloudstack_network_id", String.valueOf(network.getId())); + if (network.getVpcId() != null) { + natExt.put("cloudstack_vpc_id", String.valueOf(network.getVpcId())); + } + String gatewayLrpName = getPublicRouterPortNameForNetwork(network); + ovnNbClient.addNatRule(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, "dnat_and_snat", externalIp, logicalIp, natExt, + null, null, gatewayLrpName); + } + + @Override + public boolean applyFWRules(Network network, List rules) throws ResourceUnavailableException { + if (network.getBroadcastDomainType() != Networks.BroadcastDomainType.OVN || rules == null || rules.isEmpty()) { + return true; + } + OvnProviderVO provider = getProviderForNetwork(network); + String publicLs = getPublicLogicalSwitchNameForNetwork(network); + String publicLrpLsp = getPublicRouterSwitchPortNameForNetwork(network); + try { + for (FirewallRule rule : rules) { + programFirewallRule(provider, network, publicLs, publicLrpLsp, rule); + } + } catch (CloudRuntimeException e) { + throw new ResourceUnavailableException(e.getMessage(), DataCenter.class, network.getDataCenterId()); + } + return true; + } + + /** + * Translates a single {@link FirewallRule} into an OVN ACL row attached to the network's + * public Logical_Switch. The default-deny scoped to the public IP is kept fresh on every + * call so newly-allocated IPs only become reachable through explicit allow rules. Revoke + * state simply deletes the per-rule row by external_ids tag. + */ + protected void programFirewallRule(OvnProviderVO provider, Network network, String publicLs, + String publicLrpLsp, FirewallRule rule) { + IPAddressVO ipVo = ipAddressDao.findById(rule.getSourceIpAddressId()); + if (ipVo == null || ipVo.getAddress() == null) { + return; + } + String publicIp = ipVo.getAddress().addr(); + // Make sure the per-IP default-drop is in place before we layer allow rules on top. + ensureFirewallDefaultDeny(provider, network, publicLs, publicLrpLsp, publicIp); + + String ruleTag = "fw-" + rule.getId(); + if (rule.getState() == FirewallRule.State.Revoke) { + ovnNbClient.removeAclsOnLsByExternalId(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + publicLs, "cloudstack_fw_rule_id", String.valueOf(rule.getId())); + return; + } + + String matchExpr = buildFirewallMatch(publicLrpLsp, publicIp, rule); + if (matchExpr == null) { + // Unsupported protocol or empty rule - skip silently. + return; + } + Map ext = new HashMap<>(); + ext.put("cloudstack_fw_rule_id", String.valueOf(rule.getId())); + ext.put("cloudstack_fw_ip", publicIp); + ext.put("cloudstack_network_id", String.valueOf(network.getId())); + if (network.getVpcId() != null) { + ext.put("cloudstack_vpc_id", String.valueOf(network.getVpcId())); + } + ovnNbClient.addAclOnLs(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + publicLs, ruleTag, "to-lport", 1000L, matchExpr, "allow-related", ext); + } + + /** + * Builds the OVN match expression for a single firewall rule. ACLs on the public LS are + * evaluated in the {@code to-lport} direction toward the router patch port, so we match + * before DNAT happens - {@code ip4.dst} is still the public IP, {@code tcp/udp.dst} is + * still the public-side port the user typed in CloudStack. + */ + protected String buildFirewallMatch(String publicLrpLsp, String publicIp, FirewallRule rule) { + String proto = rule.getProtocol() == null ? "" : rule.getProtocol().toLowerCase(); + StringBuilder sb = new StringBuilder(); + sb.append("outport == \"").append(publicLrpLsp).append("\" && ip4"); + sb.append(" && ip4.dst == ").append(publicIp); + // Scope to source CIDRs if the user provided any, otherwise leave the rule open to 0.0.0.0/0. + List sourceCidrs = rule.getSourceCidrList(); + if (sourceCidrs != null && !sourceCidrs.isEmpty()) { + StringBuilder cidrs = new StringBuilder(); + boolean first = true; + for (String cidr : sourceCidrs) { + if (cidr == null || cidr.isEmpty() || "0.0.0.0/0".equals(cidr)) { + cidrs.setLength(0); + break; + } + if (!first) cidrs.append(", "); + cidrs.append(cidr); + first = false; + } + if (cidrs.length() > 0) { + sb.append(" && ip4.src == {").append(cidrs).append("}"); + } + } + switch (proto) { + case "tcp": + case "udp": { + sb.append(" && ").append(proto); + Integer s = rule.getSourcePortStart(); + Integer e = rule.getSourcePortEnd(); + if (s != null && e != null) { + if (s.equals(e)) { + sb.append(" && ").append(proto).append(".dst == ").append(s); + } else { + sb.append(" && ").append(proto).append(".dst >= ").append(s) + .append(" && ").append(proto).append(".dst <= ").append(e); + } + } + break; + } + case "icmp": { + sb.append(" && icmp4"); + if (rule.getIcmpType() != null && rule.getIcmpType() != -1) { + sb.append(" && icmp4.type == ").append(rule.getIcmpType()); + } + if (rule.getIcmpCode() != null && rule.getIcmpCode() != -1) { + sb.append(" && icmp4.code == ").append(rule.getIcmpCode()); + } + break; + } + case "all": + case "": + // No protocol filter - any IPv4 traffic to the public IP. + break; + default: + logger.warn("Skipping firewall rule {} with unsupported protocol [{}]", rule.getId(), proto); + return null; + } + return sb.toString(); + } + + /** + * Installs (or refreshes) the per-public-IP default-drop ACL. Without this, the public LS + * would forward every DNAT'd packet because OVN ACLs default-allow when none of the rules + * match - that is the opposite of CloudStack's expectation that an unprotected public IP + * is unreachable. + */ + /** + * No-op intentionally — see the multi-paragraph note below before reintroducing any + * default-drop ACL on the public LS. + * + *

Why no default-drop on the public LS

+ * + * Earlier revisions of this method installed a {@code to-lport ip4.dst==<publicIp> + * action=drop} ACL at priority 100 on the public {@code Logical_Switch}, intending to + * close every public IP that has any explicit firewall rule and only let through the + * per-rule {@code allow-related} entries at priority 1000. That worked for unsolicited + * inbound traffic (TCP/UDP probes from the internet on a port the operator did not + * open) but it also broke reply traffic for any flow the VM itself initiated: + * an ICMP / DNS / HTTPS reply to a static-NAT IP arrived on the public LS as a fresh + * inbound packet, hit the default-drop, and never reached {@code lr_in_unsnat} for + * NAT reversal. + * + *

The root cause is an OVN architectural choice. {@code ovn-northd} compiles + * {@code ls_in_pre_acl} for an LS that has any stateful ACL, but it explicitly + * bypasses {@code ct_next} for {@code router}-type and + * {@code localnet}-type LSPs: + * + *

+     *   table=4 (ls_in_pre_acl), priority=110,
+     *           match=(ip && inport == "lsp-lrp-cs-pub-265"), action=(next;)
+     *   table=4 (ls_in_pre_acl), priority=110,
+     *           match=(ip && inport == "ln-cs-pub-265"),     action=(next;)
+     * 
+ * + * Because the public LS has only those two LSP types, every packet that traverses it + * stays {@code ct_state=-trk}. The {@code ls_in_acl_hint} pipeline sets {@code reg0[9] + * = 1} for any {@code !ct.trk} packet, and OVN's compilation of {@code action=drop} + * generates a flow keyed on {@code reg0[9]==1} that fires for every untracked + * inbound packet. {@code allow-related} ACLs we tried as a counter-measure + * ({@code from-lport allow-related} on the egress side, {@code to-lport + * allow-related ct.est && ct.rpl} on replies) never fire either, because the LS + * conntrack zone is never populated in the first place — {@code ls_in_stateful}'s + * commit requires {@code reg0[1]==1}, which only the {@code ct.new} hint at + * priority 7 sets, which itself only runs after a successful {@code ct_next}. + * + *

Lab-verified: with a TCP/22 allow rule on a static-NAT IP, the VM could accept + * inbound SSH but could not ping {@code 8.8.8.8} or resolve DNS — the reply leg of + * every VM-initiated flow was dropped by the {@code reg0[9]==1} arm of the default + * drop. Removing the default drop restored connectivity. + * + *

Path forward

+ * + * The proper fix is to lift firewall enforcement off the public LS and onto an + * object whose conntrack zone is actually populated: + * + *
    + *
  • Option A (preferred): {@code Logical_Router policies} on the + * per-network or per-VPC LR. The LR's conntrack is committed by + * {@code ct_dnat} / {@code ct_snat}, so policies can use {@code ct.new} to + * drop unsolicited inbound while letting {@code ct.est} replies pass through + * to {@code lr_in_unsnat}. The per-rule allow ACL becomes a high-priority + * allow policy; the default-drop becomes a low-priority drop policy keyed on + * {@code inport == "lrp-cs-pub-<id>" && ct.new && ip4.dst == <publicIp>}.
  • + *
  • Option B: attach the per-rule ACLs to the guest LS post + * NAT-reversal, matching the VM's internal IP. That is the Neutron-OVN + * security-group pattern. It changes the operator-visible match shape + * (CloudStack rules are written against the public IP, not the VM IP).
  • + *
+ * + *

Both are out of scope for this commit; they require restructuring how + * {@link #applyFWRules}, {@link #applyStaticNats} and the public-LS / public-LRP + * lifecycle interact. The TODO is filed; in the meantime this method is a no-op so + * that adding firewall rules does not regress NAT semantics on existing + * deployments. The per-rule {@code allow-related} ACLs at priority 1000 still get + * installed by {@link #programFirewallRule} — they are now informational-only on + * the public LS but stay in place so the cleanup paths and any future LR-policy + * migration can carry the per-rule history over. Outside of the OVN data plane, + * CloudStack's iptables on the system VM and per-VM firewall on the guest still + * apply, so the IP is not less protected than the VR-backed equivalent that runs + * the same {@code FirewallRule}s through the VR's iptables.

+ */ + protected void ensureFirewallDefaultDeny(OvnProviderVO provider, Network network, String publicLs, + String publicLrpLsp, String publicIp) { + // Targeted ICMP echo-request drop. This is the slice of the original default-deny + // that we *can* enforce statelessly without breaking VM-initiated outbound: the + // match below pins the drop to icmp4.type == 8 (echo request) inbound on the + // public IP. ICMP echo replies coming back from the internet for a VM that pinged + // out have type == 0 (echo reply), so they do not match this drop; TCP/UDP replies + // are unaffected entirely because the match clauses out non-ICMP traffic. + // + // What this does NOT cover: unsolicited TCP/UDP inbound to the public IP. The LS + // pipeline cannot do stateful ACL there (see Javadoc above for the ct_next bypass + // on router/localnet LSPs), and a stateless to-lport drop on TCP/UDP would re- + // introduce the reply-traffic regression. Closing TCP/UDP requires moving firewall + // enforcement to Logical_Router policies (LR conntrack tracks ct.new vs ct.est + // correctly because ct_dnat / ct_snat populate the LR's ct zone), which is the + // separate refactor tracked elsewhere. + // + // The per-rule ACLs from programFirewallRule (priority 1000, allow-related) still + // override this drop when an operator opens ICMP via a CloudStack FirewallRule. + Map ext = new HashMap<>(); + ext.put("cloudstack_fw_default", "true"); + ext.put("cloudstack_fw_default_icmp", "true"); + ext.put("cloudstack_fw_ip", publicIp); + ext.put("cloudstack_network_id", String.valueOf(network.getId())); + if (network.getVpcId() != null) { + ext.put("cloudstack_vpc_id", String.valueOf(network.getVpcId())); + } + String match = "outport == \"" + publicLrpLsp + "\" && ip4 && ip4.dst == " + publicIp + + " && icmp4 && icmp4.type == 8"; + ovnNbClient.addAclOnLs(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + publicLs, "fw-default-icmp-" + publicIp, "to-lport", 100L, match, "drop", ext); + } + + @Override + public boolean applyNetworkACLs(Network config, List rules) throws ResourceUnavailableException { + if (config.getBroadcastDomainType() != Networks.BroadcastDomainType.OVN) { + return true; + } + OvnProviderVO provider = getProviderForNetwork(config); + String guestLs = getLogicalSwitchName(config); + String networkId = String.valueOf(config.getId()); + try { + // Full sync: wipe every ACL currently tagged to this network and re-install + // the authoritative set. This keeps OVN state consistent even when rules are + // reordered or the ACL list is replaced entirely. + ovnNbClient.removeAclsOnLsByExternalId(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + guestLs, "cloudstack_network_id", networkId); + + if (rules != null) { + for (NetworkACLItem rule : rules) { + if (rule.getState() == NetworkACLItem.State.Revoke) { + continue; + } + programNetworkAclRule(provider, config, guestLs, rule); + } + } + // Always install a default-deny at priority 1 for both directions so that + // unmatched traffic is dropped (OVN ACL default is allow when no rule matches). + ensureNetworkAclDefaultDeny(provider, config, guestLs); + + // If this ACL is also used by a peering membership, re-apply on the peering LS + if (rules != null && !rules.isEmpty()) { + long aclId = rules.get(0).getAclId(); + List peeringsWithAcl = ovnVpcPeeringDao.listByAclId(aclId); + for (OvnVpcPeeringVO peering : peeringsWithAcl) { + applyPeeringAcl(peering); + } + } + } catch (CloudRuntimeException e) { + throw new ResourceUnavailableException(e.getMessage(), DataCenter.class, config.getDataCenterId()); + } + return true; + } + + /** + * Translates a single {@link NetworkACLItem} into an OVN ACL row on the guest Logical_Switch. + * CloudStack {@code Ingress} (traffic into the VM) maps to OVN {@code to-lport}; {@code Egress} + * (traffic from the VM) maps to {@code from-lport}. Priority is derived from the rule number + * so that lower CloudStack rule numbers take precedence (higher OVN priority). + */ + protected void programNetworkAclRule(OvnProviderVO provider, Network network, + String guestLs, NetworkACLItem rule) { + String direction = rule.getTrafficType() == NetworkACLItem.TrafficType.Ingress + ? "to-lport" : "from-lport"; + String aclAction = rule.getAction() == NetworkACLItem.Action.Allow ? "allow-related" : "drop"; + // CloudStack rule number starts at 1; lower = higher CloudStack priority = higher OVN prio. + long ovnPriority = Math.max(2L, 1000L - rule.getNumber()); + String matchExpr = buildNetworkAclMatch(direction, rule); + if (matchExpr == null) { + return; + } + Map ext = new HashMap<>(); + ext.put("cloudstack_acl_rule_id", String.valueOf(rule.getId())); + ext.put("cloudstack_network_id", String.valueOf(network.getId())); + ext.put("cloudstack_acl_direction", direction); + ext.put("cloudstack_acl_id", String.valueOf(rule.getAclId())); + ext.put("cloudstack_acl_number", String.valueOf(rule.getNumber())); + if (network.getVpcId() != null) { + ext.put("cloudstack_vpc_id", String.valueOf(network.getVpcId())); + } + ovnNbClient.addAclOnLs(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + guestLs, "nacl-" + rule.getId(), direction, ovnPriority, matchExpr, aclAction, ext); + } + + /** + * Builds the OVN match expression for a NetworkACL rule on the guest LS. For {@code to-lport} + * (ingress) the source address is matched; for {@code from-lport} (egress) the destination + * address is matched. + */ + protected String buildNetworkAclMatch(String ovnDirection, NetworkACLItem rule) { + boolean isIngress = "to-lport".equals(ovnDirection); + String proto = rule.getProtocol() == null ? "all" : rule.getProtocol().toLowerCase(); + StringBuilder sb = new StringBuilder("ip4"); + List cidrs = rule.getSourceCidrList(); + if (cidrs != null && !cidrs.isEmpty()) { + StringBuilder cidrSet = new StringBuilder(); + for (String cidr : cidrs) { + if (cidr == null || cidr.isEmpty() || "0.0.0.0/0".equals(cidr)) { + cidrSet.setLength(0); + break; + } + if (cidrSet.length() > 0) cidrSet.append(", "); + cidrSet.append(cidr); + } + if (cidrSet.length() > 0) { + // For ingress the CIDR is the packet source; for egress it is the destination. + sb.append(isIngress ? " && ip4.src == {" : " && ip4.dst == {") + .append(cidrSet).append("}"); + } + } + switch (proto) { + case "tcp": + case "udp": { + sb.append(" && ").append(proto); + Integer portStart = rule.getSourcePortStart(); + Integer portEnd = rule.getSourcePortEnd(); + if (portStart != null && portEnd != null) { + String portCol = isIngress ? proto + ".dst" : proto + ".src"; + if (portStart.equals(portEnd)) { + sb.append(" && ").append(portCol).append(" == ").append(portStart); + } else { + sb.append(" && ").append(portCol).append(" >= ").append(portStart) + .append(" && ").append(portCol).append(" <= ").append(portEnd); + } + } + break; + } + case "icmp": { + sb.append(" && icmp4"); + if (rule.getIcmpType() != null && rule.getIcmpType() != -1) { + sb.append(" && icmp4.type == ").append(rule.getIcmpType()); + } + if (rule.getIcmpCode() != null && rule.getIcmpCode() != -1) { + sb.append(" && icmp4.code == ").append(rule.getIcmpCode()); + } + break; + } + case "all": + break; + default: + logger.warn("Skipping NetworkACL rule {} with unsupported protocol [{}]", rule.getId(), proto); + return null; + } + return sb.toString(); + } + + /** + * Installs a default-drop ACL at priority 1 for both directions on the guest LS. Without this + * OVN would allow any traffic not matched by an explicit rule (OVN ACL default is allow-all). + */ + protected void ensureNetworkAclDefaultDeny(OvnProviderVO provider, Network network, String guestLs) { + String networkId = String.valueOf(network.getId()); + for (String dir : new String[]{"to-lport", "from-lport"}) { + Map ext = new HashMap<>(); + ext.put("cloudstack_acl_default", "true"); + ext.put("cloudstack_network_id", networkId); + ext.put("cloudstack_acl_direction", dir); + if (network.getVpcId() != null) { + ext.put("cloudstack_vpc_id", String.valueOf(network.getVpcId())); + } + ovnNbClient.addAclOnLs(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + guestLs, "nacl-default-" + dir, dir, 1L, "ip4", "drop", ext); + } + } + + @Override + public boolean reorderAclRules(Vpc vpc, List networks, List networkACLItems) { + return true; + } + + @Override + public boolean applyLBRules(Network network, List rules) throws ResourceUnavailableException { + // CloudStack's LB manager invokes this with the rules currently in transition, not the + // full active set on the network - so an empty list means "nothing to apply right now", + // not "wipe all LBs". Removal is driven by individual rules in Revoke state (handled in + // programLBRule) and, as a safety net, by destroy() / cleanupPublicIpArtifacts which + // sweep by external_ids when a network or public IP is being torn down. + if (network.getBroadcastDomainType() != Networks.BroadcastDomainType.OVN || rules == null || rules.isEmpty()) { + return true; + } + OvnProviderVO provider = getProviderForNetwork(network); + String routerName = getRouterNameForNetwork(network); + String guestLs = getLogicalSwitchName(network); + try { + for (LoadBalancingRule rule : rules) { + programLBRule(provider, network, routerName, guestLs, rule); + } + } catch (CloudRuntimeException e) { + throw new ResourceUnavailableException(e.getMessage(), DataCenter.class, network.getDataCenterId()); + } + return true; + } + + /** + * Translates one CloudStack {@link LoadBalancingRule} into an OVN {@code Load_Balancer} row. + * Naming: {@code lb--}. Each VM destination becomes one entry in the + * vips map's backend list. Algorithm and stickiness are mapped to OVN's + * {@code selection_fields} + {@code options:affinity_timeout}. HealthCheckPolicy, when + * present, becomes one Load_Balancer_Health_Check row referenced from {@code health_check} + * with {@code ip_port_mappings} populated for SB Service_Monitor source attribution. + * + *

OVN LB is L4. CloudStack rules with {@code tcp-proxy}/{@code http}/{@code ssl} + * protocols, {@code leastconn} algorithm, or cookie-based stickiness are rejected upstream + * by {@link #validateLBRule}. Should one slip through (e.g. via DB-direct mutation), we log + * and skip rather than raise.

+ */ + protected void programLBRule(OvnProviderVO provider, Network network, + String routerName, String guestLs, LoadBalancingRule rule) { + String ruleTag = String.valueOf(rule.getId()); + String lbName = null; + + if (rule.getState() == FirewallRule.State.Revoke) { + ovnNbClient.removeLoadBalancersByExternalId(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + "cloudstack_lb_rule_id", ruleTag); + return; + } + + boolean isInternal = rule.getScheme() == LoadBalancerContainer.Scheme.Internal; + // For Public LB the VIP is sourced from the public IP allocation; for Internal LB it + // is a private VIP carried directly on the rule (tier CIDR, no user_ip_address row). + // rule.getSourceIp() works for both schemes uniformly — use it as the canonical VIP. + com.cloud.utils.net.Ip vipIp = rule.getSourceIp(); + String externalIp = vipIp != null ? vipIp.addr() : null; + if (externalIp == null || externalIp.isEmpty()) { + logger.warn("LB rule {} has no source IP; skipping", rule.getId()); + return; + } + + String protocol = rule.getProtocol() != null ? rule.getProtocol().toLowerCase() : "tcp"; + // Capability advertises only tcp/udp; keep validateLBRule and the datapath programmer in + // sync so an invalid protocol bubbling through (e.g. via direct DB mutation) is logged + // and skipped instead of silently creating a malformed Load_Balancer row. + if (!"tcp".equals(protocol) && !"udp".equals(protocol)) { + logger.warn("LB rule {} protocol [{}] is not supported by the OVN provider (tcp/udp only); skipping " + + "(validateLBRule should have rejected this)", rule.getId(), protocol); + return; + } + if (rule.getSourcePortStart() == null) { + logger.warn("LB rule {} has no source port; skipping", rule.getId()); + return; + } + int publicPort = rule.getSourcePortStart(); + + // Build the backend list ("vm_ip:port,vm_ip:port,...") from active destinations. + StringBuilder backends = new StringBuilder(); + Map ipPortMappings = new HashMap<>(); + String hcSourceIp = network.getGateway(); + for (LoadBalancingRule.LbDestination dest : rule.getDestinations()) { + if (dest.isRevoked()) { + continue; + } + String destIp = dest.getIpAddress(); + int destPort = dest.getDestinationPortStart(); + if (destIp == null || destIp.isEmpty()) { + continue; + } + if (backends.length() > 0) { + backends.append(","); + } + backends.append(destIp).append(":").append(destPort); + + // Populate ip_port_mappings: -> :. The lsp_name is + // the NIC UUID (matches our LSP naming scheme in createLogicalSwitchPort) and the + // source_ip is the LR's gateway IP on the guest LS - that is the address from which + // OVN's monitor will source HC probes. + NicVO targetNic = nicDao.findByIp4AddressAndNetworkId(destIp, network.getId()); + if (targetNic != null && hcSourceIp != null && !hcSourceIp.isEmpty()) { + ipPortMappings.put(destIp, targetNic.getUuid() + ":" + hcSourceIp); + } + } + if (backends.length() == 0) { + // No live destinations - drop the LB. Idempotent if it was never created. + logger.debug("LB rule {} has no live destinations; removing any existing LB row", rule.getId()); + ovnNbClient.removeLoadBalancersByExternalId(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + "cloudstack_lb_rule_id", ruleTag); + return; + } + + Map vips = java.util.Collections.singletonMap(externalIp + ":" + publicPort, backends.toString()); + + // Algorithm and stickiness → selection_fields + affinity_timeout. + Set selectionFields = null; + Long affinityTimeout = null; + String algorithm = rule.getAlgorithm() != null ? rule.getAlgorithm().toLowerCase() : "roundrobin"; + if ("source".equals(algorithm)) { + selectionFields = new java.util.LinkedHashSet<>(); + selectionFields.add("ip_src"); + } + if (rule.getStickinessPolicies() != null) { + for (LoadBalancingRule.LbStickinessPolicy sticky : rule.getStickinessPolicies()) { + if (sticky.isRevoked()) continue; + String method = sticky.getMethodName() != null ? sticky.getMethodName() : ""; + if ("SourceBased".equalsIgnoreCase(method)) { + if (selectionFields == null) { + selectionFields = new java.util.LinkedHashSet<>(); + selectionFields.add("ip_src"); + } + affinityTimeout = parseStickyTimeoutSeconds(sticky); + } else { + logger.warn("LB rule {} sticky method [{}] is L7 (cookie); OVN cannot honour it - degrading to source-based", + rule.getId(), method); + if (selectionFields == null) { + selectionFields = new java.util.LinkedHashSet<>(); + selectionFields.add("ip_src"); + } + } + } + } + + Map options = new HashMap<>(); + // Hairpin SNAT lets a VM behind the VIP reach its own VIP without ovn-northd + // mis-routing the reply. For a Public LB we use the VIP itself (the public IP); for an + // Internal LB whose VIP lives in a tier CIDR the public IP doesn't exist on this LR, so + // we anchor the hairpin on the tier's gateway IP — that LRP is reachable on the same + // LR, satisfies OVN's "must be an IP we own" check, and produces the right SNAT + // when a VM in the tier hits its own VIP. + options.put("hairpin_snat_ip", isInternal ? network.getGateway() : externalIp); + if (affinityTimeout != null && affinityTimeout > 0) { + options.put("affinity_timeout", String.valueOf(affinityTimeout)); + } + + Map ext = new HashMap<>(); + ext.put("cloudstack_lb_rule_id", ruleTag); + ext.put("cloudstack_network_id", String.valueOf(network.getId())); + // Tag with the VIP so the per-IP-release sweep (cleanupPublicIpArtifacts) can wipe + // Public LB rows out-of-order. Internal LBs carry a tier IP here (not a public IP), + // so the same sweep will not touch them when a public IP is released. + ext.put("cloudstack_lb_ip", externalIp); + ext.put("cloudstack_lb_kind", "loadbalancer"); + ext.put("cloudstack_lb_scheme", isInternal ? "Internal" : "Public"); + if (network.getVpcId() != null) { + ext.put("cloudstack_vpc_id", String.valueOf(network.getVpcId())); + } + + lbName = "lb-" + ruleTag + "-" + protocol; + ovnNbClient.createOrReplaceLoadBalancer(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + lbName, protocol, vips, ext, options, selectionFields, ipPortMappings); + ovnNbClient.attachLoadBalancerToRouter(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, lbName); + ovnNbClient.attachLoadBalancerToSwitch(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + guestLs, lbName); + + // Health check: take the first non-revoked policy. CloudStack today only persists one + // HealthCheckPolicy per rule but the API returns a list, so we are defensive. + applyLBHealthCheck(provider, network, rule, lbName, externalIp + ":" + publicPort, ipPortMappings); + } + + /** + * Maps a CloudStack stickiness policy timeout into OVN seconds. CS uses different param + * names depending on the method (cookie vs source-based); for SourceBased we look at + * {@code expire}/{@code idletime}/{@code holdtime}/{@code persistence_timeout} - whichever + * the user filled - and fall back to 180s if nothing parses. + */ + private static long parseStickyTimeoutSeconds(LoadBalancingRule.LbStickinessPolicy sticky) { + if (sticky.getParams() == null) return 180L; + for (org.apache.cloudstack.api.InternalIdentity pair : java.util.Collections.emptyList()) { /* unused */ } + // The Pair instances from CloudStack have .first() / .second() accessors. + for (com.cloud.utils.Pair p : sticky.getParams()) { + String name = p.first() != null ? p.first().toLowerCase() : ""; + if (name.contains("expire") || name.contains("idletime") + || name.contains("holdtime") || name.contains("timeout")) { + try { + return Long.parseLong(p.second()); + } catch (NumberFormatException ignored) { /* fall through */ } + } + } + return 180L; + } + + /** + * Applies (or clears) the OVN Load_Balancer_Health_Check for an LB rule. OVN HC is L4 TCP + * only; HTTP/PING policies from CloudStack are accepted but degraded to a TCP probe with a + * warning so the operator gets a hint to either accept it or move that workload to a + * VirtualRouter offering. + */ + protected void applyLBHealthCheck(OvnProviderVO provider, Network network, LoadBalancingRule rule, + String lbName, String hcVip, Map ipPortMappings) { + java.util.List policies = rule.getHealthCheckPolicies(); + LoadBalancingRule.LbHealthCheckPolicy active = null; + if (policies != null) { + for (LoadBalancingRule.LbHealthCheckPolicy p : policies) { + if (!p.isRevoked()) { + active = p; + break; + } + } + } + if (active == null) { + ovnNbClient.clearLoadBalancerHealthCheck(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + lbName); + return; + } + String pingPath = active.getpingpath(); + if (pingPath != null && !pingPath.isEmpty()) { + logger.warn("LB rule {} health check is HTTP/path-based ({}); OVN can only TCP-probe the backend port - " + + "honouring as TCP probe", rule.getId(), pingPath); + } + + Map hcOptions = new HashMap<>(); + // OVN options expect ms (interval/timeout) and integer counts. Map CS seconds to ms. + int intervalSec = active.getHealthcheckInterval() > 0 ? active.getHealthcheckInterval() : 5; + int responseSec = active.getResponseTime() > 0 ? active.getResponseTime() : 2; + int healthyCount = active.getHealthcheckThresshold() > 0 ? active.getHealthcheckThresshold() : 2; + int unhealthyCount = active.getUnhealthThresshold() > 0 ? active.getUnhealthThresshold() : 3; + hcOptions.put("interval", String.valueOf(intervalSec)); + hcOptions.put("timeout", String.valueOf(responseSec)); + hcOptions.put("success_count", String.valueOf(healthyCount)); + hcOptions.put("failure_count", String.valueOf(unhealthyCount)); + + Map hcExt = new HashMap<>(); + hcExt.put("cloudstack_lb_rule_id", String.valueOf(rule.getId())); + hcExt.put("cloudstack_network_id", String.valueOf(network.getId())); + + ovnNbClient.setLoadBalancerHealthCheck(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + lbName, hcVip, hcOptions, ipPortMappings, hcExt); + } + + @Override + public boolean validateLBRule(Network network, LoadBalancingRule rule) { + if (network.getBroadcastDomainType() != Networks.BroadcastDomainType.OVN) { + return true; + } + // OVN Load_Balancer is L4 and we only advertise tcp/udp in the capabilities map; keep + // this in sync with initCapabilities() so an offering that lists only tcp/udp does not + // accept a rule we cannot program. + String proto = rule.getProtocol() != null ? rule.getProtocol().toLowerCase() : "tcp"; + if (!"tcp".equals(proto) && !"udp".equals(proto)) { + logger.warn("OVN LB rejecting rule {}: protocol [{}] not supported (tcp/udp only)", + rule.getId(), proto); + return false; + } + // OVN has no per-backend connection state, so leastconn cannot be honoured. Reject + // explicitly rather than silently degrading - capabilities only advertise roundrobin + // and source. + String algo = rule.getAlgorithm() != null ? rule.getAlgorithm().toLowerCase() : ""; + if ("leastconn".equals(algo)) { + logger.warn("OVN LB rejecting rule {}: algorithm [leastconn] not supported (no backend conn state)", + rule.getId()); + return false; + } + boolean isInternal = rule.getScheme() == LoadBalancerContainer.Scheme.Internal; + com.cloud.utils.net.Ip vipIp = rule.getSourceIp(); + String vip = vipIp != null ? vipIp.addr() : null; + + if (isInternal) { + // Internal LB: VIP must be a private IP that lives inside the tier hosting the rule + // (or another tier of the same VPC, which OVN handles transparently because the LB + // is attached to the shared VPC LR). We accept any IP within the network's CIDR + // here; for cross-tier VIPs CloudStack already validates against the VPC supernet. + // Reject obvious mistakes: an empty VIP, or a VIP that maps to a real public-IP + // allocation (in which case the user wanted a Public LB). + if (vip == null || vip.isEmpty()) { + logger.warn("OVN LB rejecting Internal rule {}: no source IP", rule.getId()); + return false; + } + if (rule.getLb() != null && rule.getLb().getSourceIpAddressId() != null) { + IPAddressVO ipVo = ipAddressDao.findById(rule.getLb().getSourceIpAddressId()); + if (ipVo != null) { + logger.warn("OVN LB rejecting Internal rule {}: VIP {} resolves to a public IP " + + "allocation - use scheme=Public instead", + rule.getId(), vip); + return false; + } + } + return true; + } + + // Public LB: reject rules that target the network's SourceNat IP. The same external IP + // would carry both an LR-level snat NAT row (logical_ip=guest_cidr -> external_ip) and + // the LB's vips map; replies from a backend are SNATed back to the SourceNat IP before + // the LB un-DNAT can run, so the client sees a reply from an IP that doesn't match the + // connection it opened and TCP resets. Lab-confirmed: traffic enters the LR but no + // SYN+ACK ever reaches the upstream when LB and SourceNat share an external IP. Force + // the user to allocate a dedicated public IP for LB. + if (rule.getLb() != null && rule.getLb().getSourceIpAddressId() != null) { + IPAddressVO ipVo = ipAddressDao.findById(rule.getLb().getSourceIpAddressId()); + if (ipVo != null && ipVo.isSourceNat()) { + logger.warn("OVN LB rejecting Public rule {}: external IP {} is the network's SourceNat IP " + + "- allocate a separate public IP for the LB", + rule.getId(), ipVo.getAddress() != null ? ipVo.getAddress().addr() : ""); + return false; + } + } + return true; + } + + @Override + public List updateHealthChecks(Network network, List lbrules) { + // TODO: query OVN SB Service_Monitor table to surface backend up/down status back to + // CloudStack (so the UI shows red/green per VM member). The OVN HC writes status + // per-backend in Service_Monitor; we'd convert each to a LbDestination state. + return null; + } + + @Override + public boolean handlesOnlyRulesInTransitionState() { + return false; + } + + @Override + public boolean implementVpc(Vpc vpc, DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, ResourceUnavailableException, InsufficientCapacityException { + OvnProviderVO provider = ovnProviderDao.findByZoneId(vpc.getZoneId()); + if (provider == null) { + throw new ResourceUnavailableException( + String.format("No OVN provider configured for zone %s", vpc.getZoneId()), + DataCenter.class, vpc.getZoneId()); + } + String routerName = getVpcRouterName(vpc); + Map lrExt = new HashMap<>(); + lrExt.put("cloudstack_vpc_id", String.valueOf(vpc.getId())); + lrExt.put("cloudstack_vpc_uuid", vpc.getUuid()); + lrExt.put("cloudstack_zone_id", String.valueOf(vpc.getZoneId())); + lrExt.put("cloudstack_role", "vpc-router"); + try { + ovnNbClient.createLogicalRouter(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, lrExt); + // Wire up the public side now if CloudStack has already allocated the SourceNat IP + // for this VPC. This is the common case (VpcManagerImpl allocates the IP during VPC + // creation, before calling implementVpc). When the IP is changed later we re-run the + // same idempotent helper from updateVpcSourceNatIp. + applyVpcSourceNatPublicSide(provider, vpc); + // Re-provision any VPC peering this VPC participates in. shutdownVpc tears down + // the OVN-side artifacts but keeps the DB rows Active, so a restart-with-cleanup + // would otherwise leave us out of the mesh. provisionPeeringGroup is idempotent + // and re-runs once per group regardless of how many members live in this VPC. + List myPeerings = ovnVpcPeeringDao.listByVpcId(vpc.getId()); + Set reprovisionedGroups = new HashSet<>(); + for (OvnVpcPeeringVO p : myPeerings) { + if (reprovisionedGroups.add(p.getGroupUuid())) { + provisionPeeringGroup(p.getGroupUuid()); + } + } + } catch (CloudRuntimeException e) { + throw new ResourceUnavailableException(e.getMessage(), DataCenter.class, vpc.getZoneId()); + } + return true; + } + + /** + * Provisions or refreshes the public-side OVN artifacts for a VPC: the public Logical_Switch + * (cs-vpc-pub-{id}), its localnet port wired to the provider's external bridge / VLAN, the + * Logical_Router_Port that anchors the VPC LR on the public LS using the VPC's SourceNat IP, + * the Gateway_Chassis row, the default route to the upstream gateway, and the gARP + * announcement. + * + *

Idempotent on every component (skips inserts when the row already exists). Per-tier SNAT + * rows ({@code logical_ip = tier_cidr}) are added separately by PR-2b's tier + * {@code implement(network)} path.

+ * + *

If the VPC has no SourceNat IP allocated yet this is a no-op; the public side will come + * up on the next call (typically {@link #updateVpcSourceNatIp}).

+ */ + protected void applyVpcSourceNatPublicSide(OvnProviderVO provider, Vpc vpc) { + List ips = ipAddressDao.listByAssociatedVpc(vpc.getId(), true); + if (ips == null || ips.isEmpty()) { + logger.debug("VPC {} has no SourceNat IP yet; deferring public-side provisioning", vpc.getId()); + return; + } + String routerName = getVpcRouterName(vpc); + String publicLs = getVpcPublicLogicalSwitchName(vpc); + String publicLrpName = getVpcPublicRouterPortName(vpc); + String localnet = provider.getLocalnetName(); + String externalBridge = provider.getExternalBridge(); + for (IPAddressVO ipVo : ips) { + if (!ipVo.isSourceNat() || ipVo.getAddress() == null) { + continue; + } + String externalIp = ipVo.getAddress().addr(); + VlanVO vlan = vlanDao.findById(ipVo.getVlanId()); + String netmask = vlan != null && vlan.getVlanNetmask() != null ? vlan.getVlanNetmask() : "255.255.240.0"; + String externalGateway = vlan != null ? vlan.getVlanGateway() : null; + Integer vlanTag = null; + if (vlan != null && vlan.getVlanTag() != null && !"untagged".equalsIgnoreCase(vlan.getVlanTag())) { + String tagPart = vlan.getVlanTag().replaceAll("^vlan://", ""); + try { vlanTag = Integer.parseInt(tagPart); } catch (NumberFormatException ignored) { } + } + Map publicLsExt = new HashMap<>(); + publicLsExt.put("cloudstack_vpc_id", String.valueOf(vpc.getId())); + publicLsExt.put("cloudstack_role", "vpc-public"); + ovnNbClient.createLogicalSwitch(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + publicLs, publicLsExt); + ovnNbClient.addLocalnetPort(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + publicLs, "ln-" + publicLs, localnet != null ? localnet : externalBridge, vlanTag); + String prefix = "/" + maskToPrefix(netmask != null ? netmask : "255.255.240.0"); + ovnNbClient.attachRouterToSwitch(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, publicLs, + publicLrpName, buildVpcRouterMac(vpc.getId(), true), + java.util.Collections.singletonList(externalIp + prefix)); + String anchorChassis = pickAnchorChassisForVpc(provider, vpc); + if (anchorChassis != null) { + ovnNbClient.setLrpGatewayChassis(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + publicLrpName, anchorChassis, 10); + } + if (externalGateway != null && !externalGateway.isEmpty()) { + ovnNbClient.addStaticRoute(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, "0.0.0.0/0", externalGateway); + } + applyVpcNatAddressesAnnouncement(provider, vpc); + } + } + + @Override + public boolean shutdownVpc(Vpc vpc, ReservationContext context) throws ConcurrentOperationException, ResourceUnavailableException { + OvnProviderVO provider = ovnProviderDao.findByZoneId(vpc.getZoneId()); + if (provider == null) { + // Provider already gone — nothing to clean up. Treat as success so VPC removal can proceed. + return true; + } + String routerName = getVpcRouterName(vpc); + String publicLs = getVpcPublicLogicalSwitchName(vpc); + try { + // Wipe Load_Balancer rows tagged with this VPC. LB rows live in the global + // Load_Balancer table and are referenced from LR/LS via the load_balancer column, + // so deleting the LR/LS does not necessarily garbage-collect them when other refs + // remain. By the time shutdownVpc runs CloudStack has already destroyed every tier, + // but a defensive sweep keeps state clean for re-creates. + ovnNbClient.removeLoadBalancersByExternalId(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + "cloudstack_vpc_id", String.valueOf(vpc.getId())); + // Remove all peering memberships for this VPC before destroying the router. + removePeeringsForVpc(vpc, provider); + + // Public LS first — its router-type LSP pairs with the public LRP on the LR; deleting + // the LS removes the LSP and any localnet/firewall ACLs sitting on it. + ovnNbClient.deleteLogicalSwitch(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + publicLs); + // The LR may still have its public LRP and any tier LRPs / NAT rows referenced from + // the strong-typed columns; OVSDB GCs those rows when the LR is removed. Tier LSes + // were already deleted by destroy(network) calls preceding shutdownVpc. + ovnNbClient.deleteLogicalRouter(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName); + } catch (CloudRuntimeException e) { + throw new ResourceUnavailableException(e.getMessage(), DataCenter.class, vpc.getZoneId()); + } + return true; + } + + @Override + public boolean createPrivateGateway(PrivateGateway gateway) throws ConcurrentOperationException, ResourceUnavailableException { + // PrivateGateway is out of scope for the OVN VPC v1. + return true; + } + + @Override + public boolean deletePrivateGateway(PrivateGateway privateGateway) throws ConcurrentOperationException, ResourceUnavailableException { + // PrivateGateway is out of scope for the OVN VPC v1. + return true; + } + + @Override + public boolean applyStaticRoutes(Vpc vpc, List routes) throws ResourceUnavailableException { + // Tenant-managed static routes are out of scope for the OVN VPC v1; the only route we + // program ourselves is the upstream default in applyVpcSourceNatPublicSide. + return true; + } + + @Override + public boolean applyACLItemsToPrivateGw(PrivateGateway gateway, List rules) throws ResourceUnavailableException { + // Coupled to PrivateGateway support; out of scope for v1. + return true; + } + + @Override + public boolean updateVpcSourceNatIp(Vpc vpc, IpAddress address) { + // Re-run the public-side provisioning. applyVpcSourceNatPublicSide is idempotent and + // attaches the new SourceNat IP via attachRouterToSwitch / addStaticRoute, then refreshes + // the gARP announcement. Note: when the VPC's SourceNat IP is *changed* (rather than + // first allocated), the previous LRP IP/NAT/route rows are not torn down here — the + // OVN-only SourceNat-IP swap remains a TODO. v1 supports first-time allocation cleanly. + OvnProviderVO provider = ovnProviderDao.findByZoneId(vpc.getZoneId()); + if (provider == null) { + logger.warn("updateVpcSourceNatIp: no OVN provider for zone {}", vpc.getZoneId()); + return false; + } + try { + applyVpcSourceNatPublicSide(provider, vpc); + } catch (CloudRuntimeException e) { + logger.warn("updateVpcSourceNatIp failed for VPC {}: {}", vpc.getId(), e.getMessage()); + return false; + } + return true; + } + + // ── VPC Peering (OvnPeeringService) ────────────────────────────────────── + + private static final String PEERING_EXT_KEY = "cloudstack_peering_group"; + private static final int NAT_BYPASS_PRIORITY = 1000; + + @Override + public OvnVpcPeeringVO createVpcPeering(CreateVpcPeeringCmd cmd) { + long callerId = CallContext.current().getCallingAccount().getId(); + VpcVO vpc = vpcDao.findById(cmd.getVpcId()); + VpcVO peerVpc = vpcDao.findById(cmd.getPeerVpcId()); + if (vpc == null) { + throw new InvalidParameterValueException("VPC not found: " + cmd.getVpcId()); + } + if (peerVpc == null) { + throw new InvalidParameterValueException("Peer VPC not found: " + cmd.getPeerVpcId()); + } + if (vpc.getId() == peerVpc.getId()) { + throw new InvalidParameterValueException("Cannot peer a VPC with itself"); + } + if (vpc.getAccountId() != callerId && !accountMgr.isRootAdmin(callerId)) { + throw new PermissionDeniedException("Caller does not own VPC " + vpc.getUuid()); + } + if (peerVpc.getAccountId() != callerId && !accountMgr.isRootAdmin(callerId)) { + throw new PermissionDeniedException("Caller does not own peer VPC " + peerVpc.getUuid()); + } + if (vpc.getAccountId() != peerVpc.getAccountId()) { + throw new InvalidParameterValueException("VPC peering is only allowed between VPCs of the same account"); + } + + OvnProviderVO providerA = ovnProviderDao.findByZoneId(vpc.getZoneId()); + OvnProviderVO providerB = ovnProviderDao.findByZoneId(peerVpc.getZoneId()); + if (providerA == null) { + throw new InvalidParameterValueException("VPC zone " + vpc.getZoneId() + " has no OVN provider"); + } + if (providerB == null) { + throw new InvalidParameterValueException("Peer VPC zone " + peerVpc.getZoneId() + " has no OVN provider"); + } + + // A VPC may only belong to one peering group + List vpcExisting = ovnVpcPeeringDao.listByVpcId(vpc.getId()); + List peerExisting = ovnVpcPeeringDao.listByVpcId(peerVpc.getId()); + if (!vpcExisting.isEmpty() && !peerExisting.isEmpty() + && !vpcExisting.get(0).getGroupUuid().equals(peerExisting.get(0).getGroupUuid())) { + throw new InvalidParameterValueException("Both VPCs already belong to different peering groups. A VPC can only be in one peering group."); + } + + // Reject overlapping CIDRs. The peering data plane installs one static route per + // peer CIDR on every member's LR; if two members share an overlapping CIDR the + // routes collide and OVN cannot resolve which peer to forward at — there is no + // sane way to disambiguate at runtime. Bail out before any OVN row is created. + rejectOverlappingPeeringCidrs(vpc, peerVpc, vpcExisting, peerExisting); + + // Determine group: if peer VPC already belongs to a group, join it; otherwise check if our VPC + // already belongs to one. If neither, create a new group. + String groupUuid = null; + String groupName = cmd.getName(); + String groupDescription = cmd.getDescription(); + if (!peerExisting.isEmpty()) { + groupUuid = peerExisting.get(0).getGroupUuid(); + if (groupName == null) { + groupName = peerExisting.get(0).getName(); + } + if (groupDescription == null) { + groupDescription = peerExisting.get(0).getDescription(); + } + } + if (groupUuid == null && !vpcExisting.isEmpty()) { + groupUuid = vpcExisting.get(0).getGroupUuid(); + if (groupName == null) { + groupName = vpcExisting.get(0).getName(); + } + if (groupDescription == null) { + groupDescription = vpcExisting.get(0).getDescription(); + } + } + if (groupUuid == null) { + groupUuid = UUID.randomUUID().toString(); + } + + // Ensure both VPCs aren't already in the same group + if (ovnVpcPeeringDao.findByGroupUuidAndVpcId(groupUuid, vpc.getId()) != null + && ovnVpcPeeringDao.findByGroupUuidAndVpcId(groupUuid, peerVpc.getId()) != null) { + throw new InvalidParameterValueException("Both VPCs are already in the same peering group"); + } + + // Allocate link-local IPs for new members + List groupMembers = ovnVpcPeeringDao.listByGroupUuid(groupUuid); + Set usedIps = new HashSet<>(); + for (OvnVpcPeeringVO m : groupMembers) { + usedIps.add(m.getLinkLocalIp()); + } + + // Validate ACL belongs to the calling VPC if specified + Long aclId = cmd.getAclId(); + if (aclId != null) { + NetworkACLVO acl = networkACLDao.findById(aclId); + if (acl == null) { + throw new InvalidParameterValueException("Network ACL not found: " + aclId); + } + if (acl.getVpcId() != 0 && acl.getVpcId() != vpc.getId()) { + throw new InvalidParameterValueException("Network ACL does not belong to VPC " + vpc.getUuid()); + } + } + + // Cross-zone vs same-zone: drives which link-local pool feeds the peering LRP IP. + // We treat the group as cross-zone if the new pair OR any existing member crosses + // a zone boundary - that matches the topology decision in provisionPeeringGroup(). + boolean willBeCrossZone = vpc.getZoneId() != peerVpc.getZoneId(); + if (!willBeCrossZone) { + for (OvnVpcPeeringVO m : groupMembers) { + if (m.getZoneId() != vpc.getZoneId()) { willBeCrossZone = true; break; } + } + } + + OvnVpcPeeringVO peeringA = null; + OvnVpcPeeringVO peeringB = null; + + if (ovnVpcPeeringDao.findByGroupUuidAndVpcId(groupUuid, vpc.getId()) == null) { + String ipA = willBeCrossZone ? allocateCrossZoneLinkLocalIp(usedIps) : allocateLinkLocalIp(usedIps); + usedIps.add(ipA); + peeringA = new OvnVpcPeeringVO(groupUuid, groupName, groupDescription, vpc.getId(), vpc.getZoneId(), + vpc.getAccountId(), vpc.getDomainId(), ipA); + peeringA.setAclId(aclId); + peeringA = ovnVpcPeeringDao.persist(peeringA); + } else { + peeringA = ovnVpcPeeringDao.findByGroupUuidAndVpcId(groupUuid, vpc.getId()); + } + + if (ovnVpcPeeringDao.findByGroupUuidAndVpcId(groupUuid, peerVpc.getId()) == null) { + String ipB = willBeCrossZone ? allocateCrossZoneLinkLocalIp(usedIps) : allocateLinkLocalIp(usedIps); + usedIps.add(ipB); + peeringB = new OvnVpcPeeringVO(groupUuid, groupName, groupDescription, peerVpc.getId(), peerVpc.getZoneId(), + peerVpc.getAccountId(), peerVpc.getDomainId(), ipB); + peeringB = ovnVpcPeeringDao.persist(peeringB); + } + + // Provision OVN fabric for the entire group + provisionPeeringGroup(groupUuid); + + return peeringA; + } + + @Override + public boolean deleteVpcPeering(DeleteVpcPeeringCmd cmd) { + // The cmd.id is normally a peering row UUID, but the AutogenView list maps each + // peering "group" to a single resource keyed off groupUuid. Accept both: if the + // value matches a group, delete every member sequentially so the group as a + // whole disappears. + OvnVpcPeeringVO peering = ovnVpcPeeringDao.findByUuid(cmd.getId()); + if (peering == null) { + List groupMembers = ovnVpcPeeringDao.listByGroupUuidIncludingDisabled(cmd.getId()); + if (groupMembers != null && !groupMembers.isEmpty()) { + long callerId = CallContext.current().getCallingAccount().getId(); + if (groupMembers.get(0).getAccountId() != callerId && !accountMgr.isRootAdmin(callerId)) { + throw new PermissionDeniedException("Caller does not own this peering"); + } + boolean ok = true; + for (OvnVpcPeeringVO m : groupMembers) { + DeleteVpcPeeringCmd memberCmd = new DeleteVpcPeeringCmd(); + try { + java.lang.reflect.Field idField = DeleteVpcPeeringCmd.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(memberCmd, m.getUuid()); + } catch (ReflectiveOperationException ex) { + throw new CloudRuntimeException("Cannot rewrite DeleteVpcPeeringCmd.id: " + ex.getMessage(), ex); + } + ok &= deleteVpcPeering(memberCmd); + } + return ok; + } + throw new InvalidParameterValueException("VPC peering not found or already removed: " + cmd.getId()); + } + if ("Removed".equals(peering.getState())) { + throw new InvalidParameterValueException("VPC peering not found or already removed: " + cmd.getId()); + } + long callerId = CallContext.current().getCallingAccount().getId(); + if (peering.getAccountId() != callerId && !accountMgr.isRootAdmin(callerId)) { + throw new PermissionDeniedException("Caller does not own this peering"); + } + String groupUuid = peering.getGroupUuid(); + long vpcId = peering.getVpcId(); + VpcVO vpc = vpcDao.findById(vpcId); + + OvnProviderVO provider = ovnProviderDao.findByZoneId(peering.getZoneId()); + // Cross-zone path: OVN-IC propagates route removal automatically (since ic-route-adv + // re-runs whenever the LRP set on the TS changes), so we just need to detach this + // member's LRP+LSP from the TS and let ovn-ic do the rest. + // We must look at the FULL group history (any state, including Removed) so a bulk + // delete that has already marked earlier members Removed still routes the next + // iterations through the cross-zone path. The link-local IP itself encodes the + // pool the member was allocated from, which is the most reliable indicator. + boolean isCrossZone = peering.getLinkLocalIp() != null + && peering.getLinkLocalIp().startsWith(CROSS_ZONE_LL_PREFIX); + if (provider != null && vpc != null && isCrossZone) { + removeCrossZonePeeringMember(peering, provider, groupUuid); + peering.setState("Removed"); + peering.setRemoved(new java.util.Date()); + ovnVpcPeeringDao.update(peering.getId(), peering); + return true; + } + if (provider != null && vpc != null) { + String routerName = String.format("cs-vpc-%d", vpcId); + // Remove routes and policies on all OTHER members pointing to this VPC + List groupMembers = ovnVpcPeeringDao.listByGroupUuid(groupUuid); + for (OvnVpcPeeringVO member : groupMembers) { + if (member.getVpcId() == vpcId) continue; + OvnProviderVO memberProvider = ovnProviderDao.findByZoneId(member.getZoneId()); + if (memberProvider == null) continue; + String memberRouter = String.format("cs-vpc-%d", member.getVpcId()); + VpcVO memberVpc = vpcDao.findById(member.getVpcId()); + if (memberVpc == null) continue; + // Remove route on member pointing to this VPC + ovnNbClient.removeStaticRoute(memberProvider.getNbConnection(), + memberProvider.getCaCertPath(), memberProvider.getClientCertPath(), memberProvider.getClientPrivateKeyPath(), + memberRouter, vpc.getCidr(), peering.getLinkLocalIp()); + // Remove NAT bypass policy on member for this VPC's CIDR + ovnNbClient.removeLogicalRouterPoliciesByExternalId(memberProvider.getNbConnection(), + memberProvider.getCaCertPath(), memberProvider.getClientCertPath(), memberProvider.getClientPrivateKeyPath(), + memberRouter, PEERING_EXT_KEY + "_target", String.valueOf(vpcId)); + } + + // Remove routes and policies on THIS VPC pointing to all other members + ovnNbClient.removeStaticRoutesByExternalId(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, PEERING_EXT_KEY, groupUuid); + ovnNbClient.removeLogicalRouterPoliciesByExternalId(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, PEERING_EXT_KEY, groupUuid); + + // Remove peering ACLs for this VPC on the peering LS + String peerLs = getPeeringLsName(groupUuid); + String peeringTag = "cloudstack_peering_acl_vpc_" + vpcId; + ovnNbClient.removeAclsOnLsByExternalId(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + peerLs, peeringTag, "true"); + + // Remove LRP+LSP for this VPC on the peering switch + String lrpName = getPeeringLrpName(groupUuid, vpcId); + ovnNbClient.removeLogicalRouterPort(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, lrpName); + ovnNbClient.deleteLogicalSwitchPort(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + peerLs, getPeeringLspName(groupUuid, vpcId)); + + // If no other members, delete the peering LS from all zones + List remaining = ovnVpcPeeringDao.listByGroupUuid(groupUuid); + long activeCount = remaining.stream().filter(m -> m.getVpcId() != vpcId).count(); + if (activeCount == 0) { + Set cleanedZones = new HashSet<>(); + for (OvnVpcPeeringVO m : remaining) { + if (!cleanedZones.add(m.getZoneId())) continue; + OvnProviderVO zp = ovnProviderDao.findByZoneId(m.getZoneId()); + if (zp == null) continue; + ovnNbClient.deleteLogicalSwitch(zp.getNbConnection(), + zp.getCaCertPath(), zp.getClientCertPath(), zp.getClientPrivateKeyPath(), + peerLs); + } + } + } + + peering.setState("Removed"); + peering.setRemoved(new java.util.Date()); + ovnVpcPeeringDao.update(peering.getId(), peering); + return true; + } + + @Override + public OvnVpcPeeringVO updateVpcPeering(UpdateVpcPeeringCmd cmd) { + OvnVpcPeeringVO peering = ovnVpcPeeringDao.findByUuid(cmd.getId()); + if (peering == null || !"Active".equals(peering.getState())) { + throw new InvalidParameterValueException("VPC peering not found or already removed: " + cmd.getId()); + } + long callerId = CallContext.current().getCallingAccount().getId(); + if (peering.getAccountId() != callerId && !accountMgr.isRootAdmin(callerId)) { + throw new PermissionDeniedException("Caller does not own this peering"); + } + + Long aclId = cmd.getAclId(); + if (aclId != null) { + NetworkACLVO acl = networkACLDao.findById(aclId); + if (acl == null) { + throw new InvalidParameterValueException("Network ACL not found: " + aclId); + } + if (acl.getVpcId() != 0 && acl.getVpcId() != peering.getVpcId()) { + throw new InvalidParameterValueException("Network ACL does not belong to this peering's VPC"); + } + } + + peering.setAclId(aclId); + ovnVpcPeeringDao.update(peering.getId(), peering); + + List groupMembers = ovnVpcPeeringDao.listByGroupUuid(peering.getGroupUuid()); + if (isCrossZonePeeringGroup(groupMembers)) { + applyCrossZonePeeringAcl(peering, getTransitSwitchName(peering.getGroupUuid())); + } else { + applyPeeringAcl(peering); + } + return peering; + } + + /** + * Rejects a {@code createVpcPeering} call when the new VPC pair (or any + * existing group member) carries an IPv4 CIDR that overlaps another. The + * peering fabric installs one static route per peer CIDR on every member's + * LR; two routes for overlapping prefixes would race and OVN cannot + * disambiguate at runtime. We compare every relevant CIDR pair before any + * OVN row is touched so the operator gets a clear error instead of a + * subtle, intermittent reachability bug. + * + *

Members already in the group are taken from {@code listByVpcId}, which + * filters Active rows. Disabled members keep their slot reserved but their + * data plane is torn down — we still include them here because re-enabling + * the group must not produce overlapping routes either. + */ + protected void rejectOverlappingPeeringCidrs(VpcVO vpc, VpcVO peerVpc, + List vpcExisting, + List peerExisting) { + String cidrA = vpc.getCidr(); + String cidrB = peerVpc.getCidr(); + if (StringUtils.isNotBlank(cidrA) && StringUtils.isNotBlank(cidrB) + && com.cloud.utils.net.NetUtils.isNetworksOverlap(cidrA, cidrB)) { + throw new InvalidParameterValueException(String.format( + "VPC %s (%s) and VPC %s (%s) have overlapping CIDRs and cannot be peered", + vpc.getName(), cidrA, peerVpc.getName(), cidrB)); + } + + // Determine the group we're about to land in (mirrors the resolver below) + // and walk every other member's CIDR against the incoming pair. + String groupUuid = null; + if (!peerExisting.isEmpty()) { + groupUuid = peerExisting.get(0).getGroupUuid(); + } else if (!vpcExisting.isEmpty()) { + groupUuid = vpcExisting.get(0).getGroupUuid(); + } + if (groupUuid == null) { + return; // brand-new group; only the pair-vs-pair check applies + } + List existingGroupMembers = ovnVpcPeeringDao.listByGroupUuidIncludingDisabled(groupUuid); + for (OvnVpcPeeringVO m : existingGroupMembers) { + if (m.getVpcId() == vpc.getId() || m.getVpcId() == peerVpc.getId()) { + continue; + } + VpcVO other = vpcDao.findById(m.getVpcId()); + if (other == null || StringUtils.isBlank(other.getCidr())) { + continue; + } + if (StringUtils.isNotBlank(cidrA) && com.cloud.utils.net.NetUtils.isNetworksOverlap(cidrA, other.getCidr())) { + throw new InvalidParameterValueException(String.format( + "VPC %s (%s) overlaps existing peering member %s (%s)", + vpc.getName(), cidrA, other.getName(), other.getCidr())); + } + if (StringUtils.isNotBlank(cidrB) && com.cloud.utils.net.NetUtils.isNetworksOverlap(cidrB, other.getCidr())) { + throw new InvalidParameterValueException(String.format( + "VPC %s (%s) overlaps existing peering member %s (%s)", + peerVpc.getName(), cidrB, other.getName(), other.getCidr())); + } + } + } + + /** + * Resolves the cmd id to a peering group's full membership (every member, + * including Disabled) and verifies the caller owns the group. Throws if the + * id is unknown or the group is empty. The id may be either a group UUID or + * any single member's peering UUID. + */ + protected List resolveGroupMembersForToggle(String id) { + List members = ovnVpcPeeringDao.listByGroupUuidIncludingDisabled(id); + if (members.isEmpty()) { + OvnVpcPeeringVO single = ovnVpcPeeringDao.findByUuid(id); + if (single != null && !"Removed".equals(single.getState())) { + members = ovnVpcPeeringDao.listByGroupUuidIncludingDisabled(single.getGroupUuid()); + } + } + if (members.isEmpty()) { + throw new InvalidParameterValueException("VPC peering not found: " + id); + } + long callerId = CallContext.current().getCallingAccount().getId(); + if (members.get(0).getAccountId() != callerId && !accountMgr.isRootAdmin(callerId)) { + throw new PermissionDeniedException("Caller does not own this peering"); + } + return members; + } + + @Override + public boolean disableVpcPeering(DisableVpcPeeringCmd cmd) { + List members = resolveGroupMembersForToggle(cmd.getId()); + String groupUuid = members.get(0).getGroupUuid(); + + // Tear down OVN data plane on every member's router so traffic stops, then + // mark each row Disabled. We deliberately keep DB rows + linkLocalIp + // assignments so a subsequent enable can deterministically rebuild the + // same fabric via provisionPeeringGroup. + boolean crossZone = isCrossZonePeeringGroup(members); + for (OvnVpcPeeringVO m : members) { + if ("Disabled".equals(m.getState())) continue; + OvnProviderVO provider = ovnProviderDao.findByZoneId(m.getZoneId()); + VpcVO vpc = vpcDao.findById(m.getVpcId()); + if (provider != null && vpc != null) { + if (crossZone) { + removeCrossZonePeeringMember(m, provider, groupUuid); + } else { + String routerName = String.format("cs-vpc-%d", m.getVpcId()); + ovnNbClient.removeStaticRoutesByExternalId(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, PEERING_EXT_KEY, groupUuid); + ovnNbClient.removeLogicalRouterPoliciesByExternalId(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, PEERING_EXT_KEY, groupUuid); + String peerLs = getPeeringLsName(groupUuid); + String peeringTag = "cloudstack_peering_acl_vpc_" + m.getVpcId(); + ovnNbClient.removeAclsOnLsByExternalId(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + peerLs, peeringTag, "true"); + String lrpName = getPeeringLrpName(groupUuid, m.getVpcId()); + ovnNbClient.removeLogicalRouterPort(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, lrpName); + ovnNbClient.deleteLogicalSwitchPort(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + peerLs, getPeeringLspName(groupUuid, m.getVpcId())); + } + } + m.setState("Disabled"); + ovnVpcPeeringDao.update(m.getId(), m); + } + + // Same-zone path: also remove the peering LS once no Active members remain. + if (!crossZone) { + String peerLs = getPeeringLsName(groupUuid); + Set cleanedZones = new HashSet<>(); + for (OvnVpcPeeringVO m : members) { + if (!cleanedZones.add(m.getZoneId())) continue; + OvnProviderVO zp = ovnProviderDao.findByZoneId(m.getZoneId()); + if (zp == null) continue; + ovnNbClient.deleteLogicalSwitch(zp.getNbConnection(), + zp.getCaCertPath(), zp.getClientCertPath(), zp.getClientPrivateKeyPath(), + peerLs); + } + } + return true; + } + + @Override + public boolean enableVpcPeering(EnableVpcPeeringCmd cmd) { + List members = resolveGroupMembersForToggle(cmd.getId()); + for (OvnVpcPeeringVO m : members) { + if (!"Active".equals(m.getState())) { + m.setState("Active"); + ovnVpcPeeringDao.update(m.getId(), m); + } + } + // provisionPeeringGroup is idempotent; reads listByGroupUuid (Active only) + // so it now sees the freshly-Active members and rebuilds LS/LRPs/routes. + provisionPeeringGroup(members.get(0).getGroupUuid()); + return true; + } + + @Override + public List listVpcPeerings(ListVpcPeeringsCmd cmd) { + long callerId = CallContext.current().getCallingAccount().getId(); + List responses = new ArrayList<>(); + + // Filter by VPC: caller wants every peering record this VPC participates in + // (flat, one per row). Used by the per-VPC tab inside a VPC detail view. + if (cmd.getVpcId() != null) { + for (OvnVpcPeeringVO p : ovnVpcPeeringDao.listByVpcId(cmd.getVpcId())) { + responses.add(createVpcPeeringResponse(p)); + } + return responses; + } + + // Filter by group: return ONE aggregated row representing the whole peering + // mesh. AutogenView's detail view (/vpcpeering/) hits this branch with id = + // group_uuid (aliased onto groupuuid in the cmd), expecting members[] embedded. + if (cmd.getGroupUuid() != null) { + List members = ovnVpcPeeringDao.listByGroupUuidIncludingDisabled(cmd.getGroupUuid()); + if (!members.isEmpty()) { + responses.add(createGroupResponse(cmd.getGroupUuid(), members)); + } + return responses; + } + + // Default list: ONE row per peering group (aggregated). Driven by the + // standard list view in AutogenView. Disabled groups are also returned so + // users can see and re-enable them; "Removed" rows are excluded. + List all = accountMgr.isRootAdmin(callerId) + ? ovnVpcPeeringDao.listAllIncludingDisabled() + : ovnVpcPeeringDao.listByAccountIdIncludingDisabled(callerId); + + // Group by group_uuid preserving insertion order (= creation order, since the + // DAO already sorts by id). + Map> byGroup = new java.util.LinkedHashMap<>(); + for (OvnVpcPeeringVO p : all) { + byGroup.computeIfAbsent(p.getGroupUuid(), k -> new ArrayList<>()).add(p); + } + for (Map.Entry> e : byGroup.entrySet()) { + responses.add(createGroupResponse(e.getKey(), e.getValue())); + } + return responses; + } + + /** + * Builds a group-level VpcPeeringResponse with members[] embedded. Used by the + * AutogenView list and detail flows so a peering "group" appears as a single + * resource entity (id == groupUuid). + */ + protected VpcPeeringResponse createGroupResponse(String groupUuid, List members) { + VpcPeeringResponse response = new VpcPeeringResponse(); + response.setObjectName("vpcpeering"); + response.setId(groupUuid); + response.setGroupUuid(groupUuid); + + // Aggregate name/description from any non-blank member (they should all match). + OvnVpcPeeringVO first = members.get(0); + for (OvnVpcPeeringVO m : members) { + if (m.getName() != null) { response.setName(m.getName()); break; } + } + for (OvnVpcPeeringVO m : members) { + if (m.getDescription() != null) { response.setDescription(m.getDescription()); break; } + } + response.setState(first.getState()); + response.setCreated(first.getCreated()); + + // Zone column: a single zone name when same-zone, otherwise "multi-zone". + Set zones = new HashSet<>(); + for (OvnVpcPeeringVO m : members) zones.add(m.getZoneId()); + if (zones.size() == 1) { + DataCenterVO z = dataCenterDao.findById(first.getZoneId()); + if (z != null) response.setZoneName(z.getName()); + } else { + response.setZoneName("multi-zone"); + } + + // Member rollup: vpccount, comma-separated names, and the embedded list used + // by the VPC Peers tab. + List memberResponses = new ArrayList<>(); + StringBuilder names = new StringBuilder(); + for (OvnVpcPeeringVO m : members) { + VpcPeeringMemberResponse mr = new VpcPeeringMemberResponse(); + mr.setObjectName("member"); + mr.setId(m.getUuid()); + mr.setLinkLocalIp(m.getLinkLocalIp()); + mr.setState(m.getState()); + VpcVO vpc = vpcDao.findById(m.getVpcId()); + if (vpc != null) { + mr.setVpcId(vpc.getUuid()); + mr.setVpcName(vpc.getName()); + mr.setVpcCidr(vpc.getCidr()); + if (names.length() > 0) names.append(", "); + names.append(vpc.getName()); + } + DataCenterVO mz = dataCenterDao.findById(m.getZoneId()); + if (mz != null) { + mr.setZoneId(mz.getUuid()); + mr.setZoneName(mz.getName()); + } + if (m.getAclId() != null) { + NetworkACLVO acl = networkACLDao.findById(m.getAclId()); + if (acl != null) { + mr.setAclId(acl.getUuid()); + mr.setAclName(acl.getName()); + } + } + memberResponses.add(mr); + } + response.setMembers(memberResponses); + response.setVpcCount(memberResponses.size()); + response.setVpcNames(names.toString()); + return response; + } + + @Override + public VpcPeeringResponse createVpcPeeringResponse(OvnVpcPeeringVO peering) { + VpcPeeringResponse response = new VpcPeeringResponse(); + response.setObjectName("vpcpeering"); + response.setId(peering.getUuid()); + response.setGroupUuid(peering.getGroupUuid()); + response.setName(peering.getName()); + response.setDescription(peering.getDescription()); + response.setLinkLocalIp(peering.getLinkLocalIp()); + response.setState(peering.getState()); + response.setCreated(peering.getCreated()); + + VpcVO vpc = vpcDao.findById(peering.getVpcId()); + if (vpc != null) { + response.setVpcId(vpc.getUuid()); + response.setVpcName(vpc.getName()); + response.setVpcCidr(vpc.getCidr()); + } + + DataCenterVO zone = dataCenterDao.findById(peering.getZoneId()); + if (zone != null) { + response.setZoneId(zone.getUuid()); + response.setZoneName(zone.getName()); + } + + if (peering.getAclId() != null) { + NetworkACLVO acl = networkACLDao.findById(peering.getAclId()); + if (acl != null) { + response.setAclId(acl.getUuid()); + response.setAclName(acl.getName()); + } + } + + // Find the "other" VPCs in this group for richer response + List groupMembers = ovnVpcPeeringDao.listByGroupUuid(peering.getGroupUuid()); + for (OvnVpcPeeringVO m : groupMembers) { + if (m.getVpcId() != peering.getVpcId()) { + VpcVO peerVpc = vpcDao.findById(m.getVpcId()); + if (peerVpc != null) { + response.setPeerVpcId(peerVpc.getUuid()); + response.setPeerVpcName(peerVpc.getName()); + response.setPeerVpcCidr(peerVpc.getCidr()); + } + break; + } + } + + return response; + } + + protected void provisionPeeringGroup(String groupUuid) { + List members = ovnVpcPeeringDao.listByGroupUuid(groupUuid); + if (members.isEmpty()) return; + + if (isCrossZonePeeringGroup(members)) { + provisionCrossZonePeering(groupUuid, members); + return; + } + + String peerLs = getPeeringLsName(groupUuid); + Map lsExt = new HashMap<>(); + lsExt.put(PEERING_EXT_KEY, groupUuid); + lsExt.put("cloudstack_role", "vpc-peering"); + + // Create the peering LS in every distinct zone that participates. + // Each zone has its own OVN NB, so the LS must exist in each. + Set provisionedZones = new HashSet<>(); + for (OvnVpcPeeringVO member : members) { + if (!provisionedZones.add(member.getZoneId())) continue; + OvnProviderVO zoneProvider = ovnProviderDao.findByZoneId(member.getZoneId()); + if (zoneProvider == null) continue; + ovnNbClient.createLogicalSwitch(zoneProvider.getNbConnection(), + zoneProvider.getCaCertPath(), zoneProvider.getClientCertPath(), zoneProvider.getClientPrivateKeyPath(), + peerLs, lsExt); + } + + // Ensure each member is attached and has routes to every other member + for (OvnVpcPeeringVO member : members) { + OvnProviderVO provider = ovnProviderDao.findByZoneId(member.getZoneId()); + if (provider == null) continue; + VpcVO vpc = vpcDao.findById(member.getVpcId()); + if (vpc == null) continue; + + String routerName = String.format("cs-vpc-%d", member.getVpcId()); + String lrpName = getPeeringLrpName(groupUuid, member.getVpcId()); + String mac = buildPeeringMac(member.getVpcId()); + + // Attach router to peering switch + ovnNbClient.attachRouterToSwitch(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, peerLs, lrpName, mac, + Collections.singletonList(member.getLinkLocalIp() + "/24")); + + // Add routes and NAT bypass policies for every OTHER member + for (OvnVpcPeeringVO other : members) { + if (other.getVpcId() == member.getVpcId()) continue; + VpcVO otherVpc = vpcDao.findById(other.getVpcId()); + if (otherVpc == null || otherVpc.getCidr() == null) continue; + + Map routeExt = new HashMap<>(); + routeExt.put(PEERING_EXT_KEY, groupUuid); + routeExt.put(PEERING_EXT_KEY + "_target", String.valueOf(other.getVpcId())); + ovnNbClient.addStaticRoute(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, otherVpc.getCidr(), other.getLinkLocalIp(), routeExt); + + // NAT bypass: skip SNAT for traffic destined to peered VPC + String match = String.format("ip4.dst == %s", otherVpc.getCidr()); + Map polExt = new HashMap<>(); + polExt.put(PEERING_EXT_KEY, groupUuid); + polExt.put(PEERING_EXT_KEY + "_target", String.valueOf(other.getVpcId())); + ovnNbClient.addLogicalRouterPolicy(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, NAT_BYPASS_PRIORITY, match, "allow", null, polExt); + } + } + + // Apply ACLs on the peering LS for members that have an ACL configured + for (OvnVpcPeeringVO member : members) { + if (member.getAclId() != null) { + applyPeeringAcl(member); + } + } + } + + protected void applyPeeringAcl(OvnVpcPeeringVO peering) { + OvnProviderVO provider = ovnProviderDao.findByZoneId(peering.getZoneId()); + if (provider == null) return; + + String peerLs = getPeeringLsName(peering.getGroupUuid()); + String lspName = getPeeringLspName(peering.getGroupUuid(), peering.getVpcId()); + String peeringTag = "cloudstack_peering_acl_vpc_" + peering.getVpcId(); + + // Wipe existing ACLs for this member on the peering LS + ovnNbClient.removeAclsOnLsByExternalId(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + peerLs, peeringTag, "true"); + + Long aclId = peering.getAclId(); + if (aclId == null) { + return; + } + + List rules = networkACLItemDao.listByACL(aclId); + if (rules == null || rules.isEmpty()) { + return; + } + + for (NetworkACLItemVO rule : rules) { + if (rule.getState() == NetworkACLItem.State.Revoke) { + continue; + } + networkACLItemDao.loadCidrs(rule); + programPeeringAclRule(provider, peerLs, lspName, peering, rule); + } + + // Default deny for both directions, scoped to this member's port + for (String dir : new String[]{"to-lport", "from-lport"}) { + String portField = "to-lport".equals(dir) ? "outport" : "inport"; + String matchExpr = String.format("%s == \"%s\" && ip4", portField, lspName); + Map ext = new HashMap<>(); + ext.put(peeringTag, "true"); + ext.put("cloudstack_acl_default", "true"); + ovnNbClient.addAclOnLs(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + peerLs, "peer-acl-default-" + dir + "-vpc-" + peering.getVpcId(), + dir, 1L, matchExpr, "drop", ext); + } + } + + protected void programPeeringAclRule(OvnProviderVO provider, String peerLs, + String lspName, OvnVpcPeeringVO peering, + NetworkACLItem rule) { + String direction = rule.getTrafficType() == NetworkACLItem.TrafficType.Ingress + ? "to-lport" : "from-lport"; + String aclAction = rule.getAction() == NetworkACLItem.Action.Allow ? "allow-related" : "drop"; + long ovnPriority = Math.max(2L, 1000L - rule.getNumber()); + + // Scope the match to this member's port on the peering LS + String portField = "to-lport".equals(direction) ? "outport" : "inport"; + String baseMatch = buildNetworkAclMatch(direction, rule); + if (baseMatch == null) { + return; + } + String matchExpr = String.format("%s == \"%s\" && %s", portField, lspName, baseMatch); + + String peeringTag = "cloudstack_peering_acl_vpc_" + peering.getVpcId(); + Map ext = new HashMap<>(); + ext.put(peeringTag, "true"); + ext.put("cloudstack_acl_rule_id", String.valueOf(rule.getId())); + ext.put("cloudstack_acl_id", String.valueOf(rule.getAclId())); + ext.put("cloudstack_acl_direction", direction); + ext.put(PEERING_EXT_KEY, peering.getGroupUuid()); + + ovnNbClient.addAclOnLs(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + peerLs, "peer-acl-" + rule.getId() + "-vpc-" + peering.getVpcId(), + direction, ovnPriority, matchExpr, aclAction, ext); + } + + /** + * Tears down the OVN-side peering artifacts owned by {@code vpc} but keeps the DB rows + * intact. Called from {@link #shutdownVpc} which runs both for restart-with-cleanup + * (where we want {@link #implementVpc} to re-provision the peering after the LR comes + * back) and for VPC deletion (where the foreign-key CASCADE on {@code vpc_id} removes + * the rows automatically once the VPC is gone). Either way, leaving the DB row Active + * here is the right move - it's restartable, and a real delete still trims the row. + */ + protected void removePeeringsForVpc(Vpc vpc, OvnProviderVO provider) { + List peerings = ovnVpcPeeringDao.listByVpcId(vpc.getId()); + for (OvnVpcPeeringVO peering : peerings) { + try { + String groupUuid = peering.getGroupUuid(); + List groupMembers = ovnVpcPeeringDao.listByGroupUuid(groupUuid); + if (isCrossZonePeeringGroup(groupMembers)) { + removeCrossZonePeeringMember(peering, provider, groupUuid); + continue; + } + String routerName = String.format("cs-vpc-%d", vpc.getId()); + + // Clean up routes/policies on other members + for (OvnVpcPeeringVO member : groupMembers) { + if (member.getVpcId() == vpc.getId()) continue; + OvnProviderVO memberProvider = ovnProviderDao.findByZoneId(member.getZoneId()); + if (memberProvider == null) continue; + String memberRouter = String.format("cs-vpc-%d", member.getVpcId()); + ovnNbClient.removeStaticRoute(memberProvider.getNbConnection(), + memberProvider.getCaCertPath(), memberProvider.getClientCertPath(), memberProvider.getClientPrivateKeyPath(), + memberRouter, vpc.getCidr(), peering.getLinkLocalIp()); + ovnNbClient.removeLogicalRouterPoliciesByExternalId(memberProvider.getNbConnection(), + memberProvider.getCaCertPath(), memberProvider.getClientCertPath(), memberProvider.getClientPrivateKeyPath(), + memberRouter, PEERING_EXT_KEY + "_target", String.valueOf(vpc.getId())); + } + + // Remove our own routes/policies + ovnNbClient.removeStaticRoutesByExternalId(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, PEERING_EXT_KEY, groupUuid); + ovnNbClient.removeLogicalRouterPoliciesByExternalId(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, PEERING_EXT_KEY, groupUuid); + + // Remove LRP+LSP + ovnNbClient.removeLogicalRouterPort(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, getPeeringLrpName(groupUuid, vpc.getId())); + ovnNbClient.deleteLogicalSwitchPort(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + getPeeringLsName(groupUuid), getPeeringLspName(groupUuid, vpc.getId())); + + // Delete peering LS if last member + long activeCount = groupMembers.stream().filter(m -> m.getVpcId() != vpc.getId()).count(); + if (activeCount == 0) { + ovnNbClient.deleteLogicalSwitch(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + getPeeringLsName(groupUuid)); + } + + // DB row left Active on purpose - see method javadoc. + } catch (CloudRuntimeException e) { + logger.warn("Failed to clean up peering {} for VPC {}: {}", peering.getUuid(), vpc.getId(), e.getMessage()); + } + } + } + + private static String getPeeringLsName(String groupUuid) { + return "cs-peer-" + groupUuid; + } + + private static String getPeeringLrpName(String groupUuid, long vpcId) { + return String.format("lrp-peer-%s-vpc-%d", groupUuid, vpcId); + } + + private static String getPeeringLspName(String groupUuid, long vpcId) { + return String.format("lsp-peer-%s-vpc-%d", groupUuid, vpcId); + } + + private static String buildPeeringMac(long vpcId) { + return String.format("fa:16:3e:fa:%02x:%02x", + (int) ((vpcId >> 8) & 0xff), + (int) (vpcId & 0xff)); + } + + private static String allocateLinkLocalIp(Set usedIps) { + // Pool: 169.254.100.1 through 169.254.100.253 (skip .0 and .255) + for (int i = 1; i <= 253; i++) { + String ip = "169.254.100." + i; + if (!usedIps.contains(ip)) { + return ip; + } + } + throw new CloudRuntimeException("No available link-local IPs in peering pool 169.254.100.0/24"); + } + + // ── Cross-zone peering via OVN-IC Transit Switch ───────────────────────── + + // Pool used for the TS-facing LRP IPs - separate from same-zone peering pool + // (169.254.100.0/24) so the two paths cannot overlap if the same VO is reused. + private static final String CROSS_ZONE_LL_PREFIX = "169.254.200."; + // Don't bleed CIDRs that are local-fabric concerns across IC. Pub net (10/8) and + // any other link-local must never get learned in a peer AZ. + private static final String IC_ROUTE_BLACKLIST = "10.0.0.0/8,169.254.0.0/16"; + + protected boolean isCrossZonePeeringGroup(List members) { + if (members == null || members.size() < 2) return false; + Set zones = new HashSet<>(); + for (OvnVpcPeeringVO m : members) zones.add(m.getZoneId()); + return zones.size() > 1; + } + + private static String getTransitSwitchName(String groupUuid) { + return "ts-peer-" + groupUuid; + } + + private static String getCrossZoneLrpName(long vpcId) { + return String.format("lrp-cs-vpc-%d-ts", vpcId); + } + + private static String getCrossZoneLspName(long vpcId) { + return String.format("lsp-ts-vpc-%d", vpcId); + } + + private static String allocateCrossZoneLinkLocalIp(Set usedIps) { + for (int i = 1; i <= 253; i++) { + String ip = CROSS_ZONE_LL_PREFIX + i; + if (!usedIps.contains(ip)) { + return ip; + } + } + throw new CloudRuntimeException("No available link-local IPs in cross-zone peering pool 169.254.200.0/24"); + } + + /** + * Provisions cross-zone peering via OVN-IC. Each AZ NB gets its name, the IC NB gets the + * Transit_Switch, and each VPC's LR gets a TS-facing LRP with HA gateway-chassis pinning. + * Routes are propagated via {@code ic-route-adv}/{@code ic-route-learn} - we do NOT add + * static peer-CIDR routes manually. + * + * Pre-requisites (operator-managed, not configured by this method): + * - Each gateway-chassis hypervisor has {@code external_ids:ovn-is-interconn=true} + * - Underlay reachability between chassis encap-IPs across AZs + * - All providers in the group have {@code icNbConnection} and {@code availabilityZoneName} + * set on their OvnProviderVO row. + */ + protected void provisionCrossZonePeering(String groupUuid, List members) { + // Index providers per zone, validate IC config + Map providersByZone = new HashMap<>(); + for (OvnVpcPeeringVO m : members) { + providersByZone.computeIfAbsent(m.getZoneId(), z -> ovnProviderDao.findByZoneId(z)); + } + OvnProviderVO icProvider = null; + for (OvnProviderVO p : providersByZone.values()) { + if (p == null) { + throw new CloudRuntimeException("Cross-zone peering requires every zone in the group to have an OVN provider"); + } + if (StringUtils.isBlank(p.getIcNbConnection())) { + throw new CloudRuntimeException(String.format( + "Cross-zone peering requires icNbConnection on provider for zone %d. Set it via addOvnProvider/updateOvnProvider.", + p.getZoneId())); + } + if (StringUtils.isBlank(p.getAvailabilityZoneName())) { + throw new CloudRuntimeException(String.format( + "Cross-zone peering requires availabilityZoneName on provider for zone %d. Set it via addOvnProvider/updateOvnProvider.", + p.getZoneId())); + } + icProvider = p; + } + + String tsName = getTransitSwitchName(groupUuid); + + // Set NB_Global.name and IC route options on each AZ NB. ovn-ic uses these to + // register the AZ in IC SB and decide what to advertise/learn. + Map icOpts = new HashMap<>(); + icOpts.put("ic-route-adv", "true"); + icOpts.put("ic-route-learn", "true"); + icOpts.put("ic-route-adv-default-route", "false"); + icOpts.put("ic-route-blacklist", IC_ROUTE_BLACKLIST); + for (OvnProviderVO p : providersByZone.values()) { + ovnNbClient.setNbGlobalAvailabilityZoneName(p.getNbConnection(), + p.getCaCertPath(), p.getClientCertPath(), p.getClientPrivateKeyPath(), + p.getAvailabilityZoneName()); + ovnNbClient.setNbGlobalIcOptions(p.getNbConnection(), + p.getCaCertPath(), p.getClientCertPath(), p.getClientPrivateKeyPath(), + icOpts); + } + + // Create the Transit_Switch in the IC NB. ovn-ic propagates it to every AZ NB + // shortly after; the per-AZ LSP attachments below depend on that propagation, + // but the OVSDB transactions are eventually consistent so we just retry on miss + // via the idempotent attach helper. + Map tsExt = new HashMap<>(); + tsExt.put(PEERING_EXT_KEY, groupUuid); + tsExt.put("cloudstack_role", "vpc-peering-ic"); + ovnNbClient.createTransitSwitch(icProvider.getIcNbConnection(), + icProvider.getCaCertPath(), icProvider.getClientCertPath(), icProvider.getClientPrivateKeyPath(), + tsName, tsExt); + + // Attach each member's VPC router to the TS. ovn-ic picks up these LRPs and + // exposes them as remote ports in the other AZs' NBs - no manual cross-AZ + // sync needed. + for (OvnVpcPeeringVO member : members) { + OvnProviderVO p = providersByZone.get(member.getZoneId()); + VpcVO vpc = vpcDao.findById(member.getVpcId()); + if (vpc == null) continue; + + String routerName = String.format("cs-vpc-%d", member.getVpcId()); + String lrpName = getCrossZoneLrpName(member.getVpcId()); + String lspName = getCrossZoneLspName(member.getVpcId()); + String mac = buildPeeringMac(member.getVpcId()); + // /24 over the link-local pool keeps every member in the same broadcast + // domain on the TS, which is what OVN-IC expects. + String lrpIpCidr = member.getLinkLocalIp() + "/24"; + + List gatewayChassis = ovnNbClient.listInterconnectionChassisSystemIds( + p.getSbConnection(), + p.getCaCertPath(), p.getClientCertPath(), p.getClientPrivateKeyPath()); + if (gatewayChassis == null || gatewayChassis.isEmpty()) { + throw new CloudRuntimeException(String.format( + "No interconnection-enabled chassis found in zone %d. Mark at least one chassis with `ovs-vsctl set Open_vSwitch . external_ids:ovn-is-interconn=true` before provisioning cross-zone peering.", + p.getZoneId())); + } + + ovnNbClient.attachRouterToTransitSwitch(p.getNbConnection(), + p.getCaCertPath(), p.getClientCertPath(), p.getClientPrivateKeyPath(), + routerName, tsName, lrpName, lspName, mac, lrpIpCidr, gatewayChassis); + } + + // ACLs: cross-zone members can still carry Network_ACL constraints. Reuse the + // same per-member apply path - it scopes ACLs to the LRP/LSP that we just + // attached. (Same-zone branch had already done this.) + for (OvnVpcPeeringVO member : members) { + if (member.getAclId() != null) { + applyCrossZonePeeringAcl(member, tsName); + } + } + } + + /** + * Per-member ACL application for cross-zone path. The peering LS in the same-zone + * variant is replaced here by the TS LS in this AZ NB; the LSP we scope to is the + * router-port LSP we created in {@link #provisionCrossZonePeering}. + */ + protected void applyCrossZonePeeringAcl(OvnVpcPeeringVO peering, String tsLsName) { + OvnProviderVO provider = ovnProviderDao.findByZoneId(peering.getZoneId()); + if (provider == null) return; + String lspName = getCrossZoneLspName(peering.getVpcId()); + String peeringTag = "cloudstack_peering_acl_vpc_" + peering.getVpcId(); + + ovnNbClient.removeAclsOnLsByExternalId(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + tsLsName, peeringTag, "true"); + + Long aclId = peering.getAclId(); + if (aclId == null) return; + List rules = networkACLItemDao.listByACL(aclId); + if (rules == null || rules.isEmpty()) return; + + for (NetworkACLItemVO rule : rules) { + if (rule.getState() == NetworkACLItem.State.Revoke) continue; + networkACLItemDao.loadCidrs(rule); + programCrossZonePeeringAclRule(provider, tsLsName, lspName, peering, rule); + } + for (String dir : new String[]{"to-lport", "from-lport"}) { + String portField = "to-lport".equals(dir) ? "outport" : "inport"; + String matchExpr = String.format("%s == \"%s\" && ip4", portField, lspName); + Map ext = new HashMap<>(); + ext.put(peeringTag, "true"); + ext.put("cloudstack_acl_default", "true"); + ovnNbClient.addAclOnLs(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + tsLsName, "ts-acl-default-" + dir + "-vpc-" + peering.getVpcId(), + dir, 1L, matchExpr, "drop", ext); + } + } + + protected void programCrossZonePeeringAclRule(OvnProviderVO provider, String tsLsName, + String lspName, OvnVpcPeeringVO peering, + NetworkACLItem rule) { + String direction = rule.getTrafficType() == NetworkACLItem.TrafficType.Ingress + ? "to-lport" : "from-lport"; + String aclAction = rule.getAction() == NetworkACLItem.Action.Allow ? "allow-related" : "drop"; + long ovnPriority = Math.max(2L, 1000L - rule.getNumber()); + String portField = "to-lport".equals(direction) ? "outport" : "inport"; + String baseMatch = buildNetworkAclMatch(direction, rule); + if (baseMatch == null) return; + String matchExpr = String.format("%s == \"%s\" && %s", portField, lspName, baseMatch); + + String peeringTag = "cloudstack_peering_acl_vpc_" + peering.getVpcId(); + Map ext = new HashMap<>(); + ext.put(peeringTag, "true"); + ext.put("cloudstack_acl_rule_id", String.valueOf(rule.getId())); + ext.put("cloudstack_acl_id", String.valueOf(rule.getAclId())); + ext.put("cloudstack_acl_direction", direction); + ext.put(PEERING_EXT_KEY, peering.getGroupUuid()); + + ovnNbClient.addAclOnLs(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + tsLsName, "ts-acl-" + rule.getId() + "-vpc-" + peering.getVpcId(), + direction, ovnPriority, matchExpr, aclAction, ext); + } + + /** + * Removes a single VPC's TS attachment in its AZ NB. If the group has no remaining + * members at all, also drops the Transit_Switch from the IC NB. + */ + protected void removeCrossZonePeeringMember(OvnVpcPeeringVO peering, OvnProviderVO provider, String groupUuid) { + String tsName = getTransitSwitchName(groupUuid); + String routerName = String.format("cs-vpc-%d", peering.getVpcId()); + String lrpName = getCrossZoneLrpName(peering.getVpcId()); + String lspName = getCrossZoneLspName(peering.getVpcId()); + String peeringTag = "cloudstack_peering_acl_vpc_" + peering.getVpcId(); + + // Wipe ACLs scoped to this member on the TS LS first + ovnNbClient.removeAclsOnLsByExternalId(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + tsName, peeringTag, "true"); + + ovnNbClient.detachRouterFromTransitSwitch(provider.getNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + routerName, tsName, lrpName, lspName); + + // If group has no other live members, drop the TS in IC NB. ovn-ic propagates + // the removal to every AZ NB. + List remaining = ovnVpcPeeringDao.listByGroupUuid(groupUuid); + long active = remaining.stream().filter(m -> m.getVpcId() != peering.getVpcId()).count(); + if (active == 0 && StringUtils.isNotBlank(provider.getIcNbConnection())) { + ovnNbClient.deleteTransitSwitch(provider.getIcNbConnection(), + provider.getCaCertPath(), provider.getClientCertPath(), provider.getClientPrivateKeyPath(), + tsName); + } + } +} diff --git a/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnGuestNetworkGuru.java b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnGuestNetworkGuru.java new file mode 100644 index 000000000000..a81b8639322e --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnGuestNetworkGuru.java @@ -0,0 +1,96 @@ +// 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.service; + +import com.cloud.dc.DataCenter; +import com.cloud.deploy.DeploymentPlan; +import com.cloud.deploy.DeployDestination; +import com.cloud.exception.InsufficientVirtualNetworkCapacityException; +import com.cloud.network.Network; +import com.cloud.network.NetworkMigrationResponder; +import com.cloud.network.Networks; +import com.cloud.network.PhysicalNetwork; +import com.cloud.network.dao.NetworkVO; +import com.cloud.network.dao.PhysicalNetworkVO; +import com.cloud.network.guru.GuestNetworkGuru; +import com.cloud.offering.NetworkOffering; +import com.cloud.user.Account; +import com.cloud.vm.NicProfile; +import com.cloud.vm.ReservationContext; +import com.cloud.vm.VirtualMachineProfile; + +public class OvnGuestNetworkGuru extends GuestNetworkGuru implements NetworkMigrationResponder { + public OvnGuestNetworkGuru() { + super(); + _isolationMethods = new PhysicalNetwork.IsolationMethod[] {new PhysicalNetwork.IsolationMethod("OVN")}; + } + + @Override + public boolean canHandle(NetworkOffering offering, DataCenter.NetworkType networkType, PhysicalNetwork physicalNetwork) { + return networkType == DataCenter.NetworkType.Advanced + && isMyTrafficType(offering.getTrafficType()) + && isMyIsolationMethod(physicalNetwork) + && networkOfferingServiceMapDao.isProviderForNetworkOffering(offering.getId(), Network.Provider.Ovn); + } + + @Override + public Network design(NetworkOffering offering, DeploymentPlan plan, Network userSpecified, String name, Long vpcId, Account owner) { + PhysicalNetworkVO physicalNetwork = _physicalNetworkDao.findById(plan.getPhysicalNetworkId()); + DataCenter dataCenter = _dcDao.findById(plan.getDataCenterId()); + if (!canHandle(offering, dataCenter.getNetworkType(), physicalNetwork)) { + logger.debug("Refusing to design this network"); + return null; + } + NetworkVO network = (NetworkVO) super.design(offering, plan, userSpecified, name, vpcId, owner); + if (network == null) { + return null; + } + network.setBroadcastDomainType(Networks.BroadcastDomainType.OVN); + // Broadcast URI is deferred to implement(); the network has no persisted ID yet here. + return network; + } + + @Override + public Network implement(Network network, NetworkOffering offering, DeployDestination dest, ReservationContext context) + throws InsufficientVirtualNetworkCapacityException { + Network implemented = super.implement(network, offering, dest, context); + if (implemented == null) { + return null; + } + if (implemented instanceof NetworkVO) { + NetworkVO impl = (NetworkVO) implemented; + impl.setBroadcastDomainType(Networks.BroadcastDomainType.OVN); + impl.setBroadcastUri(Networks.BroadcastDomainType.OVN.toUri(String.format("cs-net-%d", network.getId()))); + } + return implemented; + } + + @Override + public boolean prepareMigration(NicProfile nic, Network network, VirtualMachineProfile vm, DeployDestination dest, ReservationContext context) { + return true; + } + + @Override + public void rollbackMigration(NicProfile nic, Network network, VirtualMachineProfile vm, ReservationContext src, ReservationContext dst) { + // No OVN resources are allocated during migration preparation yet. + } + + @Override + public void commitMigration(NicProfile nic, Network network, VirtualMachineProfile vm, ReservationContext src, ReservationContext dst) { + // No OVN resources are committed on migration yet. + } +} diff --git a/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnNbClient.java b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnNbClient.java new file mode 100644 index 000000000000..3cc9ebbe23f5 --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnNbClient.java @@ -0,0 +1,2903 @@ +// 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.service; + +import com.cloud.utils.exception.CloudRuntimeException; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.opendaylight.aaa.cert.api.ICertificateManager; +import org.opendaylight.ovsdb.lib.OvsdbClient; +import org.opendaylight.ovsdb.lib.impl.NettyBootstrapFactoryImpl; +import org.opendaylight.ovsdb.lib.impl.OvsdbConnectionService; +import org.opendaylight.ovsdb.lib.notation.Mutator; +import org.opendaylight.ovsdb.lib.notation.Row; +import org.opendaylight.ovsdb.lib.notation.UUID; +import org.opendaylight.ovsdb.lib.operations.DefaultOperations; +import org.opendaylight.ovsdb.lib.operations.Insert; +import org.opendaylight.ovsdb.lib.operations.Operation; +import org.opendaylight.ovsdb.lib.operations.OperationResult; +import org.opendaylight.ovsdb.lib.operations.Operations; +import org.opendaylight.ovsdb.lib.schema.ColumnSchema; +import org.opendaylight.ovsdb.lib.schema.DatabaseSchema; +import org.opendaylight.ovsdb.lib.schema.GenericTableSchema; + +import javax.annotation.PreDestroy; +import javax.net.ssl.SSLContext; +import java.net.InetAddress; +import java.security.KeyStore; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class OvnNbClient { + protected static final Logger logger = LogManager.getLogger(OvnNbClient.class); + private static final String NORTHBOUND_DB = "OVN_Northbound"; + private static final String SOUTHBOUND_DB = "OVN_Southbound"; + private static final String LOGICAL_SWITCH_TABLE = "Logical_Switch"; + private static final String LOGICAL_SWITCH_PORT_TABLE = "Logical_Switch_Port"; + private static final String LOGICAL_ROUTER_TABLE = "Logical_Router"; + private static final String LOGICAL_ROUTER_PORT_TABLE = "Logical_Router_Port"; + private static final String LOGICAL_ROUTER_STATIC_ROUTE_TABLE = "Logical_Router_Static_Route"; + private static final String GATEWAY_CHASSIS_TABLE = "Gateway_Chassis"; + private static final String DHCP_OPTIONS_TABLE = "DHCP_Options"; + private static final String NAT_TABLE = "NAT"; + private static final String ACL_TABLE = "ACL"; + private static final String LOGICAL_ROUTER_POLICY_TABLE = "Logical_Router_Policy"; + private static final String LOAD_BALANCER_TABLE = "Load_Balancer"; + private static final String LOAD_BALANCER_HEALTH_CHECK_TABLE = "Load_Balancer_Health_Check"; + private static final String NB_GLOBAL_TABLE = "NB_Global"; + private static final String CHASSIS_TABLE = "Chassis"; + private static final String IC_NORTHBOUND_DB = "OVN_IC_Northbound"; + private static final String TRANSIT_SWITCH_TABLE = "Transit_Switch"; + private static final long DEFAULT_TIMEOUT_MS = 5_000L; + private static final Pattern CONN_PATTERN = Pattern.compile("^(tcp|ssl):([^:]+):([0-9]+)$"); + private static final ICertificateManager NOOP_CERT_MANAGER = new NoopCertificateManager(); + private static final Operations OVSDB_OPS = new DefaultOperations(); + + private final long timeoutMs; + private NettyBootstrapFactoryImpl bootstrapFactory; + private OvsdbConnectionService tcpConnectionService; + + public OvnNbClient() { + this(DEFAULT_TIMEOUT_MS); + } + + public OvnNbClient(long timeoutMs) { + this.timeoutMs = timeoutMs; + } + + public boolean isValidConnectionString(String connection) { + if (StringUtils.isBlank(connection)) { + return false; + } + return CONN_PATTERN.matcher(connection).matches() || connection.startsWith("unix:/"); + } + + /** + * Opens a transient connection to NB, runs an echo, lists the databases, and disconnects. + * Throws on failure - caller treats success as proof that the NB endpoint is reachable + * and the supplied credentials/certificates are valid. + */ + public void verifyConnection(String nbConnection, String caCertPath, String clientCertPath, String clientPrivateKeyPath) { + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + client.echo().get(timeoutMs, TimeUnit.MILLISECONDS); + List dbs = client.getDatabases().get(timeoutMs, TimeUnit.MILLISECONDS); + if (dbs == null || !dbs.contains(NORTHBOUND_DB)) { + throw new CloudRuntimeException(String.format("OVN endpoint %s did not advertise %s; got %s", + nbConnection, NORTHBOUND_DB, dbs)); + } + logger.debug("OVN NB at {} reachable, databases={}", nbConnection, dbs); + return null; + }); + } + + /** + * Creates a Logical_Switch with the given name and external_ids in the OVN_Northbound database + * exposed at {@code nbConnection}. Idempotent: if a switch with the same name already exists, + * the call succeeds without modifying it. Uses the native OVSDB JSON-RPC protocol. + */ + public void createLogicalSwitch(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, String logicalSwitchName, Map externalIds) { + if (StringUtils.isBlank(logicalSwitchName)) { + throw new CloudRuntimeException("Logical switch name is blank"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema ls = schema.table(LOGICAL_SWITCH_TABLE, GenericTableSchema.class); + ColumnSchema nameCol = ls.column("name", String.class); + + if (logicalSwitchExists(client, schema, ls, nameCol, logicalSwitchName)) { + logger.debug("Logical_Switch [{}] already exists on {} - skipping create", logicalSwitchName, nbConnection); + return null; + } + + Insert insert = OVSDB_OPS.insert(ls) + .value(nameCol, logicalSwitchName); + if (externalIds != null && !externalIds.isEmpty()) { + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema extIdsCol = ls.column("external_ids", Map.class); + insert = insert.value(extIdsCol, new HashMap<>(externalIds)); + } + List results = client.transact(schema, Collections.singletonList(insert)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("create Logical_Switch %s", logicalSwitchName)); + logger.info("Created OVN Logical_Switch [{}] at {}", logicalSwitchName, nbConnection); + return null; + }); + } + + /** + * Removes a Logical_Switch by name. Idempotent: missing switch is treated as a successful no-op. + */ + public void deleteLogicalSwitch(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, String logicalSwitchName) { + if (StringUtils.isBlank(logicalSwitchName)) { + throw new CloudRuntimeException("Logical switch name is blank"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema ls = schema.table(LOGICAL_SWITCH_TABLE, GenericTableSchema.class); + ColumnSchema nameCol = ls.column("name", String.class); + + if (!logicalSwitchExists(client, schema, ls, nameCol, logicalSwitchName)) { + logger.debug("Logical_Switch [{}] not present on {} - nothing to delete", logicalSwitchName, nbConnection); + return null; + } + + Operation delete = OVSDB_OPS.delete(ls) + .where(nameCol.opEqual(logicalSwitchName)).build(); + List results = client.transact(schema, Collections.singletonList(delete)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("delete Logical_Switch %s", logicalSwitchName)); + logger.info("Deleted OVN Logical_Switch [{}] at {}", logicalSwitchName, nbConnection); + return null; + }); + } + + /** + * Creates a Logical_Switch_Port on the named Logical_Switch and binds it. + * The LSP {@code addresses} and {@code port_security} columns are seeded with the supplied + * MAC and (optional) IPv4. Idempotent: if an LSP with the same name already exists in the NB + * database, the call succeeds without modifying the row. The {@code iface-id} that ovn-controller + * looks for on the local OVS port should match {@code lspName}. + */ + public void createLogicalSwitchPort(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String logicalSwitchName, String lspName, + String mac, String ipv4, Map externalIds) { + if (StringUtils.isBlank(logicalSwitchName) || StringUtils.isBlank(lspName)) { + throw new CloudRuntimeException("Logical switch / port name is blank"); + } + if (StringUtils.isBlank(mac)) { + throw new CloudRuntimeException("MAC is required for Logical_Switch_Port " + lspName); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lsTable = schema.table(LOGICAL_SWITCH_TABLE, GenericTableSchema.class); + GenericTableSchema lspTable = schema.table(LOGICAL_SWITCH_PORT_TABLE, GenericTableSchema.class); + ColumnSchema lsNameCol = lsTable.column("name", String.class); + ColumnSchema lspNameCol = lspTable.column("name", String.class); + + if (logicalSwitchPortExists(client, schema, lspTable, lspNameCol, lspName)) { + logger.debug("Logical_Switch_Port [{}] already exists on {} - skipping create", lspName, nbConnection); + return null; + } + + String addressEntry = StringUtils.isNotBlank(ipv4) ? mac + " " + ipv4 : mac; + ColumnSchema> addressesCol = lspTable.multiValuedColumn("addresses", String.class); + ColumnSchema> portSecCol = lspTable.multiValuedColumn("port_security", String.class); + ColumnSchema> portsCol = lsTable.multiValuedColumn("ports", UUID.class); + + String namedUuid = "newlsp"; + Insert insertLsp = OVSDB_OPS.insert(lspTable) + .withId(namedUuid) + .value(lspNameCol, lspName) + .value(addressesCol, Collections.singleton(addressEntry)) + .value(portSecCol, Collections.singleton(addressEntry)); + if (externalIds != null && !externalIds.isEmpty()) { + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema extIdsCol = lspTable.column("external_ids", Map.class); + insertLsp = insertLsp.value(extIdsCol, new HashMap<>(externalIds)); + } + + UUID lspRef = new UUID(namedUuid); + Operation mutateLs = OVSDB_OPS.mutate(lsTable) + .addMutation(portsCol, Mutator.INSERT, Collections.singleton(lspRef)) + .where(lsNameCol.opEqual(logicalSwitchName)).build(); + + List results = client.transact(schema, + Arrays.asList(insertLsp, mutateLs)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("create Logical_Switch_Port %s on %s", lspName, logicalSwitchName)); + logger.info("Created OVN Logical_Switch_Port [{}] on Logical_Switch [{}] at {}", + lspName, logicalSwitchName, nbConnection); + return null; + }); + } + + /** + * Removes a Logical_Switch_Port by name and detaches it from its parent Logical_Switch. + * Idempotent: missing LSP is treated as a successful no-op. + */ + public void deleteLogicalSwitchPort(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String logicalSwitchName, String lspName) { + if (StringUtils.isBlank(lspName)) { + throw new CloudRuntimeException("Logical_Switch_Port name is blank"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lsTable = schema.table(LOGICAL_SWITCH_TABLE, GenericTableSchema.class); + GenericTableSchema lspTable = schema.table(LOGICAL_SWITCH_PORT_TABLE, GenericTableSchema.class); + ColumnSchema lsNameCol = lsTable.column("name", String.class); + ColumnSchema lspNameCol = lspTable.column("name", String.class); + + UUID lspUuid = findLspUuid(client, schema, lspTable, lspNameCol, lspName); + if (lspUuid == null) { + logger.debug("Logical_Switch_Port [{}] not present on {} - nothing to delete", lspName, nbConnection); + return null; + } + + ColumnSchema> portsCol = lsTable.multiValuedColumn("ports", UUID.class); + + List ops = new ArrayList<>(); + if (StringUtils.isNotBlank(logicalSwitchName)) { + ops.add(OVSDB_OPS.mutate(lsTable) + .addMutation(portsCol, Mutator.DELETE, Collections.singleton(lspUuid)) + .where(lsNameCol.opEqual(logicalSwitchName)).build()); + } + ops.add(OVSDB_OPS.delete(lspTable) + .where(lspNameCol.opEqual(lspName)).build()); + + List results = client.transact(schema, ops) + .get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("delete Logical_Switch_Port %s", lspName)); + logger.info("Deleted OVN Logical_Switch_Port [{}] at {}", lspName, nbConnection); + return null; + }); + } + + private boolean logicalSwitchPortExists(OvsdbClient client, DatabaseSchema schema, + GenericTableSchema lspTable, + ColumnSchema nameCol, + String name) throws Exception { + Operation select = OVSDB_OPS.select(lspTable) + .column(nameCol) + .where(nameCol.opEqual(name)).build(); + List results = client.transact(schema, Collections.singletonList(select)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (results == null || results.isEmpty()) { + return false; + } + OperationResult r = results.get(0); + if (r.getError() != null) { + throw new CloudRuntimeException("OVSDB select failed for Logical_Switch_Port " + name + ": " + r.getError()); + } + List> rows = r.getRows(); + return rows != null && !rows.isEmpty(); + } + + private UUID findLspUuid(OvsdbClient client, DatabaseSchema schema, + GenericTableSchema lspTable, + ColumnSchema nameCol, + String name) throws Exception { + ColumnSchema uuidCol = lspTable.column("_uuid", UUID.class); + Operation select = OVSDB_OPS.select(lspTable) + .column(uuidCol) + .where(nameCol.opEqual(name)).build(); + List results = client.transact(schema, Collections.singletonList(select)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (results == null || results.isEmpty()) { + return null; + } + OperationResult r = results.get(0); + if (r.getError() != null) { + throw new CloudRuntimeException("OVSDB select failed for LSP _uuid lookup " + name + ": " + r.getError()); + } + List> rows = r.getRows(); + if (rows == null || rows.isEmpty()) { + return null; + } + return rows.get(0).getColumn(uuidCol).getData(); + } + + /** + * Creates (or returns the UUID of an existing) DHCP_Options row identified by + * {@code external_ids:cloudstack_network_id}. Idempotent: if a row already matches the + * external_ids tag for this network, no new row is created and the existing UUID is returned. + */ + public String createDhcpOptions(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String cidr, Map options, Map externalIds) { + if (StringUtils.isBlank(cidr)) { + throw new CloudRuntimeException("DHCP_Options cidr is blank"); + } + if (externalIds == null || !externalIds.containsKey("cloudstack_network_id")) { + throw new CloudRuntimeException("DHCP_Options external_ids must include cloudstack_network_id"); + } + final String networkId = externalIds.get("cloudstack_network_id"); + return runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema dhcpTable = schema.table(DHCP_OPTIONS_TABLE, GenericTableSchema.class); + + UUID existing = findDhcpOptionsByNetworkId(client, schema, dhcpTable, networkId); + if (existing != null) { + logger.debug("DHCP_Options for network [{}] already exists ({}) on {} - skipping create", + networkId, existing, nbConnection); + return existing.toString(); + } + + ColumnSchema cidrCol = dhcpTable.column("cidr", String.class); + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema optionsCol = dhcpTable.column("options", Map.class); + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema extIdsCol = dhcpTable.column("external_ids", Map.class); + + String namedUuid = "newdhcp"; + Insert insert = OVSDB_OPS.insert(dhcpTable) + .withId(namedUuid) + .value(cidrCol, cidr) + .value(extIdsCol, new HashMap<>(externalIds)); + if (options != null && !options.isEmpty()) { + insert = insert.value(optionsCol, new HashMap<>(options)); + } + List results = client.transact(schema, Collections.singletonList(insert)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("create DHCP_Options for network %s", networkId)); + UUID created = results.get(0).getUuid(); + logger.info("Created OVN DHCP_Options [{}] for network [{}] cidr=[{}] at {}", + created, networkId, cidr, nbConnection); + return created != null ? created.toString() : null; + }); + } + + /** + * Removes the DHCP_Options row tagged with {@code external_ids:cloudstack_network_id=networkId}. + * Idempotent: missing row is a no-op. + */ + public void deleteDhcpOptions(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, String networkId) { + if (StringUtils.isBlank(networkId)) { + throw new CloudRuntimeException("Network id is blank"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema dhcpTable = schema.table(DHCP_OPTIONS_TABLE, GenericTableSchema.class); + UUID existing = findDhcpOptionsByNetworkId(client, schema, dhcpTable, networkId); + if (existing == null) { + logger.debug("DHCP_Options for network [{}] not present on {} - nothing to delete", + networkId, nbConnection); + return null; + } + ColumnSchema uuidCol = dhcpTable.column("_uuid", UUID.class); + Operation delete = OVSDB_OPS.delete(dhcpTable) + .where(uuidCol.opEqual(existing)).build(); + List results = client.transact(schema, Collections.singletonList(delete)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("delete DHCP_Options for network %s", networkId)); + logger.info("Deleted OVN DHCP_Options [{}] for network [{}] at {}", existing, networkId, nbConnection); + return null; + }); + } + + /** + * Sets the {@code dhcpv4_options} reference of a Logical_Switch_Port to the given DHCP_Options UUID, + * causing ovn-controller to answer DHCPv4 requests on that port from the DHCP_Options row. + */ + public void setLspDhcpv4Options(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, String lspName, String dhcpOptionsUuid) { + if (StringUtils.isBlank(lspName) || StringUtils.isBlank(dhcpOptionsUuid)) { + throw new CloudRuntimeException("LSP name or DHCP_Options uuid is blank"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lspTable = schema.table(LOGICAL_SWITCH_PORT_TABLE, GenericTableSchema.class); + ColumnSchema lspNameCol = lspTable.column("name", String.class); + ColumnSchema> dhcpCol = lspTable.multiValuedColumn("dhcpv4_options", UUID.class); + + UUID dhcpRef = new UUID(dhcpOptionsUuid); + Operation update = OVSDB_OPS.update(lspTable) + .set(dhcpCol, Collections.singleton(dhcpRef)) + .where(lspNameCol.opEqual(lspName)).build(); + List results = client.transact(schema, Collections.singletonList(update)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("set dhcpv4_options=%s on LSP %s", dhcpOptionsUuid, lspName)); + logger.debug("Set dhcpv4_options=[{}] on Logical_Switch_Port [{}] at {}", dhcpOptionsUuid, lspName, nbConnection); + return null; + }); + } + + /** + * Merges the supplied entries into the {@code options} column of an existing Logical_Switch_Port. + * Existing keys not in {@code optionsToSet} are preserved; keys in {@code optionsToSet} are + * overwritten. Use this to set values like {@code nat-addresses=" ..."} on the + * gateway-side LSP so ovn-controller emits gratuitous ARPs for SNAT/FIP addresses on claim. + */ + public void setLspOptions(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String lspName, Map optionsToSet) { + if (StringUtils.isBlank(lspName)) { + throw new CloudRuntimeException("LSP name is blank"); + } + if (optionsToSet == null || optionsToSet.isEmpty()) { + return; + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lspTable = schema.table(LOGICAL_SWITCH_PORT_TABLE, GenericTableSchema.class); + ColumnSchema lspNameCol = lspTable.column("name", String.class); + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema optsCol = lspTable.column("options", Map.class); + + Operation select = OVSDB_OPS.select(lspTable) + .column(optsCol).where(lspNameCol.opEqual(lspName)).build(); + List selResult = client.transact(schema, Collections.singletonList(select)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (selResult == null || selResult.isEmpty() || selResult.get(0).getRows() == null + || selResult.get(0).getRows().isEmpty()) { + throw new CloudRuntimeException("LSP " + lspName + " not found while setting options"); + } + @SuppressWarnings("unchecked") + Map existing = (Map) selResult.get(0).getRows().get(0) + .getColumn(optsCol).getData(); + Map merged = new HashMap<>(); + if (existing != null) merged.putAll(existing); + merged.putAll(optionsToSet); + + // Bail out when nothing actually changes - avoids spurious NB notifications that + // ripple to ovn-controller and cause unnecessary recomputes. + if (existing != null && existing.equals(merged)) { + logger.debug("LSP [{}] options already at desired state - skipping update", lspName); + return null; + } + + Operation update = OVSDB_OPS.update(lspTable) + .set(optsCol, merged).where(lspNameCol.opEqual(lspName)).build(); + List results = client.transact(schema, Collections.singletonList(update)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("set options on LSP %s", lspName)); + logger.info("Set options [{}] on Logical_Switch_Port [{}] at {}", optionsToSet, lspName, nbConnection); + return null; + }); + } + + private UUID findDhcpOptionsByNetworkId(OvsdbClient client, DatabaseSchema schema, + GenericTableSchema dhcpTable, String networkId) throws Exception { + ColumnSchema uuidCol = dhcpTable.column("_uuid", UUID.class); + // Native OVSDB conditions on map values are awkward via the ODL operations API, so we + // pull every DHCP_Options row's external_ids and filter client-side. The DHCP_Options + // table is small enough that this is acceptable. + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema extIdsCol = dhcpTable.column("external_ids", Map.class); + Operation selectAll = OVSDB_OPS.select(dhcpTable).column(uuidCol).column(extIdsCol); + List results = client.transact(schema, Collections.singletonList(selectAll)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (results == null || results.isEmpty()) return null; + OperationResult r = results.get(0); + if (r.getError() != null) { + throw new CloudRuntimeException("OVSDB select on DHCP_Options failed: " + r.getError()); + } + if (r.getRows() == null) return null; + for (Row row : r.getRows()) { + @SuppressWarnings("unchecked") + Map ext = (Map) row.getColumn(extIdsCol).getData(); + if (ext != null && networkId.equals(ext.get("cloudstack_network_id"))) { + return row.getColumn(uuidCol).getData(); + } + } + return null; + } + + /** + * Idempotently creates a Logical_Router with the given name and external_ids. + */ + public void createLogicalRouter(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, String routerName, Map externalIds) { + if (StringUtils.isBlank(routerName)) { + throw new CloudRuntimeException("Logical_Router name is blank"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lr = schema.table(LOGICAL_ROUTER_TABLE, GenericTableSchema.class); + ColumnSchema nameCol = lr.column("name", String.class); + if (rowExistsByName(client, schema, lr, nameCol, routerName)) { + logger.debug("Logical_Router [{}] already exists on {} - skipping create", routerName, nbConnection); + return null; + } + Insert insert = OVSDB_OPS.insert(lr).value(nameCol, routerName); + if (externalIds != null && !externalIds.isEmpty()) { + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema extIdsCol = lr.column("external_ids", Map.class); + insert = insert.value(extIdsCol, new HashMap<>(externalIds)); + } + List results = client.transact(schema, Collections.singletonList(insert)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("create Logical_Router %s", routerName)); + logger.info("Created OVN Logical_Router [{}] at {}", routerName, nbConnection); + return null; + }); + } + + /** + * Removes a Logical_Router by name. Idempotent. Caller is responsible for first detaching any + * router ports the LR owns and for clearing nat rules. + */ + public void deleteLogicalRouter(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, String routerName) { + if (StringUtils.isBlank(routerName)) { + throw new CloudRuntimeException("Logical_Router name is blank"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lr = schema.table(LOGICAL_ROUTER_TABLE, GenericTableSchema.class); + ColumnSchema nameCol = lr.column("name", String.class); + if (!rowExistsByName(client, schema, lr, nameCol, routerName)) { + logger.debug("Logical_Router [{}] not present on {} - nothing to delete", routerName, nbConnection); + return null; + } + Operation delete = OVSDB_OPS.delete(lr).where(nameCol.opEqual(routerName)).build(); + List results = client.transact(schema, Collections.singletonList(delete)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("delete Logical_Router %s", routerName)); + logger.info("Deleted OVN Logical_Router [{}] at {}", routerName, nbConnection); + return null; + }); + } + + /** + * Idempotently attaches a routed segment to a Logical_Router: creates a Logical_Router_Port + * with the given mac/networks and a peer Logical_Switch_Port of type=router on the Logical_Switch + * pointing back at it. Used to wire the LR to either the guest tier or the public/localnet tier. + */ + public void attachRouterToSwitch(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String routerName, String switchName, + String lrpName, String lrpMac, List lrpNetworks) { + if (StringUtils.isBlank(routerName) || StringUtils.isBlank(switchName) || StringUtils.isBlank(lrpName)) { + throw new CloudRuntimeException("Logical_Router/Switch/Port name is blank"); + } + if (StringUtils.isBlank(lrpMac) || lrpNetworks == null || lrpNetworks.isEmpty()) { + throw new CloudRuntimeException("Logical_Router_Port mac/networks are required"); + } + final String lspName = "lsp-" + lrpName; + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lrTable = schema.table(LOGICAL_ROUTER_TABLE, GenericTableSchema.class); + GenericTableSchema lrpTable = schema.table(LOGICAL_ROUTER_PORT_TABLE, GenericTableSchema.class); + GenericTableSchema lsTable = schema.table(LOGICAL_SWITCH_TABLE, GenericTableSchema.class); + GenericTableSchema lspTable = schema.table(LOGICAL_SWITCH_PORT_TABLE, GenericTableSchema.class); + ColumnSchema lrNameCol = lrTable.column("name", String.class); + ColumnSchema lrpNameCol = lrpTable.column("name", String.class); + ColumnSchema lsNameCol = lsTable.column("name", String.class); + ColumnSchema lspNameCol = lspTable.column("name", String.class); + + boolean lrpExists = rowExistsByName(client, schema, lrpTable, lrpNameCol, lrpName); + boolean lspExists = rowExistsByName(client, schema, lspTable, lspNameCol, lspName); + if (lrpExists && lspExists) { + logger.debug("Router attachment {} ↔ {} already in place - skipping", lrpName, lspName); + return null; + } + + List ops = new ArrayList<>(); + UUID lrpRef = null; + if (!lrpExists) { + ColumnSchema lrpMacCol = lrpTable.column("mac", String.class); + ColumnSchema> lrpNetCol = lrpTable.multiValuedColumn("networks", String.class); + Insert insertLrp = OVSDB_OPS.insert(lrpTable) + .withId("newlrp") + .value(lrpNameCol, lrpName) + .value(lrpMacCol, lrpMac) + .value(lrpNetCol, new java.util.HashSet<>(lrpNetworks)); + ops.add(insertLrp); + ColumnSchema> lrPortsCol = lrTable.multiValuedColumn("ports", UUID.class); + lrpRef = new UUID("newlrp"); + ops.add(OVSDB_OPS.mutate(lrTable) + .addMutation(lrPortsCol, Mutator.INSERT, Collections.singleton(lrpRef)) + .where(lrNameCol.opEqual(routerName)).build()); + } + if (!lspExists) { + ColumnSchema lspTypeCol = lspTable.column("type", String.class); + ColumnSchema> lspAddrCol = lspTable.multiValuedColumn("addresses", String.class); + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema lspOptsCol = lspTable.column("options", Map.class); + Map opts = new HashMap<>(); + opts.put("router-port", lrpName); + Insert insertLsp = OVSDB_OPS.insert(lspTable) + .withId("newlsp") + .value(lspNameCol, lspName) + .value(lspTypeCol, "router") + .value(lspAddrCol, Collections.singleton("router")) + .value(lspOptsCol, opts); + ops.add(insertLsp); + ColumnSchema> lsPortsCol = lsTable.multiValuedColumn("ports", UUID.class); + ops.add(OVSDB_OPS.mutate(lsTable) + .addMutation(lsPortsCol, Mutator.INSERT, Collections.singleton(new UUID("newlsp"))) + .where(lsNameCol.opEqual(switchName)).build()); + } + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("attach Logical_Router %s to Logical_Switch %s via %s", routerName, switchName, lrpName)); + logger.info("Attached OVN Logical_Router [{}] to Logical_Switch [{}] via LRP [{}] at {}", + routerName, switchName, lrpName, nbConnection); + return null; + }); + } + + /** + * Idempotently removes a Logical_Router_Port from a Logical_Router. Used when tearing down a + * VPC tier so its tier-LRP is detached from the shared VPC LR without touching the LR itself + * (the paired router-type LSP on the tier LS is GC'd by OVSDB when the tier LS is deleted). + */ + public void removeLogicalRouterPort(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String routerName, String lrpName) { + if (StringUtils.isBlank(routerName) || StringUtils.isBlank(lrpName)) { + throw new CloudRuntimeException("removeLogicalRouterPort arguments are incomplete"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lrTable = schema.table(LOGICAL_ROUTER_TABLE, GenericTableSchema.class); + GenericTableSchema lrpTable = schema.table(LOGICAL_ROUTER_PORT_TABLE, GenericTableSchema.class); + ColumnSchema lrNameCol = lrTable.column("name", String.class); + ColumnSchema lrpNameCol = lrpTable.column("name", String.class); + ColumnSchema lrpUuidCol = lrpTable.column("_uuid", UUID.class); + ColumnSchema> lrPortsCol = lrTable.multiValuedColumn("ports", UUID.class); + + Operation selectLrp = OVSDB_OPS.select(lrpTable).column(lrpUuidCol) + .where(lrpNameCol.opEqual(lrpName)).build(); + List selectResult = client.transact(schema, Collections.singletonList(selectLrp)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (selectResult == null || selectResult.isEmpty() || selectResult.get(0).getRows() == null + || selectResult.get(0).getRows().isEmpty()) { + logger.debug("Logical_Router_Port [{}] not present on {} - nothing to detach", lrpName, nbConnection); + return null; + } + UUID lrpUuid = selectResult.get(0).getRows().get(0).getColumn(lrpUuidCol).getData(); + List ops = new ArrayList<>(); + ops.add(OVSDB_OPS.mutate(lrTable) + .addMutation(lrPortsCol, Mutator.DELETE, Collections.singleton(lrpUuid)) + .where(lrNameCol.opEqual(routerName)).build()); + ops.add(OVSDB_OPS.delete(lrpTable).where(lrpUuidCol.opEqual(lrpUuid)).build()); + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("detach Logical_Router_Port %s from %s", lrpName, routerName)); + logger.info("Detached OVN Logical_Router_Port [{}] from Logical_Router [{}] at {}", lrpName, routerName, nbConnection); + return null; + }); + } + + /** + * Idempotently adds a localnet Logical_Switch_Port to a Logical_Switch so traffic can egress + * the OVN integration bridge through ovn-bridge-mappings to the named physical network. + */ + public void addLocalnetPort(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String switchName, String lspName, String physicalNetworkName, Integer vlanTag) { + if (StringUtils.isBlank(switchName) || StringUtils.isBlank(lspName) || StringUtils.isBlank(physicalNetworkName)) { + throw new CloudRuntimeException("Localnet port arguments are incomplete"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lsTable = schema.table(LOGICAL_SWITCH_TABLE, GenericTableSchema.class); + GenericTableSchema lspTable = schema.table(LOGICAL_SWITCH_PORT_TABLE, GenericTableSchema.class); + ColumnSchema lsNameCol = lsTable.column("name", String.class); + ColumnSchema lspNameCol = lspTable.column("name", String.class); + if (rowExistsByName(client, schema, lspTable, lspNameCol, lspName)) { + logger.debug("Localnet LSP [{}] already exists - skipping", lspName); + return null; + } + ColumnSchema typeCol = lspTable.column("type", String.class); + ColumnSchema> addrCol = lspTable.multiValuedColumn("addresses", String.class); + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema optsCol = lspTable.column("options", Map.class); + Map opts = new HashMap<>(); + opts.put("network_name", physicalNetworkName); + ColumnSchema> lsPortsCol = lsTable.multiValuedColumn("ports", UUID.class); + Insert insertLsp = OVSDB_OPS.insert(lspTable) + .withId("newln") + .value(lspNameCol, lspName) + .value(typeCol, "localnet") + .value(addrCol, Collections.singleton("unknown")) + .value(optsCol, opts); + if (vlanTag != null) { + ColumnSchema> tagCol = lspTable.multiValuedColumn("tag", Long.class); + insertLsp = insertLsp.value(tagCol, Collections.singleton((long) vlanTag)); + } + List ops = new ArrayList<>(); + ops.add(insertLsp); + ops.add(OVSDB_OPS.mutate(lsTable) + .addMutation(lsPortsCol, Mutator.INSERT, Collections.singleton(new UUID("newln"))) + .where(lsNameCol.opEqual(switchName)).build()); + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("add localnet LSP %s on %s", lspName, switchName)); + logger.info("Added OVN localnet Logical_Switch_Port [{}] on Logical_Switch [{}] (network_name={}, vlan={}) at {}", + lspName, switchName, physicalNetworkName, vlanTag, nbConnection); + return null; + }); + } + + /** + * Idempotently adds a NAT rule to a Logical_Router. {@code natType} should be {@code snat}, + * {@code dnat} or {@code dnat_and_snat}. Setting both {@code distributedMac} and + * {@code distributedLogicalPort} marks the row as distributed-NAT (so ovn-northd can apply + * the rule on the chassis hosting the workload, no Gateway_Chassis required). + */ + public void addNatRule(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String routerName, String natType, String externalIp, String logicalIp, + Map externalIds) { + addNatRule(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, + routerName, natType, externalIp, logicalIp, externalIds, null, null, null); + } + + public void addNatRule(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String routerName, String natType, String externalIp, String logicalIp, + Map externalIds, + String distributedMac, String distributedLogicalPort) { + addNatRule(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, + routerName, natType, externalIp, logicalIp, externalIds, + distributedMac, distributedLogicalPort, null); + } + + /** + * NAT row insertion with optional {@code gateway_port} reference. The reference is required + * when the LR has more than one gateway-eligible LRP (e.g. our VPCs now have both + * {@code lrp-cs-vpc-pub-X} and {@code lrp-cs-vpc-X-ts}); without it ovn-northd cannot pick + * which gateway-chassis owns the NAT and the rule is silently inert. + */ + public void addNatRule(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String routerName, String natType, String externalIp, String logicalIp, + Map externalIds, + String distributedMac, String distributedLogicalPort, + String gatewayLrpName) { + if (StringUtils.isBlank(routerName) || StringUtils.isBlank(natType) + || StringUtils.isBlank(externalIp) || StringUtils.isBlank(logicalIp)) { + throw new CloudRuntimeException("NAT rule arguments are incomplete"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lrTable = schema.table(LOGICAL_ROUTER_TABLE, GenericTableSchema.class); + GenericTableSchema natTable = schema.table(NAT_TABLE, GenericTableSchema.class); + ColumnSchema lrNameCol = lrTable.column("name", String.class); + ColumnSchema natTypeCol = natTable.column("type", String.class); + ColumnSchema natExtCol = natTable.column("external_ip", String.class); + ColumnSchema natLogCol = natTable.column("logical_ip", String.class); + + if (natRuleExists(client, schema, natTable, natType, externalIp, logicalIp)) { + logger.debug("NAT [{} {}→{}] on {} already exists - skipping", natType, logicalIp, externalIp, routerName); + return null; + } + + Insert insertNat = OVSDB_OPS.insert(natTable) + .withId("newnat") + .value(natTypeCol, natType) + .value(natExtCol, externalIp) + .value(natLogCol, logicalIp); + if (externalIds != null && !externalIds.isEmpty()) { + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema extIdsCol = natTable.column("external_ids", Map.class); + insertNat = insertNat.value(extIdsCol, new HashMap<>(externalIds)); + } + if (StringUtils.isNotBlank(distributedMac)) { + ColumnSchema extMacCol = natTable.column("external_mac", String.class); + insertNat = insertNat.value(extMacCol, distributedMac); + } + if (StringUtils.isNotBlank(distributedLogicalPort)) { + ColumnSchema logPortCol = natTable.column("logical_port", String.class); + insertNat = insertNat.value(logPortCol, distributedLogicalPort); + } + // gateway_port column is an optional weak reference to a Logical_Router_Port row. + // We resolve the LRP UUID by name first, then attach. If the LRP isn't found we + // fall back to leaving gateway_port empty - ovn-northd will use the default + // selection, which only fails on multi-gw routers (the case we actually need to + // handle). + if (StringUtils.isNotBlank(gatewayLrpName)) { + GenericTableSchema lrpTable = schema.table(LOGICAL_ROUTER_PORT_TABLE, GenericTableSchema.class); + ColumnSchema lrpNameCol = lrpTable.column("name", String.class); + ColumnSchema lrpUuidCol = lrpTable.column("_uuid", UUID.class); + Operation selLrp = OVSDB_OPS.select(lrpTable).column(lrpUuidCol) + .where(lrpNameCol.opEqual(gatewayLrpName)).build(); + List selRes = client.transact(schema, Collections.singletonList(selLrp)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (selRes != null && !selRes.isEmpty() && selRes.get(0).getRows() != null + && !selRes.get(0).getRows().isEmpty()) { + UUID lrpUuid = selRes.get(0).getRows().get(0).getColumn(lrpUuidCol).getData(); + ColumnSchema gwPortCol = natTable.column("gateway_port", UUID.class); + insertNat = insertNat.value(gwPortCol, lrpUuid); + } else { + logger.warn("addNatRule: gateway LRP [{}] not found - inserting NAT without gateway_port", gatewayLrpName); + } + } + ColumnSchema> lrNatCol = lrTable.multiValuedColumn("nat", UUID.class); + List ops = new ArrayList<>(); + ops.add(insertNat); + ops.add(OVSDB_OPS.mutate(lrTable) + .addMutation(lrNatCol, Mutator.INSERT, Collections.singleton(new UUID("newnat"))) + .where(lrNameCol.opEqual(routerName)).build()); + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("add NAT %s %s→%s on %s", natType, logicalIp, externalIp, routerName)); + logger.info("Added OVN NAT [{} {} → {}] on Logical_Router [{}] at {}", natType, logicalIp, externalIp, routerName, nbConnection); + return null; + }); + } + + /** + * Idempotently adds a port-specific NAT rule (DNAT) to a Logical_Router. The rule matches + * traffic arriving at {@code externalIp:externalPort/protocol} and DNATs it to + * {@code logicalIp:externalPort} (OVN translates destination port to the same value). + * Setting {@code distributedMac} and {@code distributedLogicalPort} marks the row as + * distributed so ovn-northd applies DNAT on the workload chassis without the gateway. + */ + public void addNatRuleWithPorts(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String routerName, String natType, String externalIp, + int externalPort, String protocol, String logicalIp, + Map externalIds, + String distributedMac, String distributedLogicalPort) { + if (StringUtils.isBlank(routerName) || StringUtils.isBlank(natType) + || StringUtils.isBlank(externalIp) || StringUtils.isBlank(logicalIp) + || StringUtils.isBlank(protocol)) { + throw new CloudRuntimeException("addNatRuleWithPorts: arguments are incomplete"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lrTable = schema.table(LOGICAL_ROUTER_TABLE, GenericTableSchema.class); + GenericTableSchema natTable = schema.table(NAT_TABLE, GenericTableSchema.class); + ColumnSchema lrNameCol = lrTable.column("name", String.class); + ColumnSchema natTypeCol = natTable.column("type", String.class); + ColumnSchema natExtCol = natTable.column("external_ip", String.class); + ColumnSchema natLogCol = natTable.column("logical_ip", String.class); + ColumnSchema> natExtPortCol = natTable.multiValuedColumn("external_port", Long.class); + ColumnSchema> natProtoCol = natTable.multiValuedColumn("protocol", String.class); + + if (natRuleWithPortExists(client, schema, natTable, natType, externalIp, externalPort, protocol)) { + logger.debug("NAT [{} {}:{}/{}→{}] on {} already exists - skipping", + natType, externalIp, externalPort, protocol, logicalIp, routerName); + return null; + } + + Insert insertNat = OVSDB_OPS.insert(natTable) + .withId("newnat") + .value(natTypeCol, natType) + .value(natExtCol, externalIp) + .value(natLogCol, logicalIp) + .value(natExtPortCol, Collections.singleton((long) externalPort)) + .value(natProtoCol, Collections.singleton(protocol)); + if (externalIds != null && !externalIds.isEmpty()) { + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema extIdsCol = natTable.column("external_ids", Map.class); + insertNat = insertNat.value(extIdsCol, new HashMap<>(externalIds)); + } + if (StringUtils.isNotBlank(distributedMac)) { + ColumnSchema extMacCol = natTable.column("external_mac", String.class); + insertNat = insertNat.value(extMacCol, distributedMac); + } + if (StringUtils.isNotBlank(distributedLogicalPort)) { + ColumnSchema logPortCol = natTable.column("logical_port", String.class); + insertNat = insertNat.value(logPortCol, distributedLogicalPort); + } + ColumnSchema> lrNatCol = lrTable.multiValuedColumn("nat", UUID.class); + List ops = new ArrayList<>(); + ops.add(insertNat); + ops.add(OVSDB_OPS.mutate(lrTable) + .addMutation(lrNatCol, Mutator.INSERT, Collections.singleton(new UUID("newnat"))) + .where(lrNameCol.opEqual(routerName)).build()); + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("add NAT %s %s:%d/%s→%s on %s", + natType, externalIp, externalPort, protocol, logicalIp, routerName)); + logger.info("Added OVN port NAT [{} {}:{}/{} → {}] on Logical_Router [{}] at {}", + natType, externalIp, externalPort, protocol, logicalIp, routerName, nbConnection); + return null; + }); + } + + /** + * Removes every NAT row matching {@code type + externalIp + externalPort + protocol} from + * the Logical_Router. Idempotent: no-op if nothing matches. + */ + public void removeNatRulesByExternalIpAndPort(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String routerName, String natType, + String externalIp, int externalPort, String protocol) { + if (StringUtils.isBlank(routerName) || StringUtils.isBlank(natType) + || StringUtils.isBlank(externalIp) || StringUtils.isBlank(protocol)) { + throw new CloudRuntimeException("removeNatRulesByExternalIpAndPort: arguments are incomplete"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema natTable = schema.table(NAT_TABLE, GenericTableSchema.class); + GenericTableSchema lrTable = schema.table(LOGICAL_ROUTER_TABLE, GenericTableSchema.class); + ColumnSchema natTypeCol = natTable.column("type", String.class); + ColumnSchema natExtCol = natTable.column("external_ip", String.class); + ColumnSchema natUuidCol = natTable.column("_uuid", UUID.class); + ColumnSchema> natExtPortCol = natTable.multiValuedColumn("external_port", Long.class); + ColumnSchema> natProtoCol = natTable.multiValuedColumn("protocol", String.class); + ColumnSchema lrNameCol = lrTable.column("name", String.class); + ColumnSchema> lrNatCol = lrTable.multiValuedColumn("nat", UUID.class); + + // Select all rows matching type+externalIp, then filter by port+protocol client-side + // because OVSDB conditions cannot match inside set columns. + Operation sel = OVSDB_OPS.select(natTable) + .column(natUuidCol).column(natExtPortCol).column(natProtoCol) + .where(natTypeCol.opEqual(natType)) + .and(natExtCol.opEqual(externalIp)).build(); + List selResult = client.transact(schema, Collections.singletonList(sel)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (selResult == null || selResult.isEmpty() || selResult.get(0).getRows() == null) { + return null; + } + List uuids = new ArrayList<>(); + for (Row row : selResult.get(0).getRows()) { + @SuppressWarnings("unchecked") + Set ports = (Set) row.getColumn(natExtPortCol).getData(); + @SuppressWarnings("unchecked") + Set protos = (Set) row.getColumn(natProtoCol).getData(); + if (ports != null && ports.contains((long) externalPort) + && protos != null && protos.contains(protocol)) { + uuids.add(row.getColumn(natUuidCol).getData()); + } + } + if (uuids.isEmpty()) { + return null; + } + List ops = new ArrayList<>(); + ops.add(OVSDB_OPS.mutate(lrTable) + .addMutation(lrNatCol, Mutator.DELETE, new java.util.HashSet<>(uuids)) + .where(lrNameCol.opEqual(routerName)).build()); + for (UUID u : uuids) { + ops.add(OVSDB_OPS.delete(natTable).where(natUuidCol.opEqual(u)).build()); + } + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("remove NAT %s %s:%d/%s on %s", + natType, externalIp, externalPort, protocol, routerName)); + logger.info("Removed {} OVN port NAT row(s) [{} {}:{}/{}] on Logical_Router [{}] at {}", + uuids.size(), natType, externalIp, externalPort, protocol, routerName, nbConnection); + return null; + }); + } + + private boolean natRuleWithPortExists(OvsdbClient client, DatabaseSchema schema, GenericTableSchema natTable, + String natType, String externalIp, int externalPort, + String protocol) throws Exception { + ColumnSchema natTypeCol = natTable.column("type", String.class); + ColumnSchema natExtCol = natTable.column("external_ip", String.class); + ColumnSchema> natExtPortCol = natTable.multiValuedColumn("external_port", Long.class); + ColumnSchema> natProtoCol = natTable.multiValuedColumn("protocol", String.class); + ColumnSchema uuidCol = natTable.column("_uuid", UUID.class); + Operation sel = OVSDB_OPS.select(natTable) + .column(uuidCol).column(natExtPortCol).column(natProtoCol) + .where(natTypeCol.opEqual(natType)) + .and(natExtCol.opEqual(externalIp)).build(); + List results = client.transact(schema, Collections.singletonList(sel)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (results == null || results.isEmpty() || results.get(0).getRows() == null) { + return false; + } + for (Row row : results.get(0).getRows()) { + @SuppressWarnings("unchecked") + Set ports = (Set) row.getColumn(natExtPortCol).getData(); + @SuppressWarnings("unchecked") + Set protos = (Set) row.getColumn(natProtoCol).getData(); + if (ports != null && ports.contains((long) externalPort) + && protos != null && protos.contains(protocol)) { + return true; + } + } + return false; + } + + /** + * Removes every NAT rule matching {@code type}+{@code external_ip} from the Logical_Router, + * regardless of logical_ip. Convenient when reverting a static NAT mapping where the caller + * does not know the previously-bound private address. Idempotent. + */ + public void removeNatRulesByExternalIp(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String routerName, String natType, String externalIp) { + if (StringUtils.isBlank(routerName) || StringUtils.isBlank(natType) || StringUtils.isBlank(externalIp)) { + throw new CloudRuntimeException("removeNatRulesByExternalIp: arguments are incomplete"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema natTable = schema.table(NAT_TABLE, GenericTableSchema.class); + GenericTableSchema lrTable = schema.table(LOGICAL_ROUTER_TABLE, GenericTableSchema.class); + ColumnSchema natTypeCol = natTable.column("type", String.class); + ColumnSchema natExtCol = natTable.column("external_ip", String.class); + ColumnSchema natUuidCol = natTable.column("_uuid", UUID.class); + ColumnSchema lrNameCol = lrTable.column("name", String.class); + ColumnSchema> lrNatCol = lrTable.multiValuedColumn("nat", UUID.class); + + // Logical_Router.nat is a strong reference set; deleting NAT rows without first + // mutating the LR.nat column triggers a referential-integrity violation. Resolve + // the UUIDs to remove via select, then mutate-and-delete in one transaction. + Operation selectUuids = OVSDB_OPS.select(natTable).column(natUuidCol) + .where(natTypeCol.opEqual(natType)) + .and(natExtCol.opEqual(externalIp)).build(); + List selectResult = client.transact(schema, Collections.singletonList(selectUuids)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (selectResult == null || selectResult.isEmpty() || selectResult.get(0).getRows() == null + || selectResult.get(0).getRows().isEmpty()) { + logger.debug("No NAT rows match type={} ext={} on {} - nothing to remove", natType, externalIp, routerName); + return null; + } + List uuids = new ArrayList<>(); + for (Row row : selectResult.get(0).getRows()) { + uuids.add(row.getColumn(natUuidCol).getData()); + } + List ops = new ArrayList<>(); + ops.add(OVSDB_OPS.mutate(lrTable) + .addMutation(lrNatCol, Mutator.DELETE, new java.util.HashSet<>(uuids)) + .where(lrNameCol.opEqual(routerName)).build()); + for (UUID u : uuids) { + ops.add(OVSDB_OPS.delete(natTable).where(natUuidCol.opEqual(u)).build()); + } + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("delete NAT %s ext=%s on %s", natType, externalIp, routerName)); + logger.info("Deleted {} OVN NAT row(s) [{} ext={}] on Logical_Router [{}] at {}", + uuids.size(), natType, externalIp, routerName, nbConnection); + return null; + }); + } + + /** + * Removes a NAT rule matching type/external_ip/logical_ip from a Logical_Router. Idempotent. + */ + public void removeNatRule(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String routerName, String natType, String externalIp, String logicalIp) { + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema natTable = schema.table(NAT_TABLE, GenericTableSchema.class); + GenericTableSchema lrTable = schema.table(LOGICAL_ROUTER_TABLE, GenericTableSchema.class); + ColumnSchema natTypeCol = natTable.column("type", String.class); + ColumnSchema natExtCol = natTable.column("external_ip", String.class); + ColumnSchema natLogCol = natTable.column("logical_ip", String.class); + ColumnSchema natUuidCol = natTable.column("_uuid", UUID.class); + ColumnSchema lrNameCol = lrTable.column("name", String.class); + ColumnSchema> lrNatCol = lrTable.multiValuedColumn("nat", UUID.class); + + Operation selectUuids = OVSDB_OPS.select(natTable).column(natUuidCol) + .where(natTypeCol.opEqual(natType)) + .and(natExtCol.opEqual(externalIp)) + .and(natLogCol.opEqual(logicalIp)).build(); + List selectResult = client.transact(schema, Collections.singletonList(selectUuids)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (selectResult == null || selectResult.isEmpty() || selectResult.get(0).getRows() == null + || selectResult.get(0).getRows().isEmpty()) { + return null; + } + List uuids = new ArrayList<>(); + for (Row row : selectResult.get(0).getRows()) { + uuids.add(row.getColumn(natUuidCol).getData()); + } + List ops = new ArrayList<>(); + ops.add(OVSDB_OPS.mutate(lrTable) + .addMutation(lrNatCol, Mutator.DELETE, new java.util.HashSet<>(uuids)) + .where(lrNameCol.opEqual(routerName)).build()); + for (UUID u : uuids) { + ops.add(OVSDB_OPS.delete(natTable).where(natUuidCol.opEqual(u)).build()); + } + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("delete NAT %s %s→%s on %s", natType, logicalIp, externalIp, routerName)); + logger.info("Deleted OVN NAT [{} {} → {}] on Logical_Router [{}] at {}", natType, logicalIp, externalIp, routerName, nbConnection); + return null; + }); + } + + /** + * Lists the chassis system-ids registered in the OVN_Southbound database. Useful for picking + * a deterministic anchor chassis for a Logical_Router gateway port without having to map + * CloudStack hostnames onto OVS system-ids. + */ + public List listSouthboundChassisNames(String sbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath) { + return runOnDb(sbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, SOUTHBOUND_DB, client -> { + DatabaseSchema schema = client.getSchema(SOUTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema chassisTable = schema.table("Chassis", GenericTableSchema.class); + ColumnSchema nameCol = chassisTable.column("name", String.class); + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema ocCol = chassisTable.column("other_config", Map.class); + Operation select = OVSDB_OPS.select(chassisTable).column(nameCol).column(ocCol); + List results = client.transact(schema, Collections.singletonList(select)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + List names = new ArrayList<>(); + if (results != null && !results.isEmpty() && results.get(0).getRows() != null) { + for (Row row : results.get(0).getRows()) { + // ovn-ic propagates remote-AZ chassis into the local SB with + // other_config:is-remote=true. They must NOT be picked as a local + // gateway-chassis anchor - the LRP would bind in the wrong zone and + // upstream ARP/forwarding would fail in the local public segment. + @SuppressWarnings("unchecked") + Map oc = (Map) row.getColumn(ocCol).getData(); + if (oc != null && "true".equalsIgnoreCase(oc.get("is-remote"))) { + continue; + } + String n = row.getColumn(nameCol).getData(); + if (n != null && !n.isEmpty()) names.add(n); + } + } + return names; + }); + } + + /** + * Removes any Gateway_Chassis row attached to {@code lrpName} whose {@code chassis_name} is + * not present in {@code liveChassisNames}. Used to clean up after a host is re-added to the + * zone with a fresh OVS system-id - the old Gateway_Chassis row would otherwise keep pointing + * to a chassis that no longer exists in SB, so ovn-northd never claims the cr-lrp port and + * the SNAT/DNAT pipeline stays unmaterialised. Returns the number of rows pruned. + */ + public int pruneStaleGatewayChassis(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String lrpName, Set liveChassisNames) { + if (StringUtils.isBlank(lrpName) || liveChassisNames == null) { + throw new CloudRuntimeException("pruneStaleGatewayChassis: arguments are incomplete"); + } + return runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lrpTable = schema.table(LOGICAL_ROUTER_PORT_TABLE, GenericTableSchema.class); + GenericTableSchema gcTable = schema.table(GATEWAY_CHASSIS_TABLE, GenericTableSchema.class); + ColumnSchema lrpNameCol = lrpTable.column("name", String.class); + ColumnSchema> lrpGcCol = lrpTable.multiValuedColumn("gateway_chassis", UUID.class); + ColumnSchema gcUuidCol = gcTable.column("_uuid", UUID.class); + ColumnSchema gcChassisCol = gcTable.column("chassis_name", String.class); + + // Get the GC UUIDs currently bound to this LRP. + Operation selLrp = OVSDB_OPS.select(lrpTable).column(lrpGcCol) + .where(lrpNameCol.opEqual(lrpName)).build(); + List lrpResult = client.transact(schema, Collections.singletonList(selLrp)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (lrpResult == null || lrpResult.isEmpty() || lrpResult.get(0).getRows() == null + || lrpResult.get(0).getRows().isEmpty()) { + return 0; + } + @SuppressWarnings("unchecked") + Set gcRefs = (Set) lrpResult.get(0).getRows().get(0).getColumn(lrpGcCol).getData(); + if (gcRefs == null || gcRefs.isEmpty()) { + return 0; + } + + // Inspect each GC row's chassis_name and collect stale ones. + Set stale = new java.util.HashSet<>(); + for (UUID gcUuid : gcRefs) { + Operation selGc = OVSDB_OPS.select(gcTable).column(gcChassisCol) + .where(gcUuidCol.opEqual(gcUuid)).build(); + List gcResult = client.transact(schema, Collections.singletonList(selGc)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (gcResult == null || gcResult.isEmpty() || gcResult.get(0).getRows() == null + || gcResult.get(0).getRows().isEmpty()) { + continue; + } + String chassisName = gcResult.get(0).getRows().get(0).getColumn(gcChassisCol).getData(); + if (chassisName == null || !liveChassisNames.contains(chassisName)) { + stale.add(gcUuid); + } + } + if (stale.isEmpty()) { + return 0; + } + + // Detach from LRP first (strong ref) then delete each GC row. + List ops = new ArrayList<>(); + ops.add(OVSDB_OPS.mutate(lrpTable) + .addMutation(lrpGcCol, Mutator.DELETE, stale) + .where(lrpNameCol.opEqual(lrpName)).build()); + for (UUID u : stale) { + ops.add(OVSDB_OPS.delete(gcTable).where(gcUuidCol.opEqual(u)).build()); + } + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("prune stale Gateway_Chassis on LRP %s", lrpName)); + logger.info("Pruned {} stale Gateway_Chassis row(s) from LRP [{}] (live chassis: {})", + stale.size(), lrpName, liveChassisNames); + return stale.size(); + }); + } + + /** + * Idempotently anchors a Logical_Router_Port to a chassis via Gateway_Chassis. This is what + * lets ovn-northd materialise the centralised NAT pipeline for the LR (lr_in_dnat, + * lr_in_unsnat, lr_out_snat) — without it the lr_in_dnat table only carries the default + * priority-0 rule and DNAT silently does not happen. + */ + /** + * Idempotently overwrites a Logical_Router_Port's {@code networks} column. No-op if the + * existing set already matches. Used to drift-correct LRPs that were created with a + * stale CIDR (e.g. by a previous peering) so ovn-ic re-advertises the right nexthop. + */ + public void setLrpNetworks(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String lrpName, List networks) { + if (StringUtils.isBlank(lrpName) || networks == null || networks.isEmpty()) { + throw new CloudRuntimeException("setLrpNetworks: arguments are incomplete"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lrpTable = schema.table(LOGICAL_ROUTER_PORT_TABLE, GenericTableSchema.class); + ColumnSchema nameCol = lrpTable.column("name", String.class); + ColumnSchema> netCol = lrpTable.multiValuedColumn("networks", String.class); + + Operation sel = OVSDB_OPS.select(lrpTable).column(netCol) + .where(nameCol.opEqual(lrpName)).build(); + List selRes = client.transact(schema, Collections.singletonList(sel)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (selRes == null || selRes.isEmpty() || selRes.get(0).getRows() == null + || selRes.get(0).getRows().isEmpty()) { + logger.debug("setLrpNetworks: LRP [{}] not present at {} - skipping", lrpName, nbConnection); + return null; + } + Set existing = selRes.get(0).getRows().get(0).getColumn(netCol).getData(); + Set desired = new java.util.HashSet<>(networks); + if (existing != null && existing.equals(desired)) { + logger.debug("setLrpNetworks: LRP [{}] already at {} - skipping", lrpName, networks); + return null; + } + Operation update = OVSDB_OPS.update(lrpTable) + .set(netCol, desired) + .where(nameCol.opEqual(lrpName)).build(); + List r = client.transact(schema, Collections.singletonList(update)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(r, "set LRP " + lrpName + " networks"); + logger.info("Set LRP [{}] networks={} at {} (was {})", lrpName, desired, nbConnection, existing); + return null; + }); + } + + public void setLrpGatewayChassis(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String lrpName, String chassisName, int priority) { + if (StringUtils.isBlank(lrpName) || StringUtils.isBlank(chassisName)) { + throw new CloudRuntimeException("Gateway_Chassis arguments are incomplete"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lrpTable = schema.table(LOGICAL_ROUTER_PORT_TABLE, GenericTableSchema.class); + GenericTableSchema gcTable = schema.table(GATEWAY_CHASSIS_TABLE, GenericTableSchema.class); + ColumnSchema lrpNameCol = lrpTable.column("name", String.class); + ColumnSchema gcChassisCol = gcTable.column("chassis_name", String.class); + ColumnSchema gcPrioCol = gcTable.column("priority", Long.class); + ColumnSchema gcNameCol = gcTable.column("name", String.class); + + Operation existingSel = OVSDB_OPS.select(gcTable).column(gcChassisCol) + .where(gcChassisCol.opEqual(chassisName)) + .and(gcNameCol.opEqual(lrpName + "_" + chassisName)).build(); + List existing = client.transact(schema, Collections.singletonList(existingSel)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (existing != null && !existing.isEmpty() + && existing.get(0).getRows() != null && !existing.get(0).getRows().isEmpty()) { + logger.debug("Gateway_Chassis for LRP [{}] on [{}] already exists - skipping", lrpName, chassisName); + return null; + } + + Insert insertGc = OVSDB_OPS.insert(gcTable) + .withId("newgc") + .value(gcNameCol, lrpName + "_" + chassisName) + .value(gcChassisCol, chassisName) + .value(gcPrioCol, (long) priority); + ColumnSchema> lrpGcCol = lrpTable.multiValuedColumn("gateway_chassis", UUID.class); + List ops = new ArrayList<>(); + ops.add(insertGc); + ops.add(OVSDB_OPS.mutate(lrpTable) + .addMutation(lrpGcCol, Mutator.INSERT, Collections.singleton(new UUID("newgc"))) + .where(lrpNameCol.opEqual(lrpName)).build()); + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("set Gateway_Chassis %s on LRP %s", chassisName, lrpName)); + logger.info("Set OVN Gateway_Chassis [{} prio={}] on Logical_Router_Port [{}] at {}", + chassisName, priority, lrpName, nbConnection); + return null; + }); + } + + /** + * Idempotently adds a static route on a Logical_Router. + */ + public void addStaticRoute(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String routerName, String ipPrefix, String nexthop) { + if (StringUtils.isBlank(routerName) || StringUtils.isBlank(ipPrefix) || StringUtils.isBlank(nexthop)) { + throw new CloudRuntimeException("Static route arguments are incomplete"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lrTable = schema.table(LOGICAL_ROUTER_TABLE, GenericTableSchema.class); + GenericTableSchema srTable = schema.table(LOGICAL_ROUTER_STATIC_ROUTE_TABLE, GenericTableSchema.class); + ColumnSchema lrNameCol = lrTable.column("name", String.class); + ColumnSchema srPrefixCol = srTable.column("ip_prefix", String.class); + ColumnSchema srNexthopCol = srTable.column("nexthop", String.class); + ColumnSchema> lrRoutesCol = lrTable.multiValuedColumn("static_routes", UUID.class); + + // Idempotency must be scoped to the target LR. Two LRs needing the same default + // route both store their own Static_Route row; skipping based on the global + // Static_Route table would leave the second LR without the route. We resolve the + // LR's existing static_routes set, look up each referenced row, and only skip when + // one of them already matches (ip_prefix, nexthop). + Operation selLr = OVSDB_OPS.select(lrTable).column(lrRoutesCol) + .where(lrNameCol.opEqual(routerName)).build(); + List lrSel = client.transact(schema, Collections.singletonList(selLr)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + Set existingRouteUuids = Collections.emptySet(); + if (lrSel != null && !lrSel.isEmpty() + && lrSel.get(0).getRows() != null && !lrSel.get(0).getRows().isEmpty()) { + Object raw = lrSel.get(0).getRows().get(0).getColumn(lrRoutesCol).getData(); + if (raw instanceof Set) { + @SuppressWarnings("unchecked") + Set casted = (Set) raw; + existingRouteUuids = casted; + } + } + if (!existingRouteUuids.isEmpty()) { + // Explicit column selection — without listing _uuid, the result Row does not + // populate it and getColumn returns null, causing a NPE later. Same reason we + // declare a single ColumnSchema instance and pass it to both .column() and + // .getColumn() instead of recreating the schema inline (recreated schemas are + // not equal by reference and may also fail the Row lookup). + ColumnSchema srUuidCol = srTable.column("_uuid", UUID.class); + Operation selRoutes = OVSDB_OPS.select(srTable) + .column(srUuidCol).column(srPrefixCol).column(srNexthopCol) + .where(srPrefixCol.opEqual(ipPrefix)).and(srNexthopCol.opEqual(nexthop)).build(); + List routesSel = client.transact(schema, Collections.singletonList(selRoutes)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (routesSel != null && !routesSel.isEmpty() && routesSel.get(0).getRows() != null) { + for (Row row : routesSel.get(0).getRows()) { + org.opendaylight.ovsdb.lib.notation.Column col = row.getColumn(srUuidCol); + if (col == null) { + continue; + } + UUID rowUuid = col.getData(); + if (rowUuid != null && existingRouteUuids.contains(rowUuid)) { + logger.debug("Static_Route {}→{} already attached to {} - skipping", + ipPrefix, nexthop, routerName); + return null; + } + } + } + } + + Insert insertSr = OVSDB_OPS.insert(srTable) + .withId("newsr") + .value(srPrefixCol, ipPrefix) + .value(srNexthopCol, nexthop); + List ops = new ArrayList<>(); + ops.add(insertSr); + ops.add(OVSDB_OPS.mutate(lrTable) + .addMutation(lrRoutesCol, Mutator.INSERT, Collections.singleton(new UUID("newsr"))) + .where(lrNameCol.opEqual(routerName)).build()); + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("add Static_Route %s→%s on %s", ipPrefix, nexthop, routerName)); + logger.info("Added OVN Static_Route [{} → {}] on Logical_Router [{}] at {}", ipPrefix, nexthop, routerName, nbConnection); + return null; + }); + } + + public void addStaticRoute(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String routerName, String ipPrefix, String nexthop, + Map externalIds) { + if (StringUtils.isBlank(routerName) || StringUtils.isBlank(ipPrefix) || StringUtils.isBlank(nexthop)) { + throw new CloudRuntimeException("Static route arguments are incomplete"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lrTable = schema.table(LOGICAL_ROUTER_TABLE, GenericTableSchema.class); + GenericTableSchema srTable = schema.table(LOGICAL_ROUTER_STATIC_ROUTE_TABLE, GenericTableSchema.class); + ColumnSchema lrNameCol = lrTable.column("name", String.class); + ColumnSchema srPrefixCol = srTable.column("ip_prefix", String.class); + ColumnSchema srNexthopCol = srTable.column("nexthop", String.class); + ColumnSchema> lrRoutesCol = lrTable.multiValuedColumn("static_routes", UUID.class); + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema srExtCol = srTable.column("external_ids", Map.class); + + Operation selLr = OVSDB_OPS.select(lrTable).column(lrRoutesCol) + .where(lrNameCol.opEqual(routerName)).build(); + List lrSel = client.transact(schema, Collections.singletonList(selLr)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + Set existingRouteUuids = Collections.emptySet(); + if (lrSel != null && !lrSel.isEmpty() + && lrSel.get(0).getRows() != null && !lrSel.get(0).getRows().isEmpty()) { + Object raw = lrSel.get(0).getRows().get(0).getColumn(lrRoutesCol).getData(); + if (raw instanceof Set) { + @SuppressWarnings("unchecked") + Set casted = (Set) raw; + existingRouteUuids = casted; + } + } + if (!existingRouteUuids.isEmpty()) { + ColumnSchema srUuidCol = srTable.column("_uuid", UUID.class); + Operation selRoutes = OVSDB_OPS.select(srTable) + .column(srUuidCol).column(srPrefixCol).column(srNexthopCol) + .where(srPrefixCol.opEqual(ipPrefix)).and(srNexthopCol.opEqual(nexthop)).build(); + List routesSel = client.transact(schema, Collections.singletonList(selRoutes)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (routesSel != null && !routesSel.isEmpty() && routesSel.get(0).getRows() != null) { + for (Row row : routesSel.get(0).getRows()) { + org.opendaylight.ovsdb.lib.notation.Column col = row.getColumn(srUuidCol); + if (col == null) continue; + UUID rowUuid = col.getData(); + if (rowUuid != null && existingRouteUuids.contains(rowUuid)) { + return null; + } + } + } + } + + Insert insertSr = OVSDB_OPS.insert(srTable) + .withId("newsr") + .value(srPrefixCol, ipPrefix) + .value(srNexthopCol, nexthop); + if (externalIds != null && !externalIds.isEmpty()) { + insertSr.value(srExtCol, externalIds); + } + List ops = new ArrayList<>(); + ops.add(insertSr); + ops.add(OVSDB_OPS.mutate(lrTable) + .addMutation(lrRoutesCol, Mutator.INSERT, Collections.singleton(new UUID("newsr"))) + .where(lrNameCol.opEqual(routerName)).build()); + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("add Static_Route %s→%s on %s", ipPrefix, nexthop, routerName)); + logger.info("Added OVN Static_Route [{} → {}] on Logical_Router [{}] at {}", ipPrefix, nexthop, routerName, nbConnection); + return null; + }); + } + + public void removeStaticRoute(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String routerName, String ipPrefix, String nexthop) { + if (StringUtils.isBlank(routerName) || StringUtils.isBlank(ipPrefix)) { + throw new CloudRuntimeException("removeStaticRoute arguments are incomplete"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lrTable = schema.table(LOGICAL_ROUTER_TABLE, GenericTableSchema.class); + GenericTableSchema srTable = schema.table(LOGICAL_ROUTER_STATIC_ROUTE_TABLE, GenericTableSchema.class); + ColumnSchema lrNameCol = lrTable.column("name", String.class); + ColumnSchema> lrRoutesCol = lrTable.multiValuedColumn("static_routes", UUID.class); + ColumnSchema srUuidCol = srTable.column("_uuid", UUID.class); + ColumnSchema srPrefixCol = srTable.column("ip_prefix", String.class); + ColumnSchema srNexthopCol = srTable.column("nexthop", String.class); + + Operation selLr = OVSDB_OPS.select(lrTable).column(lrRoutesCol) + .where(lrNameCol.opEqual(routerName)).build(); + List lrSel = client.transact(schema, Collections.singletonList(selLr)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (lrSel == null || lrSel.isEmpty() || lrSel.get(0).getRows() == null || lrSel.get(0).getRows().isEmpty()) { + return null; + } + @SuppressWarnings("unchecked") + Set routeRefs = (Set) lrSel.get(0).getRows().get(0).getColumn(lrRoutesCol).getData(); + if (routeRefs == null || routeRefs.isEmpty()) return null; + + var selectBuilder = OVSDB_OPS.select(srTable).column(srUuidCol).column(srPrefixCol).column(srNexthopCol) + .where(srPrefixCol.opEqual(ipPrefix)); + if (StringUtils.isNotBlank(nexthop)) { + selectBuilder = selectBuilder.and(srNexthopCol.opEqual(nexthop)); + } + Operation selRoutes = selectBuilder.build(); + List routeResult = client.transact(schema, Collections.singletonList(selRoutes)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (routeResult == null || routeResult.isEmpty() || routeResult.get(0).getRows() == null) { + return null; + } + + Set toRemove = new java.util.HashSet<>(); + for (Row row : routeResult.get(0).getRows()) { + UUID u = row.getColumn(srUuidCol).getData(); + if (u != null && routeRefs.contains(u)) { + toRemove.add(u); + } + } + if (toRemove.isEmpty()) return null; + + List ops = new ArrayList<>(); + ops.add(OVSDB_OPS.mutate(lrTable) + .addMutation(lrRoutesCol, Mutator.DELETE, toRemove) + .where(lrNameCol.opEqual(routerName)).build()); + for (UUID u : toRemove) { + ops.add(OVSDB_OPS.delete(srTable).where(srUuidCol.opEqual(u)).build()); + } + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("remove Static_Route %s→%s on %s", ipPrefix, nexthop, routerName)); + logger.info("Removed OVN Static_Route [{} → {}] on Logical_Router [{}] at {}", ipPrefix, nexthop, routerName, nbConnection); + return null; + }); + } + + public int removeStaticRoutesByExternalId(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String routerName, String externalIdKey, String externalIdValue) { + if (StringUtils.isBlank(routerName) || StringUtils.isBlank(externalIdKey)) { + throw new CloudRuntimeException("removeStaticRoutesByExternalId arguments are incomplete"); + } + return runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lrTable = schema.table(LOGICAL_ROUTER_TABLE, GenericTableSchema.class); + GenericTableSchema srTable = schema.table(LOGICAL_ROUTER_STATIC_ROUTE_TABLE, GenericTableSchema.class); + ColumnSchema lrNameCol = lrTable.column("name", String.class); + ColumnSchema> lrRoutesCol = lrTable.multiValuedColumn("static_routes", UUID.class); + ColumnSchema srUuidCol = srTable.column("_uuid", UUID.class); + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema srExtCol = srTable.column("external_ids", Map.class); + + Operation selLr = OVSDB_OPS.select(lrTable).column(lrRoutesCol) + .where(lrNameCol.opEqual(routerName)).build(); + List lrSel = client.transact(schema, Collections.singletonList(selLr)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (lrSel == null || lrSel.isEmpty() || lrSel.get(0).getRows() == null || lrSel.get(0).getRows().isEmpty()) { + return 0; + } + @SuppressWarnings("unchecked") + Set routeRefs = (Set) lrSel.get(0).getRows().get(0).getColumn(lrRoutesCol).getData(); + if (routeRefs == null || routeRefs.isEmpty()) return 0; + + Operation selRoutes = OVSDB_OPS.select(srTable).column(srUuidCol).column(srExtCol); + List routeResult = client.transact(schema, Collections.singletonList(selRoutes)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (routeResult == null || routeResult.isEmpty() || routeResult.get(0).getRows() == null) { + return 0; + } + + Set toRemove = new java.util.HashSet<>(); + for (Row row : routeResult.get(0).getRows()) { + UUID u = row.getColumn(srUuidCol).getData(); + if (u == null || !routeRefs.contains(u)) continue; + @SuppressWarnings("unchecked") + Map ext = (Map) row.getColumn(srExtCol).getData(); + if (ext != null && externalIdValue.equals(ext.get(externalIdKey))) { + toRemove.add(u); + } + } + if (toRemove.isEmpty()) return 0; + + List ops = new ArrayList<>(); + ops.add(OVSDB_OPS.mutate(lrTable) + .addMutation(lrRoutesCol, Mutator.DELETE, toRemove) + .where(lrNameCol.opEqual(routerName)).build()); + for (UUID u : toRemove) { + ops.add(OVSDB_OPS.delete(srTable).where(srUuidCol.opEqual(u)).build()); + } + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("remove Static_Routes by %s=%s on %s", externalIdKey, externalIdValue, routerName)); + logger.info("Removed {} Static_Route(s) tagged [{}={}] on Logical_Router [{}] at {}", + toRemove.size(), externalIdKey, externalIdValue, routerName, nbConnection); + return toRemove.size(); + }); + } + + public void addLogicalRouterPolicy(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String routerName, int priority, String match, String action, + String nexthop, Map externalIds) { + if (StringUtils.isBlank(routerName) || StringUtils.isBlank(match) || StringUtils.isBlank(action)) { + throw new CloudRuntimeException("addLogicalRouterPolicy arguments are incomplete"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lrTable = schema.table(LOGICAL_ROUTER_TABLE, GenericTableSchema.class); + GenericTableSchema polTable = schema.table(LOGICAL_ROUTER_POLICY_TABLE, GenericTableSchema.class); + ColumnSchema lrNameCol = lrTable.column("name", String.class); + ColumnSchema> lrPolCol = lrTable.multiValuedColumn("policies", UUID.class); + ColumnSchema polPrioCol = polTable.column("priority", Long.class); + ColumnSchema polMatchCol = polTable.column("match", String.class); + ColumnSchema polActionCol = polTable.column("action", String.class); + ColumnSchema> polNexthopCol = polTable.multiValuedColumn("nexthops", String.class); + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema polExtCol = polTable.column("external_ids", Map.class); + ColumnSchema polUuidCol = polTable.column("_uuid", UUID.class); + + // Idempotency: check if policy with same priority+match already exists on this LR + Operation selLr = OVSDB_OPS.select(lrTable).column(lrPolCol) + .where(lrNameCol.opEqual(routerName)).build(); + List lrSel = client.transact(schema, Collections.singletonList(selLr)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + Set existingPolUuids = Collections.emptySet(); + if (lrSel != null && !lrSel.isEmpty() + && lrSel.get(0).getRows() != null && !lrSel.get(0).getRows().isEmpty()) { + Object raw = lrSel.get(0).getRows().get(0).getColumn(lrPolCol).getData(); + if (raw instanceof Set) { + @SuppressWarnings("unchecked") + Set casted = (Set) raw; + existingPolUuids = casted; + } + } + if (!existingPolUuids.isEmpty()) { + Operation selPol = OVSDB_OPS.select(polTable) + .column(polUuidCol).column(polPrioCol).column(polMatchCol) + .where(polPrioCol.opEqual((long) priority)).and(polMatchCol.opEqual(match)).build(); + List polSel = client.transact(schema, Collections.singletonList(selPol)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (polSel != null && !polSel.isEmpty() && polSel.get(0).getRows() != null) { + for (Row row : polSel.get(0).getRows()) { + UUID u = row.getColumn(polUuidCol).getData(); + if (u != null && existingPolUuids.contains(u)) { + return null; + } + } + } + } + + Insert insertPol = OVSDB_OPS.insert(polTable) + .withId("newpol") + .value(polPrioCol, (long) priority) + .value(polMatchCol, match) + .value(polActionCol, action); + if (StringUtils.isNotBlank(nexthop)) { + insertPol.value(polNexthopCol, Collections.singleton(nexthop)); + } + if (externalIds != null && !externalIds.isEmpty()) { + insertPol.value(polExtCol, externalIds); + } + List ops = new ArrayList<>(); + ops.add(insertPol); + ops.add(OVSDB_OPS.mutate(lrTable) + .addMutation(lrPolCol, Mutator.INSERT, Collections.singleton(new UUID("newpol"))) + .where(lrNameCol.opEqual(routerName)).build()); + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("add LR Policy prio=%d match=%s on %s", priority, match, routerName)); + logger.info("Added OVN LR_Policy [prio={} match={} action={} nexthop={}] on [{}] at {}", + priority, match, action, nexthop, routerName, nbConnection); + return null; + }); + } + + public int removeLogicalRouterPoliciesByExternalId(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String routerName, String externalIdKey, String externalIdValue) { + if (StringUtils.isBlank(routerName) || StringUtils.isBlank(externalIdKey)) { + throw new CloudRuntimeException("removeLogicalRouterPoliciesByExternalId arguments are incomplete"); + } + return runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lrTable = schema.table(LOGICAL_ROUTER_TABLE, GenericTableSchema.class); + GenericTableSchema polTable = schema.table(LOGICAL_ROUTER_POLICY_TABLE, GenericTableSchema.class); + ColumnSchema lrNameCol = lrTable.column("name", String.class); + ColumnSchema> lrPolCol = lrTable.multiValuedColumn("policies", UUID.class); + ColumnSchema polUuidCol = polTable.column("_uuid", UUID.class); + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema polExtCol = polTable.column("external_ids", Map.class); + + Operation selLr = OVSDB_OPS.select(lrTable).column(lrPolCol) + .where(lrNameCol.opEqual(routerName)).build(); + List lrSel = client.transact(schema, Collections.singletonList(selLr)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (lrSel == null || lrSel.isEmpty() || lrSel.get(0).getRows() == null || lrSel.get(0).getRows().isEmpty()) { + return 0; + } + @SuppressWarnings("unchecked") + Set polRefs = (Set) lrSel.get(0).getRows().get(0).getColumn(lrPolCol).getData(); + if (polRefs == null || polRefs.isEmpty()) return 0; + + Operation selPol = OVSDB_OPS.select(polTable).column(polUuidCol).column(polExtCol); + List polResult = client.transact(schema, Collections.singletonList(selPol)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (polResult == null || polResult.isEmpty() || polResult.get(0).getRows() == null) { + return 0; + } + + Set toRemove = new java.util.HashSet<>(); + for (Row row : polResult.get(0).getRows()) { + UUID u = row.getColumn(polUuidCol).getData(); + if (u == null || !polRefs.contains(u)) continue; + @SuppressWarnings("unchecked") + Map ext = (Map) row.getColumn(polExtCol).getData(); + if (ext != null && externalIdValue.equals(ext.get(externalIdKey))) { + toRemove.add(u); + } + } + if (toRemove.isEmpty()) return 0; + + List ops = new ArrayList<>(); + ops.add(OVSDB_OPS.mutate(lrTable) + .addMutation(lrPolCol, Mutator.DELETE, toRemove) + .where(lrNameCol.opEqual(routerName)).build()); + for (UUID u : toRemove) { + ops.add(OVSDB_OPS.delete(polTable).where(polUuidCol.opEqual(u)).build()); + } + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("remove LR Policies by %s=%s on %s", externalIdKey, externalIdValue, routerName)); + logger.info("Removed {} LR_Policy(ies) tagged [{}={}] on Logical_Router [{}] at {}", + toRemove.size(), externalIdKey, externalIdValue, routerName, nbConnection); + return toRemove.size(); + }); + } + + /** + * Idempotently creates or updates a Load_Balancer row keyed by name. {@code vips} is a map of + * {@code ":"} → {@code ":[,...]"}. {@code protocol} + * must be {@code tcp}, {@code udp} or {@code sctp}. Existing row is reset to the supplied + * vips/protocol/options/external_ids - the row name is the stable identifier. + * + *

The OVN NB schema for Load_Balancer.vips is a {@code map}; OVN + * north interprets each entry as one DNAT mapping and may rewrite both IP and port (which is + * exactly what we need for CloudStack PortForwarding rules where source and destination ports + * can differ).

+ */ + public void createOrReplaceLoadBalancer(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String name, String protocol, Map vips, + Map externalIds, Map options) { + // Backward-compat wrapper for callers (PortForwarding) that don't need selection_fields + // or ip_port_mappings (which are LB-rule-specific concerns: hashing fields and HC source + // attribution respectively). + createOrReplaceLoadBalancer(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, + name, protocol, vips, externalIds, options, null, null); + } + + public void createOrReplaceLoadBalancer(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String name, String protocol, Map vips, + Map externalIds, Map options, + Set selectionFields, Map ipPortMappings) { + if (StringUtils.isBlank(name)) { + throw new CloudRuntimeException("Load_Balancer name is blank"); + } + if (vips == null || vips.isEmpty()) { + throw new CloudRuntimeException("Load_Balancer vips must be non-empty"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lbTable = schema.table(LOAD_BALANCER_TABLE, GenericTableSchema.class); + ColumnSchema nameCol = lbTable.column("name", String.class); + ColumnSchema uuidCol = lbTable.column("_uuid", UUID.class); + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema vipsCol = lbTable.column("vips", Map.class); + ColumnSchema> protoCol = lbTable.multiValuedColumn("protocol", String.class); + ColumnSchema> selFieldsCol = lbTable.multiValuedColumn("selection_fields", String.class); + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema ippmCol = lbTable.column("ip_port_mappings", Map.class); + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema optsCol = lbTable.column("options", Map.class); + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema extIdsCol = lbTable.column("external_ids", Map.class); + + // Look up existing row by name. + Operation sel = OVSDB_OPS.select(lbTable).column(uuidCol) + .where(nameCol.opEqual(name)).build(); + List selResult = client.transact(schema, Collections.singletonList(sel)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + UUID existing = null; + if (selResult != null && !selResult.isEmpty() && selResult.get(0).getRows() != null + && !selResult.get(0).getRows().isEmpty()) { + existing = selResult.get(0).getRows().get(0).getColumn(uuidCol).getData(); + } + + List ops = new ArrayList<>(); + if (existing == null) { + Insert insert = OVSDB_OPS.insert(lbTable) + .value(nameCol, name) + .value(vipsCol, new HashMap<>(vips)); + if (StringUtils.isNotBlank(protocol)) { + insert = insert.value(protoCol, Collections.singleton(protocol)); + } + if (options != null && !options.isEmpty()) { + insert = insert.value(optsCol, new HashMap<>(options)); + } + if (externalIds != null && !externalIds.isEmpty()) { + insert = insert.value(extIdsCol, new HashMap<>(externalIds)); + } + if (selectionFields != null && !selectionFields.isEmpty()) { + insert = insert.value(selFieldsCol, new java.util.HashSet<>(selectionFields)); + } + if (ipPortMappings != null && !ipPortMappings.isEmpty()) { + insert = insert.value(ippmCol, new HashMap<>(ipPortMappings)); + } + ops.add(insert); + } else { + // Replace contents in-place. Mutate explicit columns even if empty so stale entries + // from a prior revision do not leak through. + ops.add(OVSDB_OPS.update(lbTable) + .set(vipsCol, new HashMap<>(vips)) + .set(protoCol, StringUtils.isNotBlank(protocol) + ? Collections.singleton(protocol) + : Collections.emptySet()) + .set(selFieldsCol, selectionFields != null + ? new java.util.HashSet<>(selectionFields) + : Collections.emptySet()) + .set(ippmCol, ipPortMappings != null ? new HashMap<>(ipPortMappings) : new HashMap<>()) + .set(optsCol, options != null ? new HashMap<>(options) : new HashMap<>()) + .set(extIdsCol, externalIds != null ? new HashMap<>(externalIds) : new HashMap<>()) + .where(uuidCol.opEqual(existing)).build()); + } + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("createOrReplace Load_Balancer %s", name)); + logger.info("Wrote Load_Balancer [{}] vips={} protocol={} options={} selection_fields={} at {}", + name, vips, protocol, options, selectionFields, nbConnection); + return null; + }); + } + + /** + * Sets {@code Load_Balancer.health_check} to a single fresh Load_Balancer_Health_Check row + * with the given vip+options. Existing HC rows (referenced or orphaned with our external_ids + * tag) are deleted before insert so we never accumulate dead HC rows. OVN's health check is + * L4 TCP-only - the {@code options} map carries {@code interval}, {@code timeout}, + * {@code success_count}, {@code failure_count}. + * + *

{@code ipPortMappings} populates {@code Load_Balancer.ip_port_mappings} so the SB + * Service_Monitor knows from which logical port to source HC probes to each backend + * (Format: {@code ""} → {@code ":"}).

+ */ + public void setLoadBalancerHealthCheck(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String lbName, String hcVip, + Map hcOptions, + Map ipPortMappings, + Map hcExternalIds) { + if (StringUtils.isBlank(lbName) || StringUtils.isBlank(hcVip)) { + throw new CloudRuntimeException("setLoadBalancerHealthCheck: arguments are incomplete"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lbTable = schema.table(LOAD_BALANCER_TABLE, GenericTableSchema.class); + GenericTableSchema hcTable = schema.table(LOAD_BALANCER_HEALTH_CHECK_TABLE, GenericTableSchema.class); + ColumnSchema lbNameCol = lbTable.column("name", String.class); + ColumnSchema lbUuidCol = lbTable.column("_uuid", UUID.class); + ColumnSchema> lbHcCol = lbTable.multiValuedColumn("health_check", UUID.class); + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema lbIppmCol = lbTable.column("ip_port_mappings", Map.class); + ColumnSchema hcUuidCol = hcTable.column("_uuid", UUID.class); + ColumnSchema hcVipCol = hcTable.column("vip", String.class); + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema hcOptsCol = hcTable.column("options", Map.class); + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema hcExtCol = hcTable.column("external_ids", Map.class); + + // Lookup LB; pull current health_check refs so we can delete them. + Operation selLb = OVSDB_OPS.select(lbTable).column(lbUuidCol).column(lbHcCol) + .where(lbNameCol.opEqual(lbName)).build(); + List lbResult = client.transact(schema, Collections.singletonList(selLb)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (lbResult == null || lbResult.isEmpty() || lbResult.get(0).getRows() == null + || lbResult.get(0).getRows().isEmpty()) { + throw new CloudRuntimeException("Load_Balancer " + lbName + " not found while setting health check"); + } + UUID lbUuid = lbResult.get(0).getRows().get(0).getColumn(lbUuidCol).getData(); + @SuppressWarnings("unchecked") + Set oldHc = (Set) lbResult.get(0).getRows().get(0).getColumn(lbHcCol).getData(); + + String namedUuid = "newhc"; + Insert insertHc = OVSDB_OPS.insert(hcTable) + .withId(namedUuid) + .value(hcVipCol, hcVip) + .value(hcOptsCol, hcOptions != null ? new HashMap<>(hcOptions) : new HashMap<>()); + if (hcExternalIds != null && !hcExternalIds.isEmpty()) { + insertHc = insertHc.value(hcExtCol, new HashMap<>(hcExternalIds)); + } + + List ops = new ArrayList<>(); + ops.add(insertHc); + // Replace the LB.health_check set and refresh ip_port_mappings in the same txn. + ops.add(OVSDB_OPS.update(lbTable) + .set(lbHcCol, Collections.singleton(new UUID(namedUuid))) + .set(lbIppmCol, ipPortMappings != null ? new HashMap<>(ipPortMappings) : new HashMap<>()) + .where(lbUuidCol.opEqual(lbUuid)).build()); + // Delete the old HC rows now that no LB references them (strong ref). + if (oldHc != null) { + for (UUID stale : oldHc) { + ops.add(OVSDB_OPS.delete(hcTable).where(hcUuidCol.opEqual(stale)).build()); + } + } + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("set health check on Load_Balancer %s", lbName)); + logger.info("Set Load_Balancer_Health_Check on [{}] vip={} options={} ipPortMappings={} at {}", + lbName, hcVip, hcOptions, ipPortMappings, nbConnection); + return null; + }); + } + + /** + * Removes any {@code Load_Balancer_Health_Check} row attached to {@code lbName} and clears + * the LB's {@code ip_port_mappings}. Used when a CloudStack LB rule's HealthCheckPolicy is + * revoked or absent. Idempotent: a no-op when the LB has no HC. + */ + public void clearLoadBalancerHealthCheck(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String lbName) { + if (StringUtils.isBlank(lbName)) { + throw new CloudRuntimeException("clearLoadBalancerHealthCheck: name is blank"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lbTable = schema.table(LOAD_BALANCER_TABLE, GenericTableSchema.class); + GenericTableSchema hcTable = schema.table(LOAD_BALANCER_HEALTH_CHECK_TABLE, GenericTableSchema.class); + ColumnSchema lbNameCol = lbTable.column("name", String.class); + ColumnSchema lbUuidCol = lbTable.column("_uuid", UUID.class); + ColumnSchema> lbHcCol = lbTable.multiValuedColumn("health_check", UUID.class); + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema lbIppmCol = lbTable.column("ip_port_mappings", Map.class); + ColumnSchema hcUuidCol = hcTable.column("_uuid", UUID.class); + + Operation selLb = OVSDB_OPS.select(lbTable).column(lbUuidCol).column(lbHcCol) + .where(lbNameCol.opEqual(lbName)).build(); + List lbResult = client.transact(schema, Collections.singletonList(selLb)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (lbResult == null || lbResult.isEmpty() || lbResult.get(0).getRows() == null + || lbResult.get(0).getRows().isEmpty()) { + return null; + } + UUID lbUuid = lbResult.get(0).getRows().get(0).getColumn(lbUuidCol).getData(); + @SuppressWarnings("unchecked") + Set oldHc = (Set) lbResult.get(0).getRows().get(0).getColumn(lbHcCol).getData(); + if (oldHc == null || oldHc.isEmpty()) { + return null; + } + List ops = new ArrayList<>(); + ops.add(OVSDB_OPS.update(lbTable) + .set(lbHcCol, Collections.emptySet()) + .set(lbIppmCol, new HashMap<>()) + .where(lbUuidCol.opEqual(lbUuid)).build()); + for (UUID stale : oldHc) { + ops.add(OVSDB_OPS.delete(hcTable).where(hcUuidCol.opEqual(stale)).build()); + } + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("clear health check on Load_Balancer %s", lbName)); + logger.info("Cleared {} Load_Balancer_Health_Check row(s) from [{}] at {}", + oldHc.size(), lbName, nbConnection); + return null; + }); + } + + /** + * Idempotently links a Load_Balancer (by name) into a Logical_Router's {@code load_balancer} + * set. Used to make ovn-northd evaluate the LB DNAT pipeline on traffic arriving at this LR. + */ + public void attachLoadBalancerToRouter(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String routerName, String lbName) { + if (StringUtils.isBlank(routerName) || StringUtils.isBlank(lbName)) { + throw new CloudRuntimeException("Logical_Router/Load_Balancer name is blank"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lrTable = schema.table(LOGICAL_ROUTER_TABLE, GenericTableSchema.class); + GenericTableSchema lbTable = schema.table(LOAD_BALANCER_TABLE, GenericTableSchema.class); + ColumnSchema lrNameCol = lrTable.column("name", String.class); + ColumnSchema lbNameCol = lbTable.column("name", String.class); + ColumnSchema lbUuidCol = lbTable.column("_uuid", UUID.class); + ColumnSchema> lrLbCol = lrTable.multiValuedColumn("load_balancer", UUID.class); + + UUID lbUuid = lookupUuidByName(client, schema, lbTable, lbNameCol, lbUuidCol, lbName); + if (lbUuid == null) { + throw new CloudRuntimeException("Load_Balancer " + lbName + " not found while attaching to LR " + routerName); + } + Operation mutate = OVSDB_OPS.mutate(lrTable) + .addMutation(lrLbCol, Mutator.INSERT, Collections.singleton(lbUuid)) + .where(lrNameCol.opEqual(routerName)).build(); + List results = client.transact(schema, Collections.singletonList(mutate)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("attach LB %s to LR %s", lbName, routerName)); + logger.debug("Attached Load_Balancer [{}] to Logical_Router [{}] at {}", lbName, routerName, nbConnection); + return null; + }); + } + + /** + * Idempotently links a Load_Balancer (by name) into a Logical_Switch's {@code load_balancer} + * set. Required for return-traffic visibility (RHBZ#2043543) when a VM on this switch is the + * backend of a port-forwarding rule attached to the upstream router. + */ + public void attachLoadBalancerToSwitch(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String switchName, String lbName) { + if (StringUtils.isBlank(switchName) || StringUtils.isBlank(lbName)) { + throw new CloudRuntimeException("Logical_Switch/Load_Balancer name is blank"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lsTable = schema.table(LOGICAL_SWITCH_TABLE, GenericTableSchema.class); + GenericTableSchema lbTable = schema.table(LOAD_BALANCER_TABLE, GenericTableSchema.class); + ColumnSchema lsNameCol = lsTable.column("name", String.class); + ColumnSchema lbNameCol = lbTable.column("name", String.class); + ColumnSchema lbUuidCol = lbTable.column("_uuid", UUID.class); + ColumnSchema> lsLbCol = lsTable.multiValuedColumn("load_balancer", UUID.class); + + UUID lbUuid = lookupUuidByName(client, schema, lbTable, lbNameCol, lbUuidCol, lbName); + if (lbUuid == null) { + throw new CloudRuntimeException("Load_Balancer " + lbName + " not found while attaching to LS " + switchName); + } + Operation mutate = OVSDB_OPS.mutate(lsTable) + .addMutation(lsLbCol, Mutator.INSERT, Collections.singleton(lbUuid)) + .where(lsNameCol.opEqual(switchName)).build(); + List results = client.transact(schema, Collections.singletonList(mutate)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("attach LB %s to LS %s", lbName, switchName)); + logger.debug("Attached Load_Balancer [{}] to Logical_Switch [{}] at {}", lbName, switchName, nbConnection); + return null; + }); + } + + /** + * Removes every Load_Balancer row whose {@code external_ids} contains {@code key=value}. First + * walks every Logical_Router and Logical_Switch, mutates their {@code load_balancer} sets to + * detach the matching LBs, then deletes the LB rows themselves. Detach is required because + * {@code load_balancer} is a strong reference set in the OVN NB schema. + */ + public int removeLoadBalancersByExternalId(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String externalIdKey, String externalIdValue) { + if (StringUtils.isBlank(externalIdKey) || StringUtils.isBlank(externalIdValue)) { + throw new CloudRuntimeException("removeLoadBalancersByExternalId arguments are incomplete"); + } + return runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lbTable = schema.table(LOAD_BALANCER_TABLE, GenericTableSchema.class); + GenericTableSchema lrTable = schema.table(LOGICAL_ROUTER_TABLE, GenericTableSchema.class); + GenericTableSchema lsTable = schema.table(LOGICAL_SWITCH_TABLE, GenericTableSchema.class); + ColumnSchema lbUuidCol = lbTable.column("_uuid", UUID.class); + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema lbExtCol = lbTable.column("external_ids", Map.class); + ColumnSchema lrUuidCol = lrTable.column("_uuid", UUID.class); + ColumnSchema> lrLbCol = lrTable.multiValuedColumn("load_balancer", UUID.class); + ColumnSchema lsUuidCol = lsTable.column("_uuid", UUID.class); + ColumnSchema> lsLbCol = lsTable.multiValuedColumn("load_balancer", UUID.class); + + // Step 1: collect LB UUIDs whose external_ids match. + Operation selLb = OVSDB_OPS.select(lbTable).column(lbUuidCol).column(lbExtCol); + List lbResult = client.transact(schema, Collections.singletonList(selLb)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + Set matchedLbs = new java.util.HashSet<>(); + if (lbResult != null && !lbResult.isEmpty() && lbResult.get(0).getRows() != null) { + for (Row row : lbResult.get(0).getRows()) { + @SuppressWarnings("unchecked") + Map ext = (Map) row.getColumn(lbExtCol).getData(); + if (ext != null && externalIdValue.equals(ext.get(externalIdKey))) { + matchedLbs.add(row.getColumn(lbUuidCol).getData()); + } + } + } + if (matchedLbs.isEmpty()) { + return 0; + } + + // Step 2: walk LRs/LSes and detach. We pull the full set then mutate per row that + // actually contains one of our UUIDs - keeps the transaction small. + List ops = new ArrayList<>(); + collectDetachOps(client, schema, lrTable, lrUuidCol, lrLbCol, matchedLbs, ops); + collectDetachOps(client, schema, lsTable, lsUuidCol, lsLbCol, matchedLbs, ops); + + // Step 3: delete the LB rows themselves. + for (UUID u : matchedLbs) { + ops.add(OVSDB_OPS.delete(lbTable).where(lbUuidCol.opEqual(u)).build()); + } + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("remove LBs by %s=%s", externalIdKey, externalIdValue)); + logger.info("Removed {} Load_Balancer row(s) tagged [{}={}] at {}", + matchedLbs.size(), externalIdKey, externalIdValue, nbConnection); + return matchedLbs.size(); + }); + } + + private void collectDetachOps(OvsdbClient client, DatabaseSchema schema, + GenericTableSchema parentTable, + ColumnSchema parentUuidCol, + ColumnSchema> lbRefCol, + Set targetLbUuids, + List ops) throws Exception { + Operation sel = OVSDB_OPS.select(parentTable).column(parentUuidCol).column(lbRefCol); + List result = client.transact(schema, Collections.singletonList(sel)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (result == null || result.isEmpty() || result.get(0).getRows() == null) { + return; + } + for (Row row : result.get(0).getRows()) { + @SuppressWarnings("unchecked") + Set refs = (Set) row.getColumn(lbRefCol).getData(); + if (refs == null || refs.isEmpty()) continue; + Set overlap = new java.util.HashSet<>(refs); + overlap.retainAll(targetLbUuids); + if (overlap.isEmpty()) continue; + UUID parentUuid = row.getColumn(parentUuidCol).getData(); + ops.add(OVSDB_OPS.mutate(parentTable) + .addMutation(lbRefCol, Mutator.DELETE, overlap) + .where(parentUuidCol.opEqual(parentUuid)).build()); + } + } + + private UUID lookupUuidByName(OvsdbClient client, DatabaseSchema schema, + GenericTableSchema table, + ColumnSchema nameCol, + ColumnSchema uuidCol, + String name) throws Exception { + Operation sel = OVSDB_OPS.select(table).column(uuidCol) + .where(nameCol.opEqual(name)).build(); + List result = client.transact(schema, Collections.singletonList(sel)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (result == null || result.isEmpty() || result.get(0).getRows() == null + || result.get(0).getRows().isEmpty()) { + return null; + } + return result.get(0).getRows().get(0).getColumn(uuidCol).getData(); + } + + /** + * Idempotently installs an ACL row on the named Logical_Switch. The caller is expected to + * tag the ACL via {@code external_ids} so subsequent revocation can target the row by tag + * (e.g. {@code cloudstack_fw_rule_id=}). If a row with the same tag combination + * already exists on this switch it is replaced - this keeps applyFWRules idempotent across + * retries without leaking stale rows. + * + *

Logical_Switch.acls is a weak-ref set, so deleting the ACL row alone is enough to + * break the link, but we still mutate {@code acls} explicitly to keep the LS row tidy.

+ */ + public void addAclOnLs(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String logicalSwitchName, String name, String direction, long priority, + String match, String action, Map externalIds) { + if (StringUtils.isBlank(logicalSwitchName) || StringUtils.isBlank(direction) + || StringUtils.isBlank(match) || StringUtils.isBlank(action)) { + throw new CloudRuntimeException("ACL arguments are incomplete"); + } + if (externalIds == null || externalIds.isEmpty()) { + throw new CloudRuntimeException("ACL external_ids must be set so the row can be replaced/removed by tag"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lsTable = schema.table(LOGICAL_SWITCH_TABLE, GenericTableSchema.class); + GenericTableSchema aclTable = schema.table(ACL_TABLE, GenericTableSchema.class); + ColumnSchema lsNameCol = lsTable.column("name", String.class); + ColumnSchema> lsAclsCol = lsTable.multiValuedColumn("acls", UUID.class); + + ColumnSchema aclDirCol = aclTable.column("direction", String.class); + ColumnSchema aclPrioCol = aclTable.column("priority", Long.class); + ColumnSchema aclMatchCol = aclTable.column("match", String.class); + ColumnSchema aclActionCol = aclTable.column("action", String.class); + @SuppressWarnings("rawtypes") + ColumnSchema aclExtCol = aclTable.column("external_ids", Map.class); + + // First, remove any existing ACL on this LS that already carries the same external_ids + // tag. We do this in the same transaction to keep the operation atomic. + List staleAclUuids = findAclUuidsByExternalIds(client, schema, aclTable, externalIds); + List ops = new ArrayList<>(); + ColumnSchema aclUuidCol = aclTable.column("_uuid", UUID.class); + for (UUID stale : staleAclUuids) { + ops.add(OVSDB_OPS.delete(aclTable).where(aclUuidCol.opEqual(stale)).build()); + ops.add(OVSDB_OPS.mutate(lsTable) + .addMutation(lsAclsCol, Mutator.DELETE, Collections.singleton(stale)) + .where(lsNameCol.opEqual(logicalSwitchName)).build()); + } + + String namedUuid = "newacl"; + Insert insertAcl = OVSDB_OPS.insert(aclTable) + .withId(namedUuid) + .value(aclDirCol, direction) + .value(aclPrioCol, priority) + .value(aclMatchCol, match) + .value(aclActionCol, action) + .value(aclExtCol, new HashMap<>(externalIds)); + if (StringUtils.isNotBlank(name)) { + ColumnSchema aclNameCol = aclTable.column("name", String.class); + insertAcl = insertAcl.value(aclNameCol, name); + } + ops.add(insertAcl); + ops.add(OVSDB_OPS.mutate(lsTable) + .addMutation(lsAclsCol, Mutator.INSERT, Collections.singleton(new UUID(namedUuid))) + .where(lsNameCol.opEqual(logicalSwitchName)).build()); + + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("install ACL on Logical_Switch %s (priority=%d, action=%s)", + logicalSwitchName, priority, action)); + logger.info("Installed OVN ACL on Logical_Switch [{}] dir=[{}] prio=[{}] action=[{}] match=[{}] tags=[{}]", + logicalSwitchName, direction, priority, action, match, externalIds); + return null; + }); + } + + /** + * Removes every ACL row whose {@code external_ids} contains the supplied (key, value) pair + * and detaches it from the named Logical_Switch. Used to revoke individual firewall rules + * (by {@code cloudstack_fw_rule_id}) or to wipe per-IP scopes (by {@code cloudstack_fw_ip}). + */ + public int removeAclsOnLsByExternalId(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String logicalSwitchName, String externalIdKey, String externalIdValue) { + if (StringUtils.isBlank(logicalSwitchName) || StringUtils.isBlank(externalIdKey) || StringUtils.isBlank(externalIdValue)) { + throw new CloudRuntimeException("ACL removal arguments are incomplete"); + } + return runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lsTable = schema.table(LOGICAL_SWITCH_TABLE, GenericTableSchema.class); + GenericTableSchema aclTable = schema.table(ACL_TABLE, GenericTableSchema.class); + ColumnSchema lsNameCol = lsTable.column("name", String.class); + ColumnSchema> lsAclsCol = lsTable.multiValuedColumn("acls", UUID.class); + ColumnSchema aclUuidCol = aclTable.column("_uuid", UUID.class); + + Map filter = new HashMap<>(); + filter.put(externalIdKey, externalIdValue); + List candidateUuids = findAclUuidsByExternalIds(client, schema, aclTable, filter); + if (candidateUuids.isEmpty()) { + return 0; + } + // Scope to ACLs actually referenced from THIS LS. The same external_ids tag (e.g. + // cloudstack_network_id=) can legitimately appear on ACLs sitting on a + // different LS — public-IP firewall ACLs live on the VPC public LS, but they tag + // the tier's network_id because the public IP is associated with that tier. + // Without this filter we would try to free-delete an ACL still referenced from + // another LS and OVSDB would refuse: "cannot delete ACL row because of N remaining + // reference(s)". + Set lsAclSet = lsAclSet(client, schema, lsTable, lsNameCol, lsAclsCol, logicalSwitchName); + List uuids = new ArrayList<>(); + for (UUID u : candidateUuids) { + if (lsAclSet.contains(u)) { + uuids.add(u); + } + } + if (uuids.isEmpty()) { + return 0; + } + // Operation order matters: detach the ACL UUID from the LS.acls set first, then + // delete the row. The reverse order trips OVSDB's strong-ref guard with + // "referential integrity violation: cannot delete ACL row because of N remaining + // reference(s)". Bundle every detach into a single mutate per LS to keep the + // transaction tight. + List ops = new ArrayList<>(); + ops.add(OVSDB_OPS.mutate(lsTable) + .addMutation(lsAclsCol, Mutator.DELETE, new java.util.HashSet<>(uuids)) + .where(lsNameCol.opEqual(logicalSwitchName)).build()); + for (UUID u : uuids) { + ops.add(OVSDB_OPS.delete(aclTable).where(aclUuidCol.opEqual(u)).build()); + } + List results = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(results, String.format("remove ACLs on %s by %s=%s", logicalSwitchName, externalIdKey, externalIdValue)); + logger.info("Removed {} OVN ACL row(s) on Logical_Switch [{}] tagged [{}={}]", + uuids.size(), logicalSwitchName, externalIdKey, externalIdValue); + return uuids.size(); + }); + } + + /** + * Reads {@code Logical_Switch.acls} as a Set of UUIDs. Empty when the LS does not exist. + */ + @SuppressWarnings("unchecked") + private Set lsAclSet(OvsdbClient client, DatabaseSchema schema, + GenericTableSchema lsTable, + ColumnSchema lsNameCol, + ColumnSchema> lsAclsCol, + String logicalSwitchName) throws Exception { + Operation sel = OVSDB_OPS.select(lsTable).column(lsAclsCol) + .where(lsNameCol.opEqual(logicalSwitchName)).build(); + List r = client.transact(schema, Collections.singletonList(sel)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (r == null || r.isEmpty() || r.get(0).getRows() == null || r.get(0).getRows().isEmpty()) { + return Collections.emptySet(); + } + Object data = r.get(0).getRows().get(0).getColumn(lsAclsCol).getData(); + return data instanceof Set ? (Set) data : Collections.emptySet(); + } + + /** + * Returns the UUIDs of every ACL row whose {@code external_ids} map contains every entry + * present in {@code wantedExternalIds}. We pull the column server-side and filter in the + * client because OVSDB select with where-clause cannot match into a map column. + */ + @SuppressWarnings("unchecked") + private List findAclUuidsByExternalIds(OvsdbClient client, DatabaseSchema schema, + GenericTableSchema aclTable, + Map wantedExternalIds) throws Exception { + ColumnSchema uuidCol = aclTable.column("_uuid", UUID.class); + @SuppressWarnings("rawtypes") + ColumnSchema extIdsCol = aclTable.column("external_ids", Map.class); + Operation selectAll = OVSDB_OPS.select(aclTable).column(uuidCol).column(extIdsCol); + List results = client.transact(schema, Collections.singletonList(selectAll)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + List matches = new ArrayList<>(); + if (results == null || results.isEmpty() || results.get(0).getRows() == null) { + return matches; + } + for (Row row : results.get(0).getRows()) { + Map ext = (Map) row.getColumn(extIdsCol).getData(); + if (ext == null) continue; + boolean ok = true; + for (Map.Entry e : wantedExternalIds.entrySet()) { + if (!e.getValue().equals(ext.get(e.getKey()))) { + ok = false; + break; + } + } + if (ok) { + matches.add(row.getColumn(uuidCol).getData()); + } + } + return matches; + } + + private boolean natRuleExists(OvsdbClient client, DatabaseSchema schema, GenericTableSchema natTable, + String natType, String externalIp, String logicalIp) throws Exception { + ColumnSchema natTypeCol = natTable.column("type", String.class); + ColumnSchema natExtCol = natTable.column("external_ip", String.class); + ColumnSchema natLogCol = natTable.column("logical_ip", String.class); + Operation select = OVSDB_OPS.select(natTable).column(natTypeCol) + .where(natTypeCol.opEqual(natType)) + .and(natExtCol.opEqual(externalIp)) + .and(natLogCol.opEqual(logicalIp)).build(); + List results = client.transact(schema, Collections.singletonList(select)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (results == null || results.isEmpty()) return false; + OperationResult r = results.get(0); + if (r.getError() != null) { + throw new CloudRuntimeException("OVSDB select on NAT failed: " + r.getError()); + } + List> rows = r.getRows(); + return rows != null && !rows.isEmpty(); + } + + private boolean rowExistsByName(OvsdbClient client, DatabaseSchema schema, + GenericTableSchema table, ColumnSchema nameCol, + String name) throws Exception { + Operation select = OVSDB_OPS.select(table).column(nameCol).where(nameCol.opEqual(name)).build(); + List results = client.transact(schema, Collections.singletonList(select)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (results == null || results.isEmpty()) return false; + OperationResult r = results.get(0); + if (r.getError() != null) { + throw new CloudRuntimeException("OVSDB select failed: " + r.getError()); + } + List> rows = r.getRows(); + return rows != null && !rows.isEmpty(); + } + + private boolean logicalSwitchExists(OvsdbClient client, DatabaseSchema schema, + GenericTableSchema ls, ColumnSchema nameCol, + String name) throws Exception { + Operation select = OVSDB_OPS.select(ls) + .column(nameCol) + .where(nameCol.opEqual(name)).build(); + List results = client.transact(schema, Collections.singletonList(select)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (results == null || results.isEmpty()) { + return false; + } + OperationResult r = results.get(0); + if (r.getError() != null) { + throw new CloudRuntimeException("OVSDB select failed for Logical_Switch " + name + ": " + r.getError()); + } + List> rows = r.getRows(); + return rows != null && !rows.isEmpty(); + } + + private static void assertNoError(List results, String description) { + if (results == null) { + throw new CloudRuntimeException("OVSDB transact returned no result for " + description); + } + List errors = new ArrayList<>(); + for (OperationResult r : results) { + if (r != null && r.getError() != null) { + errors.add(r.getError() + ": " + r.getDetails()); + } + } + if (!errors.isEmpty()) { + throw new CloudRuntimeException(String.format("OVSDB %s failed: %s", description, String.join("; ", errors))); + } + } + + // ── OVN-IC (Interconnection) primitives ────────────────────────────────── + // + // These talk to either the per-AZ NB (NB_Global, Chassis) or the global IC NB + // (Transit_Switch). They are used by OvnElement to provision cross-zone VPC + // peering on top of OVN's Interconnection feature instead of per-zone local + // peering switches. See https://docs.ovn.org/en/latest/tutorials/ovn-interconnection.html + // for the protocol. + + /** + * Sets {@code NB_Global.name} on a per-AZ Northbound DB. The NB_Global table is a + * singleton, so the row is identified by absence of WHERE — we set on the only row. + * No-op if the name is already set to the desired value. Required before {@code ovn-ic} + * registers the AZ in the IC SB Availability_Zone table. + */ + public void setNbGlobalAvailabilityZoneName(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, String azName) { + if (StringUtils.isBlank(azName)) { + throw new CloudRuntimeException("Availability zone name is blank"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema nbGlobal = schema.table(NB_GLOBAL_TABLE, GenericTableSchema.class); + ColumnSchema nameCol = nbGlobal.column("name", String.class); + ColumnSchema uuidCol = nbGlobal.column("_uuid", UUID.class); + + // Singleton table - read the only row's _uuid + name in one shot. + Operation sel = OVSDB_OPS.select(nbGlobal).column(uuidCol).column(nameCol); + List selRes = client.transact(schema, Collections.singletonList(sel)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (selRes == null || selRes.isEmpty() || selRes.get(0).getRows() == null + || selRes.get(0).getRows().isEmpty()) { + throw new CloudRuntimeException("NB_Global has no row at " + nbConnection); + } + Row row = selRes.get(0).getRows().get(0); + String existing = row.getColumn(nameCol).getData(); + if (azName.equals(existing)) { + logger.debug("NB_Global.name already [{}] at {} - skipping", azName, nbConnection); + return null; + } + UUID rowUuid = row.getColumn(uuidCol).getData(); + Operation update = OVSDB_OPS.update(nbGlobal) + .set(nameCol, azName) + .where(uuidCol.opEqual(rowUuid)).build(); + List r = client.transact(schema, Collections.singletonList(update)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(r, "set NB_Global.name=" + azName); + logger.info("Set NB_Global.name=[{}] at {}", azName, nbConnection); + return null; + }); + } + + /** + * Merges entries into {@code NB_Global.options} on a per-AZ NB. Does not remove keys + * that are absent in the supplied map. No-op if the merged map equals the existing one. + * Used to enable {@code ic-route-adv} / {@code ic-route-learn} / {@code ic-route-blacklist}. + */ + public void setNbGlobalIcOptions(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, Map optionsToSet) { + if (optionsToSet == null || optionsToSet.isEmpty()) { + return; + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema nbGlobal = schema.table(NB_GLOBAL_TABLE, GenericTableSchema.class); + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema optsCol = nbGlobal.column("options", Map.class); + ColumnSchema uuidCol = nbGlobal.column("_uuid", UUID.class); + + Operation sel = OVSDB_OPS.select(nbGlobal).column(uuidCol).column(optsCol); + List selRes = client.transact(schema, Collections.singletonList(sel)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (selRes == null || selRes.isEmpty() || selRes.get(0).getRows() == null + || selRes.get(0).getRows().isEmpty()) { + throw new CloudRuntimeException("NB_Global has no row at " + nbConnection); + } + Row row = selRes.get(0).getRows().get(0); + UUID rowUuid = row.getColumn(uuidCol).getData(); + @SuppressWarnings("unchecked") + Map existing = (Map) row.getColumn(optsCol).getData(); + Map merged = new HashMap<>(); + if (existing != null) merged.putAll(existing); + merged.putAll(optionsToSet); + if (existing != null && existing.equals(merged)) { + logger.debug("NB_Global.options already at desired state at {} - skipping", nbConnection); + return null; + } + Operation update = OVSDB_OPS.update(nbGlobal) + .set(optsCol, merged) + .where(uuidCol.opEqual(rowUuid)).build(); + List r = client.transact(schema, Collections.singletonList(update)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(r, "merge NB_Global.options"); + logger.info("Merged NB_Global.options {} at {}", optionsToSet, nbConnection); + return null; + }); + } + + /** + * Idempotently creates a Transit_Switch in the IC NB DB. The {@code icNbConnection} + * must point at the central OVN-IC NB (typically port 6645). The TS is propagated by + * the {@code ovn-ic} daemon to every AZ NB as a Logical_Switch with type=remote ports + * for cross-AZ LRPs. + */ + public void createTransitSwitch(String icNbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, String tsName, Map externalIds) { + if (StringUtils.isBlank(tsName)) { + throw new CloudRuntimeException("Transit_Switch name is blank"); + } + runOnDb(icNbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, IC_NORTHBOUND_DB, client -> { + DatabaseSchema schema = client.getSchema(IC_NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema ts = schema.table(TRANSIT_SWITCH_TABLE, GenericTableSchema.class); + ColumnSchema nameCol = ts.column("name", String.class); + + Operation sel = OVSDB_OPS.select(ts).column(nameCol).where(nameCol.opEqual(tsName)).build(); + List selRes = client.transact(schema, Collections.singletonList(sel)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (selRes != null && !selRes.isEmpty() && selRes.get(0).getRows() != null + && !selRes.get(0).getRows().isEmpty()) { + logger.debug("Transit_Switch [{}] already exists at {} - skipping", tsName, icNbConnection); + return null; + } + Insert insert = OVSDB_OPS.insert(ts).value(nameCol, tsName); + if (externalIds != null && !externalIds.isEmpty()) { + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema extCol = ts.column("external_ids", Map.class); + insert = insert.value(extCol, new HashMap<>(externalIds)); + } + List r = client.transact(schema, Collections.singletonList(insert)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(r, "create Transit_Switch " + tsName); + logger.info("Created OVN Transit_Switch [{}] at IC NB {}", tsName, icNbConnection); + return null; + }); + } + + /** + * Idempotently deletes a Transit_Switch from the IC NB DB. Should be called when the + * last peering group member is removed; ovn-ic will then propagate the removal to all + * AZ NBs and tear down remote ports. + */ + public void deleteTransitSwitch(String icNbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, String tsName) { + if (StringUtils.isBlank(tsName)) { + throw new CloudRuntimeException("Transit_Switch name is blank"); + } + runOnDb(icNbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, IC_NORTHBOUND_DB, client -> { + DatabaseSchema schema = client.getSchema(IC_NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema ts = schema.table(TRANSIT_SWITCH_TABLE, GenericTableSchema.class); + ColumnSchema nameCol = ts.column("name", String.class); + Operation del = OVSDB_OPS.delete(ts).where(nameCol.opEqual(tsName)).build(); + List r = client.transact(schema, Collections.singletonList(del)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(r, "delete Transit_Switch " + tsName); + logger.info("Deleted OVN Transit_Switch [{}] at IC NB {}", tsName, icNbConnection); + return null; + }); + } + + /** + * Returns the system-id (Chassis.name) of the Chassis row whose hostname matches. + * Querying SB by hostname is the only reliable way to obtain the system-id we then + * pass to {@link #setLrpGatewayChassis} - the row {@code _uuid} is NOT what + * gateway_chassis.chassis_name expects (we hit this bug on the first manual lab run). + * Returns null if no chassis matches. + */ + public String lookupChassisSystemIdByHostname(String sbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, String hostname) { + if (StringUtils.isBlank(hostname)) { + return null; + } + return runOnDb(sbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, SOUTHBOUND_DB, client -> { + DatabaseSchema schema = client.getSchema(SOUTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema chassisTable = schema.table(CHASSIS_TABLE, GenericTableSchema.class); + ColumnSchema nameCol = chassisTable.column("name", String.class); + ColumnSchema hostCol = chassisTable.column("hostname", String.class); + Operation sel = OVSDB_OPS.select(chassisTable).column(nameCol) + .where(hostCol.opEqual(hostname)).build(); + List r = client.transact(schema, Collections.singletonList(sel)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (r == null || r.isEmpty() || r.get(0).getRows() == null || r.get(0).getRows().isEmpty()) { + return null; + } + return r.get(0).getRows().get(0).getColumn(nameCol).getData(); + }); + } + + /** + * Returns Chassis.name (system-id) for every Chassis in the SB whose + * {@code other_config:is-interconn} is "true". Used to pick HA gateway chassis for + * a TS-facing LRP. Order is unspecified; the caller assigns priorities. + */ + public List listInterconnectionChassisSystemIds(String sbConnection, String caCertPath, + String clientCertPath, String clientPrivateKeyPath) { + return runOnDb(sbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, SOUTHBOUND_DB, client -> { + DatabaseSchema schema = client.getSchema(SOUTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema chassisTable = schema.table(CHASSIS_TABLE, GenericTableSchema.class); + ColumnSchema nameCol = chassisTable.column("name", String.class); + @SuppressWarnings({"rawtypes", "unchecked"}) + ColumnSchema ocCol = chassisTable.column("other_config", Map.class); + Operation sel = OVSDB_OPS.select(chassisTable).column(nameCol).column(ocCol); + List r = client.transact(schema, Collections.singletonList(sel)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + List result = new ArrayList<>(); + if (r == null || r.isEmpty() || r.get(0).getRows() == null) return result; + for (Row row : r.get(0).getRows()) { + @SuppressWarnings("unchecked") + Map oc = (Map) row.getColumn(ocCol).getData(); + if (oc == null) continue; + // Skip chassis propagated from other AZs by ovn-ic - they have + // is-remote=true. Local IC-eligible chassis carry is-interconn=true. + if ("true".equalsIgnoreCase(oc.get("is-remote"))) continue; + if ("true".equalsIgnoreCase(oc.get("is-interconn"))) { + result.add(row.getColumn(nameCol).getData()); + } + } + // Deterministic order so every LRP in the same AZ ends up with the same + // primary gateway-chassis. Two LRPs anchored on different primaries break + // intra-AZ peering: the OVN logical flow lr_in_admission carries an + // is_chassis_resident("cr-lrp") guard, so a packet hopping LRP A → LRP B in + // the same TS must traverse a chassis where both cr-lrps are resident. + java.util.Collections.sort(result); + return result; + }); + } + + /** + * Idempotently attaches a Logical_Router to a Transit_Switch in this AZ's NB. The TS LS + * itself is propagated by ovn-ic from the IC NB; we only add the local-side LRP+LSP + * (the LSP must be type=router, addresses=[router], options:router-port=) and pin + * gateway-chassis HA. {@code gatewayChassisSystemIds} must contain Chassis.name values + * (system-ids) - NOT row UUIDs - in priority order (highest first). + */ + public void attachRouterToTransitSwitch(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String routerName, String tsLsName, + String lrpName, String lspName, + String lrpMac, String lrpIpCidr, + List gatewayChassisSystemIds) { + if (StringUtils.isBlank(routerName) || StringUtils.isBlank(tsLsName) + || StringUtils.isBlank(lrpName) || StringUtils.isBlank(lspName)) { + throw new CloudRuntimeException("attachRouterToTransitSwitch arguments are incomplete"); + } + if (StringUtils.isBlank(lrpMac) || StringUtils.isBlank(lrpIpCidr)) { + throw new CloudRuntimeException("LRP mac/ip required for TS attachment"); + } + // Reuse the regular LR↔LS attach helper - the propagated TS appears as a regular LS + // in this AZ NB (with type=remote ports for the other AZs). The router-port LSP we + // create gets the standard router type/addresses, so this works. + attachRouterToSwitch(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, + routerName, tsLsName, lrpName, lrpMac, Collections.singletonList(lrpIpCidr)); + // Drift-correct: if the LRP already existed (e.g. from an older peering with a + // different link-local subnet) attachRouterToSwitch left its networks alone for + // idempotency. The TS LRP IP is what ovn-ic advertises as nexthop for connected + // routes; a stale value silently misroutes traffic from peer VPCs to the wrong + // member. Force the column to match what the caller passed. + setLrpNetworks(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, + lrpName, Collections.singletonList(lrpIpCidr)); + + if (gatewayChassisSystemIds != null && !gatewayChassisSystemIds.isEmpty()) { + // Drop any existing gateway_chassis rows on this LRP before re-applying. + // Without this, repeated provisioning leaves stale rows behind - especially + // mixing the ovn-nbctl format (lrp-X-) with our setLrpGatewayChassis + // format (lrp-X_) ends up with conflicting priorities for the same + // chassis and ovn-northd silently misroutes / fails to generate flows. + clearLrpGatewayChassis(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, + lrpName); + int prio = 20; + for (String sysId : gatewayChassisSystemIds) { + if (StringUtils.isBlank(sysId)) continue; + setLrpGatewayChassis(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, + lrpName, sysId, prio); + prio = Math.max(prio - 10, 1); + } + } + } + + /** + * Removes every Gateway_Chassis row referenced by the LRP. Used by + * attachRouterToTransitSwitch to ensure a clean re-application of HA priorities + * without leaving duplicates from previous runs. + */ + public void clearLrpGatewayChassis(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, String lrpName) { + if (StringUtils.isBlank(lrpName)) { + throw new CloudRuntimeException("clearLrpGatewayChassis: lrpName is blank"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lrpTable = schema.table(LOGICAL_ROUTER_PORT_TABLE, GenericTableSchema.class); + GenericTableSchema gcTable = schema.table(GATEWAY_CHASSIS_TABLE, GenericTableSchema.class); + ColumnSchema lrpNameCol = lrpTable.column("name", String.class); + ColumnSchema> lrpGcCol = lrpTable.multiValuedColumn("gateway_chassis", UUID.class); + ColumnSchema gcUuidCol = gcTable.column("_uuid", UUID.class); + + Operation selLrp = OVSDB_OPS.select(lrpTable).column(lrpGcCol) + .where(lrpNameCol.opEqual(lrpName)).build(); + List lrpResult = client.transact(schema, Collections.singletonList(selLrp)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + if (lrpResult == null || lrpResult.isEmpty() || lrpResult.get(0).getRows() == null + || lrpResult.get(0).getRows().isEmpty()) { + return null; + } + @SuppressWarnings("unchecked") + Set gcRefs = (Set) lrpResult.get(0).getRows().get(0).getColumn(lrpGcCol).getData(); + if (gcRefs == null || gcRefs.isEmpty()) { + return null; + } + List ops = new ArrayList<>(); + // Detach all from LRP first, then delete the rows. + ops.add(OVSDB_OPS.mutate(lrpTable) + .addMutation(lrpGcCol, Mutator.DELETE, new java.util.HashSet<>(gcRefs)) + .where(lrpNameCol.opEqual(lrpName)).build()); + for (UUID gcUuid : gcRefs) { + ops.add(OVSDB_OPS.delete(gcTable).where(gcUuidCol.opEqual(gcUuid)).build()); + } + List r = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(r, "clear gateway_chassis on " + lrpName); + logger.info("Cleared {} gateway_chassis row(s) from LRP [{}] at {}", gcRefs.size(), lrpName, nbConnection); + return null; + }); + } + + /** + * Removes the LRP+LSP that connect a Logical_Router to a Transit_Switch in this AZ's NB. + * Does not touch the TS itself (its lifecycle is governed by IC NB). + */ + public void detachRouterFromTransitSwitch(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, + String routerName, String tsLsName, + String lrpName, String lspName) { + // Remove LRP first (its gateway_chassis rows are GC'd by OVSDB when the LRP row goes + // away). + removeLogicalRouterPort(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, + routerName, lrpName); + // Then remove the LSP from whatever LS still references it. The TS LS in this AZ NB + // is propagated by ovn-ic and may not match {@code tsLsName} exactly (e.g., manually + // created TS used a different name). We scan every LS that has this LSP in its + // ports, drop the reference, then delete the LSP row. This avoids referential + // integrity violations when the caller doesn't know the LS name. + deleteLogicalSwitchPortAnyLs(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, lspName); + } + + /** + * Best-effort delete of a Logical_Switch_Port that scans every Logical_Switch for the + * port reference, drops it, then deletes the LSP row. Used when the caller doesn't + * know which LS owns the port, e.g. for ovn-ic-propagated Transit Switches whose name + * may not match what the caller expected. + */ + public void deleteLogicalSwitchPortAnyLs(String nbConnection, String caCertPath, String clientCertPath, + String clientPrivateKeyPath, String lspName) { + if (StringUtils.isBlank(lspName)) { + throw new CloudRuntimeException("Logical_Switch_Port name is blank"); + } + runOn(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, client -> { + DatabaseSchema schema = client.getSchema(NORTHBOUND_DB).get(timeoutMs, TimeUnit.MILLISECONDS); + GenericTableSchema lsTable = schema.table(LOGICAL_SWITCH_TABLE, GenericTableSchema.class); + GenericTableSchema lspTable = schema.table(LOGICAL_SWITCH_PORT_TABLE, GenericTableSchema.class); + ColumnSchema lsNameCol = lsTable.column("name", String.class); + ColumnSchema lspNameCol = lspTable.column("name", String.class); + ColumnSchema lsUuidCol = lsTable.column("_uuid", UUID.class); + ColumnSchema> lsPortsCol = lsTable.multiValuedColumn("ports", UUID.class); + + UUID lspUuid = findLspUuid(client, schema, lspTable, lspNameCol, lspName); + if (lspUuid == null) { + logger.debug("Logical_Switch_Port [{}] not present on {} - nothing to delete", lspName, nbConnection); + return null; + } + // Find every LS that still references this LSP + Operation selLs = OVSDB_OPS.select(lsTable) + .column(lsUuidCol).column(lsNameCol).column(lsPortsCol); + List selRes = client.transact(schema, Collections.singletonList(selLs)) + .get(timeoutMs, TimeUnit.MILLISECONDS); + List ops = new ArrayList<>(); + if (selRes != null && !selRes.isEmpty() && selRes.get(0).getRows() != null) { + for (Row row : selRes.get(0).getRows()) { + Set ports = row.getColumn(lsPortsCol).getData(); + if (ports != null && ports.contains(lspUuid)) { + String lsName = row.getColumn(lsNameCol).getData(); + ops.add(OVSDB_OPS.mutate(lsTable) + .addMutation(lsPortsCol, Mutator.DELETE, Collections.singleton(lspUuid)) + .where(lsNameCol.opEqual(lsName)).build()); + } + } + } + ops.add(OVSDB_OPS.delete(lspTable).where(lspNameCol.opEqual(lspName)).build()); + List r = client.transact(schema, ops).get(timeoutMs, TimeUnit.MILLISECONDS); + assertNoError(r, String.format("delete-anywhere Logical_Switch_Port %s", lspName)); + logger.info("Deleted Logical_Switch_Port [{}] (LS-agnostic) at {}", lspName, nbConnection); + return null; + }); + } + + @PreDestroy + public synchronized void shutdown() { + if (tcpConnectionService != null) { + try { tcpConnectionService.close(); } catch (Exception ignored) { } + tcpConnectionService = null; + } + if (bootstrapFactory != null) { + try { bootstrapFactory.close(); } catch (Exception ignored) { } + bootstrapFactory = null; + } + } + + @FunctionalInterface + private interface NbAction { + T call(OvsdbClient client) throws Exception; + } + + private T runOn(String nbConnection, String caCertPath, String clientCertPath, String clientPrivateKeyPath, + NbAction action) { + return runOnDb(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath, NORTHBOUND_DB, action); + } + + private T runOnDb(String nbConnection, String caCertPath, String clientCertPath, String clientPrivateKeyPath, + String expectedDb, NbAction action) { + Endpoint ep = parse(nbConnection); + if (ep.scheme == Scheme.UNIX) { + throw new CloudRuntimeException("Unix-socket OVN connections are not supported by the management server client; use tcp: or ssl:"); + } + + OvsdbConnectionService service = null; + OvsdbClient client = null; + boolean closeServiceWhenDone = false; + try { + InetAddress addr = InetAddress.getByName(ep.host); + if (ep.scheme == Scheme.SSL) { + ICertificateManager cm = OvnSslContext.fromPaths(caCertPath, clientCertPath, clientPrivateKeyPath).asCertificateManager(); + service = new OvsdbConnectionService(bootstrapFactory(), cm); + closeServiceWhenDone = true; + client = service.connectWithSsl(addr, ep.port, cm); + } else { + service = tcpService(); + client = service.connect(addr, ep.port); + } + if (client == null) { + throw new CloudRuntimeException(String.format("OVN %s at %s did not accept the connection", expectedDb, nbConnection)); + } + return action.call(client); + } catch (CloudRuntimeException e) { + throw e; + } catch (Exception e) { + throw new CloudRuntimeException("OVN " + expectedDb + " operation against " + nbConnection + " failed: " + e.getMessage(), e); + } finally { + if (client != null && service != null) { + try { service.disconnect(client); } catch (Exception ignored) { } + } + if (closeServiceWhenDone && service != null) { + try { service.close(); } catch (Exception ignored) { } + } + } + } + + private synchronized NettyBootstrapFactoryImpl bootstrapFactory() { + if (bootstrapFactory == null) { + bootstrapFactory = new NettyBootstrapFactoryImpl(); + } + return bootstrapFactory; + } + + private synchronized OvsdbConnectionService tcpService() { + if (tcpConnectionService == null) { + tcpConnectionService = new OvsdbConnectionService(bootstrapFactory(), NOOP_CERT_MANAGER); + } + return tcpConnectionService; + } + + static Endpoint parse(String connection) { + if (StringUtils.isBlank(connection)) { + throw new CloudRuntimeException("OVN connection string is blank"); + } + if (connection.startsWith("unix:/")) { + return new Endpoint(Scheme.UNIX, connection.substring("unix:".length()), 0); + } + Matcher m = CONN_PATTERN.matcher(connection); + if (!m.matches()) { + throw new CloudRuntimeException("Invalid OVN connection string: " + connection); + } + Scheme scheme = "ssl".equals(m.group(1)) ? Scheme.SSL : Scheme.TCP; + return new Endpoint(scheme, m.group(2), Integer.parseInt(m.group(3))); + } + + enum Scheme { TCP, SSL, UNIX } + + static final class Endpoint { + final Scheme scheme; + final String host; + final int port; + + Endpoint(Scheme scheme, String host, int port) { + this.scheme = scheme; + this.host = host; + this.port = port; + } + } + + /** + * The OvsdbConnectionService constructor requires a non-null ICertificateManager even for plain + * TCP. None of its methods are invoked along the TCP code path. + */ + private static final class NoopCertificateManager implements ICertificateManager { + @Override public KeyStore getODLKeyStore() { return null; } + @Override public KeyStore getTrustKeyStore() { return null; } + @Override public String[] getCipherSuites() { return new String[0]; } + @Override public String[] getTlsProtocols() { return new String[0]; } + @Override public String getCertificateTrustStore(String s, String d, boolean p) { return null; } + @Override public String getODLKeyStoreCertificate(String s, boolean p) { return null; } + @Override public String genODLKeyStoreCertificateReq(String s, boolean p) { return null; } + @Override public SSLContext getServerContext() { return null; } + @Override public boolean importSslDataKeystores(String a, String b, String c, String d, String e, String[] f, String g) { return false; } + @Override public void exportSslDataKeystores() { } + } +} diff --git a/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnPeeringService.java b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnPeeringService.java new file mode 100644 index 000000000000..b935957a6501 --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnPeeringService.java @@ -0,0 +1,38 @@ +// 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.service; + +import com.cloud.network.element.OvnVpcPeeringVO; +import org.apache.cloudstack.api.command.CreateVpcPeeringCmd; +import org.apache.cloudstack.api.command.DeleteVpcPeeringCmd; +import org.apache.cloudstack.api.command.DisableVpcPeeringCmd; +import org.apache.cloudstack.api.command.EnableVpcPeeringCmd; +import org.apache.cloudstack.api.command.ListVpcPeeringsCmd; +import org.apache.cloudstack.api.command.UpdateVpcPeeringCmd; +import org.apache.cloudstack.api.response.VpcPeeringResponse; + +import java.util.List; + +public interface OvnPeeringService { + OvnVpcPeeringVO createVpcPeering(CreateVpcPeeringCmd cmd); + OvnVpcPeeringVO updateVpcPeering(UpdateVpcPeeringCmd cmd); + boolean deleteVpcPeering(DeleteVpcPeeringCmd cmd); + boolean enableVpcPeering(EnableVpcPeeringCmd cmd); + boolean disableVpcPeering(DisableVpcPeeringCmd cmd); + List listVpcPeerings(ListVpcPeeringsCmd cmd); + VpcPeeringResponse createVpcPeeringResponse(OvnVpcPeeringVO peering); +} diff --git a/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnProviderService.java b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnProviderService.java new file mode 100644 index 000000000000..156a56655cb9 --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnProviderService.java @@ -0,0 +1,32 @@ +// 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.service; + +import com.cloud.network.ovn.OvnProvider; +import com.cloud.utils.component.PluggableService; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.command.AddOvnProviderCmd; +import org.apache.cloudstack.api.response.OvnProviderResponse; + +import java.util.List; + +public interface OvnProviderService extends PluggableService { + OvnProvider addProvider(AddOvnProviderCmd cmd); + List listOvnProviders(Long zoneId); + boolean deleteOvnProvider(Long providerId); + OvnProviderResponse createOvnProviderResponse(OvnProvider provider); +} diff --git a/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnProviderServiceImpl.java b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnProviderServiceImpl.java new file mode 100644 index 000000000000..7de0dfad3485 --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnProviderServiceImpl.java @@ -0,0 +1,201 @@ +// 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.service; + +import com.cloud.dc.DataCenterVO; +import com.cloud.dc.dao.DataCenterDao; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.network.Network; +import com.cloud.network.Networks; +import com.cloud.network.dao.NetworkDao; +import com.cloud.network.dao.NetworkVO; +import com.cloud.network.dao.OvnProviderDao; +import com.cloud.network.dao.PhysicalNetworkDao; +import com.cloud.network.dao.PhysicalNetworkVO; +import com.cloud.network.element.OvnProviderVO; +import com.cloud.network.ovn.OvnProvider; +import com.cloud.network.ovn.OvnService; +import com.cloud.utils.db.Transaction; +import com.cloud.utils.db.TransactionCallback; +import com.cloud.utils.exception.CloudRuntimeException; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.command.AddOvnProviderCmd; +import org.apache.cloudstack.api.command.CreateVpcPeeringCmd; +import org.apache.cloudstack.api.command.DeleteOvnProviderCmd; +import org.apache.cloudstack.api.command.DeleteVpcPeeringCmd; +import org.apache.cloudstack.api.command.DisableVpcPeeringCmd; +import org.apache.cloudstack.api.command.EnableVpcPeeringCmd; +import org.apache.cloudstack.api.command.ListOvnProvidersCmd; +import org.apache.cloudstack.api.command.ListVpcPeeringsCmd; +import org.apache.cloudstack.api.command.UpdateVpcPeeringCmd; +import org.apache.cloudstack.api.response.OvnProviderResponse; +import org.apache.cloudstack.engine.orchestration.service.NetworkOrchestrationService; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import javax.inject.Inject; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class OvnProviderServiceImpl implements OvnProviderService { + protected Logger logger = LogManager.getLogger(getClass()); + + @Inject + DataCenterDao dataCenterDao; + @Inject + OvnProviderDao ovnProviderDao; + @Inject + PhysicalNetworkDao physicalNetworkDao; + @Inject + NetworkDao networkDao; + @Inject + OvnService ovnService; + + @Override + public OvnProvider addProvider(AddOvnProviderCmd cmd) { + validateProvider(cmd); + final long zoneId = cmd.getZoneId(); + return Transaction.execute((TransactionCallback) status -> { + OvnProviderVO provider = new OvnProviderVO.Builder() + .setZoneId(zoneId) + .setName(cmd.getName()) + .setNbConnection(cmd.getNbConnection()) + .setSbConnection(cmd.getSbConnection()) + .setCaCertPath(cmd.getCaCertPath()) + .setClientCertPath(cmd.getClientCertPath()) + .setClientPrivateKeyPath(cmd.getClientPrivateKeyPath()) + .setExternalBridge(cmd.getExternalBridge()) + .setLocalnetName(cmd.getLocalnetName()) + .setIcNbConnection(cmd.getIcNbConnection()) + .setIcSbConnection(cmd.getIcSbConnection()) + .setAvailabilityZoneName(cmd.getAvailabilityZoneName()) + .build(); + return ovnProviderDao.persist(provider); + }); + } + + protected void validateProvider(AddOvnProviderCmd cmd) { + DataCenterVO zone = dataCenterDao.findById(cmd.getZoneId()); + if (zone == null) { + throw new InvalidParameterValueException(String.format("Failed to find zone with id: %s", cmd.getZoneId())); + } + if (ovnProviderDao.findByZoneId(cmd.getZoneId()) != null) { + throw new InvalidParameterValueException(String.format("OVN provider already exists for zone: %s", cmd.getZoneId())); + } + if (!ovnService.isValidConnectionString(cmd.getNbConnection())) { + throw new InvalidParameterValueException("Invalid OVN Northbound connection string"); + } + if (StringUtils.isNotBlank(cmd.getSbConnection()) && !ovnService.isValidConnectionString(cmd.getSbConnection())) { + throw new InvalidParameterValueException("Invalid OVN Southbound connection string"); + } + boolean sslRequired = cmd.getNbConnection().startsWith("ssl:") + || (StringUtils.isNotBlank(cmd.getSbConnection()) && cmd.getSbConnection().startsWith("ssl:")); + if (sslRequired && StringUtils.isAnyBlank(cmd.getCaCertPath(), cmd.getClientCertPath(), cmd.getClientPrivateKeyPath())) { + throw new InvalidParameterValueException("OVN SSL connections require CA certificate, client certificate, and client private key paths"); + } + try { + ovnService.verifyNbConnection(cmd.getNbConnection(), cmd.getCaCertPath(), cmd.getClientCertPath(), cmd.getClientPrivateKeyPath()); + } catch (CloudRuntimeException e) { + logger.warn("OVN NB health check failed for zone {}: {}", cmd.getZoneId(), e.getMessage()); + throw new InvalidParameterValueException("OVN NB endpoint is unreachable: " + e.getMessage()); + } + } + + @Override + public List listOvnProviders(Long zoneId) { + List responseList = new ArrayList<>(); + if (zoneId != null) { + OvnProviderVO provider = ovnProviderDao.findByZoneId(zoneId); + if (provider != null) { + responseList.add(createOvnProviderResponse(provider)); + } + return responseList; + } + for (OvnProviderVO provider : ovnProviderDao.listAll()) { + responseList.add(createOvnProviderResponse(provider)); + } + return responseList; + } + + @Override + public boolean deleteOvnProvider(Long providerId) { + OvnProviderVO provider = ovnProviderDao.findById(providerId); + if (provider == null) { + throw new InvalidParameterValueException(String.format("Failed to find OVN provider with id: %s", providerId)); + } + validateNetworkState(provider.getZoneId()); + ovnProviderDao.remove(providerId); + return true; + } + + protected void validateNetworkState(long zoneId) { + List physicalNetworks = physicalNetworkDao.listByZone(zoneId); + for (PhysicalNetworkVO physicalNetwork : physicalNetworks) { + for (NetworkVO network : networkDao.listByPhysicalNetwork(physicalNetwork.getId())) { + if (network.getBroadcastDomainType() == Networks.BroadcastDomainType.OVN + && network.getState() != Network.State.Shutdown + && network.getState() != Network.State.Destroy) { + throw new CloudRuntimeException("This OVN provider cannot be deleted as there are one or more logical networks provisioned by CloudStack on it."); + } + } + } + } + + @Override + public OvnProviderResponse createOvnProviderResponse(OvnProvider provider) { + DataCenterVO zone = dataCenterDao.findById(provider.getZoneId()); + if (Objects.isNull(zone)) { + throw new CloudRuntimeException(String.format("Failed to find zone with id %s", provider.getZoneId())); + } + OvnProviderResponse response = new OvnProviderResponse(); + response.setName(provider.getName()); + response.setUuid(provider.getUuid()); + response.setZoneId(zone.getUuid()); + response.setZoneName(zone.getName()); + response.setNbConnection(provider.getNbConnection()); + response.setSbConnection(provider.getSbConnection()); + response.setCaCertPath(provider.getCaCertPath()); + response.setClientCertPath(provider.getClientCertPath()); + response.setClientPrivateKeyPath(provider.getClientPrivateKeyPath()); + response.setExternalBridge(provider.getExternalBridge()); + response.setLocalnetName(provider.getLocalnetName()); + response.setIcNbConnection(provider.getIcNbConnection()); + response.setIcSbConnection(provider.getIcSbConnection()); + response.setAvailabilityZoneName(provider.getAvailabilityZoneName()); + response.setObjectName("ovnProvider"); + return response; + } + + @Override + public List> getCommands() { + List> cmdList = new ArrayList<>(); + if (Boolean.TRUE.equals(NetworkOrchestrationService.OVN_ENABLED.value())) { + cmdList.add(AddOvnProviderCmd.class); + cmdList.add(ListOvnProvidersCmd.class); + cmdList.add(DeleteOvnProviderCmd.class); + cmdList.add(CreateVpcPeeringCmd.class); + cmdList.add(UpdateVpcPeeringCmd.class); + cmdList.add(DeleteVpcPeeringCmd.class); + cmdList.add(EnableVpcPeeringCmd.class); + cmdList.add(DisableVpcPeeringCmd.class); + cmdList.add(ListVpcPeeringsCmd.class); + } + return cmdList; + } +} diff --git a/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnServiceImpl.java b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnServiceImpl.java new file mode 100644 index 000000000000..564d3990bf7a --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnServiceImpl.java @@ -0,0 +1,75 @@ +// 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.service; + +import com.cloud.network.ovn.OvnService; +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.config.Configurable; + +import javax.annotation.PreDestroy; + +public class OvnServiceImpl implements OvnService, Configurable { + private final OvnNbClient ovnNbClient; + + public OvnServiceImpl() { + this(new OvnNbClient()); + } + + OvnServiceImpl(OvnNbClient ovnNbClient) { + this.ovnNbClient = ovnNbClient; + } + + @Override + public String getLogicalSwitchName(long networkId) { + return String.format("cs-net-%d", networkId); + } + + @Override + public String getLogicalRouterName(long vpcId) { + return String.format("cs-vpc-%d", vpcId); + } + + @Override + public String getLogicalSwitchPortName(long nicId) { + return String.format("cs-nic-%d", nicId); + } + + @Override + public boolean isValidConnectionString(String connection) { + return ovnNbClient.isValidConnectionString(connection); + } + + @Override + public void verifyNbConnection(String nbConnection, String caCertPath, String clientCertPath, String clientPrivateKeyPath) { + ovnNbClient.verifyConnection(nbConnection, caCertPath, clientCertPath, clientPrivateKeyPath); + } + + @Override + public String getConfigComponentName() { + return OvnService.class.getSimpleName(); + } + + @Override + public ConfigKey[] getConfigKeys() { + return new ConfigKey[0]; + } + + @PreDestroy + public void shutdown() { + ovnNbClient.shutdown(); + } +} diff --git a/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnSslContext.java b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnSslContext.java new file mode 100644 index 000000000000..02a569cdfea8 --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnSslContext.java @@ -0,0 +1,130 @@ +// 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.service; + +import com.cloud.utils.exception.CloudRuntimeException; +import org.apache.commons.lang3.StringUtils; +import org.opendaylight.aaa.cert.api.ICertificateManager; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class OvnSslContext { + private static final char[] EMPTY_PASSWORD = new char[0]; + private static final String KEYSTORE_TYPE = "JKS"; + private static final String CLIENT_KEY_ALIAS = "ovn-client"; + private static final String CA_ALIAS = "ovn-ca"; + private static final Pattern PEM_PRIVATE_KEY = Pattern.compile( + "-----BEGIN (?:RSA |EC )?PRIVATE KEY-----(.+?)-----END (?:RSA |EC )?PRIVATE KEY-----", + Pattern.DOTALL); + + private final KeyStore keyStore; + private final KeyStore trustStore; + + OvnSslContext(KeyStore keyStore, KeyStore trustStore) { + this.keyStore = keyStore; + this.trustStore = trustStore; + } + + public static OvnSslContext fromPaths(String caCertPath, String clientCertPath, String clientPrivateKeyPath) { + if (StringUtils.isAnyBlank(caCertPath, clientCertPath, clientPrivateKeyPath)) { + throw new CloudRuntimeException("OVN SSL connection requires CA, client certificate and client private key paths"); + } + try { + KeyStore trustStore = KeyStore.getInstance(KEYSTORE_TYPE); + trustStore.load(null, EMPTY_PASSWORD); + trustStore.setCertificateEntry(CA_ALIAS, readCertificate(caCertPath)); + + KeyStore keyStore = KeyStore.getInstance(KEYSTORE_TYPE); + keyStore.load(null, EMPTY_PASSWORD); + keyStore.setKeyEntry(CLIENT_KEY_ALIAS, readPrivateKey(clientPrivateKeyPath), EMPTY_PASSWORD, + new Certificate[]{readCertificate(clientCertPath)}); + return new OvnSslContext(keyStore, trustStore); + } catch (Exception e) { + throw new CloudRuntimeException("Failed to build OVN SSL context: " + e.getMessage(), e); + } + } + + public ICertificateManager asCertificateManager() { + return new ICertificateManager() { + @Override public KeyStore getODLKeyStore() { return keyStore; } + @Override public KeyStore getTrustKeyStore() { return trustStore; } + @Override public String[] getCipherSuites() { return new String[0]; } + @Override public String[] getTlsProtocols() { return new String[]{"TLSv1.2", "TLSv1.3"}; } + @Override public String getCertificateTrustStore(String s, String d, boolean p) { return null; } + @Override public String getODLKeyStoreCertificate(String s, boolean p) { return null; } + @Override public String genODLKeyStoreCertificateReq(String s, boolean p) { return null; } + @Override public SSLContext getServerContext() { return buildContext(); } + @Override public boolean importSslDataKeystores(String a, String b, String c, String d, String e, String[] f, String g) { return false; } + @Override public void exportSslDataKeystores() { } + }; + } + + private SSLContext buildContext() { + try { + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(keyStore, EMPTY_PASSWORD); + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null); + return ctx; + } catch (Exception e) { + throw new CloudRuntimeException("Failed to initialize OVN SSL context: " + e.getMessage(), e); + } + } + + private static X509Certificate readCertificate(String path) throws IOException { + try (InputStream in = Files.newInputStream(Path.of(path))) { + return (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(in); + } catch (java.security.cert.CertificateException e) { + throw new IOException("Cannot parse certificate at " + path + ": " + e.getMessage(), e); + } + } + + private static PrivateKey readPrivateKey(String path) throws IOException { + String pem = Files.readString(Path.of(path)); + Matcher m = PEM_PRIVATE_KEY.matcher(pem); + if (!m.find()) { + throw new IOException("No PRIVATE KEY block found at " + path); + } + byte[] der = Base64.getMimeDecoder().decode(m.group(1)); + try { + return java.security.KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(der)); + } catch (Exception eRsa) { + try { + return java.security.KeyFactory.getInstance("EC").generatePrivate(new PKCS8EncodedKeySpec(der)); + } catch (Exception eEc) { + throw new IOException("Cannot parse private key at " + path + " as RSA or EC: " + eEc.getMessage(), eEc); + } + } + } +} diff --git a/plugins/network-elements/ovn/src/main/resources/META-INF/cloudstack/core/spring-ovn-core-managers-context.xml b/plugins/network-elements/ovn/src/main/resources/META-INF/cloudstack/core/spring-ovn-core-managers-context.xml new file mode 100644 index 000000000000..7132803bce82 --- /dev/null +++ b/plugins/network-elements/ovn/src/main/resources/META-INF/cloudstack/core/spring-ovn-core-managers-context.xml @@ -0,0 +1,31 @@ + + + + + + diff --git a/plugins/network-elements/ovn/src/main/resources/META-INF/cloudstack/ovn/module.properties b/plugins/network-elements/ovn/src/main/resources/META-INF/cloudstack/ovn/module.properties new file mode 100644 index 000000000000..4469dbc3a7c9 --- /dev/null +++ b/plugins/network-elements/ovn/src/main/resources/META-INF/cloudstack/ovn/module.properties @@ -0,0 +1,21 @@ +# +# 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. +# + +name=ovn +parent=network diff --git a/plugins/network-elements/ovn/src/main/resources/META-INF/cloudstack/ovn/spring-ovn-context.xml b/plugins/network-elements/ovn/src/main/resources/META-INF/cloudstack/ovn/spring-ovn-context.xml new file mode 100644 index 000000000000..c1bd678db588 --- /dev/null +++ b/plugins/network-elements/ovn/src/main/resources/META-INF/cloudstack/ovn/spring-ovn-context.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + diff --git a/plugins/network-elements/ovn/src/test/java/org/apache/cloudstack/api/command/AddOvnProviderCmdTest.java b/plugins/network-elements/ovn/src/test/java/org/apache/cloudstack/api/command/AddOvnProviderCmdTest.java new file mode 100644 index 000000000000..cda5a7e0677c --- /dev/null +++ b/plugins/network-elements/ovn/src/test/java/org/apache/cloudstack/api/command/AddOvnProviderCmdTest.java @@ -0,0 +1,93 @@ +// 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; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.network.ovn.OvnProvider; +import com.cloud.user.Account; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.OvnProviderResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.service.OvnProviderService; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import java.lang.reflect.Field; + +public class AddOvnProviderCmdTest { + private OvnProviderService ovnProviderService; + private CallContext callContext; + private MockedStatic callContextMockedStatic; + private AddOvnProviderCmd cmd; + + @Before + public void setup() throws Exception { + ovnProviderService = Mockito.mock(OvnProviderService.class); + callContext = Mockito.mock(CallContext.class); + callContextMockedStatic = Mockito.mockStatic(CallContext.class); + callContextMockedStatic.when(CallContext::current).thenReturn(callContext); + + cmd = new AddOvnProviderCmd(); + Field svc = AddOvnProviderCmd.class.getDeclaredField("ovnProviderService"); + svc.setAccessible(true); + svc.set(cmd, ovnProviderService); + } + + @After + public void tearDown() throws Exception { + if (callContextMockedStatic != null) { + callContextMockedStatic.close(); + } + } + + @Test + public void testExecuteSuccess() throws ConcurrentOperationException { + OvnProvider provider = Mockito.mock(OvnProvider.class); + OvnProviderResponse response = Mockito.mock(OvnProviderResponse.class); + Mockito.when(ovnProviderService.addProvider(cmd)).thenReturn(provider); + Mockito.when(ovnProviderService.createOvnProviderResponse(provider)).thenReturn(response); + + cmd.execute(); + + Mockito.verify(ovnProviderService).addProvider(cmd); + Mockito.verify(ovnProviderService).createOvnProviderResponse(provider); + Mockito.verify(response).setResponseName(cmd.getCommandName()); + Assert.assertEquals(response, cmd.getResponseObject()); + } + + @Test(expected = ServerApiException.class) + public void testExecuteFailure() throws ConcurrentOperationException { + OvnProvider provider = Mockito.mock(OvnProvider.class); + Mockito.when(ovnProviderService.addProvider(cmd)).thenReturn(provider); + Mockito.when(ovnProviderService.createOvnProviderResponse(provider)).thenReturn(null); + + cmd.execute(); + } + + @Test + public void testGetEntityOwnerId() { + Account account = Mockito.mock(Account.class); + Mockito.when(account.getId()).thenReturn(123L); + Mockito.when(callContext.getCallingAccount()).thenReturn(account); + + Assert.assertEquals(123L, cmd.getEntityOwnerId()); + } +} diff --git a/plugins/network-elements/ovn/src/test/java/org/apache/cloudstack/api/command/DeleteOvnProviderCmdTest.java b/plugins/network-elements/ovn/src/test/java/org/apache/cloudstack/api/command/DeleteOvnProviderCmdTest.java new file mode 100644 index 000000000000..c497c8e1b41f --- /dev/null +++ b/plugins/network-elements/ovn/src/test/java/org/apache/cloudstack/api/command/DeleteOvnProviderCmdTest.java @@ -0,0 +1,97 @@ +// 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; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.utils.exception.CloudRuntimeException; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.service.OvnProviderService; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import java.lang.reflect.Field; + +public class DeleteOvnProviderCmdTest { + @Mock + private OvnProviderService ovnProviderService; + + @InjectMocks + private DeleteOvnProviderCmd cmd; + + private AutoCloseable closeable; + private static final long PROVIDER_ID = 1L; + + @Before + public void setup() throws Exception { + closeable = MockitoAnnotations.openMocks(this); + setPrivateField("id", PROVIDER_ID); + } + + @After + public void tearDown() throws Exception { + closeable.close(); + } + + @Test + public void testExecuteSuccess() throws ConcurrentOperationException { + Mockito.when(ovnProviderService.deleteOvnProvider(PROVIDER_ID)).thenReturn(true); + + cmd.execute(); + + Mockito.verify(ovnProviderService).deleteOvnProvider(PROVIDER_ID); + Assert.assertTrue(cmd.getResponseObject() instanceof SuccessResponse); + SuccessResponse response = (SuccessResponse) cmd.getResponseObject(); + Assert.assertEquals(cmd.getCommandName(), response.getResponseName()); + } + + @Test(expected = ServerApiException.class) + public void testExecuteFailure() throws ConcurrentOperationException { + Mockito.when(ovnProviderService.deleteOvnProvider(PROVIDER_ID)).thenReturn(false); + cmd.execute(); + } + + @Test(expected = ServerApiException.class) + public void testExecuteInvalidParameterException() throws ConcurrentOperationException { + Mockito.when(ovnProviderService.deleteOvnProvider(PROVIDER_ID)).thenThrow(new InvalidParameterValueException("invalid")); + cmd.execute(); + } + + @Test(expected = ServerApiException.class) + public void testExecuteCloudRuntimeException() throws ConcurrentOperationException { + Mockito.when(ovnProviderService.deleteOvnProvider(PROVIDER_ID)).thenThrow(new CloudRuntimeException("runtime")); + cmd.execute(); + } + + @Test + public void testGetEntityOwnerId() { + Assert.assertEquals(0L, cmd.getEntityOwnerId()); + } + + private void setPrivateField(String fieldName, Object value) throws Exception { + Field field = DeleteOvnProviderCmd.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(cmd, value); + } +} diff --git a/plugins/network-elements/ovn/src/test/java/org/apache/cloudstack/api/command/ListOvnProvidersCmdTest.java b/plugins/network-elements/ovn/src/test/java/org/apache/cloudstack/api/command/ListOvnProvidersCmdTest.java new file mode 100644 index 000000000000..f1294c78fd33 --- /dev/null +++ b/plugins/network-elements/ovn/src/test/java/org/apache/cloudstack/api/command/ListOvnProvidersCmdTest.java @@ -0,0 +1,82 @@ +// 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; + +import com.cloud.exception.ConcurrentOperationException; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.OvnProviderResponse; +import org.apache.cloudstack.service.OvnProviderService; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.List; + +public class ListOvnProvidersCmdTest { + @Mock + private OvnProviderService ovnProviderService; + + @InjectMocks + private ListOvnProvidersCmd cmd; + + private AutoCloseable closeable; + private static final long ZONE_ID = 1L; + + @Before + public void setup() throws Exception { + closeable = MockitoAnnotations.openMocks(this); + setPrivateField("zoneId", ZONE_ID); + } + + @After + public void tearDown() throws Exception { + closeable.close(); + } + + @Test + public void testExecuteSuccess() throws ConcurrentOperationException { + OvnProviderResponse providerResponse = Mockito.mock(OvnProviderResponse.class); + List providerList = Arrays.asList(providerResponse); + Mockito.when(ovnProviderService.listOvnProviders(ZONE_ID)).thenReturn(providerList); + + cmd.execute(); + + Mockito.verify(ovnProviderService).listOvnProviders(ZONE_ID); + Assert.assertTrue(cmd.getResponseObject() instanceof ListResponse); + ListResponse response = (ListResponse) cmd.getResponseObject(); + Assert.assertEquals(cmd.getCommandName(), response.getResponseName()); + } + + @Test + public void testGetEntityOwnerId() { + Assert.assertEquals(0L, cmd.getEntityOwnerId()); + } + + private void setPrivateField(String fieldName, Object value) throws Exception { + Field field = ListOvnProvidersCmd.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(cmd, value); + } +} diff --git a/plugins/network-elements/ovn/src/test/java/org/apache/cloudstack/service/OvnElementTest.java b/plugins/network-elements/ovn/src/test/java/org/apache/cloudstack/service/OvnElementTest.java new file mode 100644 index 000000000000..8007c209c228 --- /dev/null +++ b/plugins/network-elements/ovn/src/test/java/org/apache/cloudstack/service/OvnElementTest.java @@ -0,0 +1,47 @@ +// 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.service; + +import com.cloud.network.Network; +import org.junit.Assert; +import org.junit.Test; + +import java.util.Map; + +public class OvnElementTest { + @Test + public void testGetProvider() { + Assert.assertEquals(Network.Provider.Ovn, new OvnElement().getProvider()); + } + + @Test + public void testCapabilitiesIncludeInitialOvnServices() { + Map> capabilities = new OvnElement().getCapabilities(); + + Assert.assertTrue(capabilities.containsKey(Network.Service.Dhcp)); + Assert.assertTrue(capabilities.containsKey(Network.Service.Dns)); + Assert.assertTrue(capabilities.containsKey(Network.Service.SourceNat)); + Assert.assertTrue(capabilities.containsKey(Network.Service.StaticNat)); + Assert.assertTrue(capabilities.containsKey(Network.Service.PortForwarding)); + Assert.assertTrue(capabilities.containsKey(Network.Service.Firewall)); + Assert.assertTrue(capabilities.containsKey(Network.Service.NetworkACL)); + Assert.assertTrue(capabilities.containsKey(Network.Service.Lb)); + Assert.assertTrue(capabilities.containsKey(Network.Service.Gateway)); + Assert.assertTrue(capabilities.containsKey(Network.Service.Connectivity)); + Assert.assertFalse(capabilities.containsKey(Network.Service.SecurityGroup)); + } +} diff --git a/plugins/network-elements/ovn/src/test/java/org/apache/cloudstack/service/OvnNbClientTest.java b/plugins/network-elements/ovn/src/test/java/org/apache/cloudstack/service/OvnNbClientTest.java new file mode 100644 index 000000000000..23949ec9a9ea --- /dev/null +++ b/plugins/network-elements/ovn/src/test/java/org/apache/cloudstack/service/OvnNbClientTest.java @@ -0,0 +1,69 @@ +// 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.service; + +import com.cloud.utils.exception.CloudRuntimeException; +import org.junit.Assert; +import org.junit.Test; + +public class OvnNbClientTest { + private final OvnNbClient client = new OvnNbClient(); + + @Test + public void testIsValidConnectionString() { + Assert.assertTrue(client.isValidConnectionString("tcp:127.0.0.1:6641")); + Assert.assertTrue(client.isValidConnectionString("ssl:ovn.example.com:6641")); + Assert.assertTrue(client.isValidConnectionString("unix:/var/run/ovn/ovnnb_db.sock")); + Assert.assertFalse(client.isValidConnectionString("http://1.2.3.4:6641")); + Assert.assertFalse(client.isValidConnectionString("tcp:1.2.3.4")); + Assert.assertFalse(client.isValidConnectionString("")); + Assert.assertFalse(client.isValidConnectionString(null)); + } + + @Test + public void testParseTcpEndpoint() { + OvnNbClient.Endpoint ep = OvnNbClient.parse("tcp:10.0.34.51:6641"); + Assert.assertEquals(OvnNbClient.Scheme.TCP, ep.scheme); + Assert.assertEquals("10.0.34.51", ep.host); + Assert.assertEquals(6641, ep.port); + } + + @Test + public void testParseSslEndpoint() { + OvnNbClient.Endpoint ep = OvnNbClient.parse("ssl:nb.example.com:6641"); + Assert.assertEquals(OvnNbClient.Scheme.SSL, ep.scheme); + Assert.assertEquals("nb.example.com", ep.host); + Assert.assertEquals(6641, ep.port); + } + + @Test + public void testParseUnixEndpoint() { + OvnNbClient.Endpoint ep = OvnNbClient.parse("unix:/var/run/ovn/ovnnb_db.sock"); + Assert.assertEquals(OvnNbClient.Scheme.UNIX, ep.scheme); + Assert.assertEquals("/var/run/ovn/ovnnb_db.sock", ep.host); + } + + @Test(expected = CloudRuntimeException.class) + public void testParseInvalidThrows() { + OvnNbClient.parse("not-a-connection-string"); + } + + @Test(expected = CloudRuntimeException.class) + public void testVerifyConnectionRejectsUnix() { + client.verifyConnection("unix:/var/run/ovn/ovnnb_db.sock", null, null, null); + } +} diff --git a/plugins/network-elements/ovn/src/test/java/org/apache/cloudstack/service/OvnProviderServiceImplTest.java b/plugins/network-elements/ovn/src/test/java/org/apache/cloudstack/service/OvnProviderServiceImplTest.java new file mode 100644 index 000000000000..a912fd659aad --- /dev/null +++ b/plugins/network-elements/ovn/src/test/java/org/apache/cloudstack/service/OvnProviderServiceImplTest.java @@ -0,0 +1,202 @@ +// 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.service; + +import com.cloud.dc.DataCenterVO; +import com.cloud.dc.dao.DataCenterDao; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.network.Network; +import com.cloud.network.Networks; +import com.cloud.network.dao.NetworkDao; +import com.cloud.network.dao.NetworkVO; +import com.cloud.network.dao.OvnProviderDao; +import com.cloud.network.dao.PhysicalNetworkDao; +import com.cloud.network.dao.PhysicalNetworkVO; +import com.cloud.network.element.OvnProviderVO; +import com.cloud.network.ovn.OvnService; +import com.cloud.utils.db.Transaction; +import com.cloud.utils.db.TransactionCallback; +import com.cloud.utils.exception.CloudRuntimeException; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.command.AddOvnProviderCmd; +import org.apache.cloudstack.api.response.OvnProviderResponse; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.List; + +public class OvnProviderServiceImplTest { + @Mock + private DataCenterDao dataCenterDao; + @Mock + private OvnProviderDao ovnProviderDao; + @Mock + private PhysicalNetworkDao physicalNetworkDao; + @Mock + private NetworkDao networkDao; + @Mock + private OvnService ovnService; + + @InjectMocks + private OvnProviderServiceImpl ovnProviderService; + + private AutoCloseable closeable; + private MockedStatic transactionMockedStatic; + + private static final long ZONE_ID = 1L; + private static final long PROVIDER_ID = 3L; + private static final String NAME = "test-ovn"; + private static final String NB_CONNECTION = "tcp:127.0.0.1:6641"; + private static final String SB_CONNECTION = "tcp:127.0.0.1:6642"; + + @Before + public void setup() { + closeable = MockitoAnnotations.openMocks(this); + transactionMockedStatic = Mockito.mockStatic(Transaction.class); + } + + @After + public void tearDown() throws Exception { + transactionMockedStatic.close(); + closeable.close(); + } + + @Test + public void testAddProviderPersistsProvider() throws Exception { + AddOvnProviderCmd cmd = new AddOvnProviderCmd(); + setPrivateField(cmd, "zoneId", ZONE_ID); + setPrivateField(cmd, "name", NAME); + setPrivateField(cmd, "nbConnection", NB_CONNECTION); + setPrivateField(cmd, "sbConnection", SB_CONNECTION); + + Mockito.when(dataCenterDao.findById(ZONE_ID)).thenReturn(Mockito.mock(DataCenterVO.class)); + Mockito.when(ovnProviderDao.findByZoneId(ZONE_ID)).thenReturn(null); + Mockito.when(ovnService.isValidConnectionString(NB_CONNECTION)).thenReturn(true); + Mockito.when(ovnService.isValidConnectionString(SB_CONNECTION)).thenReturn(true); + Mockito.doNothing().when(ovnService).verifyNbConnection(Mockito.eq(NB_CONNECTION), Mockito.any(), Mockito.any(), Mockito.any()); + Mockito.when(ovnProviderDao.persist(Mockito.any(OvnProviderVO.class))).thenAnswer(invocation -> invocation.getArgument(0)); + transactionMockedStatic.when(() -> Transaction.execute(Mockito.>any())).thenAnswer(invocation -> { + TransactionCallback callback = invocation.getArgument(0); + return callback.doInTransaction(null); + }); + + OvnProviderVO provider = (OvnProviderVO) ovnProviderService.addProvider(cmd); + + Assert.assertEquals(ZONE_ID, provider.getZoneId()); + Assert.assertEquals(NAME, provider.getName()); + Assert.assertEquals(NB_CONNECTION, provider.getNbConnection()); + Assert.assertEquals(SB_CONNECTION, provider.getSbConnection()); + Mockito.verify(ovnProviderDao).persist(Mockito.any(OvnProviderVO.class)); + } + + @Test(expected = InvalidParameterValueException.class) + public void testAddProviderRejectsInvalidNbConnection() throws Exception { + AddOvnProviderCmd cmd = new AddOvnProviderCmd(); + setPrivateField(cmd, "zoneId", ZONE_ID); + setPrivateField(cmd, "name", NAME); + setPrivateField(cmd, "nbConnection", "invalid"); + Mockito.when(dataCenterDao.findById(ZONE_ID)).thenReturn(Mockito.mock(DataCenterVO.class)); + Mockito.when(ovnService.isValidConnectionString("invalid")).thenReturn(false); + + ovnProviderService.addProvider(cmd); + } + + @Test + public void testListOvnProvidersWithZoneId() { + OvnProviderVO providerVO = Mockito.mock(OvnProviderVO.class); + Mockito.when(providerVO.getZoneId()).thenReturn(ZONE_ID); + Mockito.when(ovnProviderDao.findByZoneId(ZONE_ID)).thenReturn(providerVO); + DataCenterVO zone = getZone(); + Mockito.when(dataCenterDao.findById(ZONE_ID)).thenReturn(zone); + + List result = ovnProviderService.listOvnProviders(ZONE_ID); + + Assert.assertEquals(1, result.size()); + Assert.assertTrue(result.get(0) instanceof OvnProviderResponse); + } + + @Test + public void testDeleteOvnProviderSuccess() { + OvnProviderVO providerVO = Mockito.mock(OvnProviderVO.class); + Mockito.when(providerVO.getZoneId()).thenReturn(ZONE_ID); + Mockito.when(ovnProviderDao.findById(PROVIDER_ID)).thenReturn(providerVO); + Mockito.when(physicalNetworkDao.listByZone(ZONE_ID)).thenReturn(Arrays.asList(Mockito.mock(PhysicalNetworkVO.class))); + + NetworkVO network = Mockito.mock(NetworkVO.class); + Mockito.when(networkDao.listByPhysicalNetwork(Mockito.anyLong())).thenReturn(Arrays.asList(network)); + Mockito.when(network.getBroadcastDomainType()).thenReturn(Networks.BroadcastDomainType.OVN); + Mockito.when(network.getState()).thenReturn(Network.State.Shutdown); + + Assert.assertTrue(ovnProviderService.deleteOvnProvider(PROVIDER_ID)); + Mockito.verify(ovnProviderDao).remove(PROVIDER_ID); + } + + @Test(expected = CloudRuntimeException.class) + public void testDeleteOvnProviderWithActiveNetworks() { + OvnProviderVO providerVO = Mockito.mock(OvnProviderVO.class); + Mockito.when(providerVO.getZoneId()).thenReturn(ZONE_ID); + Mockito.when(ovnProviderDao.findById(PROVIDER_ID)).thenReturn(providerVO); + Mockito.when(physicalNetworkDao.listByZone(ZONE_ID)).thenReturn(Arrays.asList(Mockito.mock(PhysicalNetworkVO.class))); + + NetworkVO network = Mockito.mock(NetworkVO.class); + Mockito.when(networkDao.listByPhysicalNetwork(Mockito.anyLong())).thenReturn(Arrays.asList(network)); + Mockito.when(network.getBroadcastDomainType()).thenReturn(Networks.BroadcastDomainType.OVN); + Mockito.when(network.getState()).thenReturn(Network.State.Implemented); + + ovnProviderService.deleteOvnProvider(PROVIDER_ID); + } + + @Test + public void testCreateOvnProviderResponse() { + OvnProviderVO provider = Mockito.mock(OvnProviderVO.class); + Mockito.when(provider.getZoneId()).thenReturn(ZONE_ID); + Mockito.when(provider.getName()).thenReturn(NAME); + Mockito.when(provider.getNbConnection()).thenReturn(NB_CONNECTION); + Mockito.when(provider.getSbConnection()).thenReturn(SB_CONNECTION); + DataCenterVO zone = getZone(); + Mockito.when(dataCenterDao.findById(ZONE_ID)).thenReturn(zone); + + OvnProviderResponse response = ovnProviderService.createOvnProviderResponse(provider); + + Assert.assertNotNull(response); + Assert.assertEquals(NAME, response.getName()); + Assert.assertEquals(NB_CONNECTION, response.getNbConnection()); + Assert.assertEquals(SB_CONNECTION, response.getSbConnection()); + } + + private DataCenterVO getZone() { + DataCenterVO zone = Mockito.mock(DataCenterVO.class); + Mockito.when(zone.getName()).thenReturn("test-zone"); + Mockito.when(zone.getUuid()).thenReturn("zone-uuid"); + return zone; + } + + private void setPrivateField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/plugins/network-elements/ovn/src/test/java/org/apache/cloudstack/service/OvnServiceImplTest.java b/plugins/network-elements/ovn/src/test/java/org/apache/cloudstack/service/OvnServiceImplTest.java new file mode 100644 index 000000000000..59330d2aa859 --- /dev/null +++ b/plugins/network-elements/ovn/src/test/java/org/apache/cloudstack/service/OvnServiceImplTest.java @@ -0,0 +1,47 @@ +// 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.service; + +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; + +public class OvnServiceImplTest { + private final OvnNbClient mockClient = Mockito.mock(OvnNbClient.class); + private final OvnServiceImpl service = new OvnServiceImpl(mockClient); + + @Test + public void testDeterministicObjectNames() { + Assert.assertEquals("cs-net-10", service.getLogicalSwitchName(10L)); + Assert.assertEquals("cs-vpc-20", service.getLogicalRouterName(20L)); + Assert.assertEquals("cs-nic-30", service.getLogicalSwitchPortName(30L)); + } + + @Test + public void testConnectionStringValidationDelegatesToClient() { + Mockito.when(mockClient.isValidConnectionString("tcp:127.0.0.1:6641")).thenReturn(true); + Mockito.when(mockClient.isValidConnectionString("bogus")).thenReturn(false); + Assert.assertTrue(service.isValidConnectionString("tcp:127.0.0.1:6641")); + Assert.assertFalse(service.isValidConnectionString("bogus")); + } + + @Test + public void testVerifyNbConnectionDelegatesToClient() { + service.verifyNbConnection("tcp:1.2.3.4:6641", null, null, null); + Mockito.verify(mockClient).verifyConnection("tcp:1.2.3.4:6641", null, null, null); + } +} diff --git a/plugins/network-elements/ovs/src/main/java/com/cloud/network/guru/OvsGuestNetworkGuru.java b/plugins/network-elements/ovs/src/main/java/com/cloud/network/guru/OvsGuestNetworkGuru.java index 327b4eb42e52..d58653ed75c6 100644 --- a/plugins/network-elements/ovs/src/main/java/com/cloud/network/guru/OvsGuestNetworkGuru.java +++ b/plugins/network-elements/ovs/src/main/java/com/cloud/network/guru/OvsGuestNetworkGuru.java @@ -77,6 +77,7 @@ protected boolean canHandle(NetworkOffering offering, && isMyTrafficType(offering.getTrafficType()) && offering.getGuestType() == Network.GuestType.Isolated && isMyIsolationMethod(physicalNetwork) + && _ntwkOfferingSrvcDao.isProviderForNetworkOffering(offering.getId(), Network.Provider.Ovs) && _ntwkOfferingSrvcDao.areServicesSupportedByNetworkOffering( offering.getId(), Service.Connectivity)) { return true; diff --git a/plugins/pom.xml b/plugins/pom.xml index e4904ccdf40b..324737edfbbb 100755 --- a/plugins/pom.xml +++ b/plugins/pom.xml @@ -107,6 +107,7 @@ network-elements/netscaler network-elements/nicira-nvp network-elements/opendaylight + network-elements/ovn network-elements/ovs network-elements/palo-alto network-elements/stratosphere-ssp diff --git a/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java b/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java index 6da5dda967d0..df3ce4369771 100644 --- a/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java +++ b/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java @@ -8354,8 +8354,8 @@ private void validateProvider(NetworkOfferingVO sourceOffering, String detectedProvider, String networkMode) { detectedProvider = getExternalNetworkProvider(detectedProvider, sourceServiceProviderMap); - // If this is an NSX/Netris offering, prevent network mode changes - if (detectedProvider != null && (detectedProvider.equals("NSX") || detectedProvider.equals("Netris"))) { + // If this is an external provider offering, prevent network mode changes + if (detectedProvider != null && (detectedProvider.equals("NSX") || detectedProvider.equals("Netris") || detectedProvider.equals("OVN"))) { if (networkMode != null && sourceOffering.getNetworkMode() != null) { if (!networkMode.equalsIgnoreCase(sourceOffering.getNetworkMode().toString())) { throw new InvalidParameterValueException( @@ -8388,6 +8388,9 @@ public static String getExternalNetworkProvider(String detectedProvider, if (provider == Provider.Netris) { return "Netris"; } + if (provider == Provider.Ovn) { + return "OVN"; + } } } diff --git a/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyManagerImpl.java b/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyManagerImpl.java index b3da6af21138..b12b1b57fd65 100644 --- a/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyManagerImpl.java +++ b/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyManagerImpl.java @@ -120,7 +120,6 @@ import com.cloud.utils.DateUtil; import com.cloud.utils.NumbersUtil; import com.cloud.utils.Pair; -import com.cloud.utils.PasswordGenerator; import com.cloud.utils.StringUtils; import com.cloud.utils.component.ManagerBase; import com.cloud.utils.db.DB; @@ -1269,7 +1268,7 @@ public boolean finalizeVirtualMachineProfile(VirtualMachineProfile profile, Depl if (VirtualMachine.Type.ConsoleProxy == profile.getVirtualMachine().getType()) { buf.append(" vncport=").append(getVncPort(datacenterId)); } - buf.append(" keystore_password=").append(VirtualMachineGuru.getEncodedString(PasswordGenerator.generateRandomPassword(16))); + VirtualMachineGuru.appendCertificateDetails(buf, certificate); if (SystemVmEnableUserData.valueIn(dc.getId())) { String userDataUuid = ConsoleProxyVmUserData.valueIn(dc.getId()); @@ -1285,7 +1284,7 @@ public boolean finalizeVirtualMachineProfile(VirtualMachineProfile profile, Depl String bootArgs = buf.toString(); if (logger.isDebugEnabled()) { - logger.debug("Boot Args for " + profile + ": " + bootArgs); + logger.debug("Boot Args for " + profile + ": " + bootArgs.replaceAll("(certificate|cacertificate|privatekey|keystore_password)=[^\\s]+", "$1=******")); } return true; diff --git a/server/src/main/java/com/cloud/network/NetworkServiceImpl.java b/server/src/main/java/com/cloud/network/NetworkServiceImpl.java index c9884f8c469a..daa83592401a 100644 --- a/server/src/main/java/com/cloud/network/NetworkServiceImpl.java +++ b/server/src/main/java/com/cloud/network/NetworkServiceImpl.java @@ -4331,6 +4331,13 @@ public PhysicalNetworkVO doInTransaction(TransactionStatus status) { logger.warn("Failed to add Netris provider to physical network due to:", ex.getMessage()); } + // Add OVN provider + try { + addOvnProviderToPhysicalNetwork(pNetwork.getId()); + } catch (Exception ex) { + logger.warn("Failed to add OVN provider to physical network due to:", ex.getMessage()); + } + CallContext.current().putContextParameter(PhysicalNetwork.class, pNetwork.getUuid()); return pNetwork; @@ -5758,6 +5765,21 @@ private PhysicalNetworkServiceProvider addNetrisProviderToPhysicalNetwork(long p return null; } + private PhysicalNetworkServiceProvider addOvnProviderToPhysicalNetwork(long physicalNetworkId) { + PhysicalNetworkVO pvo = _physicalNetworkDao.findById(physicalNetworkId); + DataCenterVO dvo = _dcDao.findById(pvo.getDataCenterId()); + if (dvo.getNetworkType() == NetworkType.Advanced) { + Provider provider = Network.Provider.getProvider(Provider.Ovn.getName()); + if (provider == null) { + return null; + } + + addProviderToPhysicalNetwork(physicalNetworkId, Provider.Ovn.getName(), null, null); + enableProvider(Provider.Ovn.getName()); + } + return null; + } + protected boolean isNetworkSystem(Network network) { NetworkOffering no = _networkOfferingDao.findByIdIncludingRemoved(network.getNetworkOfferingId()); if (no.isSystemOnly()) { diff --git a/server/src/main/java/com/cloud/network/element/ConfigDriveNetworkElement.java b/server/src/main/java/com/cloud/network/element/ConfigDriveNetworkElement.java index 6cdd6f5753d7..4ad20dd77974 100644 --- a/server/src/main/java/com/cloud/network/element/ConfigDriveNetworkElement.java +++ b/server/src/main/java/com/cloud/network/element/ConfigDriveNetworkElement.java @@ -631,13 +631,13 @@ private Map> getSupportedServicesByElementForNetwork Map> supportedServices = new HashMap<>(); for (NicProfile nic: nics) { ArrayList serviceList = new ArrayList<>(); - if (_networkModel.isProviderSupportServiceInNetwork(nic.getNetworkId(), Service.Dns, getProvider())) { + if (_networkModel.areServicesSupportedInNetwork(nic.getNetworkId(), Service.Dns)) { serviceList.add(Service.Dns); } if (_networkModel.isProviderSupportServiceInNetwork(nic.getNetworkId(), Service.UserData, getProvider())) { serviceList.add(Service.UserData); } - if (_networkModel.isProviderSupportServiceInNetwork(nic.getNetworkId(), Service.Dhcp, getProvider())) { + if (_networkModel.areServicesSupportedInNetwork(nic.getNetworkId(), Service.Dhcp)) { serviceList.add(Service.Dhcp); } supportedServices.put(nic.getId(), serviceList); 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 5b4ab908ca4c..942d6478f06a 100644 --- a/server/src/main/java/com/cloud/network/vpc/VpcManagerImpl.java +++ b/server/src/main/java/com/cloud/network/vpc/VpcManagerImpl.java @@ -137,6 +137,7 @@ import com.cloud.network.dao.NetworkVO; import com.cloud.network.dao.NetrisProviderDao; import com.cloud.network.dao.NsxProviderDao; +import com.cloud.network.dao.OvnProviderDao; import com.cloud.network.dao.RemoteAccessVpnDao; import com.cloud.network.dao.RemoteAccessVpnVO; import com.cloud.network.dao.Site2SiteCustomerGatewayDao; @@ -147,6 +148,7 @@ import com.cloud.network.element.NetworkACLServiceProvider; import com.cloud.network.element.NetworkElement; import com.cloud.network.element.NsxProviderVO; +import com.cloud.network.element.OvnProviderVO; import com.cloud.network.element.StaticNatServiceProvider; import com.cloud.network.element.VpcProvider; import com.cloud.network.router.CommandSetupHelper; @@ -314,6 +316,8 @@ public class VpcManagerImpl extends ManagerBase implements VpcManager, VpcProvis @Inject private NetrisProviderDao netrisProviderDao; @Inject + private OvnProviderDao ovnProviderDao; + @Inject RoutedIpv4Manager routedIpv4Manager; @Inject DomainRouterDao domainRouterDao; @@ -334,7 +338,7 @@ public class VpcManagerImpl extends ManagerBase implements VpcManager, VpcProvis private List vpcElements = null; private final List nonSupportedServices = Arrays.asList(Service.SecurityGroup, Service.Firewall); private final List supportedProviders = Arrays.asList(Provider.VPCVirtualRouter, Provider.NiciraNvp, Provider.InternalLbVm, Provider.Netscaler, - Provider.JuniperContrailVpcRouter, Provider.Ovs, Provider.BigSwitchBcf, Provider.ConfigDrive, Provider.Nsx, Provider.Netris); + Provider.JuniperContrailVpcRouter, Provider.Ovs, Provider.BigSwitchBcf, Provider.ConfigDrive, Provider.Nsx, Provider.Netris, Provider.Ovn); int _cleanupInterval; int _maxNetworks; @@ -506,6 +510,35 @@ public void doInTransactionWithoutResult(final TransactionStatus status) { State.Enabled, null, false, false, false, NetworkOffering.NetworkMode.NATTED, null, false, false); } + + // Default OVN-backed VPC offering (NAT mode). Mirrors the Netris NAT entry + // above but binds every service the OVN provider can satisfy natively (Vpc, + // SourceNat, StaticNat, PortForwarding, Lb, NetworkACL, Firewall, Dhcp, Dns, + // Gateway) to the Ovn provider; UserData is delivered via ConfigDrive (the + // OVN data-plane has no metadata service of its own). The offering pairs with + // DefaultNATOVNNetworkOfferingForVpc on the tier side. + if (_vpcOffDao.findByUniqueName(VpcOffering.DEFAULT_VPC_NAT_OVN_OFFERING_NAME) == null) { + logger.debug(String.format("Creating default VPC offering for OVN network service provider %s in NAT mode", + VpcOffering.DEFAULT_VPC_NAT_OVN_OFFERING_NAME)); + final Map> svcProviderMap = new HashMap<>(); + final Set ovnProvider = Set.of(Provider.Ovn); + final Set configDriveProvider = Set.of(Provider.ConfigDrive); + for (final Service svc : getSupportedServices()) { + if (svc == Service.UserData) { + svcProviderMap.put(svc, configDriveProvider); + } else if (svc == Service.Vpn) { + continue; + } else { + svcProviderMap.put(svc, ovnProvider); + } + } + // OVN implements Firewall natively via OVN ACLs; VPC tiers use it + // instead of (or alongside) NetworkACL. getSupportedServices() + // excludes Firewall for legacy VR-based VPCs, so add it explicitly. + svcProviderMap.put(Service.Firewall, ovnProvider); + createVpcOffering(VpcOffering.DEFAULT_VPC_NAT_OVN_OFFERING_NAME, VpcOffering.DEFAULT_VPC_NAT_OVN_OFFERING_NAME, svcProviderMap, false, + State.Enabled, null, false, false, false, NetworkOffering.NetworkMode.NATTED, null, false, false); + } } }); @@ -1733,10 +1766,11 @@ public Vpc createVpc(CreateVPCCmd cmd) throws ResourceAllocationException { String sourceNatIP = cmd.getSourceNatIP(); boolean forNsx = isVpcForProvider(Provider.Nsx, vpc); boolean forNetris = isVpcForProvider(Provider.Netris, vpc); + boolean forOvn = isVpcForProvider(Provider.Ovn, vpc); try { - if (sourceNatIP != null || forNsx || forNetris) { - if (forNsx || forNetris) { - logger.info("Provided source NAT IP will be ignored in an NSX-enabled or Netris-enabled zone"); + if (sourceNatIP != null || forNsx || forNetris || forOvn) { + if (forNsx || forNetris || forOvn) { + logger.info("Provided source NAT IP will be ignored in an NSX-enabled, Netris-enabled or OVN-enabled zone"); sourceNatIP = null; } logger.info(String.format("Trying to allocate the specified IP [%s] as the source NAT of VPC [%s].", sourceNatIP, vpc)); @@ -2511,8 +2545,9 @@ public void validateNtwkOffForVpc(final NetworkOffering guestNtwkOff, final List // 2) Only Isolated networks with Source nat service enabled can be // added to vpc boolean isForNsx = _ntwkModel.isProviderForNetworkOffering(Provider.Nsx, guestNtwkOff.getId()); - boolean isForNNetris = _ntwkModel.isProviderForNetworkOffering(Provider.Netris, guestNtwkOff.getId()); - if (!isForNsx && !isForNNetris + boolean isForNetris = _ntwkModel.isProviderForNetworkOffering(Provider.Netris, guestNtwkOff.getId()); + boolean isForOvn = _ntwkModel.isProviderForNetworkOffering(Provider.Ovn, guestNtwkOff.getId()); + if (!isForNsx && !isForNetris && !isForOvn && !(guestNtwkOff.getGuestType() == GuestType.Isolated && (supportedSvcs.contains(Service.SourceNat) || supportedSvcs.contains(Service.Gateway)))) { throw new InvalidParameterValueException("Only network offerings of type " + GuestType.Isolated + " with service " + Service.SourceNat.getName() @@ -3898,6 +3933,8 @@ public PublicIp assignSourceNatIpAddressToVpc(final Account owner, final Vpc vpc boolean forNsx = nsxProvider != null; NetrisProviderVO netrisProvider = netrisProviderDao.findByZoneId(dcId); boolean forNetris = netrisProvider != null; + OvnProviderVO ovnProvider = ovnProviderDao.findByZoneId(dcId); + boolean forOvn = ovnProvider != null; final IPAddressVO sourceNatIp = getExistingSourceNatInVpc(owner.getId(), vpc.getId(), forNsx, forNetris); @@ -3906,7 +3943,7 @@ public PublicIp assignSourceNatIpAddressToVpc(final Account owner, final Vpc vpc if (sourceNatIp != null) { ipToReturn = PublicIp.createFromAddrAndVlan(sourceNatIp, _vlanDao.findById(sourceNatIp.getVlanId())); } else { - if (forNsx || forNetris) { + if (forNsx || forNetris || forOvn) { // Assign VR (helper VM) public NIC IP address from the separate provider Public IP range/pool // NSX: VR uses Public IP from the system VM range // Netris: VR uses Public IP from the non system VM range @@ -3954,11 +3991,13 @@ public boolean isSrcNatIpRequired(long vpcOfferingId) { return (Objects.nonNull(vpcOffSvcProvidersMap.get(Network.Service.SourceNat)) && (vpcOffSvcProvidersMap.get(Network.Service.SourceNat).contains(Network.Provider.VPCVirtualRouter) || vpcOffSvcProvidersMap.get(Service.SourceNat).contains(Provider.Nsx) - || vpcOffSvcProvidersMap.get(Service.SourceNat).contains(Provider.Netris))) + || vpcOffSvcProvidersMap.get(Service.SourceNat).contains(Provider.Netris) + || vpcOffSvcProvidersMap.get(Service.SourceNat).contains(Provider.Ovn))) || (Objects.nonNull(vpcOffSvcProvidersMap.get(Network.Service.Gateway)) && (vpcOffSvcProvidersMap.get(Service.Gateway).contains(Network.Provider.VPCVirtualRouter) || vpcOffSvcProvidersMap.get(Service.Gateway).contains(Provider.Nsx) - || vpcOffSvcProvidersMap.get(Service.Gateway).contains(Network.Provider.Netris))); + || vpcOffSvcProvidersMap.get(Service.Gateway).contains(Network.Provider.Netris) + || vpcOffSvcProvidersMap.get(Service.Gateway).contains(Network.Provider.Ovn))); } @Override diff --git a/server/src/main/java/com/cloud/server/ConfigurationServerImpl.java b/server/src/main/java/com/cloud/server/ConfigurationServerImpl.java index 8f10dd84b54d..976d1bd7a6da 100644 --- a/server/src/main/java/com/cloud/server/ConfigurationServerImpl.java +++ b/server/src/main/java/com/cloud/server/ConfigurationServerImpl.java @@ -1222,6 +1222,14 @@ public void doInTransactionWithoutResult(TransactionStatus status) { // Offering #15 - network offering for Netris provider for VPCs - NATTED mode createAndPersistDefaultProviderOffering(NetworkOffering.DEFAULT_NAT_NETRIS_OFFERING_FOR_VPC, "Offering for Netris enabled networks on VPCs - NAT mode", NetworkOffering.NetworkMode.NATTED, true, true, true, Provider.Netris); + + // Offering #16 - network offering for OVN provider - NATTED mode + createAndPersistDefaultProviderOffering(NetworkOffering.DEFAULT_NAT_OVN_OFFERING, "Offering for OVN enabled networks - NAT mode", + NetworkOffering.NetworkMode.NATTED, false, true, false, Provider.Ovn); + + // Offering #17 - network offering for OVN provider for VPCs - NATTED mode + createAndPersistDefaultProviderOffering(NetworkOffering.DEFAULT_NAT_OVN_OFFERING_FOR_VPC, "Offering for OVN enabled networks on VPCs - NAT mode", + NetworkOffering.NetworkMode.NATTED, true, true, false, Provider.Ovn); } }); } @@ -1251,15 +1259,21 @@ private void createAndPersistDefaultProviderOffering(String name, String display private Map getServicesAndProvidersForProviderNetwork(NetworkOffering.NetworkMode networkMode, boolean forVpc, Provider provider) { final Map serviceProviderMap = new HashMap<>(); Provider routerProvider = forVpc ? Provider.VPCVirtualRouter : Provider.VirtualRouter; - serviceProviderMap.put(Service.Dhcp, routerProvider); - serviceProviderMap.put(Service.Dns, routerProvider); - serviceProviderMap.put(Service.UserData, routerProvider); + Provider controlPlaneProvider = Provider.Ovn.equals(provider) ? provider : routerProvider; + serviceProviderMap.put(Service.Dhcp, controlPlaneProvider); + serviceProviderMap.put(Service.Dns, controlPlaneProvider); + if (!Provider.Ovn.equals(provider)) { + serviceProviderMap.put(Service.UserData, routerProvider); + } if (forVpc) { serviceProviderMap.put(Service.NetworkACL, provider); } else { serviceProviderMap.put(Service.Firewall, provider); } if (networkMode == NetworkOffering.NetworkMode.NATTED) { + if (Provider.Ovn.equals(provider)) { + serviceProviderMap.put(Service.Gateway, provider); + } serviceProviderMap.put(Service.SourceNat, provider); serviceProviderMap.put(Service.StaticNat, provider); serviceProviderMap.put(Service.PortForwarding, provider); diff --git a/services/secondary-storage/controller/src/main/java/org/apache/cloudstack/secondarystorage/SecondaryStorageManagerImpl.java b/services/secondary-storage/controller/src/main/java/org/apache/cloudstack/secondarystorage/SecondaryStorageManagerImpl.java index 9d4c73111595..7cdd6b2dd51f 100644 --- a/services/secondary-storage/controller/src/main/java/org/apache/cloudstack/secondarystorage/SecondaryStorageManagerImpl.java +++ b/services/secondary-storage/controller/src/main/java/org/apache/cloudstack/secondarystorage/SecondaryStorageManagerImpl.java @@ -137,7 +137,6 @@ import com.cloud.utils.DateUtil; import com.cloud.utils.NumbersUtil; import com.cloud.utils.Pair; -import com.cloud.utils.PasswordGenerator; import com.cloud.utils.component.ManagerBase; import com.cloud.utils.db.GlobalLock; import com.cloud.utils.db.QueryBuilder; @@ -1233,7 +1232,7 @@ public boolean finalizeVirtualMachineProfile(VirtualMachineProfile profile, Depl if (StringUtils.isNotBlank(nfsVersion)) { buf.append(" nfsVersion=").append(nfsVersion); } - buf.append(" keystore_password=").append(VirtualMachineGuru.getEncodedString(PasswordGenerator.generateRandomPassword(16))); + VirtualMachineGuru.appendCertificateDetails(buf, certificate); if (SystemVmEnableUserData.valueIn(dc.getId())) { String userDataUuid = SecondaryStorageVmUserData.valueIn(dc.getId()); @@ -1249,7 +1248,8 @@ public boolean finalizeVirtualMachineProfile(VirtualMachineProfile profile, Depl String bootArgs = buf.toString(); if (logger.isDebugEnabled()) { - logger.debug(String.format("Boot args for machine profile [%s]: [%s].", profile.toString(), bootArgs)); + logger.debug(String.format("Boot args for machine profile [%s]: [%s].", profile.toString(), + bootArgs.replaceAll("(certificate|cacertificate|privatekey|keystore_password)=[^\\s]+", "$1=******"))); } boolean useHttpsToUpload = VolumeApiService.UseHttpsToUpload.valueIn(dc.getId()); diff --git a/systemvm/debian/opt/cloud/bin/setup/bootstrap.sh b/systemvm/debian/opt/cloud/bin/setup/bootstrap.sh index f7c071c8cc0e..a6f452fe255f 100755 --- a/systemvm/debian/opt/cloud/bin/setup/bootstrap.sh +++ b/systemvm/debian/opt/cloud/bin/setup/bootstrap.sh @@ -24,6 +24,7 @@ rm -f /var/cache/cloud/enabled_svcs rm -f /var/cache/cloud/disabled_svcs . /lib/lsb/init-functions +. /opt/cloud/bin/setup/common.sh log_it() { echo "$(date) $@" >> /var/log/cloud.log @@ -64,16 +65,60 @@ patch_systemvm() { echo "Restored keystore file and certs using backup" >> $logfile fi rm -fr $backupfolder + + setup_agent_keystore || return 1 + # Import global cacerts into 'cloud' service's keystore keytool -importkeystore -srckeystore /etc/ssl/certs/java/cacerts -destkeystore /usr/local/cloud/systemvm/certs/realhostip.keystore -srcstorepass changeit -deststorepass vmops.com -noprompt || true return 0 } +decode_boot_arg() { + printf '%s' "$1" | base64 -d 2>/dev/null | tr '^' '\n' | tr '~' ' ' +} + +setup_agent_keystore() { + parse_cmd_line + + if [ -z "${KEYSTORE_PSSWD// }" ] || [ -z "${CERTIFICATE// }" ] || [ -z "${CACERTIFICATE// }" ]; then + log_it "Skipping agent keystore setup as certificate boot arguments are missing" + return 0 + fi + + local propsfile="/usr/local/cloud/systemvm/conf/agent.properties" + local ksfile="/usr/local/cloud/systemvm/conf/cloud.jks" + local certfile="/usr/local/cloud/systemvm/conf/cloud.crt" + local cacertfile="/usr/local/cloud/systemvm/conf/cloud.ca.crt" + local keyfile="/usr/local/cloud/systemvm/conf/cloud.key" + local import_script="/usr/local/cloud/systemvm/scripts/util/keystore-cert-import" + local ks_pass + local cert + local cacert + local privatekey + + ks_pass=$(decode_boot_arg "$KEYSTORE_PSSWD") + cert=$(decode_boot_arg "$CERTIFICATE") + cacert=$(decode_boot_arg "$CACERTIFICATE") + if [ -n "${PRIVATEKEY// }" ]; then + privatekey=$(decode_boot_arg "$PRIVATEKEY") + fi + + sed -i "/^keystore.passphrase=/d" "$propsfile" + echo "keystore.passphrase=$ks_pass" >> "$propsfile" + + if [ ! -x "$import_script" ]; then + log_it "Unable to setup agent keystore, missing $import_script" + return 1 + fi + + "$import_script" "$propsfile" "$ks_pass" "$ksfile" "agent" "$certfile" "$cert" "$cacertfile" "$cacert" "$keyfile" "$privatekey" +} + patch() { local PATCH_MOUNT=/var/cache/cloud/ local logfile="/var/log/patchsystemvm.log" - if [ "$TYPE" == "consoleproxy" ] || [ "$TYPE" == "secstorage" ] && [ -f ${PATCH_MOUNT}/agent.zip ] && [ -f /var/cache/cloud/patch.required ] + if { [ "$TYPE" == "consoleproxy" ] || [ "$TYPE" == "secstorage" ]; } && [ -f ${PATCH_MOUNT}/agent.zip ] && [ -f /var/cache/cloud/patch.required ] then echo "Patching systemvm for cloud service with mount=$PATCH_MOUNT for type=$TYPE" >> $logfile patch_systemvm ${PATCH_MOUNT}/agent.zip diff --git a/systemvm/debian/opt/cloud/bin/setup/cloud-early-config b/systemvm/debian/opt/cloud/bin/setup/cloud-early-config index ee1e872f627c..8edeb6f896fa 100755 --- a/systemvm/debian/opt/cloud/bin/setup/cloud-early-config +++ b/systemvm/debian/opt/cloud/bin/setup/cloud-early-config @@ -109,6 +109,8 @@ cleanup() { start() { log_it "Executing cloud-early-config" + exec 9>/var/lock/cloud-early-config.lock + flock 9 # Clear /tmp for file lock rm -f /tmp/*.lock diff --git a/systemvm/debian/opt/cloud/bin/setup/common.sh b/systemvm/debian/opt/cloud/bin/setup/common.sh index ef1576ab588c..7484cf8de2b0 100755 --- a/systemvm/debian/opt/cloud/bin/setup/common.sh +++ b/systemvm/debian/opt/cloud/bin/setup/common.sh @@ -730,9 +730,9 @@ parse_cmd_line() { for i in $CMDLINE do - # search for foo=bar pattern and cut out foo + # Search for foo=bar and preserve any additional '=' in encoded values. KEY=$(echo $i | cut -d= -f1) - VALUE=$(echo $i | cut -d= -f2) + VALUE=$(echo $i | cut -d= -f2-) echo -en ${COMMA} >> ${CHEF_TMP_FILE} # Two lines so values do not accidentally interpretted as escapes!! echo -n \"${KEY}\"': '\"${VALUE}\" >> ${CHEF_TMP_FILE} diff --git a/tools/apidoc/gen_toc.py b/tools/apidoc/gen_toc.py index 292f52d809bf..bf30f473b4f2 100644 --- a/tools/apidoc/gen_toc.py +++ b/tools/apidoc/gen_toc.py @@ -99,6 +99,7 @@ 'addNsxController': 'NSX', 'deleteNsxController': 'NSX', 'NetrisProvider': 'Netris', + 'OvnProvider': 'OVN', 'Vpn': 'VPN', 'Limit': 'Resource Limit', 'Netscaler': 'Netscaler', diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 4a23b454252a..88e606692149 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -777,6 +777,7 @@ "label.default": "Default", "label.default.use": "Default use", "label.default.view": "Default view", +"label.default.allow.all": "Default (Allow All)", "label.default.network.domain.isolated.network": "Default network domain for Isolated networks", "label.default.network.guestcidraddress.isolated.network": "Default guest CIDR for Isolated Networks", "label.defaultnetwork": "Default Network", @@ -1694,6 +1695,15 @@ "label.netris.provider.tenant.name": "Netris provider Admin Tenant name", "label.netris.provider.tag": "Netris Tag", "label.netris.provider.url": "Netris provider URL", +"label.ovn.provider": "OVN Provider", +"label.ovn.provider.name": "OVN provider name", +"label.ovn.provider.nb.connection": "OVN Northbound DB connection", +"label.ovn.provider.sb.connection": "OVN Southbound DB connection", +"label.ovn.provider.external.bridge": "OVN external bridge", +"label.ovn.provider.localnet.name": "OVN localnet (physical network mapping)", +"label.ovn.provider.ca.cert.path": "OVN TLS CA certificate path", +"label.ovn.provider.client.cert.path": "OVN TLS client certificate path", +"label.ovn.provider.client.private.key.path": "OVN TLS client private key path", "label.netscaler": "NetScaler", "label.netscaler.mpx": "NetScaler MPX LoadBalancer", "label.netscaler.sdx": "NetScaler SDX LoadBalancer", @@ -1787,6 +1797,11 @@ "label.nsx.provider.transportzone": "NSX provider transport Zone", "label.nsx.supports.internal.lb": "Enable NSX internal LB service", "label.nsx.supports.lb": "Enable NSX LB service", +"label.ovn": "OVN", +"label.ovnnbconnection": "OVN Northbound connection", +"label.ovnsbconnection": "OVN Southbound connection", +"label.ovnexternalbridge": "OVN external bridge", +"label.ovnlocalnetname": "OVN localnet name", "label.num.cpu.cores": "# of CPU cores", "label.numanode": "NUMA node", "label.number": "#Rule", @@ -1959,6 +1974,38 @@ "label.privatemtu": "Private Interface MTU", "label.private.gateway": "Private Gateway", "label.private.interface": "Private interface", +"label.vpc.peering": "VPC Peering", +"label.groupuuid": "Group UUID", +"label.vpccount": "VPC count", +"label.vpcnames": "VPC names", +"label.delete.vpc.peering": "Delete VPC peering", +"label.enable.vpc.peering": "Enable VPC peering", +"label.disable.vpc.peering": "Disable VPC peering", +"label.add.vpc.peering": "Create VPC Peering", +"label.add.vpc.to.peering": "Add VPC to Peering Group", +"label.peer.vpc": "Peer VPC", +"label.peering.group": "Peering Group", +"label.link.local.ip": "Link-Local IP", +"label.vpc.peering.members": "Member VPCs", +"label.vpc.peering.count": "VPCs", +"label.vpc.peering.select.vpcs": "Select VPCs to peer", +"label.vpc.peering.select.min": "Select at least 2 VPCs", +"label.vpc.peering.already.peered": "Already in a peering", +"label.vpc.peers": "VPC Peers", +"message.confirm.remove.vpc.from.peering": "Are you sure you want to remove this VPC from the peering group?", +"message.success.remove.vpc.from.peering": "VPC removed from peering group successfully", +"message.add.vpc.peering": "Add VPC peering", +"message.add.vpc.peering.processing": "Creating VPC peering...", +"message.add.vpc.peering.failed": "Failed to create VPC peering", +"message.success.add.vpc.peering": "VPC peering created successfully", +"message.confirm.delete.vpc.peering.group": "Are you sure you want to delete this entire peering group? All member VPCs will be removed from the group.", +"message.confirm.delete.vpc.peering": "Are you sure you want to remove this VPC from the peering group?", +"message.confirm.enable.vpc.peering": "Are you sure you want to enable this VPC peering group? Routes and NAT bypass will be re-applied to all members.", +"message.confirm.disable.vpc.peering": "Are you sure you want to disable this VPC peering group? Routes and NAT bypass will be removed from all members; records are preserved.", +"message.vpc.peering.acl.scope": "The ACL is applied to this VPC's traffic over the peering link only. Pick \"Default (Allow All)\" to remove ACL filtering.", +"message.delete.vpc.peering.processing": "Removing VPC peering...", +"message.delete.vpc.peering.failed": "Failed to remove VPC peering", +"message.success.update.vpc.peering": "VPC peering ACL updated successfully", "label.private.registry": "Private registry", "label.privateinterface": "Private interface", "label.privateip": "Private IP address", @@ -3109,6 +3156,7 @@ "message.add.latest.kubernetes.iso.failed": "Failed to add latest Kubernetes ISO", "message.add.minimum.required.compute.offering.kubernetes.cluster.failed": "Failed to add minimum required Compute Offering for Kubernetes cluster nodes", "message.add.netris.controller": "Add Netris Provider", +"message.add.ovn.controller": "Add OVN Provider", "message.add.nsx.controller": "Add NSX Provider", "message.add.network": "Add a new network for Zone: ", "message.add.network.acl.failed": "Adding network ACL failed.", @@ -3624,6 +3672,7 @@ "message.info.cloudian.console": "Cloudian Management Console should open in another window.", "message.installwizard.cloudstack.helptext.website": " * Project website:\t ", "message.infra.setup.netris.description": "This zone must contain a Netris provider because the isolation method is Netris", +"message.infra.setup.ovn.description": "This zone must contain an OVN provider because the isolation method is OVN. Provide the OVN central NB/SB endpoints and the bridge mapping used by ovn-controller on the hypervisors.", "message.infra.setup.nsx.description": "This Zone must contain an NSX provider because the isolation method is NSX", "message.infra.setup.tungsten.description": "This Zone must contain a Tungsten-Fabric provider because the isolation method is TF", "message.installwizard.cloudstack.helptext.document": " * Documentation:\t ", @@ -3648,6 +3697,14 @@ "message.installwizard.tooltip.netris.provider.site": "Netris Provider Site name not provided", "message.installwizard.tooltip.netris.provider.tag": "Netris Tag to be assigned to vNets", "message.installwizard.tooltip.netris.provider.tenant.name": "Netris Provider Admin Tenant name not provided", +"message.installwizard.tooltip.ovn.provider.name": "Friendly name for this OVN provider entry, e.g. 'ovn-central-1'", +"message.installwizard.tooltip.ovn.provider.nb.connection": "OVN Northbound DB connection string. Examples: tcp:10.0.0.10:6641, ssl:nb.ovn.example:6641", +"message.installwizard.tooltip.ovn.provider.sb.connection": "OVN Southbound DB connection string (recommended). Used to enumerate live Chassis for Gateway_Chassis pinning. Example: tcp:10.0.0.10:6642", +"message.installwizard.tooltip.ovn.provider.external.bridge": "OVS bridge that ovn-controller uses for the public network. Example: br-ex", +"message.installwizard.tooltip.ovn.provider.localnet.name": "Logical network name used in ovn-bridge-mappings on each hypervisor. Example: physnet1 (matches ovn-bridge-mappings=physnet1:br-ex)", +"message.installwizard.tooltip.ovn.provider.ca.cert.path": "Filesystem path to the CA certificate used by OVN OVSDB SSL endpoints. Required only when nb/sb connection uses ssl://", +"message.installwizard.tooltip.ovn.provider.client.cert.path": "Filesystem path to the client certificate used by CloudStack to authenticate to OVN OVSDB. Required only when ssl:// is in use", +"message.installwizard.tooltip.ovn.provider.client.private.key.path": "Filesystem path to the client private key paired with the client certificate. Required only when ssl:// is in use", "message.installwizard.tooltip.nsx.provider.hostname": "NSX Provider hostname / IP address not provided", "message.installwizard.tooltip.nsx.provider.username": "NSX Provider username not provided", "message.installwizard.tooltip.nsx.provider.password": "NSX Provider password not provided", diff --git a/ui/public/locales/pt_BR.json b/ui/public/locales/pt_BR.json index c7ca36c1278d..5fbcc1599b3d 100644 --- a/ui/public/locales/pt_BR.json +++ b/ui/public/locales/pt_BR.json @@ -1792,6 +1792,34 @@ "label.vpc": "VPC", "label.vpc.id": "ID da VPC", "label.vpc.offerings": "Ofertas VPC", +"label.vpc.peering": "Peering de VPC", +"label.vpc.peers": "VPCs do peering", +"label.add.vpc.peering": "Criar peering de VPC", +"label.delete.vpc.peering": "Excluir peering de VPC", +"label.enable.vpc.peering": "Habilitar peering de VPC", +"label.disable.vpc.peering": "Desabilitar peering de VPC", +"label.add.vpc.to.peering": "Adicionar VPC ao grupo de peering", +"label.peer.vpc": "VPC pareada", +"label.peering.group": "Grupo de peering", +"label.link.local.ip": "IP link-local", +"label.default.allow.all": "Padrão (permitir tudo)", +"label.vpccount": "VPCs no peering", +"label.vpcnames": "Nomes das VPCs", +"label.groupuuid": "UUID do grupo", +"label.vpc.peering.members": "VPCs membros", +"label.vpc.peering.count": "VPCs", +"label.vpc.peering.select.vpcs": "Selecione as VPCs para o peering", +"label.vpc.peering.select.min": "Selecione pelo menos 2 VPCs", +"label.vpc.peering.already.peered": "Já pertence a um peering", +"message.confirm.remove.vpc.from.peering": "Tem certeza que quer remover esta VPC do grupo de peering?", +"message.confirm.delete.vpc.peering": "Tem certeza que quer remover esta VPC do grupo de peering?", +"message.confirm.delete.vpc.peering.group": "Tem certeza que quer excluir todo o grupo de peering? Todas as VPCs membros serão removidas.", +"message.confirm.enable.vpc.peering": "Tem certeza que quer habilitar este grupo de peering? Rotas e bypass de NAT serão reaplicados em todos os membros.", +"message.confirm.disable.vpc.peering": "Tem certeza que quer desabilitar este grupo de peering? Rotas e bypass de NAT serão removidos de todos os membros; os registros são preservados.", +"message.success.add.vpc.peering": "Peering de VPC criado com sucesso", +"message.success.remove.vpc.from.peering": "VPC removida do grupo de peering", +"message.success.update.vpc.peering": "ACL do peering atualizada", +"message.vpc.peering.acl.scope": "A ACL é aplicada apenas ao tráfego desta VPC sobre o link de peering. Selecione \"Padrão (permitir tudo)\" para remover o filtro.", "label.vpc.virtual.router": "Roteador virtual VPC", "label.vpcid": "VPC", "label.vpclimit": "Limites VPC", diff --git a/ui/src/components/view/ListView.vue b/ui/src/components/view/ListView.vue index 66dd6b3db9e6..3b107f65a111 100644 --- a/ui/src/components/view/ListView.vue +++ b/ui/src/components/view/ListView.vue @@ -1224,7 +1224,7 @@ export default { }, enableGroupAction () { return ['vm', 'alert', 'vmgroup', 'ssh', 'userdata', 'affinitygroup', 'autoscalevmgroup', 'volume', 'snapshot', - 'vmsnapshot', 'backup', 'guestnetwork', 'vpc', 'publicip', 'vpnuser', 'vpncustomergateway', 'vnfapp', + 'vmsnapshot', 'backup', 'guestnetwork', 'vpc', 'vpcpeering', 'publicip', 'vpnuser', 'vpncustomergateway', 'vnfapp', 'project', 'account', 'systemvm', 'router', 'computeoffering', 'systemoffering', 'diskoffering', 'backupoffering', 'networkoffering', 'vpcoffering', 'ilbvm', 'kubernetes', 'comment', 'buckets', 'webhook', 'webhookdeliveries', 'sharedfs', 'ipv4subnets', 'asnumbers', 'guestos', 'gpucard', 'gpudevices', 'vgpuprofile' diff --git a/ui/src/config/section/infra/phynetworks.js b/ui/src/config/section/infra/phynetworks.js index 0863eff6ec0b..b0dcb61eb30d 100644 --- a/ui/src/config/section/infra/phynetworks.js +++ b/ui/src/config/section/infra/phynetworks.js @@ -57,7 +57,7 @@ export default { args: ['name', 'zoneid', 'isolationmethods', 'vlan', 'tags', 'networkspeed', 'broadcastdomainrange'], mapping: { isolationmethods: { - options: ['VLAN', 'VXLAN', 'GRE', 'STT', 'BCF_SEGMENT', 'SSP', 'ODL', 'L3VPN', 'VCS', 'NSX', 'NETRIS'] + options: ['VLAN', 'VXLAN', 'GRE', 'STT', 'BCF_SEGMENT', 'SSP', 'ODL', 'L3VPN', 'VCS', 'NSX', 'NETRIS', 'OVN'] } } }, diff --git a/ui/src/config/section/network.js b/ui/src/config/section/network.js index 37cdd0c8b98a..db501acfe1a7 100644 --- a/ui/src/config/section/network.js +++ b/ui/src/config/section/network.js @@ -933,6 +933,70 @@ export default { } ] }, + { + name: 'vpcpeering', + title: 'label.vpc.peering', + icon: 'swap-outlined', + permission: ['listVpcPeerings'], + resourceType: 'VpcPeering', + columns: ['name', 'description', 'vpccount', 'vpcnames', 'state', 'zonename'], + details: ['name', 'id', 'groupuuid', 'description', 'state', 'zonename', 'vpccount', 'vpcnames', 'created'], + searchFilters: ['name', 'zoneid'], + tabs: [{ + name: 'details', + component: shallowRef(defineAsyncComponent(() => import('@/components/view/DetailsTab.vue'))) + }, { + name: 'vpc.peers', + component: shallowRef(defineAsyncComponent(() => import('@/views/network/VpcPeeringMembersTab.vue'))) + }], + actions: [ + { + api: 'createVpcPeering', + icon: 'plus-outlined', + label: 'label.add.vpc.peering', + listView: true, + popup: true, + component: shallowRef(defineAsyncComponent(() => import('@/views/network/CreateVpcPeering.vue'))) + }, + { + api: 'enableVpcPeering', + icon: 'play-circle-outlined', + label: 'label.enable.vpc.peering', + message: 'message.confirm.enable.vpc.peering', + dataView: true, + groupAction: true, + popup: true, + show: (record) => record.state === 'Disabled', + groupShow: (selection, store) => true, + groupMap: (selection) => { return selection.map(x => { return { id: x } }) } + }, + { + api: 'disableVpcPeering', + icon: 'pause-circle-outlined', + label: 'label.disable.vpc.peering', + message: 'message.confirm.disable.vpc.peering', + dataView: true, + groupAction: true, + popup: true, + show: (record) => record.state === 'Active', + groupShow: (selection, store) => true, + groupMap: (selection) => { return selection.map(x => { return { id: x } }) } + }, + { + api: 'deleteVpcPeering', + icon: 'delete-outlined', + label: 'label.delete.vpc.peering', + message: 'message.confirm.delete.vpc.peering.group', + dataView: true, + groupAction: true, + popup: true, + groupMap: (selection) => { return selection.map(x => { return { id: x } }) } + } + ], + show: () => { + return isZoneCreated() + } + }, { name: 'privategw', title: 'label.private.gateway', diff --git a/ui/src/views/AutogenView.vue b/ui/src/views/AutogenView.vue index f4aa842d8f2b..918ad613e1c1 100644 --- a/ui/src/views/AutogenView.vue +++ b/ui/src/views/AutogenView.vue @@ -1082,7 +1082,10 @@ export default { this.$store.getters.customColumns[this.$store.getters.userInfo.id] = {} this.$store.getters.customColumns[this.$store.getters.userInfo.id][this.$route.path] = this.selectedColumns } else { - this.selectedColumns = this.$store.getters.customColumns[this.$store.getters.userInfo.id][this.$route.path] || this.selectedColumns + const cached = this.$store.getters.customColumns[this.$store.getters.userInfo.id][this.$route.path] + const allKeys = this.allColumns.map(c => c.dataIndex) + const validCached = Array.isArray(cached) ? cached.filter(k => allKeys.includes(k)) : [] + this.selectedColumns = validCached.length > 0 ? validCached : this.selectedColumns if (this.$store.getters.listAllProjects && !this.projectView) { this.selectedColumns.push('project') } diff --git a/ui/src/views/infra/network/ServiceProvidersTab.vue b/ui/src/views/infra/network/ServiceProvidersTab.vue index f659ce1f0167..bc48793a275c 100644 --- a/ui/src/views/infra/network/ServiceProvidersTab.vue +++ b/ui/src/views/infra/network/ServiceProvidersTab.vue @@ -1113,6 +1113,50 @@ export default { columns: ['name', 'netrisurl', 'site', 'tenantname', 'netristag'] } ] + }, + { + title: 'Ovn', + details: ['name', 'state', 'id', 'physicalnetworkid', 'servicelist'], + actions: [ + { + api: 'updateNetworkServiceProvider', + icon: 'stop-outlined', + listView: true, + label: 'label.disable.provider', + confirm: 'message.confirm.disable.provider', + show: (record) => { return (record && record.id && record.state === 'Enabled') }, + mapping: { + state: { + value: (record) => { return 'Disabled' } + } + } + }, + { + api: 'updateNetworkServiceProvider', + icon: 'play-circle-outlined', + listView: true, + label: 'label.enable.provider', + confirm: 'message.confirm.enable.provider', + show: (record) => { return (record && record.id && record.state === 'Disabled') }, + mapping: { + state: { + value: (record) => { return 'Enabled' } + } + } + } + ], + lists: [ + { + title: 'label.ovn.provider', + api: 'listOvnProviders', + mapping: { + zoneid: { + value: (record) => { return record.zoneid } + } + }, + columns: ['name', 'ovnnbconnection', 'ovnsbconnection', 'ovnexternalbridge', 'ovnlocalnetname'] + } + ] } ] } diff --git a/ui/src/views/infra/zone/PhysicalNetworksTab.vue b/ui/src/views/infra/zone/PhysicalNetworksTab.vue index cccb8719805f..387a7ea8bf98 100644 --- a/ui/src/views/infra/zone/PhysicalNetworksTab.vue +++ b/ui/src/views/infra/zone/PhysicalNetworksTab.vue @@ -200,7 +200,7 @@ export default { }, computed: { isolationMethods () { - return ['VLAN', 'VXLAN', 'GRE', 'STT', 'BCF_SEGMENT', 'SSP', 'ODL', 'L3VPN', 'VCS', 'NSX', 'NETRIS'] + return ['VLAN', 'VXLAN', 'GRE', 'STT', 'BCF_SEGMENT', 'SSP', 'ODL', 'L3VPN', 'VCS', 'NSX', 'NETRIS', 'OVN'] } }, methods: { diff --git a/ui/src/views/infra/zone/ZoneWizardLaunchZone.vue b/ui/src/views/infra/zone/ZoneWizardLaunchZone.vue index e16afbcf89e3..1730c1587d46 100644 --- a/ui/src/views/infra/zone/ZoneWizardLaunchZone.vue +++ b/ui/src/views/infra/zone/ZoneWizardLaunchZone.vue @@ -493,6 +493,13 @@ export default { physicalNetwork.traffics.findIndex(traffic => traffic.type === 'public' || traffic.type === 'guest') > -1) { this.stepData.isNetrisZone = true } + // Same gating as Netris/NSX: only flag the zone as OVN when the physical + // network actually carries public or guest traffic. Storage- or management- + // only physnets that happen to be tagged OVN do not need a provider entry. + if (physicalNetwork.isolationMethod.toLowerCase() === 'ovn' && + physicalNetwork.traffics.findIndex(traffic => traffic.type === 'public' || traffic.type === 'guest') > -1) { + this.stepData.isOvnZone = true + } } else { this.stepData.physicalNetworkReturned = this.stepData.physicalNetworkItem['createPhysicalNetwork' + index] } @@ -910,6 +917,8 @@ export default { await this.stepAddNsxController() } else if (this.stepData.isNetrisZone) { await this.stepAddNetrisProvider() + } else if (this.stepData.isOvnZone) { + await this.stepAddOvnProvider() } else { await this.stepConfigureStorageTraffic() } @@ -1157,6 +1166,54 @@ export default { this.setStepStatus(STATUS_FAILED) } }, + async stepAddOvnProvider () { + // Mirror of stepAddNetrisProvider: register the OVN central (NB/SB) endpoints with + // the zone via addOvnProvider so OvnGuestNetworkGuru can resolve the provider when + // a network is implemented. Only `name` and `nbconnection` are mandatory; the rest + // are optional and skipped when the operator left the field blank in the wizard. + this.setStepStatus(STATUS_FINISH) + this.currentStep++ + this.addStep('message.add.ovn.controller', 'ovn') + if (this.stepData.stepMove.includes('ovn')) { + await this.stepConfigureStorageTraffic() + return + } + try { + if (!this.stepData.stepMove.includes('addOvnProvider')) { + const providerParams = {} + providerParams.zoneid = this.stepData.zoneReturned.id + providerParams.name = this.prefillContent?.ovnName || '' + providerParams.nbconnection = this.prefillContent?.ovnNbConnection || '' + if (this.prefillContent?.ovnSbConnection) { + providerParams.sbconnection = this.prefillContent.ovnSbConnection + } + if (this.prefillContent?.ovnExternalBridge) { + providerParams.externalbridge = this.prefillContent.ovnExternalBridge + } + if (this.prefillContent?.ovnLocalnetName) { + providerParams.localnetname = this.prefillContent.ovnLocalnetName + } + if (this.prefillContent?.ovnCaCertPath) { + providerParams.cacertpath = this.prefillContent.ovnCaCertPath + } + if (this.prefillContent?.ovnClientCertPath) { + providerParams.clientcertpath = this.prefillContent.ovnClientCertPath + } + if (this.prefillContent?.ovnClientPrivateKeyPath) { + providerParams.clientprivatekeypath = this.prefillContent.ovnClientPrivateKeyPath + } + + await this.addOvnProvider(providerParams) + this.stepData.stepMove.push('addOvnProvider') + } + this.stepData.stepMove.push('ovn') + await this.stepConfigureStorageTraffic() + } catch (e) { + this.messageError = e + this.processStatus = STATUS_FAILED + this.setStepStatus(STATUS_FAILED) + } + }, async stepConfigureStorageTraffic () { let targetNetwork = false this.prefillContent.physicalNetworks.forEach(physicalNetwork => { @@ -2339,6 +2396,16 @@ export default { }) }) }, + addOvnProvider (args) { + return new Promise((resolve, reject) => { + postAPI('addOvnProvider', args).then(json => { + resolve() + }).catch(error => { + const message = error.response.headers['x-description'] + reject(message) + }) + }) + }, configTungstenFabricService (args) { return new Promise((resolve, reject) => { postAPI('configTungstenFabricService', args).then(json => { diff --git a/ui/src/views/infra/zone/ZoneWizardNetworkSetupStep.vue b/ui/src/views/infra/zone/ZoneWizardNetworkSetupStep.vue index 3f00c3c38386..dd9da49d9355 100644 --- a/ui/src/views/infra/zone/ZoneWizardNetworkSetupStep.vue +++ b/ui/src/views/infra/zone/ZoneWizardNetworkSetupStep.vue @@ -102,6 +102,18 @@ :isFixError="isFixError" /> + + network.isolationMethod === 'OVN') > -1 + }, allSteps () { const steps = [] steps.push({ @@ -261,6 +282,12 @@ export default { formKey: 'netris' }) } + if (this.isOvnZone) { + steps.push({ + title: 'label.ovn.provider', + formKey: 'ovn' + }) + } if (this.havingNetscaler) { steps.push({ title: 'label.netScaler', @@ -523,6 +550,68 @@ export default { ] return fields }, + ovnFields () { + // Mirrors the AddOvnProviderCmd parameters in + // plugins/network-elements/ovn/.../api/command/AddOvnProviderCmd.java. + // Only `name` and `nbConnection` are mandatory on the API; everything else is + // optional but operator-recommended for a usable zone: + // - sbConnection lets us prune stale Gateway_Chassis rows (PR-2a/PR-2b) + // - externalBridge / localnetName are how the public LS bridges to the + // physical network via ovn-bridge-mappings + // - the three TLS paths are required when the operator has secured the + // OVSDB endpoints with TLS (NB/SB on ssl:host:6641|6642). + const fields = [ + { + title: 'label.ovn.provider.name', + key: 'ovnName', + placeHolder: 'message.installwizard.tooltip.ovn.provider.name', + required: true + }, + { + title: 'label.ovn.provider.nb.connection', + key: 'ovnNbConnection', + placeHolder: 'message.installwizard.tooltip.ovn.provider.nb.connection', + required: true + }, + { + title: 'label.ovn.provider.sb.connection', + key: 'ovnSbConnection', + placeHolder: 'message.installwizard.tooltip.ovn.provider.sb.connection', + required: false + }, + { + title: 'label.ovn.provider.external.bridge', + key: 'ovnExternalBridge', + placeHolder: 'message.installwizard.tooltip.ovn.provider.external.bridge', + required: false + }, + { + title: 'label.ovn.provider.localnet.name', + key: 'ovnLocalnetName', + placeHolder: 'message.installwizard.tooltip.ovn.provider.localnet.name', + required: false + }, + { + title: 'label.ovn.provider.ca.cert.path', + key: 'ovnCaCertPath', + placeHolder: 'message.installwizard.tooltip.ovn.provider.ca.cert.path', + required: false + }, + { + title: 'label.ovn.provider.client.cert.path', + key: 'ovnClientCertPath', + placeHolder: 'message.installwizard.tooltip.ovn.provider.client.cert.path', + required: false + }, + { + title: 'label.ovn.provider.client.private.key.path', + key: 'ovnClientPrivateKeyPath', + placeHolder: 'message.installwizard.tooltip.ovn.provider.client.private.key.path', + required: false + } + ] + return fields + }, guestTrafficFields () { const fields = [ { @@ -594,6 +683,7 @@ export default { tungstenSetupDescription: 'message.infra.setup.tungsten.description', nsxSetupDescription: 'message.infra.setup.nsx.description', netrisSetupDescription: 'message.infra.setup.netris.description', + ovnSetupDescription: 'message.infra.setup.ovn.description', netscalerSetupDescription: 'label.please.specify.netscaler.info', storageTrafficDescription: 'label.zonewizard.traffictype.storage', podFields: [ diff --git a/ui/src/views/infra/zone/ZoneWizardPhysicalNetworkSetupStep.vue b/ui/src/views/infra/zone/ZoneWizardPhysicalNetworkSetupStep.vue index 88f681de3796..9e5df5e3baa6 100644 --- a/ui/src/views/infra/zone/ZoneWizardPhysicalNetworkSetupStep.vue +++ b/ui/src/views/infra/zone/ZoneWizardPhysicalNetworkSetupStep.vue @@ -68,6 +68,7 @@ TF NSX NETRIS + OVN + + + + diff --git a/ui/src/views/network/VpcPeeringMembersTab.vue b/ui/src/views/network/VpcPeeringMembersTab.vue new file mode 100644 index 000000000000..187732fec25b --- /dev/null +++ b/ui/src/views/network/VpcPeeringMembersTab.vue @@ -0,0 +1,307 @@ +// 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/src/views/network/VpcTab.vue b/ui/src/views/network/VpcTab.vue index 12e21cd8ec94..dbb01d2ff4cd 100644 --- a/ui/src/views/network/VpcTab.vue +++ b/ui/src/views/network/VpcTab.vue @@ -433,6 +433,7 @@ import AnnotationsTab from '@/components/view/AnnotationsTab' import ResourceIcon from '@/components/view/ResourceIcon' import BgpPeersTab from '@/views/infra/zone/BgpPeersTab.vue' import StaticRoutesTab from './StaticRoutesTab' +import TooltipButton from '@/components/widgets/TooltipButton' export default { name: 'VpcTab', @@ -445,6 +446,7 @@ export default { VpcTiersTab, VnfAppliancesTab, StaticRoutesTab, + TooltipButton, EventsTab, AnnotationsTab, ResourceIcon @@ -465,6 +467,10 @@ export default { fetchLoading: false, privateGateways: [], associatedNetworks: [], + vpcPeerings: [], + peerableVpcs: [], + peeringAclList: [], + editingPeeringId: null, vpnGateways: [], publicIps: [], vpnConnections: [], @@ -476,7 +482,11 @@ export default { vpnGatewayLoading: false, vpnConnection: false, vpnConnectionLoading: false, - networkAcl: false + networkAcl: false, + peering: false, + peeringLoading: false, + peeringAcl: false, + peeringAclLoading: false }, placeholders: { vlan: null, @@ -541,10 +551,46 @@ export default { dataIndex: 'description' } ], + vpcPeeringsColumns: [ + { + key: 'peervpcname', + title: this.$t('label.peer.vpc'), + dataIndex: 'peervpcname' + }, + { + title: 'CIDR', + dataIndex: 'peervpccidr' + }, + { + title: this.$t('label.zone'), + dataIndex: 'zonename' + }, + { + title: this.$t('label.link.local.ip'), + dataIndex: 'linklocalip' + }, + { + key: 'aclname', + title: this.$t('label.aclid'), + dataIndex: 'aclname' + }, + { + key: 'state', + title: this.$t('label.state'), + dataIndex: 'state' + }, + { + key: 'actions', + title: '', + dataIndex: 'actions', + width: 100 + } + ], itemCounts: { privateGateways: 0, vpnConnections: 0, - networkAcls: 0 + networkAcls: 0, + vpcPeerings: 0 }, page: 1, pageSize: 10, @@ -615,6 +661,9 @@ export default { case 'acl': this.fetchAclList() break + case 'peering': + this.fetchVpcPeerings() + break case 'comments': this.fetchComments() break @@ -807,6 +856,15 @@ export default { } this.modals.networkAcl = true break + case 'peering': + this.rules = { + peervpcid: [{ required: true, message: this.$t('label.required') }] + } + this.form.peeringaclid = undefined + this.modals.peering = true + this.fetchPeerableVpcs() + this.fetchPeeringAclList() + break } }, handleGatewayFormSubmit () { @@ -989,6 +1047,134 @@ export default { this.fetchLoading = false }) }, + fetchVpcPeerings () { + this.fetchLoading = true + getAPI('listVpcPeerings', { + vpcid: this.resource.id, + page: this.page, + pagesize: this.pageSize + }).then(json => { + this.vpcPeerings = json.listvpcpeeringsresponse?.vpcpeering || [] + this.itemCounts.vpcPeerings = json.listvpcpeeringsresponse?.count || 0 + }).catch(error => { + this.$notifyError(error) + }).finally(() => { + this.fetchLoading = false + }) + }, + fetchPeerableVpcs () { + this.modals.peeringLoading = true + this.peerableVpcs = [] + // First get OVN-enabled zones + var ovnZoneIds = new Set() + var fetchVpcsAfterZones = () => { + getAPI('listVPCs', { + account: this.resource.account, + domainid: this.resource.domainid, + listAll: true + }).then(json => { + var vpcs = json.listvpcsresponse?.vpc || [] + this.peerableVpcs = vpcs.filter(v => + v.id !== this.resource.id && (ovnZoneIds.size === 0 || ovnZoneIds.has(v.zoneid)) + ) + if (this.peerableVpcs.length > 0) { + this.form.peervpcid = this.peerableVpcs[0].id + } + }).catch(error => { + this.$notifyError(error) + }).finally(() => { + this.modals.peeringLoading = false + }) + } + if ('listOvnProviders' in this.$store.getters.apis) { + getAPI('listOvnProviders', {}).then(json => { + var providers = json.listovnprovidersresponse?.ovnprovider || [] + providers.forEach(p => ovnZoneIds.add(p.zoneid)) + }).catch(() => { + // If listOvnProviders fails, show all VPCs + }).finally(() => { + fetchVpcsAfterZones() + }) + } else { + fetchVpcsAfterZones() + } + }, + handleCreateVpcPeering () { + if (this.modals.peeringLoading) return + this.modals.peeringLoading = true + + this.formRef.value.validate().then(() => { + const data = toRaw(this.form) + const params = { + vpcid: this.resource.id, + peervpcid: data.peervpcid + } + if (data.peeringaclid) { + params.aclid = data.peeringaclid + } + postAPI('createVpcPeering', params).then(response => { + this.$message.success(this.$t('message.success.add.vpc.peering')) + this.modals.peering = false + this.fetchVpcPeerings() + }).catch(error => { + this.$notifyError(error) + }).finally(() => { + this.modals.peeringLoading = false + }) + }).catch(error => { + this.formRef.value.scrollToField(error.errorFields[0].name) + this.modals.peeringLoading = false + }) + }, + handleDeleteVpcPeering (record) { + this.fetchLoading = true + postAPI('deleteVpcPeering', { + id: record.id + }).then(() => { + this.$message.success(this.$t('label.action.delete.succeeded')) + this.fetchVpcPeerings() + }).catch(error => { + this.$notifyError(error) + }).finally(() => { + this.fetchLoading = false + }) + }, + fetchPeeringAclList () { + getAPI('listNetworkACLLists', { + vpcid: this.resource.id, + listAll: true + }).then(json => { + this.peeringAclList = json.listnetworkacllistsresponse?.networkacllist || [] + }).catch(error => { + this.$notifyError(error) + }) + }, + handleEditPeeringAcl (record) { + this.editingPeeringId = record.id + this.form.peeringaclid = record.aclid || undefined + this.modals.peeringAcl = true + this.fetchPeeringAclList() + }, + handleUpdatePeeringAcl () { + if (this.modals.peeringAclLoading) return + this.modals.peeringAclLoading = true + const data = toRaw(this.form) + const params = { + id: this.editingPeeringId + } + if (data.peeringaclid) { + params.aclid = data.peeringaclid + } + postAPI('updateVpcPeering', params).then(() => { + this.$message.success(this.$t('message.success.update.vpc.peering')) + this.modals.peeringAcl = false + this.fetchVpcPeerings() + }).catch(error => { + this.$notifyError(error) + }).finally(() => { + this.modals.peeringAclLoading = false + }) + }, changePage (page, pageSize) { this.page = page this.pageSize = pageSize