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..16bdccbeb833 --- /dev/null +++ b/api/src/main/java/com/cloud/network/ovn/OvnProvider.java @@ -0,0 +1,33 @@ +// 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(); +} 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..e7c61497c720 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,13 @@ 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 S3_ACCESS_KEY = "accesskey"; public static final String SECRET_KEY = "secretkey"; public static final String S3_END_POINT = "endpoint"; @@ -1307,6 +1314,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/element/OvnProviderVO.java b/engine/schema/src/main/java/com/cloud/network/element/OvnProviderVO.java new file mode 100644 index 000000000000..959e0315ec16 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/network/element/OvnProviderVO.java @@ -0,0 +1,282 @@ +// 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 = "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; + } + + 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; + + 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 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); + return provider; + } + } +} 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..8467f9950fd2 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,7 @@ + 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..9b032ba02d5e 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,26 @@ 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', + `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; 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/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..4d3e07229f14 --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/command/AddOvnProviderCmd.java @@ -0,0 +1,125 @@ +// 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; + + 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; + } + + @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/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/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/response/OvnProviderResponse.java b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/response/OvnProviderResponse.java new file mode 100644 index 000000000000..8782a4d92f66 --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/api/response/OvnProviderResponse.java @@ -0,0 +1,159 @@ +// 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; + + 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; + } +} 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..0b12aa719f99 --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnElement.java @@ -0,0 +1,2060 @@ +// 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.element.OvnProviderVO; +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.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 java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.inject.Inject; + +public class OvnElement extends AdapterBase implements DhcpServiceProvider, DnsServiceProvider, VpcProvider, + StaticNatServiceProvider, IpDeployer, PortForwardingServiceProvider, FirewallServiceProvider, + NetworkACLServiceProvider, LoadBalancingServiceProvider { + + 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; + + 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); + } + } 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)); + } + + /** + * 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.isSourceNat()) { + continue; + } + addresses.append(' ').append(ipVo.getAddress().addr()); + any = true; + } + } + if (!any) { + return; + } + Map options = new HashMap<>(); + options.put("nat-addresses", addresses.toString()); + // Without this, ovn-controller would also gARP every Load_Balancer VIP on the LR; we have + // no LBs yet, but this stays consistent with the Neutron OVN driver default. + options.put("exclude-lb-vips-from-garp", "true"); + 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.isSourceNat()) { + continue; + } + addresses.append(' ').append(ipVo.getAddress().addr()); + any = true; + } + } + if (!any) { + return; + } + Map options = new HashMap<>(); + options.put("nat-addresses", addresses.toString()); + options.put("exclude-lb-vips-from-garp", "true"); + 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); + 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); + } + + @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); + } 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); + } 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())); + // 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; + } +} 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..96059e1b4c19 --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnNbClient.java @@ -0,0 +1,2083 @@ +// 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 LOAD_BALANCER_TABLE = "Load_Balancer"; + private static final String LOAD_BALANCER_HEALTH_CHECK_TABLE = "Load_Balancer_Health_Check"; + 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); + } + + 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) { + 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); + } + 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); + Operation select = OVSDB_OPS.select(chassisTable).column(nameCol); + 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()) { + 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. + */ + 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; + }); + } + + /** + * 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))); + } + } + + @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/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..b47be9967132 --- /dev/null +++ b/plugins/network-elements/ovn/src/main/java/org/apache/cloudstack/service/OvnProviderServiceImpl.java @@ -0,0 +1,183 @@ +// 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.DeleteOvnProviderCmd; +import org.apache.cloudstack.api.command.ListOvnProvidersCmd; +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()) + .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.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); + } + 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/vpc/VpcManagerImpl.java b/server/src/main/java/com/cloud/network/vpc/VpcManagerImpl.java index 5b4ab908ca4c..8deb8a5869ac 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,32 @@ 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) { + // Out of scope for OVN VPC v1 - no VPN provider in the offering. + continue; + } else { + svcProviderMap.put(svc, 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 +1763,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 +2542,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 +3930,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 +3940,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 +3988,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..60bd3c2b7438 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -1694,6 +1694,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 +1796,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", @@ -3109,6 +3123,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 +3639,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 +3664,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/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/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