weizhouapache commented on code in PR #13032: URL: https://github.com/apache/cloudstack/pull/13032#discussion_r3342437049
########## framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/network/NetworkExtensionElement.java: ########## @@ -0,0 +1,2728 @@ +// 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.framework.extensions.network; + +import java.io.File; +import java.net.URI; +import java.net.InetAddress; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.inject.Inject; +import javax.naming.ConfigurationException; + +import com.cloud.agent.api.to.LoadBalancerTO; +import com.cloud.dc.DataCenter; +import com.cloud.dc.Vlan; +import com.cloud.dc.VlanVO; +import com.cloud.dc.dao.DataCenterDao; +import com.cloud.dc.dao.VlanDao; +import com.cloud.deploy.DeployDestination; +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.host.dao.HostDao; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.network.IpAddressManager; +import com.cloud.network.IpAddress; +import com.cloud.network.Network; +import com.cloud.network.Network.Capability; +import com.cloud.network.Network.Provider; +import com.cloud.network.Network.Service; +import com.cloud.network.NetworkModel; +import com.cloud.network.Networks; +import com.cloud.network.dao.NetworkDetailVO; +import com.cloud.network.dao.NetworkDao; +import com.cloud.network.dao.NetworkVO; +import com.cloud.network.dao.PhysicalNetworkDao; +import com.cloud.network.dao.PhysicalNetworkVO; +import com.cloud.network.PhysicalNetworkServiceProvider; +import com.cloud.network.PublicIpAddress; +import com.cloud.network.addr.PublicIp; +import com.cloud.network.dao.FirewallRulesDao; +import com.cloud.network.dao.IPAddressDao; +import com.cloud.network.dao.IPAddressVO; +import com.cloud.network.dao.NetworkDetailsDao; +import com.cloud.network.dao.NetworkServiceMapDao; +import com.cloud.network.vpc.dao.VpcDao; +import com.cloud.network.element.AggregatedCommandExecutor; +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.network.element.NetworkElement; +import com.cloud.network.element.PortForwardingServiceProvider; +import com.cloud.network.element.SourceNatServiceProvider; +import com.cloud.network.element.StaticNatServiceProvider; +import com.cloud.network.element.UserDataServiceProvider; +import com.cloud.network.element.VpcProvider; +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.network.lb.LoadBalancingRule; +import com.cloud.network.rules.FirewallRule; +import com.cloud.network.rules.FirewallRuleVO; +import com.cloud.network.rules.PortForwardingRule; +import com.cloud.network.rules.StaticNat; +import com.cloud.offerings.NetworkOfferingVO; +import com.cloud.offerings.dao.NetworkOfferingDao; +import com.cloud.service.ServiceOfferingVO; +import com.cloud.service.dao.ServiceOfferingDao; +import com.cloud.storage.dao.GuestOSCategoryDao; +import com.cloud.storage.dao.GuestOSDao; +import com.cloud.user.AccountService; +import com.cloud.uservm.UserVm; +import com.cloud.offering.NetworkOffering; +import com.cloud.user.Account; +import com.cloud.utils.Pair; +import com.cloud.utils.component.AdapterBase; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.vm.Nic; +import com.cloud.vm.NicProfile; +import com.cloud.vm.NicVO; +import com.cloud.vm.ReservationContext; +import com.cloud.vm.UserVmVO; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.VirtualMachineManager; +import com.cloud.vm.VirtualMachineProfile; +import com.cloud.vm.VMInstanceDetailVO; +import com.cloud.vm.VmDetailConstants; +import com.cloud.vm.dao.NicDao; +import com.cloud.vm.dao.UserVmDao; +import com.cloud.vm.dao.VMInstanceDetailsDao; +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.engine.orchestration.service.NetworkOrchestrationService; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.extension.ExtensionHelper; +import org.apache.cloudstack.extension.NetworkCustomActionProvider; +import org.apache.cloudstack.framework.extensions.dao.ExtensionDetailsDao; +import org.apache.cloudstack.resourcedetail.dao.VpcDetailsDao; +import org.apache.commons.lang3.EnumUtils; +import org.apache.commons.lang3.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.stream.Collectors; + + +public class NetworkExtensionElement extends AdapterBase implements + NetworkElement, SourceNatServiceProvider, StaticNatServiceProvider, + PortForwardingServiceProvider, IpDeployer, NetworkCustomActionProvider, + DhcpServiceProvider, DnsServiceProvider, FirewallServiceProvider, + UserDataServiceProvider, LoadBalancingServiceProvider, + VpcProvider, NetworkACLServiceProvider, AggregatedCommandExecutor { + + private static final Map<Service, Map<Capability, String>> DEFAULT_CAPABILITIES = new HashMap<>(); + + + /** + * When non-null, restricts all operations to the extension whose name + * matches this provider name. + */ + private String providerName; + + @Inject + private NetworkModel networkModel; + @Inject + private NetworkServiceMapDao ntwkSrvcDao; + @Inject + private ExtensionHelper extensionHelper; + @Inject + private NetworkDetailsDao networkDetailsDao; + @Inject + private IpAddressManager ipAddressManager; + @Inject + private NetworkOrchestrationService networkManager; + @Inject + private AccountService accountService; + @Inject + private PhysicalNetworkDao physicalNetworkDao; + @Inject + private ExtensionDetailsDao extensionDetailsDao; + @Inject + private NetworkDao networkDao; + @Inject + private DataCenterDao dataCenterDao; + @Inject + private VlanDao vlanDao; + @Inject + private GuestOSCategoryDao guestOSCategoryDao; + @Inject + private GuestOSDao guestOSDao; + @Inject + private HostDao hostDao; + @Inject + private VMInstanceDetailsDao vmInstanceDetailsDao; + @Inject + private UserVmDao userVmDao; + @Inject + private NicDao nicDao; + @Inject + private NetworkOfferingDao networkOfferingDao; + @Inject + private ServiceOfferingDao serviceOfferingDao; + @Inject + private FirewallRulesDao firewallRulesDao; + @Inject + private IPAddressDao ipAddressDao; + @Inject + private VpcDao vpcDao; + @Inject + private VpcDetailsDao vpcDetailsDao; + + // ---- Script argument names ---- + + public static final String ARG_PHYSICAL_NETWORK_EXTENSION_DETAILS = "physical-network-extension-details"; + public static final String ARG_NETWORK_EXTENSION_DETAILS = "network-extension-details"; + public static final String ARG_PAYLOAD = "payload"; + public static final String ARG_ACTION_PARAMS = "action-params"; + + public static final int DEFAULT_SCRIPT_TIMEOUT_SECONDS = 60; + + public static final int EXIT_CODE_SUCCESS = 0; + public static final int EXIT_CODE_FAILURE = -1; + + // ---- Script command names ---- + + public static final String CMD_IMPLEMENT_NETWORK = "implement-network"; + public static final String CMD_SHUTDOWN_NETWORK = "shutdown-network"; + public static final String CMD_DESTROY_NETWORK = "destroy-network"; + public static final String CMD_ENSURE_NETWORK_DEVICE = "ensure-network-device"; + public static final String CMD_ASSIGN_IP = "assign-ip"; + public static final String CMD_RELEASE_IP = "release-ip"; + public static final String CMD_ADD_STATIC_NAT = "add-static-nat"; + public static final String CMD_DELETE_STATIC_NAT = "delete-static-nat"; + public static final String CMD_ADD_PORT_FORWARD = "add-port-forward"; + public static final String CMD_DELETE_PORT_FORWARD = "delete-port-forward"; + public static final String CMD_ADD_DHCP_ENTRY = "add-dhcp-entry"; + public static final String CMD_CONFIG_DHCP_SUBNET = "config-dhcp-subnet"; + public static final String CMD_REMOVE_DHCP_SUBNET = "remove-dhcp-subnet"; + public static final String CMD_SET_DHCP_OPTIONS = "set-dhcp-options"; + public static final String CMD_REMOVE_DHCP_ENTRY = "remove-dhcp-entry"; + public static final String CMD_ADD_DNS_ENTRY = "add-dns-entry"; + public static final String CMD_CONFIG_DNS_SUBNET = "config-dns-subnet"; + public static final String CMD_REMOVE_DNS_SUBNET = "remove-dns-subnet"; + public static final String CMD_SAVE_VM_DATA = "save-vm-data"; + public static final String CMD_SAVE_PASSWORD = "save-password"; + public static final String CMD_SAVE_USERDATA = "save-userdata"; + public static final String CMD_SAVE_SSHKEY = "save-sshkey"; + public static final String CMD_SAVE_HYPERVISOR_HOSTNAME = "save-hypervisor-hostname"; + public static final String CMD_APPLY_LB_RULES = "apply-lb-rules"; + public static final String CMD_APPLY_FW_RULES = "apply-fw-rules"; + public static final String CMD_RESTORE_NETWORK = "restore-network"; + public static final String CMD_IMPLEMENT_VPC = "implement-vpc"; + public static final String CMD_SHUTDOWN_VPC = "shutdown-vpc"; + public static final String CMD_UPDATE_VPC_SOURCE_NAT_IP = "update-vpc-source-nat-ip"; + public static final String CMD_APPLY_NETWORK_ACL = "apply-network-acl"; + public static final String CMD_CUSTOM_ACTION = "custom-action"; + + // ---- Network detail key ---- + + /** + * Key used to persist the per-network JSON blob in {@code network_details}. + * The blob is produced by the network-extension.sh's {@code ensure-network-device} + * command and may contain any fields the script needs (e.g. selected host, + * namespace name, VRF ID, …). + */ + public static final String NETWORK_DETAIL_EXTENSION_DETAILS = "extension.details"; + + public String getProviderName() { + return providerName; + } + + /** + * Returns a new {@link NetworkExtensionElement} scoped to {@code providerName}, + * sharing all injected dependencies with this instance. + */ + public NetworkExtensionElement withProviderName(String providerName) { + NetworkExtensionElement copy = new NetworkExtensionElement(); + copy.networkModel = this.networkModel; + copy.ntwkSrvcDao = this.ntwkSrvcDao; + copy.extensionHelper = this.extensionHelper; + copy.networkDetailsDao = this.networkDetailsDao; + copy.ipAddressManager = this.ipAddressManager; + copy.physicalNetworkDao = this.physicalNetworkDao; + copy.extensionDetailsDao = this.extensionDetailsDao; + copy.networkDao = this.networkDao; + copy.dataCenterDao = this.dataCenterDao; + copy.vlanDao = this.vlanDao; + copy.guestOSCategoryDao = this.guestOSCategoryDao; + copy.guestOSDao = this.guestOSDao; + copy.hostDao = this.hostDao; + copy.vmInstanceDetailsDao = this.vmInstanceDetailsDao; + copy.userVmDao = this.userVmDao; + copy.nicDao = this.nicDao; + copy.networkManager = this.networkManager; + copy.networkOfferingDao = this.networkOfferingDao; + copy.serviceOfferingDao = this.serviceOfferingDao; + copy.accountService = this.accountService; + copy.firewallRulesDao = this.firewallRulesDao; + copy.ipAddressDao = this.ipAddressDao; + copy.vpcDao = this.vpcDao; + copy.vpcDetailsDao = this.vpcDetailsDao; + copy.providerName = providerName; + + logger.debug("NetworkExtensionElement initialised with provider name '{}'", providerName); + return copy; + } + + // ---- Capabilities ---- + + @Override + public Map<Service, Map<Capability, String>> getCapabilities() { + try { + // If this element is scoped to a provider name, prefer capabilities stored + // in the extension's "network.service.capabilities" detail. The ExtensionHelper + // exposes a helper that loads the Service→Capability map from the DB. + if (providerName != null && !providerName.isBlank()) { + Map<Service, Map<Capability, String>> caps = extensionHelper.getNetworkCapabilitiesForProvider(null, providerName); + if (caps != null && !caps.isEmpty()) { + return caps; + } + } + } catch (Exception e) { + logger.warn("Failed to load network service capabilities from extension details for provider '{}': {}", providerName, e.getMessage()); + } + + return DEFAULT_CAPABILITIES; + } + + @Override + public Provider getProvider() { + if (providerName != null) { + return Provider.createTransientProvider(providerName); + } + return Provider.NetworkExtension; + } + + // ---- Extension / provider resolution ---- + + protected Extension resolveExtension(Network network) { + Long physicalNetworkId = network.getPhysicalNetworkId(); + if (physicalNetworkId == null) { + logger.warn("Network {} has no physical network — cannot resolve extension", network.getId()); + return null; + } + if (providerName != null && !providerName.isBlank()) { + Extension ext = extensionHelper.getExtensionForPhysicalNetworkAndProvider(physicalNetworkId, providerName); + if (ext != null) { + return ext; + } + logger.warn("No extension found for scoped provider '{}' on physical network {}", providerName, physicalNetworkId); + } + List<String> providers = ntwkSrvcDao.getDistinctProviders(network.getId()); + if (providers != null) { + for (String p : providers) { + Extension ext = extensionHelper.getExtensionForPhysicalNetworkAndProvider(physicalNetworkId, p); + if (ext != null) { + return ext; + } + } + } + return null; + } + + protected boolean canHandle(Network network, Service service) { + Long physicalNetworkId = network.getPhysicalNetworkId(); + if (physicalNetworkId == null) { + return false; + } + if (providerName != null && !providerName.isBlank()) { + boolean hasExt = extensionHelper.getExtensionForPhysicalNetworkAndProvider(physicalNetworkId, providerName) != null; + if (!hasExt) { + return false; + } + if (service == null) { + return true; + } + List<String> sp = ntwkSrvcDao.getProvidersForServiceInNetwork(network.getId(), service); + return sp != null && sp.stream() + .anyMatch(p -> extensionHelper.getExtensionForPhysicalNetworkAndProvider(physicalNetworkId, p) != null); + } + List<String> providers = ntwkSrvcDao.getDistinctProviders(network.getId()); + if (providers == null || providers.isEmpty()) { + return false; + } + boolean hasExtProv = providers.stream().anyMatch( + p -> extensionHelper.getExtensionForPhysicalNetworkAndProvider(physicalNetworkId, p) != null); + if (!hasExtProv) { + return false; + } + if (service == null) { + return true; + } + List<String> sp = ntwkSrvcDao.getProvidersForServiceInNetwork(network.getId(), service); + return sp != null && sp.stream() + .anyMatch(p -> extensionHelper.getExtensionForPhysicalNetworkAndProvider(physicalNetworkId, p) != null); + } + + @Override + public boolean configure(String name, Map<String, Object> params) throws ConfigurationException { + super.configure(name, params); + return true; + } + + // ---- NetworkElement lifecycle ---- + + @Override + public boolean implement(Network network, NetworkOffering offering, DeployDestination dest, + ReservationContext context) throws ConcurrentOperationException, + ResourceUnavailableException, InsufficientCapacityException { + if (!canHandle(network, null)) { + return false; + } + logger.info("Implementing network extension for network {} (VLAN {})", network.getId(), network.getBroadcastUri()); + + // Step 1: Ensure a network device is selected and its details stored. + ensureExtensionDetails(network); + + // Step 2: Allocate the IPs for DHCP/DNS/UserData service if needed + String extensionIp = ensureExtensionIp(network); + + String vlanId = getVlanId(network); + + // Step 2: Create the network on the device. + JsonObject implementPayload = new JsonObject(); + implementPayload.addProperty("network_id", String.valueOf(network.getId())); + implementPayload.addProperty("vlan", safeStr(vlanId)); + implementPayload.addProperty("gateway", safeStr(network.getGateway())); + implementPayload.addProperty("cidr", safeStr(network.getCidr())); + implementPayload.addProperty("extension_ip", safeStr(extensionIp)); + addVpcIdToPayload(implementPayload, network); + + Pair<Integer, String> result = executeScriptAndReturnOutput(network, CMD_IMPLEMENT_NETWORK, implementPayload); + + if (result.first() != EXIT_CODE_SUCCESS) { + return false; + } + + // Update the network properties from the output + applyNetworkUpdateFromScriptOutput(network, result.second()); + + // Step 3: Configure source NAT for both VPC and non-VPC networks for + // compatibility (other network-element providers may also implement VPC tiers). + // When this is a VPC tier, the script's assign-ip does nothing for source-nat + // because VPC source NAT is managed at the VPC level by implementVpc(). + if (canHandle(network, Service.SourceNat)) { + try { + if (network.getVpcId() == null) { + // Isolated network: apply the network's own source NAT IP. + Account owner = accountService.getAccount(network.getAccountId()); + PublicIpAddress existingIp = networkModel.getSourceNatIpAddressForGuestNetwork(owner, network); + if (existingIp != null) { + applyIps(network, List.of(existingIp), Set.of(Service.SourceNat)); + } + } else { + // VPC tier: apply the VPC-level source NAT IP (script is a no-op for SNAT). + final PublicIpAddress vpcSourceNatIp = getVpcSourceNatIp(network.getVpcId()); + if (vpcSourceNatIp != null) { + applyIps(network, List.of(vpcSourceNatIp), Set.of(Service.SourceNat)); + } + } + } catch (Exception e) { + logger.warn("Failed to configure source NAT IP for network {}: {}", network.getId(), e.getMessage(), e); + } + } + + return true; + } + + @Override + public boolean prepare(Network network, NicProfile nic, VirtualMachineProfile vm, + DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, ResourceUnavailableException, InsufficientCapacityException { + // Copy from VirtualRouterElement.java + if (vm.getType() != VirtualMachine.Type.User || vm.getHypervisorType() == Hypervisor.HypervisorType.BareMetal) { + return false; + } + + if (!canHandle(network, null)) { + return false; + } + + if (!networkModel.isProviderEnabledInPhysicalNetwork(networkModel.getPhysicalNetworkId(network), getProvider().getName())) { + return false; + } + + // Sync nic with network + applyNicUpdateFromNetwork(network, nic.getId()); + + final NetworkOfferingVO offering = networkOfferingDao.findById(network.getNetworkOfferingId()); + implement(network, offering, dest, context); + + return true; + } + + private void applyNicUpdateFromNetwork(Network network, Long nicId) { + if (nicId == null) { + return; + } + try { + NicVO nicVo = nicDao.findById(nicId); + if (nicVo == null) { + return; + } + if (network.getBroadcastUri() != null) { + nicVo.setBroadcastUri(network.getBroadcastUri()); + nicVo.setIsolationUri(network.getBroadcastUri()); + } + nicDao.update(nicId, nicVo); + } catch (Exception e) { + logger.debug("Failed to update nic {}: {}", nicId, e.getMessage()); + } + } + + @Override + public boolean release(Network network, NicProfile nic, VirtualMachineProfile vm, + ReservationContext context) throws ConcurrentOperationException, ResourceUnavailableException { + return true; + } + + @Override + public boolean shutdown(Network network, ReservationContext context, boolean cleanup) + throws ConcurrentOperationException, ResourceUnavailableException { + logger.info("Shutting down network extension for network {}", network.getId()); + JsonObject payload = new JsonObject(); + payload.addProperty("network_id", String.valueOf(network.getId())); + payload.addProperty("vlan", safeStr(getVlanId(network))); + addVpcIdToPayload(payload, network); + boolean result = executeScript(network, CMD_SHUTDOWN_NETWORK, payload); + if (result) { + // Remove stored per-network extension details (e.g. namespace). For VPC-backed networks + // the namespace is named cs-vpc-<vpcId>, stored in the extension details. Removing the + // stored details ensures the namespace is deleted/forgotten on shutdown. + try { + networkDetailsDao.removeDetail(network.getId(), NETWORK_DETAIL_EXTENSION_DETAILS); + } catch (Exception e) { + logger.warn("Failed to remove network extension details for network {}: {}", network.getId(), e.getMessage()); + } + } + return result; + } + + @Override + public boolean destroy(Network network, ReservationContext context) + throws ConcurrentOperationException, ResourceUnavailableException { + logger.info("Destroying network extension for network {}", network.getId()); + JsonObject payload = new JsonObject(); + payload.addProperty("network_id", String.valueOf(network.getId())); + payload.addProperty("vlan", safeStr(getVlanId(network))); + addVpcIdToPayload(payload, network); + // For both isolated and VPC tier networks, use destroy-network. + // For VPC tiers, the script preserves the shared namespace; + // the VPC namespace is removed only when shutdownVpc() calls shutdown-vpc. + boolean result = executeScript(network, CMD_DESTROY_NETWORK, payload); + if (result) { + cleanupPlaceholderNicIp(network, context); + networkDetailsDao.removeDetail(network.getId(), NETWORK_DETAIL_EXTENSION_DETAILS); + } + return result; + } + + /** + * Releases placeholder NIC IPs allocated for DHCP/DNS/UserData extension traffic, + * then removes the placeholder NIC record(s) for this network. + */ + protected void cleanupPlaceholderNicIp(Network network, ReservationContext context) { + List<NicVO> placeholderNics = nicDao.listPlaceholderNicsByNetworkIdAndVmType( + network.getId(), VirtualMachine.Type.DomainRouter); + if (placeholderNics == null || placeholderNics.isEmpty()) { + return; + } + + long userId = accountService.getSystemUser().getId(); + Account caller = accountService.getSystemAccount(); + if (context != null && context.getAccount() != null) { + caller = context.getAccount(); + } + + for (NicVO placeholderNic : placeholderNics) { + try { + String ip = placeholderNic.getIPv4Address(); + if (ip != null && !ip.isBlank()) { + logger.debug("Cleaning up PlaceHolder IP {} on network {}", ip, network.getId()); + IPAddressVO ipAddress = ipAddressDao.findByIpAndSourceNetworkId(network.getId(), ip); + if (ipAddress != null) { + if (Network.GuestType.Shared.equals(network.getGuestType())) { + ipAddressManager.disassociatePublicIpAddress(ipAddress, userId, caller); + } else { + ipAddressManager.markIpAsUnavailable(ipAddress.getId()); + ipAddressDao.unassignIpAddress(ipAddress.getId()); + } + } + } + } catch (Exception e) { + logger.warn("Failed to release placeholder IP for network {} and nic {}: {}", + network.getId(), placeholderNic.getId(), e.getMessage()); + } + + try { + nicDao.remove(placeholderNic.getId()); + } catch (Exception e) { + logger.warn("Failed to remove placeholder nic {} for network {}: {}", + placeholderNic.getId(), network.getId(), e.getMessage()); + } + } + } + + @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<Service> services) { + return true; + } + + // ---- ensure-network-device ---- + + /** + * Calls the network-extension.sh script with {@code ensure-network-device} before + * the first network operation. The script verifies the previously selected + * device is reachable (using the {@code hosts} list in the physical-network + * extension details) and performs failover if needed. The returned JSON is + * persisted in {@code network_details} and forwarded to all subsequent calls + * via {@value #ARG_NETWORK_EXTENSION_DETAILS}. + * + * <p>For VPC tier networks the extension details are inherited from the VPC-level + * details (stored in {@code vpc_details}) so all tiers in the same VPC share + * the same host/namespace binding. The script's {@code ensure-network-device} + * is only called at the VPC level (see {@link #ensureExtensionDetails(Vpc)}).</p> + */ + protected void ensureExtensionDetails(Network network) { + if (network.getVpcId() != null) { + Vpc vpc = vpcDao.findById(network.getVpcId()); + ensureExtensionDetails(vpc); + return; + } + + // Isolated network: run ensure-network-device to select / validate the host. + Map<String, String> stored = networkDetailsDao.listDetailsKeyPairs(network.getId()); + String currentDetails = stored != null + ? stored.getOrDefault(NETWORK_DETAIL_EXTENSION_DETAILS, "{}") : "{}"; + + logger.info("Ensuring network device for network {} (current={})", network.getId(), currentDetails); + + Extension extension = resolveExtension(network); + File scriptFile = resolveScriptFile(network, extension); + + JsonObject argsPayload = new JsonObject(); + argsPayload.addProperty("network_id", String.valueOf(network.getId())); + argsPayload.addProperty("vlan", safeStr(getVlanId(network))); + argsPayload.addProperty("zone_id", String.valueOf(network.getDataCenterId())); + argsPayload.addProperty("current_details", currentDetails); + addVpcIdToPayload(argsPayload, network); + JsonObject payload = buildNetworkScriptPayload(network, argsPayload, extension); + + try { + Pair<Integer, String> result = executeScriptWithFilePayload(scriptFile, + CMD_ENSURE_NETWORK_DEVICE, payload, "Network extension"); + String output = result.second() != null ? result.second() : ""; + + if (result.first() != EXIT_CODE_SUCCESS) { + logger.warn("ensure-network-device exited {} for network {} — keeping current details", + -1, network.getId()); + if ("{}".equals(currentDetails)) { + networkDetailsDao.addDetail(network.getId(), NETWORK_DETAIL_EXTENSION_DETAILS, "{}", false); + } + return; + } + if (output.isEmpty()) { + output = "{}".equals(currentDetails) ? "{}" : currentDetails; + } + if (!output.equals(currentDetails)) { + logger.info("Network device updated for network {}: {}", network.getId(), output); + networkDetailsDao.addDetail(network.getId(), NETWORK_DETAIL_EXTENSION_DETAILS, output, false); + } else { + logger.debug("Network device unchanged for network {}: {}", network.getId(), output); + } + } catch (Exception e) { + logger.warn("Failed ensure-network-device for network {}: {}", network.getId(), e.getMessage()); + if ("{}".equals(currentDetails)) { + networkDetailsDao.addDetail(network.getId(), NETWORK_DETAIL_EXTENSION_DETAILS, "{}", false); + } + } + } + + /* + * If the network supports DHCP/DNS/UserData but not SourceNat/Gateway, + * an additional IP is needed on the external network to host these services. + * This method ensures that IP is allocated and configured on the external network and returns its address. + */ + protected String ensureExtensionIp(Network network) { + if (networkModel.isAnyServiceSupportedInNetwork(network.getId(), this.getProvider(), + Service.SourceNat, Service.Gateway)) { + // Gateway or Source NAT will be configured on the external network + return network.getGateway(); + } + + if (networkModel.isAnyServiceSupportedInNetwork(network.getId(), this.getProvider(), + Service.Dhcp, Service.Dns, Service.UserData)) { + try { + // An extra IP will be allocated and configured on the external network + Nic placeholderNic = networkModel.getPlaceholderNicForRouter(network, null); + if (placeholderNic == null) { + NetworkDetailVO routerIpDetail = networkDetailsDao.findDetail(network.getId(), ApiConstants.ROUTER_IP); + String routerIp = routerIpDetail != null ? routerIpDetail.getValue() : null; + Account account = accountService.getAccount(network.getAccountId()); + String extensionIp = Network.GuestType.Shared.equals(network.getGuestType()) ? + ipAddressManager.assignPublicIpAddress(network.getDataCenterId(), null, account, Vlan.VlanType.DirectAttached, network.getId(), routerIp, false, false).getAddress().toString(): + ipAddressManager.acquireGuestIpAddress(network, routerIp); + logger.debug("Saving placeholder nic with ip4 address {} for the network", extensionIp, network); + networkManager.savePlaceholderNic(network, extensionIp, null, VirtualMachine.Type.DomainRouter); + return extensionIp; + } + return placeholderNic.getIPv4Address(); + } catch (Exception e) { + logger.warn("Failed to acquire extension IP for network {}: {}", network.getId(), e.getMessage()); + } + } + return null; + } + + // ---- IpDeployer ---- + + @Override + public boolean applyIps(Network network, List<? extends PublicIpAddress> ipAddress, Set<Service> services) + throws ResourceUnavailableException { + if (ipAddress == null || ipAddress.isEmpty()) { + return true; + } + logger.info("Applying {} IPs for network {}", ipAddress.size(), network.getId()); + String vlanId = getVlanId(network); + + for (PublicIpAddress ip : ipAddress) { + boolean isSourceNat = ip.isSourceNat(); + boolean isRevoke = ip.getState() == IpAddress.State.Releasing; + String action = isRevoke ? CMD_RELEASE_IP : CMD_ASSIGN_IP; + + // Public VLAN tag (e.g. "101") from the IP's VLAN record. + String publicVlanTag = safeStr(ip.getVlanTag()); + + // Compute public IP gateway and CIDR (from the PublicIpAddress if available) + String publicGateway; + String publicCidr; + try { + publicGateway = ip.getGateway(); + String publicIpStr = ip.getAddress() != null ? ip.getAddress().addr() : null; + String publicNetmask = ip.getNetmask(); + publicCidr = buildCidrFromIpAndNetmask(publicIpStr, publicNetmask); + } catch (Exception e) { + publicGateway = null; + publicCidr = null; + } + + JsonObject payload = new JsonObject(); + payload.addProperty("network_id", String.valueOf(network.getId())); + payload.addProperty("vlan", safeStr(vlanId)); + payload.addProperty("public_ip", ip.getAddress().addr()); + payload.addProperty("source_nat", String.valueOf(isSourceNat)); + payload.addProperty("gateway", safeStr(network.getGateway())); + payload.addProperty("cidr", safeStr(network.getCidr())); + payload.addProperty("public_gateway", safeStr(publicGateway)); + payload.addProperty("public_cidr", safeStr(publicCidr)); + payload.addProperty("public_vlan", publicVlanTag); + addVpcIdToPayload(payload, network); + + boolean result = executeScript(network, action, payload); + if (!result) { + throw new ResourceUnavailableException( + "Failed to " + action + " for IP " + ip.getAddress().addr(), + Network.class, network.getId()); + } + } + return true; + } + + /** + * Build a CIDR string from IP address and dotted netmask (or prefix). + * Returns "" if either value is null or parsing fails. + */ + private String buildCidrFromIpAndNetmask(String ipStr, String netmaskStr) { + if (ipStr == null || ipStr.isEmpty() || netmaskStr == null || netmaskStr.isEmpty()) { + return ""; + } + // If netmask is already CIDR (contains '/'), try to return network/prefix + if (netmaskStr.contains("/")) { + return netmaskStr; + } + try { + InetAddress ip = InetAddress.getByName(ipStr); + InetAddress mask = InetAddress.getByName(netmaskStr); + int maskInt = ByteBuffer.wrap(mask.getAddress()).getInt(); + int prefix = Integer.bitCount(maskInt); + // Return the provided IP with the calculated prefix so the address retains its host value + return ip.getHostAddress() + "/" + prefix; + } catch (Exception e) { + logger.debug("Failed to compute CIDR from ip/netmask {} {}: {}", ipStr, netmaskStr, e.getMessage()); + return ""; + } + } + + // ---- StaticNatServiceProvider ---- + + @Override + public boolean applyStaticNats(Network config, List<? extends StaticNat> rules) + throws ResourceUnavailableException { + if (rules == null || rules.isEmpty()) { + return true; + } + if (!canHandle(config, Service.StaticNat)) { + return false; + } + logger.info("Applying {} static NAT rules for network {}", rules.size(), config.getId()); + String vlanId = getVlanId(config); + for (StaticNat rule : rules) { + String action = rule.isForRevoke() ? CMD_DELETE_STATIC_NAT : CMD_ADD_STATIC_NAT; + String publicCidr = getPublicCidr(rule.getSourceIpAddressId()); + String publicVlanTag = getPublicVlanTag(rule.getSourceIpAddressId()); + + JsonObject payload = new JsonObject(); + payload.addProperty("network_id", String.valueOf(config.getId())); + payload.addProperty("vlan", safeStr(vlanId)); + payload.addProperty("public_ip", getIpAddress(rule.getSourceIpAddressId())); + payload.addProperty("public_cidr", safeStr(publicCidr)); + payload.addProperty("public_vlan", publicVlanTag); + payload.addProperty("private_ip", safeStr(rule.getDestIpAddress())); + addVpcIdToPayload(payload, config); + boolean result = executeScript(config, action, payload); + if (!result) { + throw new ResourceUnavailableException("Failed to " + action + " for static NAT rule", + Network.class, config.getId()); + } + } + return true; + } + + // ---- PortForwardingServiceProvider ---- + + @Override + public boolean applyPFRules(Network network, List<PortForwardingRule> rules) + throws ResourceUnavailableException { + if (rules == null || rules.isEmpty()) { + return true; + } + if (!canHandle(network, Service.PortForwarding)) { + return false; + } + logger.info("Applying {} port forwarding rules for network {}", rules.size(), network.getId()); + String vlanId = getVlanId(network); + for (PortForwardingRule rule : rules) { + boolean isRevoke = rule.getState() == FirewallRule.State.Revoke; + String action = isRevoke ? CMD_DELETE_PORT_FORWARD : CMD_ADD_PORT_FORWARD; + String publicPort = PortForwardingServiceProvider.getPublicPortRange(rule); + String privatePort = PortForwardingServiceProvider.getPrivatePFPortRange(rule); + String publicCidr = getPublicCidr(rule.getSourceIpAddressId()); + String publicVlanTag = getPublicVlanTag(rule.getSourceIpAddressId()); + + JsonObject payload = new JsonObject(); + payload.addProperty("network_id", String.valueOf(network.getId())); + payload.addProperty("vlan", safeStr(vlanId)); + payload.addProperty("public_ip", getIpAddress(rule.getSourceIpAddressId())); + payload.addProperty("public_cidr", safeStr(publicCidr)); + payload.addProperty("public_vlan", publicVlanTag); + payload.addProperty("public_port", safeStr(publicPort)); + payload.addProperty("private_ip", safeStr(rule.getDestinationIpAddress() != null + ? rule.getDestinationIpAddress().addr() : null)); + payload.addProperty("private_port", safeStr(privatePort)); + payload.addProperty("protocol", safeStr(rule.getProtocol())); + addVpcIdToPayload(payload, network); + boolean result = executeScript(network, action, payload); + if (!result) { + throw new ResourceUnavailableException("Failed to " + action + " for port forwarding rule", + Network.class, network.getId()); + } + } + return true; + } + + // ---- Script execution ---- + + protected boolean executeScript(Network network, String command, JsonObject argsPayload) { + return executeScriptAndReturnOutput(network, command, argsPayload).first() == 0; + } + + protected Pair<Integer, String> executeScriptAndReturnOutput(Network network, String command, JsonObject argsPayload) { + ensureExtensionDetails(network); + Extension extension = resolveExtension(network); + JsonObject payload = buildNetworkScriptPayload(network, argsPayload, extension); + return executeScriptWithFilePayload(network, command, payload); + } + + private JsonObject parseJsonOutput(String outputStr) { + if (StringUtils.isBlank(outputStr)) { + return null; + } + try { + JsonElement parsed = JsonParser.parseString(outputStr); + if (!parsed.isJsonObject()) { + logger.debug("Ignoring non-object script output: {}", outputStr); + return null; + } + return parsed.getAsJsonObject(); + } catch (Exception e) { + logger.debug("Ignoring non-JSON script output: {}", outputStr); + return null; + } + } + + private String getJsonString(JsonObject jsonObject, String keyPath) { + if (jsonObject == null || StringUtils.isBlank(keyPath)) { + return null; + } + JsonElement value = jsonObject.has(keyPath) ? jsonObject.get(keyPath) : null; + if (value == null) { + JsonElement current = jsonObject; + String[] parts = keyPath.split("\\."); + for (String part : parts) { + if (current == null || !current.isJsonObject()) { + current = null; + break; + } + JsonObject currentObj = current.getAsJsonObject(); + if (!currentObj.has(part)) { + current = null; + break; + } + current = currentObj.get(part); + } + value = current; + } + if (value == null || value.isJsonNull()) { + return null; + } + return value.getAsString(); + } + + private void applyNetworkUpdateFromScriptOutput(Network network, String outputStr) { + JsonObject outputJson = parseJsonOutput(outputStr); + + String networkBroadcastUri = getJsonString(outputJson, "network.broadcast_uri"); + String networkBroadcastDomainType = getJsonString(outputJson, "network.broadcast_domain_type"); + if (networkBroadcastUri == null && networkBroadcastDomainType == null) { + return; + } + + try { + NetworkVO networkVo = networkDao.findById(network.getId()); + if (networkVo == null) { + return; + } + + boolean changed = false; + if (networkBroadcastDomainType != null) { + Networks.BroadcastDomainType domainType = EnumUtils.getEnumIgnoreCase(Networks.BroadcastDomainType.class, networkBroadcastDomainType); + if (domainType != null) { + networkVo.setBroadcastDomainType(domainType); + changed = true; + } else { + logger.warn("Ignoring unknown broadcast domain type '{}' for network {}", + networkBroadcastDomainType, network.getId()); + } + } + + if (networkBroadcastUri != null) { + networkVo.setBroadcastUri(URI.create(networkBroadcastUri)); + changed = true; + } + + if (changed) { + networkDao.update(networkVo.getId(), networkVo); + for (NicVO nicVO : nicDao.listByNetworkId(networkVo.getId())) { + applyNicUpdateFromNetwork(network, nicVO.getId()); + } + } + } catch (Exception e) { + logger.warn("Failed to update network {} from script output: {}", network.getId(), e.getMessage()); + } + } + + protected Pair<Integer, String> executeScriptWithFilePayload(Network network, String command, JsonObject payload) { + Extension extension = resolveExtension(network); + File scriptFile = resolveScriptFile(network, extension); + return executeScriptWithFilePayload(scriptFile, command, payload, "Network extension"); + } + + private Pair<Integer, String> executeScriptWithFilePayload(File scriptFile, String command, + JsonObject payload, String logPrefix) { + File payloadFile = null; + try { + payloadFile = File.createTempFile("cs-extnet-" + command + "-", ".payload"); + String payloadJson = payload != null ? new Gson().toJson(payload) : "{}"; + logger.debug("Writing payload to payload file {}", payloadFile); + Files.writeString(payloadFile.toPath(), payloadJson, StandardCharsets.UTF_8); + + List<String> cmdLine = new ArrayList<>(); + cmdLine.add(scriptFile.getAbsolutePath()); + cmdLine.add(command); + cmdLine.add(payloadFile.getAbsolutePath()); + cmdLine.add(String.valueOf(DEFAULT_SCRIPT_TIMEOUT_SECONDS)); + + logger.debug("Executing {} script: {}", logPrefix, String.join(" ", cmdLine)); + + ProcessBuilder processBuilder = new ProcessBuilder(cmdLine); + processBuilder.redirectErrorStream(true); + Process process = processBuilder.start(); + byte[] output = process.getInputStream().readAllBytes(); + int exitCode = process.waitFor(); + + String outputStr = new String(output).trim(); + if (!outputStr.isEmpty()) { + logger.debug("Script output: {}", outputStr); + } + + if (exitCode != EXIT_CODE_SUCCESS) { + logger.error("{} script {} failed with exit code {}: {}", logPrefix, command, exitCode, outputStr); + return new Pair<>(exitCode, outputStr); + } + + JsonObject outputJson = parseJsonOutput(outputStr); + String status = outputJson != null ? getJsonString(outputJson, "status") : null; + if (StringUtils.isNotBlank(status) && !"success".equalsIgnoreCase(status)) { + logger.error("{} script {} returned non-success status '{}': {}", logPrefix, command, status, outputStr); + return new Pair<>(EXIT_CODE_FAILURE, outputStr); + } + + return new Pair<>(EXIT_CODE_SUCCESS, outputStr); + } catch (Exception e) { + throw new CloudRuntimeException( + String.format("Failed preparing payload file for command %s", command), e); + } finally { + if (payloadFile != null && payloadFile.exists() && !payloadFile.delete()) { + payloadFile.deleteOnExit(); + } + } + } + + private JsonObject buildNetworkScriptPayload(Network network, JsonObject argsPayload, Extension extension) { + JsonObject payload = new JsonObject(); + payload.add(ARG_PHYSICAL_NETWORK_EXTENSION_DETAILS, + buildPhysicalNetworkExtensionDetailsPayload(network.getPhysicalNetworkId(), extension)); + payload.add(ARG_NETWORK_EXTENSION_DETAILS, buildNetworkExtensionDetailsPayload(network)); + payload.add(ARG_PAYLOAD, argsPayload != null ? argsPayload : new JsonObject()); + return payload; + } + + private void addVpcIdToPayload(JsonObject payload, Network network) { + if (payload != null && network != null && network.getVpcId() != null) { + payload.addProperty("vpc_id", String.valueOf(network.getVpcId())); + } + } + + private void addNicUuidToPayload(JsonObject payload, NicProfile nic) { + if (payload != null && nic != null && StringUtils.isNotBlank(nic.getUuid())) { + payload.addProperty("nic_uuid", nic.getUuid()); + } + } + + // ---- Detail helpers ---- + + /** + * Returns all {@code extension_resource_map_details} for the given extension + * on the physical network as a plain map, enriched with physical-network + * metadata (name, kvmnetworklabel, vmwarenetworklabel, xennetworklabel, + * public_kvmnetworklabel) so the wrapper script can derive bridge names and + * interface names without extra lookups. + */ + private Map<String, String> buildPhysicalNetworkDetailsMap(Long physicalNetworkId, Extension extension) { + Map<String, String> details = new HashMap<>(); + if (physicalNetworkId == null || extension == null) { + return details; + } + // Start with registered extension_resource_map_details + Map<String, String> mapDetails = extensionHelper.getAllResourceMapDetailsForExtensionOnPhysicalNetwork( + physicalNetworkId, extension.getId()); + if (mapDetails != null) { + details.putAll(mapDetails); + } + + // Enrich with physical-network record fields + PhysicalNetworkVO pn = physicalNetworkDao.findById(physicalNetworkId); + if (pn != null && pn.getName() != null) { + details.put("physicalnetworkname", pn.getName()); + } + + return details; + } + + /** + * Builds the physical-network extension details as a {@link JsonObject}. + * Includes all {@code extension_resource_map_details} for the extension on the + * physical network, enriched with physical-network metadata fields. + */ + private JsonObject buildPhysicalNetworkExtensionDetailsPayload(Long physicalNetworkId, Extension extension) { + Map<String, String> map = buildPhysicalNetworkDetailsMap(physicalNetworkId, extension); + JsonObject obj = new JsonObject(); + for (Map.Entry<String, String> entry : map.entrySet()) { + if (entry.getValue() != null) { + obj.addProperty(entry.getKey(), entry.getValue()); + } + } + return obj; + } + + /** + * Returns the per-network extension-details JSON blob (the value stored under + * {@code NETWORK_DETAIL_EXTENSION_DETAILS} in {@code network_details} or + * {@code vpc_details}) as a {@link JsonObject}. + * Returns an empty object when no blob has been stored yet. + */ + private JsonObject buildNetworkExtensionDetailsPayload(Network network) { + String json; + if (network.getVpcId() != null) { + Map<String, String> vpcDetails = vpcDetailsDao.listDetailsKeyPairs(network.getVpcId()); + json = vpcDetails != null ? vpcDetails.getOrDefault(NETWORK_DETAIL_EXTENSION_DETAILS, "{}") : "{}"; + } else { + Map<String, String> networkDetails = networkDetailsDao.listDetailsKeyPairs(network.getId()); + json = networkDetails != null ? networkDetails.getOrDefault(NETWORK_DETAIL_EXTENSION_DETAILS, "{}") : "{}"; + } + return parseJsonObjectOrEmpty(json); + } + + /** + * Returns the VPC-level extension-details JSON blob (stored under + * {@code NETWORK_DETAIL_EXTENSION_DETAILS} in {@code vpc_details}) as a + * {@link JsonObject}. Returns an empty object when no blob has been stored. + */ + private JsonObject buildVpcExtensionDetailsPayload(long vpcId) { + Map<String, String> vpcDetails = vpcDetailsDao.listDetailsKeyPairs(vpcId); + String json = vpcDetails != null ? vpcDetails.getOrDefault(NETWORK_DETAIL_EXTENSION_DETAILS, "{}") : "{}"; + return parseJsonObjectOrEmpty(json); + } + + /** + * Builds the custom-action parameters as a {@link JsonObject}. + * Returns an empty object for {@code null} or empty parameter maps. + */ + private JsonObject buildActionParamsPayload(Map<String, Object> parameters) { + JsonObject obj = new JsonObject(); + if (parameters == null || parameters.isEmpty()) { + return obj; + } + for (Map.Entry<String, Object> entry : parameters.entrySet()) { + obj.addProperty(entry.getKey(), + entry.getValue() != null ? entry.getValue().toString() : ""); + } + return obj; + } + + /** + * Parses a JSON string into a {@link JsonObject}. + * Returns an empty {@link JsonObject} when the input is {@code null}, blank, + * or not a valid JSON object. + */ + private JsonObject parseJsonObjectOrEmpty(String json) { + if (json == null || json.isBlank()) { + return new JsonObject(); + } + try { + JsonElement element = JsonParser.parseString(json); + return element.isJsonObject() ? element.getAsJsonObject() : new JsonObject(); + } catch (Exception e) { + return new JsonObject(); + } + } + + // ---- Custom action ---- + + @Override + public boolean canHandleCustomAction(Network network) { + return canHandle(network, Service.CustomAction); + } + + /** + * Runs a custom action on the external network device. + * The custom action payload is written to a temporary file and passed to the + * extension script via {@link #executeScriptWithFilePayload(File, String, JsonObject, String)}. + */ + @Override + public String runCustomAction(Network network, String actionName, Map<String, Object> parameters) { + Extension extension = resolveExtension(network); + File scriptFile = resolveScriptFile(network, extension); + + JsonObject payload = buildCustomActionPayload(network, extension, actionName, parameters); + + logger.info("Running custom action '{}' on network {} (extension: {}, params: {} key(s))", + actionName, network.getId(), extension != null ? extension.getName() : "unknown", + parameters != null ? parameters.size() : 0); + + try { + Pair<Integer, String> result = executeScriptWithFilePayload(scriptFile, CMD_CUSTOM_ACTION, payload, + "Network extension"); + String outputStr = result.second() != null ? result.second().trim() : ""; + + if (result.first() != EXIT_CODE_SUCCESS) { + logger.error("Custom action '{}' failed: {}", actionName, outputStr); + return null; + } + logger.info("Custom action '{}' completed successfully", actionName); + return outputStr.isEmpty() ? "OK" : outputStr; + } catch (Exception e) { + logger.error("Failed to execute custom action '{}': {}", actionName, e.getMessage(), e); + throw new CloudRuntimeException("Failed to execute custom action: " + actionName, e); + } + } + + @Override + public boolean canHandleVpcCustomAction(Vpc vpc) { + return resolveExtensionForVpc(vpc) != null; + } + + /** + * Runs a custom action on the external network device for a VPC. + * The custom action payload is written to a temporary file and passed to the + * extension script via {@link #executeScriptWithFilePayload(File, String, JsonObject, String)}. + */ + @Override + public String runCustomAction(Vpc vpc, String actionName, Map<String, Object> parameters) { + Pair<Long, Extension> physNetAndExt = resolveExtensionForVpc(vpc); + if (physNetAndExt == null) { + throw new CloudRuntimeException("No extension found for VPC " + vpc.getId()); + } + Long physicalNetworkId = physNetAndExt.first(); + Extension extension = physNetAndExt.second(); + File scriptFile = resolveScriptFileForVpc(physicalNetworkId, extension); + + JsonObject payload = buildCustomActionPayload(vpc, physicalNetworkId, extension, actionName, parameters); + + logger.info("Running custom action '{}' on VPC {} (extension: {}, params: {} key(s))", + actionName, vpc.getId(), extension != null ? extension.getName() : "unknown", + parameters != null ? parameters.size() : 0); + + try { + Pair<Integer, String> result = executeScriptWithFilePayload(scriptFile, CMD_CUSTOM_ACTION, payload, + "VPC extension"); + String outputStr = result.second() != null ? result.second().trim() : ""; + + if (result.first() != EXIT_CODE_SUCCESS) { + logger.error("VPC custom action '{}' failed: {}", actionName, outputStr); + return null; + } + logger.info("VPC custom action '{}' completed successfully", actionName); + return outputStr.isEmpty() ? "OK" : outputStr; + } catch (Exception e) { + logger.error("Failed to execute VPC custom action '{}': {}", actionName, e.getMessage(), e); + throw new CloudRuntimeException("Failed to execute VPC custom action: " + actionName, e); + } + } + + private JsonObject buildCustomActionPayload(Network network, Extension extension, String actionName, + Map<String, Object> parameters) { + JsonObject payload = new JsonObject(); + payload.addProperty("network_id", String.valueOf(network.getId())); + addVpcIdToPayload(payload, network); + payload.addProperty("action", actionName); + payload.add(ARG_ACTION_PARAMS, buildActionParamsPayload(parameters)); + payload.add(ARG_PHYSICAL_NETWORK_EXTENSION_DETAILS, + buildPhysicalNetworkExtensionDetailsPayload(network.getPhysicalNetworkId(), extension)); + payload.add(ARG_NETWORK_EXTENSION_DETAILS, + buildNetworkExtensionDetailsPayload(network)); + return payload; + } + + private JsonObject buildCustomActionPayload(Vpc vpc, Long physicalNetworkId, Extension extension, + String actionName, Map<String, Object> parameters) { + JsonObject payload = new JsonObject(); + payload.addProperty("vpc_id", String.valueOf(vpc.getId())); + payload.addProperty("action", actionName); + payload.add(ARG_ACTION_PARAMS, buildActionParamsPayload(parameters)); + payload.add(ARG_PHYSICAL_NETWORK_EXTENSION_DETAILS, + buildPhysicalNetworkExtensionDetailsPayload(physicalNetworkId, extension)); + payload.add(ARG_NETWORK_EXTENSION_DETAILS, + buildVpcExtensionDetailsPayload(vpc.getId())); + return payload; + } + + // ---- Script file resolution ---- + + /** + * Resolves the executable script file from the given extension. + * + * <p>Lookup order (first match wins):</p> + * <ol> + * <li>{@code <extensionPath>/<extensionName>.sh} — preferred convention, + * e.g. for an extension named {@code network-extension} the script is + * {@code network-extension.sh}.</li> + * <li>{@code <extensionPath>} itself, if it is a file and is executable.</li> + * </ol> + */ + protected File resolveScriptFile(Network network, Extension extension) { + Long physicalNetworkId = network.getPhysicalNetworkId(); + if (physicalNetworkId == null) { + throw new CloudRuntimeException("Network " + network.getId() + " has no physical network"); + } + if (extension == null) { + throw new CloudRuntimeException( + "No NetworkOrchestrator extension found for network " + network.getId() + + " on physical network " + physicalNetworkId); + } + if (!Extension.Type.NetworkOrchestrator.equals(extension.getType())) { + throw new CloudRuntimeException("Extension " + extension.getName() + " is not of type NetworkOrchestrator"); + } + if (!Extension.State.Enabled.equals(extension.getState())) { + throw new CloudRuntimeException("Extension " + extension.getName() + " is not enabled"); + } + if (!extension.isPathReady()) { + throw new CloudRuntimeException("Extension " + extension.getName() + " path is not ready"); + } + + String extensionPath = extensionHelper.getExtensionScriptPath(extension); + if (extensionPath == null) { + throw new CloudRuntimeException("Could not resolve path for extension " + extension.getName()); + } + + File extensionDir = new File(extensionPath); + if (extensionDir.isFile() && extensionDir.canExecute()) { + return extensionDir; + } + + throw new CloudRuntimeException( + "No executable script found in extension path " + extensionPath + + ". Expected '" + extension.getName() + ".sh' inside the extension directory."); + } + + // ---- Helpers ---- + + private String getVlanId(Network network) { + return network.getBroadcastUri() != null + ? Networks.BroadcastDomainType.getValue(network.getBroadcastUri()) : null; + } + + private String getIpAddress(Long ipAddressId) { + if (ipAddressId == null) { + return ""; + } + IpAddress ip = networkModel.getIp(ipAddressId); + return ip != null ? ip.getAddress().addr() : ""; + } + + private String getPublicCidr(Long ipAddressId) { + if (ipAddressId == null) { + return ""; + } + IpAddress ip = networkModel.getIp(ipAddressId); + if (ip.getAddress() == null) { + return ""; + } + VlanVO vlan = vlanDao.findById(ip.getVlanId()); + return buildCidrFromIpAndNetmask(ip.getAddress().addr(), vlan.getVlanNetmask()); + } + + private String getPublicVlanTag(Long ipAddressId) { + if (ipAddressId == null) { + return ""; + } + IpAddress ip = networkModel.getIp(ipAddressId); + if (ip == null) { + return ""; + } + VlanVO vlan = vlanDao.findById(ip.getVlanId()); + return vlan != null ? safeStr(vlan.getVlanTag()) : ""; + } + + private String safeStr(String value) { + return value != null ? value : ""; + } + + // ---- DhcpServiceProvider ---- + + private String getNetworkDns(final Network network) { + final DataCenter dc = dataCenterDao.findById(network.getDataCenterId()); + Pair<String, String> dnsList = networkModel.getNetworkIp4Dns(network, dc); + return dnsList.first() + (dnsList.second() != null ? "," + dnsList.second() : ""); + } + + @Override + public boolean addDhcpEntry(Network network, NicProfile nic, VirtualMachineProfile vm, + DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, InsufficientCapacityException, ResourceUnavailableException { + if (!canHandle(network, Service.Dhcp)) { + return false; + } + String extensionIp = ensureExtensionIp(network); + logger.debug("addDhcpEntry: network={} mac={} ip={}", network.getId(), + nic.getMacAddress(), nic.getIPv4Address()); + JsonObject payload = new JsonObject(); + payload.addProperty("network_id", String.valueOf(network.getId())); + payload.addProperty("mac", safeStr(nic.getMacAddress())); + payload.addProperty("ip", safeStr(nic.getIPv4Address())); + payload.addProperty("hostname", safeStr(vm.getHostName())); + payload.addProperty("gateway", safeStr(network.getGateway())); + payload.addProperty("cidr", safeStr(network.getCidr())); + payload.addProperty("dns", safeStr(getNetworkDns(network))); + payload.addProperty("default_nic", String.valueOf(nic.isDefaultNic())); + payload.addProperty("domain", safeStr(network.getNetworkDomain())); + payload.addProperty("extension_ip", safeStr(extensionIp)); + addNicUuidToPayload(payload, nic); + addVpcIdToPayload(payload, network); + return executeScript(network, CMD_ADD_DHCP_ENTRY, payload); + } + + @Override + public boolean configDhcpSupportForSubnet(Network network, NicProfile nic, VirtualMachineProfile vm, + DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, InsufficientCapacityException, ResourceUnavailableException { + if (!canHandle(network, Service.Dhcp)) { + return false; + } + logger.debug("configDhcpSupportForSubnet: network={}", network.getId()); + String extensionIp = ensureExtensionIp(network); + JsonObject payload = new JsonObject(); + payload.addProperty("network_id", String.valueOf(network.getId())); + payload.addProperty("gateway", safeStr(network.getGateway())); + payload.addProperty("cidr", safeStr(network.getCidr())); + payload.addProperty("dns", safeStr(getNetworkDns(network))); + payload.addProperty("vlan", safeStr(getVlanId(network))); + payload.addProperty("domain", safeStr(network.getNetworkDomain())); + payload.addProperty("extension_ip", safeStr(extensionIp)); + addNicUuidToPayload(payload, nic); + addVpcIdToPayload(payload, network); + return executeScript(network, CMD_CONFIG_DHCP_SUBNET, payload); + } + + @Override + public boolean removeDhcpSupportForSubnet(Network network) throws ResourceUnavailableException { + if (!canHandle(network, Service.Dhcp)) { + return false; + } + logger.debug("removeDhcpSupportForSubnet: network={}", network.getId()); + String extensionIp = ensureExtensionIp(network); + JsonObject payload = new JsonObject(); + payload.addProperty("network_id", String.valueOf(network.getId())); + payload.addProperty("extension_ip", safeStr(extensionIp)); + addVpcIdToPayload(payload, network); + return executeScript(network, CMD_REMOVE_DHCP_SUBNET, payload); + } + + @Override + public boolean setExtraDhcpOptions(Network network, long nicId, Map<Integer, String> dhcpOptions) { + if (!canHandle(network, Service.Dhcp)) { + return false; + } + if (dhcpOptions == null || dhcpOptions.isEmpty()) { + return true; + } + logger.debug("setExtraDhcpOptions: network={} nicId={} options={}", network.getId(), nicId, dhcpOptions.size()); + // Serialise options as a compact JSON object: {"<code>":"<value>", ...} + StringBuilder json = new StringBuilder("{"); + boolean first = true; + for (Map.Entry<Integer, String> e : dhcpOptions.entrySet()) { + if (!first) json.append(","); + json.append("\"").append(e.getKey()).append("\":\"") + .append(e.getValue() != null ? e.getValue().replace("\"", "\\\"") : "") + .append("\""); + first = false; + } + json.append("}"); + String extensionIp = ensureExtensionIp(network); + JsonObject payload = new JsonObject(); + payload.addProperty("network_id", String.valueOf(network.getId())); + payload.addProperty("nic_id", String.valueOf(nicId)); + payload.addProperty("options", json.toString()); + payload.addProperty("extension_ip", safeStr(extensionIp)); + addVpcIdToPayload(payload, network); + try { + return executeScript(network, CMD_SET_DHCP_OPTIONS, payload); + } catch (Exception e) { + logger.warn("setExtraDhcpOptions failed for network {}: {}", network.getId(), e.getMessage()); + return false; + } + } + + @Override + public boolean removeDhcpEntry(Network network, NicProfile nic, VirtualMachineProfile vmProfile) + throws ResourceUnavailableException { + if (!canHandle(network, Service.Dhcp)) { + return false; + } + logger.debug("removeDhcpEntry: network={} mac={} ip={}", network.getId(), + nic.getMacAddress(), nic.getIPv4Address()); + String extensionIp = ensureExtensionIp(network); + JsonObject payload = new JsonObject(); + payload.addProperty("network_id", String.valueOf(network.getId())); + payload.addProperty("mac", safeStr(nic.getMacAddress())); + payload.addProperty("ip", safeStr(nic.getIPv4Address())); + payload.addProperty("extension_ip", safeStr(extensionIp)); + addNicUuidToPayload(payload, nic); + addVpcIdToPayload(payload, network); + return executeScript(network, CMD_REMOVE_DHCP_ENTRY, payload); + } + + // ---- DnsServiceProvider ---- + + @Override + public boolean addDnsEntry(Network network, NicProfile nic, VirtualMachineProfile vm, + DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, InsufficientCapacityException, ResourceUnavailableException { + if (!canHandle(network, Service.Dns)) { + return false; + } + String hostname = vm.getHostName(); + logger.debug("addDnsEntry: network={} hostname={} ip={}", network.getId(), + hostname, nic.getIPv4Address()); + String extensionIp = ensureExtensionIp(network); + JsonObject payload = new JsonObject(); + payload.addProperty("network_id", String.valueOf(network.getId())); + payload.addProperty("ip", safeStr(nic.getIPv4Address())); + payload.addProperty("hostname", safeStr(hostname)); + payload.addProperty("extension_ip", safeStr(extensionIp)); + addNicUuidToPayload(payload, nic); + addVpcIdToPayload(payload, network); + return executeScript(network, CMD_ADD_DNS_ENTRY, payload); + } + + @Override + public boolean configDnsSupportForSubnet(Network network, NicProfile nic, VirtualMachineProfile vm, + DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, InsufficientCapacityException, ResourceUnavailableException { + if (!canHandle(network, Service.Dns)) { + return false; + } + logger.debug("configDnsSupportForSubnet: network={}", network.getId()); + String extensionIp = ensureExtensionIp(network); + JsonObject payload = new JsonObject(); + payload.addProperty("network_id", String.valueOf(network.getId())); + payload.addProperty("gateway", safeStr(network.getGateway())); + payload.addProperty("cidr", safeStr(network.getCidr())); + payload.addProperty("dns", safeStr(getNetworkDns(network))); + payload.addProperty("vlan", safeStr(getVlanId(network))); + payload.addProperty("domain", safeStr(network.getNetworkDomain())); + payload.addProperty("extension_ip", safeStr(extensionIp)); + addNicUuidToPayload(payload, nic); + addVpcIdToPayload(payload, network); + return executeScript(network, CMD_CONFIG_DNS_SUBNET, payload); + } + + @Override + public boolean removeDnsSupportForSubnet(Network network) throws ResourceUnavailableException { + if (!canHandle(network, Service.Dns)) { + return false; + } + logger.debug("removeDnsSupportForSubnet: network={}", network.getId()); + String extensionIp = ensureExtensionIp(network); + JsonObject payload = new JsonObject(); + payload.addProperty("network_id", String.valueOf(network.getId())); + payload.addProperty("extension_ip", safeStr(extensionIp)); + addVpcIdToPayload(payload, network); + return executeScript(network, CMD_REMOVE_DNS_SUBNET, payload); + } + + // ---- UserDataServiceProvider ---- + + @Override + public boolean addPasswordAndUserdata(Network network, NicProfile nic, VirtualMachineProfile profile, + DeployDestination dest, ReservationContext context) + throws ConcurrentOperationException, InsufficientCapacityException, ResourceUnavailableException { + if (!canHandle(network, Service.UserData)) { + return false; + } + + VirtualMachine vm = profile.getVirtualMachine(); + + // SSH public key from VM instance details + String sshPublicKey = null; + try { + VMInstanceDetailVO sshKeyDetail = vmInstanceDetailsDao.findDetail(profile.getId(), VmDetailConstants.SSH_PUBLIC_KEY); + if (sshKeyDetail != null) { + sshPublicKey = sshKeyDetail.getValue(); + } + } catch (Exception e) { + logger.debug("Could not fetch SSH public key for VM {}: {}", profile.getId(), e.getMessage()); + } + + // Service offering display name + String serviceOfferingName = ""; + try { + serviceOfferingName = profile.getServiceOffering().getDisplayText(); + } catch (Exception e) { + logger.debug("Could not fetch service offering for VM {}: {}", profile.getId(), e.getMessage()); + } + + // Is Windows guest? + boolean isWindows = false; + try { + isWindows = guestOSCategoryDao + .findById(guestOSDao.findById(vm.getGuestOSId()).getCategoryId()) + .getName().equalsIgnoreCase("Windows"); + } catch (Exception e) { + logger.debug("Could not determine OS type for VM {}: {}", profile.getId(), e.getMessage()); + } + + // Hypervisor hostname – prefer dest host, fall back to current host + String destHostname = null; + try { + if (dest != null && dest.getHost() != null) { + destHostname = VirtualMachineManager.getHypervisorHostname(dest.getHost().getName()); + } else if (vm.getHostId() != null) { + destHostname = VirtualMachineManager.getHypervisorHostname( + hostDao.findById(vm.getHostId()).getName()); + } + } catch (Exception e) { + logger.debug("Could not resolve hypervisor hostname for VM {}: {}", profile.getId(), e.getMessage()); + } + + // Password from the VM profile parameter (set by UserVmManager before deployment) + String password = (String) profile.getParameter(VirtualMachineProfile.Param.VmPassword); + + // Use this NIC's IP — the metadata server in each namespace identifies requesters + // by REMOTE_ADDR, which will be the VM's IP on THIS network (not necessarily the + // default NIC IP), so we always key metadata by the NIC's IP on this network. + String nicIpAddress = nic.getIPv4Address(); + + logger.debug("addPasswordAndUserdata: network={} ip={} hasPassword={} hasSshKey={}", + network.getId(), nicIpAddress, + password != null && !password.isEmpty(), + sshPublicKey != null && !sshPublicKey.isEmpty()); + + final UserVmVO userVm = userVmDao.findById(vm.getId()); + if (userVm == null) { + throw new CloudRuntimeException("Could not find UserVmVO for VM " + vm.getId()); + } + + // Generate the full metadata set (userdata, meta-data/*, password) in one go + List<String[]> vmData = networkModel.generateVmData( + userVm.getUserData(), + userVm.getUserDataDetails(), + serviceOfferingName, + vm.getDataCenterId(), + profile.getInstanceName(), + profile.getHostName(), + profile.getId(), + profile.getUuid(), + nicIpAddress, + sshPublicKey, + password, + isWindows, + destHostname); + + if (vmData == null || vmData.isEmpty()) { + logger.debug("addPasswordAndUserdata: no VM data generated for network={} ip={}", network.getId(), nicIpAddress); + return true; + } + + // Serialise vmData as JSON array. + // For the userdata entry CloudStack stores user-data base64-encoded; decode it so the + // wrapper writes the actual bytes. All other fields are plain strings. In both cases we + // then re-encode with Base64 so the single --vm-data argument is shell-safe. + StringBuilder json = new StringBuilder("["); + boolean first = true; + for (String[] entry : vmData) { + String dir = entry[NetworkModel.CONFIGDATA_DIR]; + String file = entry[NetworkModel.CONFIGDATA_FILE]; + String content = entry.length > NetworkModel.CONFIGDATA_CONTENT + ? entry[NetworkModel.CONFIGDATA_CONTENT] : null; + if (content == null) content = ""; + + byte[] contentBytes; + if (NetworkModel.USERDATA_DIR.equals(dir) && NetworkModel.USERDATA_FILE.equals(file)) { + // user-data is stored as base64 in CloudStack DB; decode it for the wrapper + try { + contentBytes = Base64.getDecoder().decode(content); + } catch (Exception e) { + contentBytes = content.getBytes(StandardCharsets.UTF_8); + } + } else { + contentBytes = content.getBytes(StandardCharsets.UTF_8); + } + + if (!first) json.append(","); + first = false; + json.append("{\"dir\":\"").append(jsonEscape(dir)) + .append("\",\"file\":\"").append(jsonEscape(file)) + .append("\",\"content\":\"") + .append(Base64.getEncoder().encodeToString(contentBytes)) + .append("\"}"); + } + json.append("]"); + + // Wrap the entire JSON as base64 to avoid any shell quoting / escaping issues + String vmDataArg = Base64.getEncoder().encodeToString( + json.toString().getBytes(StandardCharsets.UTF_8)); + + JsonObject payload = new JsonObject(); + payload.addProperty("network_id", String.valueOf(network.getId())); + payload.addProperty("ip", safeStr(nicIpAddress)); + payload.addProperty("gateway", safeStr(nic.getIPv4Gateway())); + payload.addProperty("extension_ip", safeStr(ensureExtensionIp(network))); + payload.addProperty("vm_data", vmDataArg); + addNicUuidToPayload(payload, nic); + addVpcIdToPayload(payload, network); + + return executeScript(network, CMD_SAVE_VM_DATA, payload); + } + + @Override + public boolean savePassword(Network network, NicProfile nic, VirtualMachineProfile vm) + throws ResourceUnavailableException { + if (!canHandle(network, Service.UserData)) { + return false; + } + String password = (String) vm.getParameter(VirtualMachineProfile.Param.VmPassword); + if (password == null || password.isEmpty()) { + return true; + } + logger.debug("savePassword: network={} ip={}", network.getId(), nic.getIPv4Address()); + String extensionIp = ensureExtensionIp(network); + JsonObject payload = new JsonObject(); + payload.addProperty("network_id", String.valueOf(network.getId())); + payload.addProperty("ip", safeStr(nic.getIPv4Address())); + payload.addProperty("gateway", safeStr(nic.getIPv4Gateway())); + payload.addProperty("password", password); + payload.addProperty("extension_ip", safeStr(extensionIp)); + addNicUuidToPayload(payload, nic); + addVpcIdToPayload(payload, network); + return executeScript(network, CMD_SAVE_PASSWORD, payload); + } + + @Override + public boolean saveUserData(Network network, NicProfile nic, VirtualMachineProfile vm) + throws ResourceUnavailableException { + if (!canHandle(network, Service.UserData)) { + return false; + } + String userData = null; + if (vm.getVirtualMachine() instanceof UserVm) { + userData = ((UserVm) vm.getVirtualMachine()).getUserData(); + } + if (userData == null || userData.isEmpty()) { + return true; + } + logger.debug("saveUserData: network={} ip={}", network.getId(), nic.getIPv4Address()); + // userData is stored as base64; pass it directly so the script can decode it + String extensionIp = ensureExtensionIp(network); + JsonObject payload = new JsonObject(); + payload.addProperty("network_id", String.valueOf(network.getId())); + payload.addProperty("ip", safeStr(nic.getIPv4Address())); + payload.addProperty("gateway", safeStr(nic.getIPv4Gateway())); + payload.addProperty("userdata", userData); + payload.addProperty("extension_ip", safeStr(extensionIp)); + addNicUuidToPayload(payload, nic); + addVpcIdToPayload(payload, network); + return executeScript(network, CMD_SAVE_USERDATA, payload); + } + + @Override + public boolean saveSSHKey(Network network, NicProfile nic, VirtualMachineProfile vm, + String sshPublicKey) throws ResourceUnavailableException { + if (!canHandle(network, Service.UserData)) { + return false; + } + if (sshPublicKey == null || sshPublicKey.isEmpty()) { + return true; + } + logger.debug("saveSSHKey: network={} ip={}", network.getId(), nic.getIPv4Address()); + // Encode SSH key as base64 to safely pass via CLI + String sshKeyBase64 = Base64.getEncoder().encodeToString(sshPublicKey.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String extensionIp = ensureExtensionIp(network); + JsonObject payload = new JsonObject(); + payload.addProperty("network_id", String.valueOf(network.getId())); + payload.addProperty("ip", safeStr(nic.getIPv4Address())); + payload.addProperty("gateway", safeStr(nic.getIPv4Gateway())); + payload.addProperty("sshkey", sshKeyBase64); + payload.addProperty("extension_ip", safeStr(extensionIp)); + addNicUuidToPayload(payload, nic); + addVpcIdToPayload(payload, network); + return executeScript(network, CMD_SAVE_SSHKEY, payload); + } + + @Override + public boolean saveHypervisorHostname(NicProfile nic, Network network, VirtualMachineProfile vm, + DeployDestination dest) throws ResourceUnavailableException { + if (!canHandle(network, Service.UserData)) { + return false; + } + String hostname = dest != null && dest.getHost() != null ? dest.getHost().getName() : null; + if (hostname == null || hostname.isEmpty()) { + return true; + } + logger.debug("saveHypervisorHostname: network={} ip={} host={}", network.getId(), + nic.getIPv4Address(), hostname); + String extensionIp = ensureExtensionIp(network); + JsonObject payload = new JsonObject(); + payload.addProperty("network_id", String.valueOf(network.getId())); + payload.addProperty("ip", safeStr(nic.getIPv4Address())); + payload.addProperty("gateway", safeStr(nic.getIPv4Gateway())); + payload.addProperty("hypervisor_hostname", hostname); + payload.addProperty("extension_ip", safeStr(extensionIp)); + addNicUuidToPayload(payload, nic); + addVpcIdToPayload(payload, network); + return executeScript(network, CMD_SAVE_HYPERVISOR_HOSTNAME, payload); + } + + // ---- LoadBalancingServiceProvider ---- + + @Override + public boolean applyLBRules(Network network, List<LoadBalancingRule> rules) + throws ResourceUnavailableException { + if (rules == null || rules.isEmpty()) { + return true; + } + if (!canHandle(network, Service.Lb)) { + return false; + } + logger.info("Applying {} LB rules for network {}", rules.size(), network.getId()); + String vlanId = getVlanId(network); + + // Serialise all rules as a JSON array and pass as a single --lb-rules argument + StringBuilder json = new StringBuilder("["); Review Comment: yes, we should use json object and discard base64 encoding it is due to the legacy CLI arguments will do -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
