diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d101777c70..d1a295e6149 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,7 +89,10 @@ jobs: smoke/test_nested_virtualization smoke/test_set_sourcenat smoke/test_webhook_lifecycle - smoke/test_purge_expunged_vms", + smoke/test_purge_expunged_vms + smoke/test_extension_lifecycle + smoke/test_extension_custom_action_lifecycle + smoke/test_extension_custom", "smoke/test_network smoke/test_network_acl smoke/test_network_ipv6 diff --git a/api/src/main/java/com/cloud/agent/api/Command.java b/api/src/main/java/com/cloud/agent/api/Command.java index 4766c30ead2..c4e99cb4170 100644 --- a/api/src/main/java/com/cloud/agent/api/Command.java +++ b/api/src/main/java/com/cloud/agent/api/Command.java @@ -19,9 +19,10 @@ package com.cloud.agent.api; import java.util.HashMap; import java.util.Map; -import com.cloud.agent.api.LogLevel.Log4jLevel; -import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.cloud.agent.api.LogLevel.Log4jLevel; /** * implemented by classes that extends the Command class. Command specifies @@ -60,6 +61,7 @@ public abstract class Command { private int wait; //in second private boolean bypassHostMaintenance = false; private transient long requestSequence = 0L; + protected Map> externalDetails; protected Command() { this.wait = 0; @@ -128,6 +130,14 @@ public abstract class Command { this.requestSequence = requestSequence; } + public void setExternalDetails(Map> externalDetails) { + this.externalDetails = externalDetails; + } + + public Map> getExternalDetails() { + return externalDetails; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/api/src/main/java/com/cloud/agent/api/to/VirtualMachineTO.java b/api/src/main/java/com/cloud/agent/api/to/VirtualMachineTO.java index d67ce679684..cffb9874080 100644 --- a/api/src/main/java/com/cloud/agent/api/to/VirtualMachineTO.java +++ b/api/src/main/java/com/cloud/agent/api/to/VirtualMachineTO.java @@ -19,12 +19,14 @@ package com.cloud.agent.api.to; import java.util.List; import java.util.Map; import java.util.HashMap; +import java.util.stream.Collectors; import com.cloud.agent.api.LogLevel; import com.cloud.network.element.NetworkElement; import com.cloud.template.VirtualMachineTemplate.BootloaderType; import com.cloud.vm.VirtualMachine; import com.cloud.vm.VirtualMachine.Type; +import com.cloud.vm.VmDetailConstants; public class VirtualMachineTO { private long id; @@ -496,4 +498,16 @@ public class VirtualMachineTO { public String toString() { return String.format("VM {id: \"%s\", name: \"%s\", uuid: \"%s\", type: \"%s\"}", id, name, uuid, type); } + + public Map getExternalDetails() { + if (details == null) { + return new HashMap<>(); + } + return details.entrySet().stream() + .filter(entry -> entry.getKey().startsWith(VmDetailConstants.EXTERNAL_DETAIL_PREFIX)) + .collect(Collectors.toMap( + entry -> entry.getKey().substring(VmDetailConstants.EXTERNAL_DETAIL_PREFIX.length()), + Map.Entry::getValue + )); + } } diff --git a/api/src/main/java/com/cloud/event/EventTypes.java b/api/src/main/java/com/cloud/event/EventTypes.java index 1eb1998850f..beed8432df2 100644 --- a/api/src/main/java/com/cloud/event/EventTypes.java +++ b/api/src/main/java/com/cloud/event/EventTypes.java @@ -29,13 +29,15 @@ import org.apache.cloudstack.api.response.PodResponse; import org.apache.cloudstack.api.response.ZoneResponse; import org.apache.cloudstack.config.Configuration; import org.apache.cloudstack.datacenter.DataCenterIpv4GuestSubnet; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.extension.ExtensionCustomAction; import org.apache.cloudstack.ha.HAConfig; import org.apache.cloudstack.network.BgpPeer; import org.apache.cloudstack.network.Ipv4GuestSubnetNetworkMap; import org.apache.cloudstack.quota.QuotaTariff; -import org.apache.cloudstack.storage.sharedfs.SharedFS; import org.apache.cloudstack.storage.object.Bucket; import org.apache.cloudstack.storage.object.ObjectStore; +import org.apache.cloudstack.storage.sharedfs.SharedFS; import org.apache.cloudstack.usage.Usage; import org.apache.cloudstack.vm.schedule.VMSchedule; @@ -806,6 +808,7 @@ public class EventTypes { // Management Server public static final String EVENT_MANAGEMENT_SERVER_REMOVE = "MANAGEMENT.SERVER.REMOVE"; + // VM Lease public static final String VM_LEASE_EXPIRED = "VM.LEASE.EXPIRED"; public static final String VM_LEASE_DISABLED = "VM.LEASE.DISABLED"; public static final String VM_LEASE_CANCELLED = "VM.LEASE.CANCELLED"; @@ -816,6 +819,19 @@ public class EventTypes { public static final String EVENT_GUI_THEME_REMOVE = "GUI.THEME.REMOVE"; public static final String EVENT_GUI_THEME_UPDATE = "GUI.THEME.UPDATE"; + // Extension + public static final String EVENT_EXTENSION_CREATE = "EXTENSION.CREATE"; + public static final String EVENT_EXTENSION_UPDATE = "EXTENSION.UPDATE"; + public static final String EVENT_EXTENSION_DELETE = "EXTENSION.DELETE"; + public static final String EVENT_EXTENSION_RESOURCE_REGISTER = "EXTENSION.RESOURCE.REGISTER"; + public static final String EVENT_EXTENSION_RESOURCE_UNREGISTER = "EXTENSION.RESOURCE.UNREGISTER"; + public static final String EVENT_EXTENSION_CUSTOM_ACTION_ADD = "EXTENSION.CUSTOM.ACTION.ADD"; + public static final String EVENT_EXTENSION_CUSTOM_ACTION_UPDATE = "EXTENSION.CUSTOM.ACTION.UPDATE"; + public static final String EVENT_EXTENSION_CUSTOM_ACTION_DELETE = "EXTENSION.CUSTOM.ACTION.DELETE"; + + // Custom Action + public static final String EVENT_CUSTOM_ACTION = "CUSTOM.ACTION"; + static { // TODO: need a way to force author adding event types to declare the entity details as well, with out braking @@ -1324,6 +1340,16 @@ public class EventTypes { entityEventDetails.put(EVENT_GUI_THEME_CREATE, "GuiTheme"); entityEventDetails.put(EVENT_GUI_THEME_REMOVE, "GuiTheme"); entityEventDetails.put(EVENT_GUI_THEME_UPDATE, "GuiTheme"); + + // Extension + entityEventDetails.put(EVENT_EXTENSION_CREATE, Extension.class); + entityEventDetails.put(EVENT_EXTENSION_UPDATE, Extension.class); + entityEventDetails.put(EVENT_EXTENSION_DELETE, Extension.class); + entityEventDetails.put(EVENT_EXTENSION_RESOURCE_REGISTER, Extension.class); + entityEventDetails.put(EVENT_EXTENSION_RESOURCE_UNREGISTER, Extension.class); + entityEventDetails.put(EVENT_EXTENSION_CUSTOM_ACTION_ADD, ExtensionCustomAction.class); + entityEventDetails.put(EVENT_EXTENSION_CUSTOM_ACTION_UPDATE, ExtensionCustomAction.class); + entityEventDetails.put(EVENT_EXTENSION_CUSTOM_ACTION_DELETE, ExtensionCustomAction.class); } public static boolean isNetworkEvent(String eventType) { diff --git a/api/src/main/java/com/cloud/hypervisor/Hypervisor.java b/api/src/main/java/com/cloud/hypervisor/Hypervisor.java index 27ffef1c370..5baac484772 100644 --- a/api/src/main/java/com/cloud/hypervisor/Hypervisor.java +++ b/api/src/main/java/com/cloud/hypervisor/Hypervisor.java @@ -54,6 +54,7 @@ public class Hypervisor { public static final HypervisorType Ovm3 = new HypervisorType("Ovm3", ImageFormat.RAW); public static final HypervisorType LXC = new HypervisorType("LXC"); public static final HypervisorType Custom = new HypervisorType("Custom", null, EnumSet.of(RootDiskSizeOverride)); + public static final HypervisorType External = new HypervisorType("External", null, EnumSet.of(RootDiskSizeOverride)); public static final HypervisorType Any = new HypervisorType("Any"); /*If you don't care about the hypervisor type*/ private final String name; private final ImageFormat imageFormat; diff --git a/api/src/main/java/com/cloud/network/NetworkModel.java b/api/src/main/java/com/cloud/network/NetworkModel.java index a4cd87af008..eb496ac4e0b 100644 --- a/api/src/main/java/com/cloud/network/NetworkModel.java +++ b/api/src/main/java/com/cloud/network/NetworkModel.java @@ -305,6 +305,8 @@ public interface NetworkModel { NicProfile getNicProfile(VirtualMachine vm, long networkId, String broadcastUri); + NicProfile getNicProfile(VirtualMachine vm, Nic nic, DataCenter dataCenter); + Set getAvailableIps(Network network, String requestedIp); String getDomainNetworkDomain(long domainId, long zoneId); diff --git a/api/src/main/java/com/cloud/network/NetworkService.java b/api/src/main/java/com/cloud/network/NetworkService.java index 36d58c737cc..fd51cbfa774 100644 --- a/api/src/main/java/com/cloud/network/NetworkService.java +++ b/api/src/main/java/com/cloud/network/NetworkService.java @@ -19,7 +19,6 @@ package com.cloud.network; import java.util.List; import java.util.Map; -import com.cloud.dc.DataCenter; import org.apache.cloudstack.acl.ControlledEntity; import org.apache.cloudstack.api.command.admin.address.ReleasePodIpCmdByAdmin; import org.apache.cloudstack.api.command.admin.network.DedicateGuestVlanRangeCmd; @@ -39,13 +38,16 @@ import org.apache.cloudstack.api.command.user.network.UpdateNetworkCmd; import org.apache.cloudstack.api.command.user.vm.ListNicsCmd; import org.apache.cloudstack.api.response.AcquirePodIpCmdResponse; import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.network.element.InternalLoadBalancerElementService; +import com.cloud.agent.api.to.NicTO; +import com.cloud.dc.DataCenter; import com.cloud.exception.ConcurrentOperationException; import com.cloud.exception.InsufficientAddressCapacityException; import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.InvalidParameterValueException; import com.cloud.exception.ResourceAllocationException; import com.cloud.exception.ResourceUnavailableException; -import com.cloud.exception.InvalidParameterValueException; import com.cloud.network.Network.IpAddresses; import com.cloud.network.Network.Service; import com.cloud.network.Networks.TrafficType; @@ -57,7 +59,6 @@ import com.cloud.utils.Pair; import com.cloud.utils.exception.CloudRuntimeException; import com.cloud.vm.Nic; import com.cloud.vm.NicSecondaryIp; -import org.apache.cloudstack.network.element.InternalLoadBalancerElementService; /** * The NetworkService interface is the "public" api to entities that make requests to the orchestration engine @@ -270,4 +271,6 @@ public interface NetworkService { List getInternalLoadBalancerElements(); boolean handleCksIsoOnNetworkVirtualRouter(Long virtualRouterId, boolean mount) throws ResourceUnavailableException; + + String getNicVlanValueForExternalVm(NicTO nic); } diff --git a/api/src/main/java/com/cloud/network/nsx/NsxService.java b/api/src/main/java/com/cloud/network/nsx/NsxService.java index bc4e6aafbfe..1adb7461cc0 100644 --- a/api/src/main/java/com/cloud/network/nsx/NsxService.java +++ b/api/src/main/java/com/cloud/network/nsx/NsxService.java @@ -16,9 +16,10 @@ // under the License. package com.cloud.network.nsx; +import org.apache.cloudstack.framework.config.ConfigKey; + import com.cloud.network.IpAddress; import com.cloud.network.vpc.Vpc; -import org.apache.cloudstack.framework.config.ConfigKey; public interface NsxService { @@ -33,4 +34,5 @@ public interface NsxService { boolean createVpcNetwork(Long zoneId, long accountId, long domainId, Long vpcId, String vpcName, boolean sourceNatEnabled); boolean updateVpcSourceNatIp(Vpc vpc, IpAddress address); + String getSegmentId(long domainId, long accountId, long zoneId, Long vpcId, long networkId); } diff --git a/api/src/main/java/com/cloud/storage/Storage.java b/api/src/main/java/com/cloud/storage/Storage.java index 05b8b3ab7a8..1ad3731b9ea 100644 --- a/api/src/main/java/com/cloud/storage/Storage.java +++ b/api/src/main/java/com/cloud/storage/Storage.java @@ -30,6 +30,7 @@ public class Storage { OVA(true, true, true, "ova"), VHDX(true, true, true, "vhdx"), BAREMETAL(false, false, false, "BAREMETAL"), + EXTERNAL(false, false, false, "EXTERNAL"), VMDK(true, true, false, "vmdk"), VDI(true, true, false, "vdi"), TAR(false, false, false, "tar"), diff --git a/api/src/main/java/com/cloud/template/VirtualMachineTemplate.java b/api/src/main/java/com/cloud/template/VirtualMachineTemplate.java index 89953d225a0..b8c646048b9 100644 --- a/api/src/main/java/com/cloud/template/VirtualMachineTemplate.java +++ b/api/src/main/java/com/cloud/template/VirtualMachineTemplate.java @@ -153,4 +153,6 @@ public interface VirtualMachineTemplate extends ControlledEntity, Identity, Inte CPU.CPUArch getArch(); + Long getExtensionId(); + } diff --git a/api/src/main/java/com/cloud/vm/VmDetailConstants.java b/api/src/main/java/com/cloud/vm/VmDetailConstants.java index 14f15c9fd0f..ea5d209a5d4 100644 --- a/api/src/main/java/com/cloud/vm/VmDetailConstants.java +++ b/api/src/main/java/com/cloud/vm/VmDetailConstants.java @@ -114,7 +114,15 @@ public interface VmDetailConstants { String GUEST_CPU_MODE = "guest.cpu.mode"; String GUEST_CPU_MODEL = "guest.cpu.model"; + // Lease related String INSTANCE_LEASE_EXPIRY_DATE = "leaseexpirydate"; String INSTANCE_LEASE_EXPIRY_ACTION = "leaseexpiryaction"; String INSTANCE_LEASE_EXECUTION = "leaseactionexecution"; + + // External orchestrator related + String MAC_ADDRESS = "mac_address"; + String EXPUNGE_EXTERNAL_VM = "expunge.external.vm"; + String EXTERNAL_DETAIL_PREFIX = "External:"; + String CLOUDSTACK_VM_DETAILS = "cloudstack.vm.details"; + String CLOUDSTACK_VLAN = "cloudstack.vlan"; } diff --git a/api/src/main/java/org/apache/cloudstack/acl/RoleType.java b/api/src/main/java/org/apache/cloudstack/acl/RoleType.java index 005d47c85bc..46e4f1bc510 100644 --- a/api/src/main/java/org/apache/cloudstack/acl/RoleType.java +++ b/api/src/main/java/org/apache/cloudstack/acl/RoleType.java @@ -23,8 +23,11 @@ import com.google.common.base.Enums; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.LogManager; +import java.util.Collection; +import java.util.EnumSet; import java.util.HashMap; import java.util.Map; +import java.util.Set; // Enum for default roles in CloudStack public enum RoleType { @@ -100,6 +103,30 @@ public enum RoleType { return roleId; } + public static int toCombinedMask(Collection roles) { + int combinedMask = 0; + if (roles != null) { + for (RoleType role : roles) { + combinedMask |= role.getMask(); + } + } + return combinedMask; + } + + public static Set fromCombinedMask(int combinedMask) { + Set roles = EnumSet.noneOf(RoleType.class); + for (RoleType roleType : RoleType.values()) { + if ((combinedMask & roleType.getMask()) != 0) { + roles.add(roleType); + } + } + if (roles.isEmpty()) { + roles.add(Unknown); + } + return roles; + } + + /** * This method returns the role account type if the role isn't null, else it returns the default account type. * */ diff --git a/api/src/main/java/org/apache/cloudstack/alert/AlertService.java b/api/src/main/java/org/apache/cloudstack/alert/AlertService.java index 1250284b5c2..5146e5c38e8 100644 --- a/api/src/main/java/org/apache/cloudstack/alert/AlertService.java +++ b/api/src/main/java/org/apache/cloudstack/alert/AlertService.java @@ -73,6 +73,7 @@ public interface AlertService { public static final AlertType ALERT_TYPE_VM_SNAPSHOT = new AlertType((short)32, "ALERT.VM.SNAPSHOT", true); public static final AlertType ALERT_TYPE_VR_PUBLIC_IFACE_MTU = new AlertType((short)32, "ALERT.VR.PUBLIC.IFACE.MTU", true); public static final AlertType ALERT_TYPE_VR_PRIVATE_IFACE_MTU = new AlertType((short)32, "ALERT.VR.PRIVATE.IFACE.MTU", true); + public static final AlertType ALERT_TYPE_EXTENSION_PATH_NOT_READY = new AlertType((short)33, "ALERT.TYPE.EXTENSION.PATH.NOT.READY", true); public short getType() { return type; diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java b/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java index 4cd89c877b2..4d33ba859a5 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java @@ -87,7 +87,9 @@ public enum ApiCommandResourceType { QuotaTariff(org.apache.cloudstack.quota.QuotaTariff.class), KubernetesCluster(com.cloud.kubernetes.cluster.KubernetesCluster.class), KubernetesSupportedVersion(null), - SharedFS(org.apache.cloudstack.storage.sharedfs.SharedFS.class); + SharedFS(org.apache.cloudstack.storage.sharedfs.SharedFS.class), + Extension(org.apache.cloudstack.extension.Extension.class), + ExtensionCustomAction(org.apache.cloudstack.extension.ExtensionCustomAction.class); private final Class clazz; 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 71bd5c9addc..00382b76f32 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -32,6 +32,7 @@ public class ApiConstants { public static final String ALLOCATED_DATE = "allocateddate"; public static final String ALLOCATED_ONLY = "allocatedonly"; public static final String ALLOCATED_TIME = "allocated"; + public static final String ALLOWED_ROLE_TYPES = "allowedroletypes"; public static final String ALLOW_USER_FORCE_STOP_VM = "allowuserforcestopvm"; public static final String ANNOTATION = "annotation"; public static final String API_KEY = "apikey"; @@ -140,6 +141,7 @@ public class ApiConstants { public static final String CUSTOMIZED = "customized"; public static final String CUSTOMIZED_IOPS = "customizediops"; public static final String CUSTOM_ID = "customid"; + public static final String CUSTOM_ACTION_ID = "customactionid"; public static final String CUSTOM_JOB_ID = "customjobid"; public static final String CURRENT_START_IP = "currentstartip"; public static final String CURRENT_END_IP = "currentendip"; @@ -164,6 +166,7 @@ public class ApiConstants { public static final String DISK = "disk"; public static final String DISK_OFFERING_ID = "diskofferingid"; public static final String NEW_DISK_OFFERING_ID = "newdiskofferingid"; + public static final String ORCHESTRATOR_REQUIRES_PREPARE_VM = "orchestratorrequirespreparevm"; public static final String OVERRIDE_DISK_OFFERING_ID = "overridediskofferingid"; public static final String DISK_KBS_READ = "diskkbsread"; public static final String DISK_KBS_WRITE = "diskkbswrite"; @@ -205,6 +208,7 @@ public class ApiConstants { public static final String END_IPV6 = "endipv6"; public static final String END_PORT = "endport"; public static final String ENTRY_TIME = "entrytime"; + public static final String ERROR_MESSAGE = "errormessage"; public static final String EVENT_ID = "eventid"; public static final String EVENT_TYPE = "eventtype"; public static final String EXPIRES = "expires"; @@ -215,6 +219,12 @@ public class ApiConstants { public static final String EXTRA_DHCP_OPTION_VALUE = "extradhcpvalue"; public static final String EXTERNAL = "external"; public static final String EXTERNAL_UUID = "externaluuid"; + public static final String EXTERNAL_DETAILS = "externaldetails"; + public static final String PARAMETERS = "parameters"; + public static final String EXTENSION = "extension"; + public static final String EXTENSION_ID = "extensionid"; + public static final String EXTENSION_NAME = "extensionname"; + public static final String EXTENSIONS_PATH = "extensionspath"; public static final String FENCE = "fence"; public static final String FETCH_LATEST = "fetchlatest"; public static final String FILESYSTEM = "filesystem"; @@ -351,6 +361,7 @@ public class ApiConstants { public static final String MAX_CPU_NUMBER = "maxcpunumber"; public static final String MAX_MEMORY = "maxmemory"; public static final String MEMORY_OVERCOMMIT_RATIO = "memoryOvercommitRatio"; + public static final String MESSAGE = "message"; public static final String MIN_CPU_NUMBER = "mincpunumber"; public static final String MIN_MEMORY = "minmemory"; public static final String MIGRATION_TYPE = "migrationtype"; @@ -412,6 +423,7 @@ public class ApiConstants { public static final String PASSWORD_ENABLED = "passwordenabled"; public static final String SSHKEY_ENABLED = "sshkeyenabled"; public static final String PATH = "path"; + public static final String PATH_READY = "pathready"; public static final String PAYLOAD = "payload"; public static final String PAYLOAD_URL = "payloadurl"; public static final String PEERS = "peers"; @@ -434,6 +446,7 @@ public class ApiConstants { public static final String POST_URL = "postURL"; public static final String POWER_STATE = "powerstate"; public static final String PRECEDENCE = "precedence"; + public static final String PREPARE_VM = "preparevm"; public static final String PRIVATE_INTERFACE = "privateinterface"; public static final String PRIVATE_IP = "privateip"; public static final String PRIVATE_PORT = "privateport"; @@ -457,6 +470,7 @@ public class ApiConstants { public static final String RECOVER = "recover"; public static final String REPAIR = "repair"; public static final String REQUIRES_HVM = "requireshvm"; + public static final String RESOURCES = "resources"; public static final String RESOURCE_COUNT = "resourcecount"; public static final String RESOURCE_NAME = "resourcename"; public static final String RESOURCE_TYPE = "resourcetype"; @@ -529,6 +543,7 @@ public class ApiConstants { public static final String POD_STORAGE_ACCESS_GROUPS = "podstorageaccessgroups"; public static final String ZONE_STORAGE_ACCESS_GROUPS = "zonestorageaccessgroups"; public static final String SUCCESS = "success"; + public static final String SUCCESS_MESSAGE = "successmessage"; public static final String SUITABLE_FOR_VM = "suitableforvirtualmachine"; public static final String SUPPORTS_STORAGE_SNAPSHOT = "supportsstoragesnapshot"; public static final String TARGET_IQN = "targetiqn"; @@ -570,7 +585,10 @@ public class ApiConstants { public static final String USE_VIRTUAL_NETWORK = "usevirtualnetwork"; public static final String USE_VIRTUAL_ROUTER_IP_RESOLVER = "userouteripresolver"; public static final String UPDATE_IN_SEQUENCE = "updateinsequence"; + public static final String VALIDATION_FORMAT = "validationformat"; public static final String VALUE = "value"; + public static final String VALUE_OPTIONS = "valueoptions"; + public static final String VIRTUAL_MACHINE = "virtualmachine"; public static final String VIRTUAL_MACHINE_ID = "virtualmachineid"; public static final String VIRTUAL_MACHINE_IDS = "virtualmachineids"; public static final String VIRTUAL_MACHINE_NAME = "virtualmachinename"; @@ -669,7 +687,7 @@ public class ApiConstants { public static final String NETWORK_DEVICE_PARAMETER_LIST = "networkdeviceparameterlist"; public static final String ZONE_TOKEN = "zonetoken"; public static final String DHCP_PROVIDER = "dhcpprovider"; - public static final String RESULT = "success"; + public static final String RESULT = "result"; public static final String RESUME = "resume"; public static final String LUN_ID = "lunId"; public static final String IQN = "iqn"; @@ -1063,6 +1081,7 @@ public class ApiConstants { public static final String RESOURCE_DETAILS = "resourcedetails"; public static final String RESOURCE_ICON = "icon"; + public static final String RESOURCE_MAP = "resourcemap"; public static final String EXPUNGE = "expunge"; public static final String FOR_DISPLAY = "fordisplay"; public static final String PASSIVE = "passive"; @@ -1098,6 +1117,7 @@ public class ApiConstants { public static final String OVM3_CLUSTER = "ovm3cluster"; public static final String OVM3_VIP = "ovm3vip"; public static final String CLEAN_UP_DETAILS = "cleanupdetails"; + public static final String CLEAN_UP_PARAMETERS = "cleanupparameters"; public static final String VIRTUAL_SIZE = "virtualsize"; public static final String NETSCALER_CONTROLCENTER_ID = "netscalercontrolcenterid"; public static final String NETSCALER_SERVICEPACKAGE_ID = "netscalerservicepackageid"; @@ -1341,6 +1361,10 @@ public class ApiConstants { all, resource, min; } + public enum ExtensionDetails { + all, resource, external, min; + } + public enum ApiKeyAccess { DISABLED(false), ENABLED(true), diff --git a/api/src/main/java/org/apache/cloudstack/api/BaseCmd.java b/api/src/main/java/org/apache/cloudstack/api/BaseCmd.java index 457afdc8847..317d72eb971 100644 --- a/api/src/main/java/org/apache/cloudstack/api/BaseCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/BaseCmd.java @@ -94,6 +94,7 @@ import com.cloud.utils.ReflectUtil; import com.cloud.utils.db.EntityManager; import com.cloud.utils.db.UUIDManager; import com.cloud.vm.UserVmService; +import com.cloud.vm.VmDetailConstants; import com.cloud.vm.snapshot.VMSnapshotService; public abstract class BaseCmd { @@ -484,4 +485,14 @@ public abstract class BaseCmd { } return detailsMap; } + + public Map convertExternalDetailsToMap(Map externalDetails) { + Map customparameterMap = convertDetailsToMap(externalDetails); + Map details = new HashMap<>(); + for (String key : customparameterMap.keySet()) { + String value = customparameterMap.get(key); + details.put(VmDetailConstants.EXTERNAL_DETAIL_PREFIX + key, value); + } + return details; + } } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/cluster/AddClusterCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/cluster/AddClusterCmd.java index 15265f561e7..3aef11b92e9 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/cluster/AddClusterCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/cluster/AddClusterCmd.java @@ -19,21 +19,22 @@ package org.apache.cloudstack.api.command.admin.cluster; import java.util.ArrayList; import java.util.List; - -import com.cloud.cpu.CPU; -import org.apache.cloudstack.api.ApiCommandResourceType; +import java.util.Map; import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; 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.ClusterResponse; +import org.apache.cloudstack.api.response.ExtensionResponse; import org.apache.cloudstack.api.response.ListResponse; import org.apache.cloudstack.api.response.PodResponse; import org.apache.cloudstack.api.response.ZoneResponse; +import com.cloud.cpu.CPU; import com.cloud.exception.DiscoveryException; import com.cloud.exception.ResourceInUseException; import com.cloud.org.Cluster; @@ -43,7 +44,6 @@ import com.cloud.user.Account; requestHasSensitiveInfo = true, responseHasSensitiveInfo = false) public class AddClusterCmd extends BaseCmd { - @Parameter(name = ApiConstants.CLUSTER_NAME, type = CommandType.STRING, required = true, description = "the cluster name") private String clusterName; @@ -65,7 +65,7 @@ public class AddClusterCmd extends BaseCmd { @Parameter(name = ApiConstants.HYPERVISOR, type = CommandType.STRING, required = true, - description = "hypervisor type of the cluster: XenServer,KVM,VMware,Hyperv,BareMetal,Simulator,Ovm3") + description = "hypervisor type of the cluster: XenServer,KVM,VMware,Hyperv,BareMetal,Simulator,Ovm3,External") private String hypervisor; @Parameter(name = ApiConstants.ARCH, type = CommandType.STRING, @@ -118,12 +118,26 @@ public class AddClusterCmd extends BaseCmd { private String ovm3cluster; @Parameter(name = ApiConstants.OVM3_VIP, type = CommandType.STRING, required = false, description = "Ovm3 vip to use for pool (and cluster)") private String ovm3vip; + @Parameter(name = ApiConstants.STORAGE_ACCESS_GROUPS, type = CommandType.LIST, collectionType = CommandType.STRING, description = "comma separated list of storage access groups for the hosts in the cluster", since = "4.21.0") private List storageAccessGroups; + + @Parameter(name = ApiConstants.EXTENSION_ID, + type = CommandType.UUID, entityType = ExtensionResponse.class, + description = "UUID of the extension", + since = "4.21.0") + private Long extensionId; + + @Parameter(name = ApiConstants.EXTERNAL_DETAILS, + type = CommandType.MAP, + description = "Details in key/value pairs to be added to the extension-resource mapping. Use the format externaldetails[i].=. Example: externaldetails[0].endpoint.url=https://example.com", + since = "4.21.0") + protected Map externalDetails; + public String getOvm3Pool() { return ovm3pool; } @@ -190,6 +204,10 @@ public class AddClusterCmd extends BaseCmd { return hypervisor; } + public Long getExtensionId() { + return extensionId; + } + public String getClusterType() { return clusterType; } @@ -224,6 +242,10 @@ public class AddClusterCmd extends BaseCmd { return CPU.CPUArch.fromType(arch); } + public Map getExternalDetails() { + return convertDetailsToMap(externalDetails); + } + @Override public void execute() { try { diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/cluster/ListClustersCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/cluster/ListClustersCmd.java index 9cc39503fbf..65dd1b232fa 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/cluster/ListClustersCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/cluster/ListClustersCmd.java @@ -17,7 +17,11 @@ package org.apache.cloudstack.api.command.admin.cluster; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; + +import javax.inject.Inject; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; @@ -27,9 +31,13 @@ import org.apache.cloudstack.api.response.ClusterResponse; import org.apache.cloudstack.api.response.ListResponse; import org.apache.cloudstack.api.response.PodResponse; import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.extension.ExtensionHelper; +import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang3.StringUtils; import com.cloud.cpu.CPU; +import com.cloud.hypervisor.Hypervisor; import com.cloud.org.Cluster; import com.cloud.utils.Pair; @@ -37,6 +45,8 @@ import com.cloud.utils.Pair; requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) public class ListClustersCmd extends BaseListCmd { + @Inject + ExtensionHelper extensionHelper; ///////////////////////////////////////////////////// //////////////// API parameters ///////////////////// @@ -143,15 +153,38 @@ public class ListClustersCmd extends BaseListCmd { /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// + protected void updateClustersExtensions(final List clusterResponses) { + if (CollectionUtils.isEmpty(clusterResponses)) { + return; + } + Map idExtensionMap = new HashMap<>(); + for (ClusterResponse response : clusterResponses) { + if (!Hypervisor.HypervisorType.External.getHypervisorDisplayName().equals(response.getHypervisorType())) { + continue; + } + Long extensionId = extensionHelper.getExtensionIdForCluster(response.getInternalId()); + if (extensionId == null) { + continue; + } + Extension extension = idExtensionMap.computeIfAbsent(extensionId, id -> extensionHelper.getExtension(id)); + if (extension == null) { + continue; + } + response.setExtensionId(extension.getUuid()); + response.setExtensionName(extension.getName()); + } + } + protected Pair, Integer> getClusterResponses() { Pair, Integer> result = _mgr.searchForClusters(this); - List clusterResponses = new ArrayList(); + List clusterResponses = new ArrayList<>(); for (Cluster cluster : result.first()) { ClusterResponse clusterResponse = _responseGenerator.createClusterResponse(cluster, showCapacities); clusterResponse.setObjectName("cluster"); clusterResponses.add(clusterResponse); } - return new Pair, Integer>(clusterResponses, result.second()); + updateClustersExtensions(clusterResponses); + return new Pair<>(clusterResponses, result.second()); } @Override diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/host/AddHostCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/host/AddHostCmd.java index 6531444b52e..cc124ab8106 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/host/AddHostCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/host/AddHostCmd.java @@ -18,7 +18,7 @@ package org.apache.cloudstack.api.command.admin.host; import java.util.ArrayList; import java.util.List; - +import java.util.Map; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; @@ -81,6 +81,12 @@ public class AddHostCmd extends BaseCmd { since = "4.21.0") private List storageAccessGroups; + @Parameter(name = ApiConstants.EXTERNAL_DETAILS, + type = CommandType.MAP, + description = "Details in key/value pairs using format externaldetails[i].keyname=keyvalue. Example: externaldetails[0].endpoint.url=urlvalue", + since = "4.21.0") + protected Map externalDetails; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -129,6 +135,10 @@ public class AddHostCmd extends BaseCmd { return allocationState; } + public Map getExternalDetails() { + return convertExternalDetailsToMap(externalDetails); + } + ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/host/UpdateHostCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/host/UpdateHostCmd.java index 397f9c80735..9baea58b7b5 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/host/UpdateHostCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/host/UpdateHostCmd.java @@ -16,8 +16,9 @@ // under the License. package org.apache.cloudstack.api.command.admin.host; -import com.cloud.host.Host; -import com.cloud.user.Account; +import java.util.List; +import java.util.Map; + import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; @@ -28,7 +29,8 @@ import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.response.GuestOSCategoryResponse; import org.apache.cloudstack.api.response.HostResponse; -import java.util.List; +import com.cloud.host.Host; +import com.cloud.user.Account; @APICommand(name = "updateHost", description = "Updates a host.", responseObject = HostResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) @@ -67,6 +69,9 @@ public class UpdateHostCmd extends BaseCmd { @Parameter(name = ApiConstants.ANNOTATION, type = CommandType.STRING, description = "Add an annotation to this host", since = "4.11", authorized = {RoleType.Admin}) private String annotation; + @Parameter(name = ApiConstants.EXTERNAL_DETAILS, type = CommandType.MAP, description = "Details in key/value pairs using format externaldetails[i].keyname=keyvalue. Example: externaldetails[0].endpoint.url=urlvalue", since = "4.21.0") + protected Map externalDetails; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -103,6 +108,10 @@ public class UpdateHostCmd extends BaseCmd { return annotation; } + public Map getExternalDetails() { + return convertExternalDetailsToMap(externalDetails); + } + ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/offering/CreateServiceOfferingCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/offering/CreateServiceOfferingCmd.java index 4cadaad0e47..019ed58febb 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/offering/CreateServiceOfferingCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/offering/CreateServiceOfferingCmd.java @@ -29,16 +29,16 @@ 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.DiskOfferingResponse; import org.apache.cloudstack.api.response.DomainResponse; import org.apache.cloudstack.api.response.ServiceOfferingResponse; import org.apache.cloudstack.api.response.VsphereStoragePoliciesResponse; import org.apache.cloudstack.api.response.ZoneResponse; -import org.apache.cloudstack.api.response.DiskOfferingResponse; import org.apache.cloudstack.vm.lease.VMLeaseManager; +import org.apache.commons.collections.CollectionUtils; import org.apache.commons.collections.MapUtils; import org.apache.commons.lang3.EnumUtils; import org.apache.commons.lang3.StringUtils; -import org.apache.commons.collections.CollectionUtils; import com.cloud.exception.InvalidParameterValueException; import com.cloud.offering.ServiceOffering; @@ -263,6 +263,12 @@ public class CreateServiceOfferingCmd extends BaseCmd { description = "Lease expiry action, valid values are STOP and DESTROY") private String leaseExpiryAction; + @Parameter(name = ApiConstants.EXTERNAL_DETAILS, + type = CommandType.MAP, + description = "Details in key/value pairs using format externaldetails[i].keyname=keyvalue. Example: externaldetails[0].endpoint.url=urlvalue", + since = "4.21.0") + private Map externalDetails; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -369,9 +375,15 @@ public class CreateServiceOfferingCmd extends BaseCmd { } } } + + detailsMap.putAll(getExternalDetails()); return detailsMap; } + public Map getExternalDetails() { + return convertExternalDetailsToMap(externalDetails); + } + public Long getRootDiskSize() { return rootDiskSize; } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/offering/UpdateServiceOfferingCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/offering/UpdateServiceOfferingCmd.java index e580f0d9f41..7f66ac7058a 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/offering/UpdateServiceOfferingCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/offering/UpdateServiceOfferingCmd.java @@ -18,6 +18,7 @@ package org.apache.cloudstack.api.command.admin.offering; import java.util.ArrayList; import java.util.List; +import java.util.Map; import com.cloud.offering.ServiceOffering.State; import org.apache.cloudstack.api.APICommand; @@ -94,6 +95,12 @@ public class UpdateServiceOfferingCmd extends BaseCmd { since="4.20") private Boolean purgeResources; + @Parameter(name = ApiConstants.EXTERNAL_DETAILS, + type = CommandType.MAP, + description = "Details in key/value pairs using format externaldetails[i].keyname=keyvalue. Example: externaldetails[0].endpoint.url=urlvalue", + since = "4.21.0") + private Map externalDetails; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -194,6 +201,10 @@ public class UpdateServiceOfferingCmd extends BaseCmd { return Boolean.TRUE.equals(purgeResources); } + public Map getExternalDetails() { + return convertExternalDetailsToMap(externalDetails); + } + ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/MigrateVMCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/MigrateVMCmd.java index 8881a2bc354..0bc993ef1f7 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/MigrateVMCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/vm/MigrateVMCmd.java @@ -16,6 +16,7 @@ // under the License. package org.apache.cloudstack.api.command.admin.vm; +import com.cloud.hypervisor.Hypervisor; import org.apache.cloudstack.api.ApiCommandResourceType; import org.apache.cloudstack.api.APICommand; @@ -145,6 +146,10 @@ public class MigrateVMCmd extends BaseAsyncCmd { throw new InvalidParameterValueException("Unable to find the VM by id=" + getVirtualMachineId()); } + if (Hypervisor.HypervisorType.External.equals(userVm.getHypervisorType())) { + throw new InvalidParameterValueException("Migrate VM instance operation is not allowed for External hypervisor type"); + } + Host destinationHost = null; // OfflineMigration performed when this parameter is specified StoragePool destStoragePool = null; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/config/ListCapabilitiesCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/config/ListCapabilitiesCmd.java index 77a7a7fd8ea..91632b910ff 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/config/ListCapabilitiesCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/config/ListCapabilitiesCmd.java @@ -73,6 +73,7 @@ public class ListCapabilitiesCmd extends BaseCmd { response.setSharedFsVmMinCpuCount((Integer)capabilities.get(ApiConstants.SHAREDFSVM_MIN_CPU_COUNT)); response.setSharedFsVmMinRamSize((Integer)capabilities.get(ApiConstants.SHAREDFSVM_MIN_RAM_SIZE)); response.setInstanceLeaseEnabled((Boolean) capabilities.get(ApiConstants.INSTANCE_LEASE_ENABLED)); + response.setExtensionsPath((String)capabilities.get(ApiConstants.EXTENSIONS_PATH)); response.setObjectName("capability"); response.setResponseName(getCommandName()); this.setResponseObject(response); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/template/ListTemplatesCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/template/ListTemplatesCmd.java index 585114e07ad..4727e395c41 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/template/ListTemplatesCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/template/ListTemplatesCmd.java @@ -28,6 +28,7 @@ import org.apache.cloudstack.api.BaseListTaggedResourcesCmd; import org.apache.cloudstack.api.Parameter; import org.apache.cloudstack.api.ResponseObject.ResponseView; import org.apache.cloudstack.api.command.user.UserCmd; +import org.apache.cloudstack.api.response.ExtensionResponse; import org.apache.cloudstack.api.response.GuestOSCategoryResponse; import org.apache.cloudstack.api.response.ListResponse; import org.apache.cloudstack.api.response.TemplateResponse; @@ -115,11 +116,16 @@ public class ListTemplatesCmd extends BaseListTaggedResourcesCmd implements User since = "4.20") private String arch; - @Parameter(name = ApiConstants.OS_CATEGORY_ID, type = CommandType.UUID, entityType= GuestOSCategoryResponse.class, + @Parameter(name = ApiConstants.OS_CATEGORY_ID, type = CommandType.UUID, entityType = GuestOSCategoryResponse.class, description = "the ID of the OS category for the template", since = "4.21.0") private Long osCategoryId; + @Parameter(name = ApiConstants.EXTENSION_ID, type = CommandType.UUID, entityType = ExtensionResponse.class, + description = "ID of the extension for the template", + since = "4.21.0") + private Long extensionId; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -220,6 +226,10 @@ public class ListTemplatesCmd extends BaseListTaggedResourcesCmd implements User return osCategoryId; } + public Long getExtensionId() { + return extensionId; + } + @Override public String getCommandName() { return s_name; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/template/RegisterTemplateCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/template/RegisterTemplateCmd.java index 6ea149fd90d..5d5cab219c1 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/template/RegisterTemplateCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/template/RegisterTemplateCmd.java @@ -16,15 +16,12 @@ // under the License. package org.apache.cloudstack.api.command.user.template; -import com.cloud.cpu.CPU; -import com.cloud.hypervisor.Hypervisor; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; -import com.cloud.hypervisor.HypervisorGuru; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiCommandResourceType; import org.apache.cloudstack.api.ApiConstants; @@ -35,6 +32,7 @@ import org.apache.cloudstack.api.ResponseObject.ResponseView; import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.command.user.UserCmd; import org.apache.cloudstack.api.response.DomainResponse; +import org.apache.cloudstack.api.response.ExtensionResponse; import org.apache.cloudstack.api.response.GuestOSResponse; import org.apache.cloudstack.api.response.ListResponse; import org.apache.cloudstack.api.response.ProjectResponse; @@ -43,7 +41,10 @@ import org.apache.cloudstack.api.response.ZoneResponse; import org.apache.cloudstack.context.CallContext; import org.apache.commons.lang3.StringUtils; +import com.cloud.cpu.CPU; import com.cloud.exception.ResourceAllocationException; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.hypervisor.HypervisorGuru; import com.cloud.template.VirtualMachineTemplate; @APICommand(name = "registerTemplate", description = "Registers an existing template into the CloudStack cloud. ", responseObject = TemplateResponse.class, responseView = ResponseView.Restricted, @@ -183,6 +184,14 @@ public class RegisterTemplateCmd extends BaseCmd implements UserCmd { since = "4.20") private String arch; + @Parameter(name = ApiConstants.EXTENSION_ID, type = CommandType.UUID, entityType = ExtensionResponse.class, + description = "ID of the extension", + since = "4.21.0") + private Long extensionId; + + @Parameter(name = ApiConstants.EXTERNAL_DETAILS, type = CommandType.MAP, description = "Details in key/value pairs using format externaldetails[i].keyname=keyvalue. Example: externaldetails[0].endpoint.url=urlvalue", since = "4.21.0") + protected Map externalDetails; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -312,6 +321,14 @@ public class RegisterTemplateCmd extends BaseCmd implements UserCmd { return CPU.CPUArch.fromType(arch); } + public Long getExtensionId() { + return extensionId; + } + + public Map getExternalDetails() { + return convertExternalDetailsToMap(externalDetails); + } + ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/DeployVMCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/DeployVMCmd.java index ef3eb900970..afd23cfd871 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/DeployVMCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/DeployVMCmd.java @@ -16,28 +16,19 @@ // under the License. package org.apache.cloudstack.api.command.user.vm; -import com.cloud.agent.api.LogLevel; -import com.cloud.event.EventTypes; -import com.cloud.exception.ConcurrentOperationException; -import com.cloud.exception.InsufficientCapacityException; -import com.cloud.exception.InsufficientServerCapacityException; -import com.cloud.exception.InvalidParameterValueException; -import com.cloud.exception.ResourceAllocationException; -import com.cloud.exception.ResourceUnavailableException; -import com.cloud.hypervisor.Hypervisor.HypervisorType; -import com.cloud.network.Network; -import com.cloud.network.Network.IpAddresses; -import com.cloud.offering.DiskOffering; -import com.cloud.template.VirtualMachineTemplate; -import com.cloud.uservm.UserVm; -import com.cloud.utils.exception.CloudRuntimeException; -import com.cloud.utils.net.Dhcp; -import com.cloud.utils.net.NetUtils; -import com.cloud.vm.VirtualMachine; -import com.cloud.vm.VmDetailConstants; - +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.stream.Stream; + +import javax.annotation.Nonnull; + import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.affinity.AffinityGroupResponse; import org.apache.cloudstack.api.ACL; @@ -73,15 +64,25 @@ import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.EnumUtils; import org.apache.commons.lang3.StringUtils; -import javax.annotation.Nonnull; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.HashMap; -import java.util.Iterator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; +import com.cloud.agent.api.LogLevel; +import com.cloud.event.EventTypes; +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.InsufficientServerCapacityException; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.hypervisor.Hypervisor.HypervisorType; +import com.cloud.network.Network; +import com.cloud.network.Network.IpAddresses; +import com.cloud.offering.DiskOffering; +import com.cloud.template.VirtualMachineTemplate; +import com.cloud.uservm.UserVm; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.utils.net.Dhcp; +import com.cloud.utils.net.NetUtils; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.VmDetailConstants; @APICommand(name = "deployVirtualMachine", description = "Creates and automatically starts a virtual machine based on a service offering, disk offering, and template.", responseObject = UserVmResponse.class, responseView = ResponseView.Restricted, entityType = {VirtualMachine.class}, requestHasSensitiveInfo = false, responseHasSensitiveInfo = true) @@ -297,6 +298,13 @@ public class DeployVMCmd extends BaseAsyncCreateCustomIdCmd implements SecurityG @Parameter(name = ApiConstants.SNAPSHOT_ID, type = CommandType.UUID, entityType = SnapshotResponse.class, since = "4.21") private Long snapshotId; + + @Parameter(name = ApiConstants.EXTERNAL_DETAILS, + type = CommandType.MAP, + description = "Details in key/value pairs using format externaldetails[i].keyname=keyvalue. Example: externaldetails[0].server.type=typevalue", + since = "4.21.0") + protected Map externalDetails; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -370,9 +378,16 @@ public class DeployVMCmd extends BaseAsyncCreateCustomIdCmd implements SecurityG customparameterMap.put(VmDetailConstants.NIC_PACKED_VIRTQUEUES_ENABLED, BooleanUtils.toStringTrueFalse(nicPackedVirtQueues)); } + if (MapUtils.isNotEmpty(externalDetails)) { + customparameterMap.putAll(getExternalDetails()); + } + return customparameterMap; } + public Map getExternalDetails() { + return convertExternalDetailsToMap(externalDetails); + } public ApiConstants.BootMode getBootMode() { if (StringUtils.isNotBlank(bootMode)) { diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/ListVMsCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/ListVMsCmd.java index 9fc4258c6d9..8489a0a68a0 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/ListVMsCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/ListVMsCmd.java @@ -38,6 +38,7 @@ import org.apache.cloudstack.api.ResponseObject.ResponseView; import org.apache.cloudstack.api.command.user.UserCmd; import org.apache.cloudstack.api.response.AutoScaleVmGroupResponse; import org.apache.cloudstack.api.response.BackupOfferingResponse; +import org.apache.cloudstack.api.response.ExtensionResponse; import org.apache.cloudstack.api.response.InstanceGroupResponse; import org.apache.cloudstack.api.response.IsoVmResponse; import org.apache.cloudstack.api.response.ListResponse; @@ -171,6 +172,11 @@ public class ListVMsCmd extends BaseListRetrieveOnlyResourceCountCmd implements since = "4.21.0") private Boolean onlyLeasedInstances = false; + @Parameter(name = ApiConstants.EXTENSION_ID, type = CommandType.UUID, + entityType = ExtensionResponse.class, description = "The ID of the Orchestrator extension for the VM", + since = "4.21.0") + private Long extensionId; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -318,6 +324,10 @@ public class ListVMsCmd extends BaseListRetrieveOnlyResourceCountCmd implements return BooleanUtils.toBoolean(onlyLeasedInstances); } + public Long getExtensionId() { + return extensionId; + } + ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// diff --git a/api/src/main/java/org/apache/cloudstack/api/response/CapabilitiesResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/CapabilitiesResponse.java index 74dbfa15a43..b0a82c86fb5 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/CapabilitiesResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/CapabilitiesResponse.java @@ -16,6 +16,7 @@ // under the License. package org.apache.cloudstack.api.response; +import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.BaseResponse; @@ -140,6 +141,10 @@ public class CapabilitiesResponse extends BaseResponse { @Param(description = "true if instance lease feature is enabled", since = "4.21.0") private Boolean instanceLeaseEnabled; + @SerializedName(ApiConstants.EXTENSIONS_PATH) + @Param(description = "The path of the extensions directory", since = "4.21.0", authorized = {RoleType.Admin}) + private String extensionsPath; + public void setSecurityGroupsEnabled(boolean securityGroupsEnabled) { this.securityGroupsEnabled = securityGroupsEnabled; } @@ -255,4 +260,8 @@ public class CapabilitiesResponse extends BaseResponse { public void setInstanceLeaseEnabled(Boolean instanceLeaseEnabled) { this.instanceLeaseEnabled = instanceLeaseEnabled; } + + public void setExtensionsPath(String extensionsPath) { + this.extensionsPath = extensionsPath; + } } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/ClusterResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/ClusterResponse.java index 17c86072b98..202ff4bd870 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/ClusterResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/ClusterResponse.java @@ -31,6 +31,8 @@ import com.google.gson.annotations.SerializedName; @EntityReference(value = Cluster.class) public class ClusterResponse extends BaseResponseWithAnnotations { + private transient long internalId; + @SerializedName(ApiConstants.ID) @Param(description = "the cluster ID") private String id; @@ -107,6 +109,22 @@ public class ClusterResponse extends BaseResponseWithAnnotations { @Param(description = "comma-separated list of storage access groups on the zone", since = "4.21.0") private String zoneStorageAccessGroups; + @SerializedName(ApiConstants.EXTENSION_ID) + @Param(description="The ID of extension for this cluster", since = "4.21.0") + private String extensionId; + + @SerializedName(ApiConstants.EXTENSION_NAME) + @Param(description="The name of extension for this cluster", since = "4.21.0") + private String extensionName; + + public void setInternalId(long internalId) { + this.internalId = internalId; + } + + public long getInternalId() { + return internalId; + } + public String getId() { return id; } @@ -295,4 +313,20 @@ public class ClusterResponse extends BaseResponseWithAnnotations { public void setZoneStorageAccessGroups(String zoneStorageAccessGroups) { this.zoneStorageAccessGroups = zoneStorageAccessGroups; } + + public void setExtensionId(String extensionId) { + this.extensionId = extensionId; + } + + public String getExtensionId() { + return extensionId; + } + + public void setExtensionName(String extensionName) { + this.extensionName = extensionName; + } + + public String getExtensionName() { + return extensionName; + } } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/CreateConsoleEndpointResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/CreateConsoleEndpointResponse.java index c60917bbe7a..c1e04beee73 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/CreateConsoleEndpointResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/CreateConsoleEndpointResponse.java @@ -26,7 +26,7 @@ public class CreateConsoleEndpointResponse extends BaseResponse { public CreateConsoleEndpointResponse() { } - @SerializedName(ApiConstants.RESULT) + @SerializedName(ApiConstants.SUCCESS) @Param(description = "true if the console endpoint is generated properly") private Boolean result; diff --git a/api/src/main/java/org/apache/cloudstack/api/response/ExtensionCustomActionParameterResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/ExtensionCustomActionParameterResponse.java new file mode 100644 index 00000000000..d627f8077dc --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/ExtensionCustomActionParameterResponse.java @@ -0,0 +1,58 @@ +// 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 java.util.List; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +public class ExtensionCustomActionParameterResponse extends BaseResponse { + + @SerializedName(ApiConstants.NAME) + @Param(description = "Name of the parameter") + private String name; + + @SerializedName(ApiConstants.TYPE) + @Param(description = "Type of the parameter") + private String type; + + @SerializedName(ApiConstants.VALIDATION_FORMAT) + @Param(description = "Validation format for value of the parameter. Available for specific types") + private String validationFormat; + + @SerializedName(ApiConstants.VALUE_OPTIONS) + @Param(description = "Comma-separated list of options for value of the parameter") + private List valueOptions; + + @SerializedName(ApiConstants.REQUIRED) + @Param(description = "Whether the parameter is required or not") + private Boolean required; + + public ExtensionCustomActionParameterResponse(String name, String type, String validationFormat, List valueOptions, + boolean required) { + this.name = name; + this.type = type; + this.validationFormat = validationFormat; + this.valueOptions = valueOptions; + this.required = required; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/ExtensionCustomActionResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/ExtensionCustomActionResponse.java new file mode 100644 index 00000000000..96edf6d2fd8 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/ExtensionCustomActionResponse.java @@ -0,0 +1,184 @@ +// 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 java.util.Date; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; +import org.apache.cloudstack.extension.ExtensionCustomAction; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +@EntityReference(value = ExtensionCustomAction.class) +public class ExtensionCustomActionResponse extends BaseResponse { + + @SerializedName(ApiConstants.ID) + @Param(description = "ID of the custom action") + private String id; + + @SerializedName(ApiConstants.NAME) + @Param(description = "Name of the custom action") + private String name; + + @SerializedName(ApiConstants.DESCRIPTION) + @Param(description = "Description of the custom action") + private String description; + + @SerializedName(ApiConstants.EXTENSION_ID) + @Param(description = "ID of the extension that this custom action belongs to") + private String extensionId; + + @SerializedName(ApiConstants.EXTENSION_NAME) + @Param(description = "Name of the extension that this custom action belongs to") + private String extensionName; + + @SerializedName(ApiConstants.RESOURCE_TYPE) + @Param(description = "Resource type for which the action is available") + private String resourceType; + + @SerializedName(ApiConstants.ALLOWED_ROLE_TYPES) + @Param(description = "List of role types allowed for the custom action") + private List allowedRoleTypes; + + @SerializedName(ApiConstants.SUCCESS_MESSAGE) + @Param(description = "Message that will be used on successful execution of the action") + private String successMessage; + + @SerializedName(ApiConstants.ERROR_MESSAGE) + @Param(description = "Message that will be used on failure during execution of the action") + private String errorMessage; + + @SerializedName(ApiConstants.TIMEOUT) + @Param(description = "Specifies the timeout in seconds to wait for the action to complete before failing") + private Integer timeout; + + @SerializedName(ApiConstants.ENABLED) + @Param(description = "Whether the custom action is enabled or not") + private Boolean enabled; + + @SerializedName(ApiConstants.DETAILS) + @Param(description = "Details of the custom action") + private Map details; + + @SerializedName(ApiConstants.PARAMETERS) + @Param(description = "List of the parameters for the action", responseObject = ExtensionCustomActionParameterResponse.class) + private List parameters; + + @SerializedName(ApiConstants.CREATED) + @Param(description = "Creation timestamp of the custom action") + private Date created; + + public ExtensionCustomActionResponse(String id, String name, String description) { + this.id = id; + this.name = name; + this.description = description; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setExtensionId(String extensionId) { + this.extensionId = extensionId; + } + + public void setExtensionName(String extensionName) { + this.extensionName = extensionName; + } + + public String getResourceType() { + return resourceType; + } + + public void setResourceType(String resourceType) { + this.resourceType = resourceType; + } + + public List getAllowedRoleTypes() { + return allowedRoleTypes; + } + + public void setAllowedRoleTypes(List allowedRoleTypes) { + this.allowedRoleTypes = allowedRoleTypes; + } + + public void setSuccessMessage(String successMessage) { + this.successMessage = successMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public void setTimeout(Integer timeout) { + this.timeout = timeout; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public void setParameters(List parameters) { + this.parameters = parameters; + } + + public List getParameters() { + return parameters; + } + + public void setDetails(Map details) { + this.details = details; + } + + public Map getDetails() { + return details; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/ExtensionResourceResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/ExtensionResourceResponse.java new file mode 100644 index 00000000000..aa370887b74 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/ExtensionResourceResponse.java @@ -0,0 +1,95 @@ +// 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 org.apache.cloudstack.extension.ExtensionResourceMap; +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; + +import java.util.Date; +import java.util.Map; + +@EntityReference(value = ExtensionResourceMap.class) +public class ExtensionResourceResponse extends BaseResponse { + + @SerializedName(ApiConstants.ID) + @Param(description = "ID of the resource associated with the extension") + private String id; + + @SerializedName(ApiConstants.NAME) + @Param(description = "Name of the resource associated with this mapping") + private String name; + + @SerializedName(ApiConstants.TYPE) + @Param(description = "Type of the resource") + private String type; + + @SerializedName(ApiConstants.DETAILS) + @Param(description = "the details of the resource map") + private Map details; + + @SerializedName(ApiConstants.CREATED) + @Param(description = "Creation timestamp of the mapping") + private Date created; + + public ExtensionResourceResponse() { + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public Map getDetails() { + return details; + } + + public void setDetails(Map details) { + this.details = details; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/ExtensionResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/ExtensionResponse.java new file mode 100644 index 00000000000..fdf1e87df50 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/ExtensionResponse.java @@ -0,0 +1,182 @@ +// 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 java.util.Date; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.extension.Extension; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +@EntityReference(value = Extension.class) +public class ExtensionResponse extends BaseResponse { + + @SerializedName(ApiConstants.ID) + @Param(description = "ID of the extension") + private String id; + + @SerializedName(ApiConstants.NAME) + @Param(description = "Name of the extension") + private String name; + + @SerializedName(ApiConstants.DESCRIPTION) + @Param(description = "Description of the extension") + private String description; + + @SerializedName(ApiConstants.TYPE) + @Param(description = "Type of the extension") + private String type; + + @SerializedName(ApiConstants.PATH) + @Param(description = "The path of the entry point fo the extension") + private String path; + + @SerializedName(ApiConstants.PATH_READY) + @Param(description = "True if the extension path is in ready state across management servers") + private Boolean pathReady; + + @SerializedName(ApiConstants.IS_USER_DEFINED) + @Param(description = "True if the extension is added by admin") + private Boolean userDefined; + + @SerializedName(ApiConstants.ORCHESTRATOR_REQUIRES_PREPARE_VM) + @Parameter(description = "Only honored when type is Orchestrator. Whether prepare VM is needed or not") + private Boolean orchestratorRequiresPrepareVm; + + @SerializedName(ApiConstants.STATE) + @Param(description = "The state of the extension") + private String state; + + @SerializedName(ApiConstants.DETAILS) + @Param(description = "The details of the extension") + private Map details; + + @SerializedName(ApiConstants.RESOURCES) + @Param(description = "List of resources to which extension is registered to", responseObject = ExtensionResourceResponse.class) + private List resources; + + @SerializedName(ApiConstants.CREATED) + @Param(description = "Creation timestamp of the extension") + private Date created; + + @SerializedName(ApiConstants.REMOVED) + @Param(description = "Removal timestamp of the extension, if applicable") + private Date removed; + + public ExtensionResponse(String id, String name, String description, String type) { + this.id = id; + this.name = name; + this.description = description; + this.type = type; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getType() { + return type; + } + + public String getPath() { + return path; + } + + public Boolean isPathReady() { + return pathReady; + } + + public Boolean isUserDefined() { + return userDefined; + } + + public Boolean isOrchestratorRequiresPrepareVm() { + return orchestratorRequiresPrepareVm; + } + + public String getState() { + return state; + } + + public Map getDetails() { + return details; + } + + public void setPath(String path) { + this.path = path; + } + + public void setPathReady(Boolean pathReady) { + this.pathReady = pathReady; + } + + public void setUserDefined(Boolean userDefined) { + this.userDefined = userDefined; + } + + public void setOrchestratorRequiresPrepareVm(Boolean orchestratorRequiresPrepareVm) { + this.orchestratorRequiresPrepareVm = orchestratorRequiresPrepareVm; + } + + public void setState(String state) { + this.state = state; + } + + public void setDetails(Map details) { + this.details = details; + } + + public List getResources() { + return resources; + } + + public void setResources(List resources) { + this.resources = resources; + } + + 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; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/HostResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/HostResponse.java index 342a1eb7df3..692779b0e30 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/HostResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/HostResponse.java @@ -90,7 +90,6 @@ public class HostResponse extends BaseResponseWithAnnotations { @SerializedName(ApiConstants.HYPERVISOR) @Param(description = "the host hypervisor") private String hypervisor; - @SerializedName("cpusockets") @Param(description = "the number of CPU sockets on the host") private Integer cpuSockets; @@ -198,6 +197,8 @@ public class HostResponse extends BaseResponseWithAnnotations { @Param(description = "the management server name of the host", since = "4.21.0") private String managementServerName; + private transient long clusterInternalId; + @SerializedName("clusterid") @Param(description = "the cluster ID of the host") private String clusterId; @@ -318,6 +319,14 @@ public class HostResponse extends BaseResponseWithAnnotations { @Param(description = "comma-separated list of storage access groups on the zone", since = "4.21.0") private String zoneStorageAccessGroups; + @SerializedName(ApiConstants.EXTENSION_ID) + @Param(description="The ID of extension for this cluster", since = "4.21.0") + private String extensionId; + + @SerializedName(ApiConstants.EXTENSION_NAME) + @Param(description="The name of extension for this cluster", since = "4.21.0") + private String extensionName; + @Override public String getObjectId() { return this.getId(); @@ -471,6 +480,14 @@ public class HostResponse extends BaseResponseWithAnnotations { this.managementServerName = managementServerName; } + public long getClusterInternalId() { + return clusterInternalId; + } + + public void setClusterInternalId(long clusterInternalId) { + this.clusterInternalId = clusterInternalId; + } + public void setClusterId(String clusterId) { this.clusterId = clusterId; } @@ -942,4 +959,20 @@ public class HostResponse extends BaseResponseWithAnnotations { public Boolean getInstanceConversionSupported() { return instanceConversionSupported; } + + public void setExtensionId(String extensionId) { + this.extensionId = extensionId; + } + + public String getExtensionId() { + return extensionId; + } + + public void setExtensionName(String extensionName) { + this.extensionName = extensionName; + } + + public String getExtensionName() { + return extensionName; + } } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/RouterHealthCheckResultResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/RouterHealthCheckResultResponse.java index f98cf0acd5d..00f1e4e3bb0 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/RouterHealthCheckResultResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/RouterHealthCheckResultResponse.java @@ -34,7 +34,7 @@ public class RouterHealthCheckResultResponse extends BaseResponse { @Param(description = "the type of the health check - basic or advanced") private String checkType; - @SerializedName(ApiConstants.RESULT) + @SerializedName(ApiConstants.SUCCESS) @Param(description = "result of the health check") private boolean result; diff --git a/api/src/main/java/org/apache/cloudstack/api/response/TemplateResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/TemplateResponse.java index 57970368d7e..a94dbd95a56 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/TemplateResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/TemplateResponse.java @@ -254,6 +254,12 @@ public class TemplateResponse extends BaseResponseWithTagInformation implements @SerializedName(ApiConstants.USER_DATA_PARAMS) @Param(description="list of parameters which contains the list of keys or string parameters that are needed to be passed for any variables declared in userdata", since = "4.18.0") private String userDataParams; + @SerializedName(ApiConstants.EXTENSION_ID) @Param(description="The ID of extension linked to this template", since = "4.21.0") + private String extensionId; + + @SerializedName(ApiConstants.EXTENSION_NAME) @Param(description="The name of extension linked to this template", since = "4.21.0") + private String extensionName; + public TemplateResponse() { tags = new LinkedHashSet<>(); } @@ -547,4 +553,20 @@ public class TemplateResponse extends BaseResponseWithTagInformation implements public void setArch(String arch) { this.arch = arch; } + + public String getExtensionId() { + return extensionId; + } + + public void setExtensionId(String extensionId) { + this.extensionId = extensionId; + } + + public String getExtensionName() { + return extensionName; + } + + public void setExtensionName(String extensionName) { + this.extensionName = extensionName; + } } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/UnmanageVMInstanceResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/UnmanageVMInstanceResponse.java index cec70f20cff..e9d45cb506a 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/UnmanageVMInstanceResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/UnmanageVMInstanceResponse.java @@ -24,7 +24,7 @@ import org.apache.cloudstack.api.BaseResponse; public class UnmanageVMInstanceResponse extends BaseResponse { - @SerializedName(ApiConstants.RESULT) + @SerializedName(ApiConstants.SUCCESS) @Param(description = "result of the unmanage VM operation") private boolean success; diff --git a/api/src/main/java/org/apache/cloudstack/extension/CustomActionResultResponse.java b/api/src/main/java/org/apache/cloudstack/extension/CustomActionResultResponse.java new file mode 100644 index 00000000000..33ff70fcace --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/extension/CustomActionResultResponse.java @@ -0,0 +1,65 @@ +// 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.extension; + +import java.util.Map; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +public class CustomActionResultResponse extends BaseResponse { + + @SerializedName(ApiConstants.ID) + @Param(description = "ID of the action") + private String id; + + @SerializedName(ApiConstants.NAME) + @Param(description = "Name of the action") + private String name; + + @SerializedName(ApiConstants.SUCCESS) + @Param(description = "Whether custom action succeed or not") + private Boolean success; + + @SerializedName(ApiConstants.RESULT) + @Param(description = "Result of the action execution") + private Map result; + + public void setId(String id) { + this.id = id; + } + + public void setName(String name) { + this.name = name; + } + + public void setSuccess(Boolean success) { + this.success = success; + } + + public Boolean getSuccess() { + return success; + } + + public void setResult(Map result) { + this.result = result; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/extension/Extension.java b/api/src/main/java/org/apache/cloudstack/extension/Extension.java new file mode 100644 index 00000000000..3068612ed6f --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/extension/Extension.java @@ -0,0 +1,44 @@ +//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.extension; + +import java.util.Date; + +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; + +public interface Extension extends InternalIdentity, Identity { + enum Type { + Orchestrator + } + enum State { + Enabled, Disabled; + }; + String getName(); + String getDescription(); + Type getType(); + String getRelativePath(); + boolean isPathReady(); + boolean isUserDefined(); + State getState(); + Date getCreated(); + + static String getDirectoryName(String name) { + return name.replaceAll("[^a-zA-Z0-9._-]", "_").toLowerCase(); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/extension/ExtensionCustomAction.java b/api/src/main/java/org/apache/cloudstack/extension/ExtensionCustomAction.java new file mode 100644 index 00000000000..776b42f671b --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/extension/ExtensionCustomAction.java @@ -0,0 +1,386 @@ +//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.extension; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; +import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.EnumUtils; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.utils.DateUtil; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.reflect.TypeToken; + +public interface ExtensionCustomAction extends InternalIdentity, Identity { + enum ResourceType { + VirtualMachine(com.cloud.vm.VirtualMachine.class); + + private final Class clazz; + + ResourceType(Class clazz) { + this.clazz = clazz; + } + + public Class getAssociatedClass() { + return this.clazz; + } + } + + String getName(); + + String getDescription(); + + long getExtensionId(); + + ResourceType getResourceType(); + + Integer getAllowedRoleTypes(); + + String getSuccessMessage(); + + String getErrorMessage(); + + int getTimeout(); + + boolean isEnabled(); + + Date getCreated(); + + + class Parameter { + + public enum Type { + STRING(true), + NUMBER(true), + BOOLEAN(false), + DATE(false); + + private final boolean supportsOptions; + + Type(boolean supportsOptions) { + this.supportsOptions = supportsOptions; + } + + public boolean canSupportsOptions() { + return supportsOptions; + } + } + + public enum ValidationFormat { + // Universal default format + NONE(null), + + // String formats + UUID(Type.STRING), + EMAIL(Type.STRING), + PASSWORD(Type.STRING), + URL(Type.STRING), + + // Number formats + DECIMAL(Type.NUMBER); + + private final Type baseType; + + ValidationFormat(Type baseType) { + this.baseType = baseType; + } + + public Type getBaseType() { + return baseType; + } + } + + private static final Gson gson = new GsonBuilder() + .registerTypeAdapter(Parameter.class, new ParameterDeserializer()) + .setPrettyPrinting() + .create(); + + private final String name; + private final Type type; + private final ValidationFormat validationformat; + private final List valueoptions; + private final boolean required; + + public Parameter(String name, Type type, ValidationFormat validationformat, List valueoptions, boolean required) { + this.name = name; + this.type = type; + this.validationformat = validationformat; + this.valueoptions = valueoptions; + this.required = required; + } + + /** + * Parses a CSV string into a list of validated options. + */ + private static List parseValueOptions(String name, String csv, Type parsedType, ValidationFormat parsedFormat) { + if (StringUtils.isBlank(csv)) { + return null; + } + List values = Arrays.stream(csv.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + switch (parsedType) { + case STRING: + if (parsedFormat != null && parsedFormat != ValidationFormat.NONE) { + for (String value : values) { + if (!isValidStringValue(value, parsedFormat)) { + throw new InvalidParameterValueException(String.format("Invalid value options with validation format: %s for parameter: %s", parsedFormat.name(), name)); + } + } + } + return new ArrayList<>(values); + case NUMBER: + try { + return values.stream() + .map(v -> parseNumber(v, parsedFormat)) + .collect(Collectors.toList()); + } catch (NumberFormatException ignored) { + throw new InvalidParameterValueException(String.format("Invalid value options with validation format: %s for parameter: %s", parsedFormat.name(), name)); + } + default: + throw new InvalidParameterValueException(String.format("Options not supported for type: %s for parameter: %s", parsedType, name)); + } + } + + private static Object parseNumber(String value, ValidationFormat parsedFormat) { + if (parsedFormat == ValidationFormat.DECIMAL) { + return Float.parseFloat(value); + } + return Integer.parseInt(value); + } + + private static boolean isValidStringValue(String value, ValidationFormat validationFormat ) { + switch (validationFormat) { + case NONE: + return true; + case UUID: + try { + UUID.fromString(value); + return true; + } catch (Exception ignored) { + return false; + } + case EMAIL: + return value.matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$"); + case PASSWORD: + return !value.trim().isEmpty(); + case URL: + try { + new java.net.URL(value); + return true; + } catch (Exception ignored) { + return false; + } + default: + return false; + } + } + + public static Parameter fromMap(Map map) throws InvalidParameterValueException { + final String name = map.get(ApiConstants.NAME); + final String typeStr = map.get(ApiConstants.TYPE); + final String validationFormatStr = map.get(ApiConstants.VALIDATION_FORMAT); + final String required = map.get(ApiConstants.REQUIRED); + final String valueOptionsStr = map.get(ApiConstants.VALUE_OPTIONS); + if (StringUtils.isBlank(name)) { + throw new InvalidParameterValueException("Invalid parameter specified with empty name"); + } + if (StringUtils.isBlank(typeStr)) { + throw new InvalidParameterValueException(String.format("No type specified for parameter: %s", name)); + } + Type parsedType = EnumUtils.getEnumIgnoreCase(Type.class, typeStr); + if (parsedType == null) { + throw new InvalidParameterValueException(String.format("Invalid type: %s specified for parameter: %s", + typeStr, name)); + } + ValidationFormat parsedFormat = StringUtils.isBlank(validationFormatStr) ? + ValidationFormat.NONE : EnumUtils.getEnumIgnoreCase(ValidationFormat.class, validationFormatStr); + if (parsedFormat == null || (!ValidationFormat.NONE.equals(parsedFormat) && + parsedFormat.getBaseType() != parsedType)) { + throw new InvalidParameterValueException( + String.format("Invalid validation format: %s specified for type: %s", + parsedFormat, parsedType.name())); + } + List valueOptions = parseValueOptions(name, valueOptionsStr, parsedType, parsedFormat); + return new Parameter(name, parsedType, parsedFormat, valueOptions, Boolean.parseBoolean(required)); + } + + public String getName() { + return name; + } + + public Type getType() { + return type; + } + + public ValidationFormat getValidationFormat() { + return validationformat; + } + + public List getValueOptions() { + return valueoptions; + } + + public boolean isRequired() { + return required; + } + + @Override + public String toString() { + return String.format("Parameter %s", ReflectionToStringBuilderUtils.reflectOnlySelectedFields(this, + ApiConstants.NAME, ApiConstants.TYPE, ApiConstants.REQUIRED)); + } + + public static String toJsonFromList(List parameters) { + return gson.toJson(parameters); + } + + public static List toListFromJson(String json) { + java.lang.reflect.Type listType = new TypeToken>() {}.getType(); + return gson.fromJson(json, listType); + } + + private void validateValueInOptions(Object value) { + if (CollectionUtils.isNotEmpty(valueoptions) && !valueoptions.contains(value)) { + throw new InvalidParameterValueException("Invalid value for parameter '" + name + "': " + value + + ". Valid options are: " + valueoptions.stream().map(Object::toString).collect(Collectors.joining(", "))); + } + } + + public Object validatedValue(String value) { + if (StringUtils.isBlank(value)) { + throw new InvalidParameterValueException("Empty value for parameter '" + name + "': " + value); + } + try { + switch (type) { + case BOOLEAN: + if (!Arrays.asList("true", "false").contains(value)) { + throw new IllegalArgumentException(); + } + return Boolean.parseBoolean(value); + case DATE: + return DateUtil.parseTZDateString(value); + case NUMBER: + Object obj = parseNumber(value, validationformat); + validateValueInOptions(obj); + return obj; + default: + if (!isValidStringValue(value, validationformat)) { + throw new IllegalArgumentException(); + } + validateValueInOptions(value); + return value; + } + } catch (Exception e) { + throw new InvalidParameterValueException("Invalid value for parameter '" + name + "': " + value); + } + } + + public static Map validateParameterValues(List parameterDefinitions, + Map suppliedValues) throws InvalidParameterValueException { + if (suppliedValues == null) { + suppliedValues = new HashMap<>(); + } + for (Parameter param : parameterDefinitions) { + String value = suppliedValues.get(param.getName()); + if (param.isRequired()) { + if (value == null || value.trim().isEmpty()) { + throw new InvalidParameterValueException("Missing or empty required parameter: " + param.getName()); + } + } + } + Map validatedParams = new HashMap<>(); + for (Map.Entry entry : suppliedValues.entrySet()) { + String name = entry.getKey(); + String value = entry.getValue(); + Parameter param = parameterDefinitions.stream() + .filter(p -> p.getName().equals(name)) + .findFirst() + .orElse(null); + if (param != null) { + validatedParams.put(name, param.validatedValue(value)); + } else { + validatedParams.put(name, value); + } + } + return validatedParams; + } + + static class ParameterDeserializer implements JsonDeserializer { + + @Override + public Parameter deserialize(JsonElement json, java.lang.reflect.Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + JsonObject obj = json.getAsJsonObject(); + String name = obj.get(ApiConstants.NAME).getAsString(); + String typeStr = obj.get(ApiConstants.TYPE).getAsString(); + String validationFormatStr = obj.has(ApiConstants.VALIDATION_FORMAT) ? obj.get(ApiConstants.VALIDATION_FORMAT).getAsString() : null; + boolean required = obj.has(ApiConstants.REQUIRED) && obj.get(ApiConstants.REQUIRED).getAsBoolean(); + + Parameter.Type typeEnum = Parameter.Type.valueOf(typeStr); + Parameter.ValidationFormat validationFormatEnum = (validationFormatStr != null) + ? Parameter.ValidationFormat.valueOf(validationFormatStr) + : Parameter.ValidationFormat.NONE; + + List valueOptions = null; + if (obj.has(ApiConstants.VALUE_OPTIONS) && obj.get(ApiConstants.VALUE_OPTIONS).isJsonArray()) { + JsonArray valueOptionsArray = obj.getAsJsonArray(ApiConstants.VALUE_OPTIONS); + valueOptions = new ArrayList<>(); + for (JsonElement el : valueOptionsArray) { + switch (typeEnum) { + case STRING: + valueOptions.add(el.getAsString()); + break; + case NUMBER: + if (validationFormatEnum == Parameter.ValidationFormat.DECIMAL) { + valueOptions.add(el.getAsFloat()); + } else { + valueOptions.add(el.getAsInt()); + } + break; + default: + throw new JsonParseException("Unsupported type for value options"); + } + } + } + + return new Parameter(name, typeEnum, validationFormatEnum, valueOptions, required); + } + } + } +} diff --git a/api/src/main/java/org/apache/cloudstack/extension/ExtensionHelper.java b/api/src/main/java/org/apache/cloudstack/extension/ExtensionHelper.java new file mode 100644 index 00000000000..f50f841ed74 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/extension/ExtensionHelper.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 org.apache.cloudstack.extension; + +public interface ExtensionHelper { + Long getExtensionIdForCluster(long clusterId); + Extension getExtension(long id); + Extension getExtensionForCluster(long clusterId); +} diff --git a/api/src/main/java/org/apache/cloudstack/extension/ExtensionResourceMap.java b/api/src/main/java/org/apache/cloudstack/extension/ExtensionResourceMap.java new file mode 100644 index 00000000000..40ebc19eb5e --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/extension/ExtensionResourceMap.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 org.apache.cloudstack.extension; + +import java.util.Date; + +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; + +public interface ExtensionResourceMap extends InternalIdentity, Identity { + enum ResourceType { + Cluster + } + + long getExtensionId(); + long getResourceId(); + ResourceType getResourceType(); + Date getCreated(); +} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/admin/cluster/ListClustersCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/admin/cluster/ListClustersCmdTest.java new file mode 100644 index 00000000000..af53a539e67 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/command/admin/cluster/ListClustersCmdTest.java @@ -0,0 +1,83 @@ +// 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.admin.cluster; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.apache.cloudstack.api.response.ClusterResponse; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.extension.ExtensionHelper; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.hypervisor.Hypervisor; + +@RunWith(MockitoJUnitRunner.class) +public class ListClustersCmdTest { + + ExtensionHelper extensionHelper; + ListClustersCmd listClustersCmd = new ListClustersCmd(); + + @Before + public void setUp() { + extensionHelper = mock(ExtensionHelper.class); + listClustersCmd.extensionHelper = extensionHelper; + } + + @Test + public void updateClustersExtensions_emptyList_noAction() { + listClustersCmd.updateClustersExtensions(Collections.emptyList()); + // No exception, nothing to verify + } + + @Test + public void updateClustersExtensions_nullList_noAction() { + listClustersCmd.updateClustersExtensions(null); + // No exception, nothing to verify + } + + @Test + public void updateClustersExtensions_withClusterResponses_setsExtension() { + ClusterResponse cluster1 = mock(ClusterResponse.class); + ClusterResponse cluster2 = mock(ClusterResponse.class); + when(cluster1.getInternalId()).thenReturn(1L); + when(cluster1.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.External.name()); + when(cluster2.getInternalId()).thenReturn(2L); + when(cluster2.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.External.name()); + Extension ext1 = mock(Extension.class); + when(ext1.getUuid()).thenReturn("a"); + Extension ext2 = mock(Extension.class); + when(ext2.getUuid()).thenReturn("b"); + when(extensionHelper.getExtensionIdForCluster(anyLong())).thenAnswer(invocation -> invocation.getArguments()[0]); + when(extensionHelper.getExtension(1L)).thenReturn(ext1); + when(extensionHelper.getExtension(2L)).thenReturn(ext2); + List clusters = Arrays.asList(cluster1, cluster2); + listClustersCmd.updateClustersExtensions(clusters); + verify(cluster1).setExtensionId("a"); + verify(cluster2).setExtensionId("b"); + } +} diff --git a/api/src/test/java/org/apache/cloudstack/extension/ExtensionCustomActionTest.java b/api/src/test/java/org/apache/cloudstack/extension/ExtensionCustomActionTest.java new file mode 100644 index 00000000000..ae4314aa11a --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/extension/ExtensionCustomActionTest.java @@ -0,0 +1,484 @@ +//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.extension; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.api.ApiConstants; +import org.junit.Test; + +import com.cloud.exception.InvalidParameterValueException; + +public class ExtensionCustomActionTest { + + @Test + public void testResourceType() { + ExtensionCustomAction.ResourceType vmType = ExtensionCustomAction.ResourceType.VirtualMachine; + assertEquals(com.cloud.vm.VirtualMachine.class, vmType.getAssociatedClass()); + } + + @Test + public void testParameterTypeSupportsOptions() { + assertTrue(ExtensionCustomAction.Parameter.Type.STRING.canSupportsOptions()); + assertTrue(ExtensionCustomAction.Parameter.Type.NUMBER.canSupportsOptions()); + assertFalse(ExtensionCustomAction.Parameter.Type.BOOLEAN.canSupportsOptions()); + assertFalse(ExtensionCustomAction.Parameter.Type.DATE.canSupportsOptions()); + } + + @Test + public void testValidationFormatBaseType() { + assertEquals(ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.UUID.getBaseType()); + assertEquals(ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.EMAIL.getBaseType()); + assertEquals(ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.PASSWORD.getBaseType()); + assertEquals(ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.URL.getBaseType()); + assertEquals(ExtensionCustomAction.Parameter.Type.NUMBER, + ExtensionCustomAction.Parameter.ValidationFormat.DECIMAL.getBaseType()); + assertNull(ExtensionCustomAction.Parameter.ValidationFormat.NONE.getBaseType()); + } + + @Test + public void testParameterFromMapValid() { + Map map = new HashMap<>(); + map.put(ApiConstants.NAME, "testParam"); + map.put(ApiConstants.TYPE, "STRING"); + map.put(ApiConstants.VALIDATION_FORMAT, "EMAIL"); + map.put(ApiConstants.REQUIRED, "true"); + map.put(ApiConstants.VALUE_OPTIONS, "test@example.com,another@test.com"); + + ExtensionCustomAction.Parameter param = ExtensionCustomAction.Parameter.fromMap(map); + + assertEquals("testParam", param.getName()); + assertEquals(ExtensionCustomAction.Parameter.Type.STRING, param.getType()); + assertEquals(ExtensionCustomAction.Parameter.ValidationFormat.EMAIL, param.getValidationFormat()); + assertTrue(param.isRequired()); + assertEquals(2, param.getValueOptions().size()); + assertTrue(param.getValueOptions().contains("test@example.com")); + assertTrue(param.getValueOptions().contains("another@test.com")); + } + + @Test(expected = InvalidParameterValueException.class) + public void testParameterFromMapEmptyName() { + Map map = new HashMap<>(); + map.put(ApiConstants.NAME, ""); + map.put(ApiConstants.TYPE, "STRING"); + + ExtensionCustomAction.Parameter.fromMap(map); + } + + @Test(expected = InvalidParameterValueException.class) + public void testParameterFromMapNoType() { + Map map = new HashMap<>(); + map.put(ApiConstants.NAME, "testParam"); + + ExtensionCustomAction.Parameter.fromMap(map); + } + + @Test(expected = InvalidParameterValueException.class) + public void testParameterFromMapInvalidType() { + Map map = new HashMap<>(); + map.put(ApiConstants.NAME, "testParam"); + map.put(ApiConstants.TYPE, "INVALID_TYPE"); + + ExtensionCustomAction.Parameter.fromMap(map); + } + + @Test(expected = InvalidParameterValueException.class) + public void testParameterFromMapInvalidValidationFormat() { + Map map = new HashMap<>(); + map.put(ApiConstants.NAME, "testParam"); + map.put(ApiConstants.TYPE, "STRING"); + map.put(ApiConstants.VALIDATION_FORMAT, "INVALID_FORMAT"); + + ExtensionCustomAction.Parameter.fromMap(map); + } + + @Test(expected = InvalidParameterValueException.class) + public void testParameterFromMapMismatchedTypeAndFormat() { + Map map = new HashMap<>(); + map.put(ApiConstants.NAME, "testParam"); + map.put(ApiConstants.TYPE, "STRING"); + map.put(ApiConstants.VALIDATION_FORMAT, "DECIMAL"); + + ExtensionCustomAction.Parameter.fromMap(map); + } + + @Test + public void testParameterFromMapWithNumberOptions() { + Map map = new HashMap<>(); + map.put(ApiConstants.NAME, "testParam"); + map.put(ApiConstants.TYPE, "NUMBER"); + map.put(ApiConstants.VALIDATION_FORMAT, "DECIMAL"); + map.put(ApiConstants.VALUE_OPTIONS, "1.5,2.7,3.0"); + + ExtensionCustomAction.Parameter param = ExtensionCustomAction.Parameter.fromMap(map); + + assertEquals(ExtensionCustomAction.Parameter.Type.NUMBER, param.getType()); + assertEquals(ExtensionCustomAction.Parameter.ValidationFormat.DECIMAL, param.getValidationFormat()); + assertEquals(3, param.getValueOptions().size()); + assertTrue(param.getValueOptions().contains(1.5f)); + assertTrue(param.getValueOptions().contains(2.7f)); + assertTrue(param.getValueOptions().contains(3.0f)); + } + + @Test(expected = InvalidParameterValueException.class) + public void testParameterFromMapInvalidNumberOptions() { + Map map = new HashMap<>(); + map.put(ApiConstants.NAME, "testParam"); + map.put(ApiConstants.TYPE, "NUMBER"); + map.put(ApiConstants.VALUE_OPTIONS, "1.5,invalid,3.0"); + + ExtensionCustomAction.Parameter.fromMap(map); + } + + @Test(expected = InvalidParameterValueException.class) + public void testParameterFromMapInvalidEmailOptions() { + Map map = new HashMap<>(); + map.put(ApiConstants.NAME, "testParam"); + map.put(ApiConstants.TYPE, "STRING"); + map.put(ApiConstants.VALIDATION_FORMAT, "EMAIL"); + map.put(ApiConstants.VALUE_OPTIONS, "valid@email.com,invalid-email"); + + ExtensionCustomAction.Parameter.fromMap(map); + } + + @Test + public void testValidatedValueString() { + ExtensionCustomAction.Parameter param = new ExtensionCustomAction.Parameter( + "testParam", + ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.EMAIL, + null, + false + ); + + Object result = param.validatedValue("test@example.com"); + assertEquals("test@example.com", result); + } + + @Test(expected = InvalidParameterValueException.class) + public void testValidatedValueInvalidEmail() { + ExtensionCustomAction.Parameter param = new ExtensionCustomAction.Parameter( + "testParam", + ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.EMAIL, + null, + false + ); + + param.validatedValue("invalid-email"); + } + + @Test + public void testValidatedValueUUID() { + ExtensionCustomAction.Parameter param = new ExtensionCustomAction.Parameter( + "testParam", + ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.UUID, + null, + false + ); + + String validUUID = "550e8400-e29b-41d4-a716-446655440000"; + Object result = param.validatedValue(validUUID); + assertEquals(validUUID, result); + } + + @Test(expected = InvalidParameterValueException.class) + public void testValidatedValueInvalidUUID() { + ExtensionCustomAction.Parameter param = new ExtensionCustomAction.Parameter( + "testParam", + ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.UUID, + null, + false + ); + + param.validatedValue("invalid-uuid"); + } + + @Test + public void testValidatedValueURL() { + ExtensionCustomAction.Parameter param = new ExtensionCustomAction.Parameter( + "testParam", + ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.URL, + null, + false + ); + + Object result = param.validatedValue("https://example.com"); + assertEquals("https://example.com", result); + } + + @Test(expected = InvalidParameterValueException.class) + public void testValidatedValueInvalidURL() { + ExtensionCustomAction.Parameter param = new ExtensionCustomAction.Parameter( + "testParam", + ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.URL, + null, + false + ); + + param.validatedValue("not-a-url"); + } + + @Test + public void testValidatedValuePassword() { + ExtensionCustomAction.Parameter param = new ExtensionCustomAction.Parameter( + "testParam", + ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.PASSWORD, + null, + false + ); + + Object result = param.validatedValue("mypassword"); + assertEquals("mypassword", result); + } + + @Test(expected = InvalidParameterValueException.class) + public void testValidatedValueEmptyPassword() { + ExtensionCustomAction.Parameter param = new ExtensionCustomAction.Parameter( + "testParam", + ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.PASSWORD, + null, + false + ); + + param.validatedValue(" "); + } + + @Test + public void testValidatedValueNumber() { + ExtensionCustomAction.Parameter param = new ExtensionCustomAction.Parameter( + "testParam", + ExtensionCustomAction.Parameter.Type.NUMBER, + ExtensionCustomAction.Parameter.ValidationFormat.NONE, + null, + false + ); + + Object result = param.validatedValue("42"); + assertEquals(42, result); + } + + @Test + public void testValidatedValueDecimal() { + ExtensionCustomAction.Parameter param = new ExtensionCustomAction.Parameter( + "testParam", + ExtensionCustomAction.Parameter.Type.NUMBER, + ExtensionCustomAction.Parameter.ValidationFormat.DECIMAL, + null, + false + ); + + Object result = param.validatedValue("3.14"); + assertEquals(3.14f, result); + } + + @Test(expected = InvalidParameterValueException.class) + public void testValidatedValueInvalidNumber() { + ExtensionCustomAction.Parameter param = new ExtensionCustomAction.Parameter( + "testParam", + ExtensionCustomAction.Parameter.Type.NUMBER, + ExtensionCustomAction.Parameter.ValidationFormat.NONE, + null, + false + ); + + param.validatedValue("not-a-number"); + } + + @Test + public void testValidatedValueBoolean() { + ExtensionCustomAction.Parameter param = new ExtensionCustomAction.Parameter( + "testParam", + ExtensionCustomAction.Parameter.Type.BOOLEAN, + ExtensionCustomAction.Parameter.ValidationFormat.NONE, + null, + false + ); + + Object result = param.validatedValue("true"); + assertEquals(true, result); + } + + @Test(expected = InvalidParameterValueException.class) + public void testValidatedValueInvalidBoolean() { + ExtensionCustomAction.Parameter param = new ExtensionCustomAction.Parameter( + "testParam", + ExtensionCustomAction.Parameter.Type.BOOLEAN, + ExtensionCustomAction.Parameter.ValidationFormat.NONE, + null, + false + ); + + Object result = param.validatedValue("maybe"); + } + + @Test + public void testValidatedValueWithOptions() { + List options = Arrays.asList("option1", "option2", "option3"); + ExtensionCustomAction.Parameter param = new ExtensionCustomAction.Parameter( + "testParam", + ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.NONE, + options, + false + ); + + Object result = param.validatedValue("option2"); + assertEquals("option2", result); + } + + @Test(expected = InvalidParameterValueException.class) + public void testValidatedValueNotInOptions() { + List options = Arrays.asList("option1", "option2", "option3"); + ExtensionCustomAction.Parameter param = new ExtensionCustomAction.Parameter( + "testParam", + ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.NONE, + options, + false + ); + + param.validatedValue("option4"); + } + + @Test(expected = InvalidParameterValueException.class) + public void testValidatedValueEmpty() { + ExtensionCustomAction.Parameter param = new ExtensionCustomAction.Parameter( + "testParam", + ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.NONE, + null, + false + ); + + param.validatedValue(""); + } + + @Test + public void testValidateParameterValues() { + List paramDefs = Arrays.asList( + new ExtensionCustomAction.Parameter("required1", ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.NONE, null, true), + new ExtensionCustomAction.Parameter("required2", ExtensionCustomAction.Parameter.Type.NUMBER, + ExtensionCustomAction.Parameter.ValidationFormat.NONE, null, true), + new ExtensionCustomAction.Parameter("optional", ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.NONE, null, false) + ); + + Map suppliedValues = new HashMap<>(); + suppliedValues.put("required1", "value1"); + suppliedValues.put("required2", "42"); + suppliedValues.put("optional", "optionalValue"); + + Map result = ExtensionCustomAction.Parameter.validateParameterValues(paramDefs, suppliedValues); + + assertEquals("value1", result.get("required1")); + assertEquals(42, result.get("required2")); + assertEquals("optionalValue", result.get("optional")); + } + + @Test(expected = InvalidParameterValueException.class) + public void testValidateParameterValuesMissingRequired() { + List paramDefs = Arrays.asList( + new ExtensionCustomAction.Parameter("required1", ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.NONE, null, true) + ); + + Map suppliedValues = new HashMap<>(); + + ExtensionCustomAction.Parameter.validateParameterValues(paramDefs, suppliedValues); + } + + @Test(expected = InvalidParameterValueException.class) + public void testValidateParameterValuesEmptyRequired() { + List paramDefs = Arrays.asList( + new ExtensionCustomAction.Parameter("required1", ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.NONE, null, true) + ); + + Map suppliedValues = new HashMap<>(); + suppliedValues.put("required1", " "); + + ExtensionCustomAction.Parameter.validateParameterValues(paramDefs, suppliedValues); + } + + @Test + public void testValidateParameterValuesNullSupplied() { + List paramDefs = Arrays.asList( + new ExtensionCustomAction.Parameter("optional", ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.NONE, null, false) + ); + + Map result = ExtensionCustomAction.Parameter.validateParameterValues(paramDefs, null); + assertTrue(result.isEmpty()); + } + + @Test + public void testJsonSerializationDeserialization() { + List originalParams = Arrays.asList( + new ExtensionCustomAction.Parameter("param1", ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.EMAIL, Arrays.asList("test@example.com"), true), + new ExtensionCustomAction.Parameter("param2", ExtensionCustomAction.Parameter.Type.NUMBER, + ExtensionCustomAction.Parameter.ValidationFormat.DECIMAL, Arrays.asList(1.5f, 2.7f), false) + ); + + String json = ExtensionCustomAction.Parameter.toJsonFromList(originalParams); + List deserializedParams = ExtensionCustomAction.Parameter.toListFromJson(json); + + assertEquals(originalParams.size(), deserializedParams.size()); + assertEquals(originalParams.get(0).getName(), deserializedParams.get(0).getName()); + assertEquals(originalParams.get(0).getType(), deserializedParams.get(0).getType()); + assertEquals(originalParams.get(0).getValidationFormat(), deserializedParams.get(0).getValidationFormat()); + assertEquals(originalParams.get(0).isRequired(), deserializedParams.get(0).isRequired()); + assertEquals(originalParams.get(0).getValueOptions(), deserializedParams.get(0).getValueOptions()); + } + + @Test + public void testToString() { + ExtensionCustomAction.Parameter param = new ExtensionCustomAction.Parameter( + "testParam", + ExtensionCustomAction.Parameter.Type.STRING, + ExtensionCustomAction.Parameter.ValidationFormat.EMAIL, + null, + true + ); + + String result = param.toString(); + assertTrue(result.contains("testParam")); + assertTrue(result.contains("STRING")); + assertTrue(result.contains("true")); + } +} diff --git a/build/replace.properties b/build/replace.properties index ce38727b80a..8c3812eb7d2 100644 --- a/build/replace.properties +++ b/build/replace.properties @@ -28,3 +28,4 @@ MSMNTDIR=/mnt COMPONENTS-SPEC=components.xml REMOTEHOST=localhost COMMONLIBDIR=client/target/common/ +EXTENSIONSDEPLOYMENTMODE=developer diff --git a/client/conf/server.properties.in b/client/conf/server.properties.in index 0a6078048d3..fd75c9d3ea0 100644 --- a/client/conf/server.properties.in +++ b/client/conf/server.properties.in @@ -55,3 +55,6 @@ webapp.dir=/usr/share/cloudstack-management/webapp # The path to access log file access.log=/var/log/cloudstack/management/access.log + +# The deployment mode for the extensions +extensions.deployment.mode=@EXTENSIONSDEPLOYMENTMODE@ diff --git a/client/pom.xml b/client/pom.xml index 38a4c405120..81e2b780934 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -337,6 +337,11 @@ cloud-plugin-hypervisor-hyperv ${project.version} + + org.apache.cloudstack + cloud-plugin-hypervisor-external + ${project.version} + org.apache.cloudstack cloud-plugin-storage-allocator-random diff --git a/core/src/main/java/com/cloud/agent/api/PrepareExternalProvisioningAnswer.java b/core/src/main/java/com/cloud/agent/api/PrepareExternalProvisioningAnswer.java new file mode 100644 index 00000000000..b94d18c537e --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/PrepareExternalProvisioningAnswer.java @@ -0,0 +1,51 @@ +// +// 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.agent.api; + +import java.util.Map; + +import com.cloud.agent.api.to.VirtualMachineTO; + +public class PrepareExternalProvisioningAnswer extends Answer { + + Map serverDetails; + VirtualMachineTO virtualMachineTO; + + public PrepareExternalProvisioningAnswer() { + super(); + } + + public PrepareExternalProvisioningAnswer(PrepareExternalProvisioningCommand cmd, Map externalDetails, VirtualMachineTO virtualMachineTO, String details) { + super(cmd, true, details); + this.serverDetails = externalDetails; + this.virtualMachineTO = virtualMachineTO; + } + + public PrepareExternalProvisioningAnswer(PrepareExternalProvisioningCommand cmd, boolean success, String details) { + super(cmd, success, details); + } + + public Map getServerDetails() { + return serverDetails; + } + + public VirtualMachineTO getVirtualMachineTO() { + return virtualMachineTO; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/PrepareExternalProvisioningCommand.java b/core/src/main/java/com/cloud/agent/api/PrepareExternalProvisioningCommand.java new file mode 100644 index 00000000000..44f57607eba --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/PrepareExternalProvisioningCommand.java @@ -0,0 +1,39 @@ +// +// 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.agent.api; + +import com.cloud.agent.api.to.VirtualMachineTO; + +public class PrepareExternalProvisioningCommand extends Command { + + VirtualMachineTO virtualMachineTO; + + public PrepareExternalProvisioningCommand(VirtualMachineTO vmTO) { + this.virtualMachineTO = vmTO; + } + + public VirtualMachineTO getVirtualMachineTO() { + return virtualMachineTO; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/RunCustomActionAnswer.java b/core/src/main/java/com/cloud/agent/api/RunCustomActionAnswer.java new file mode 100644 index 00000000000..1deb789c995 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/RunCustomActionAnswer.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 com.cloud.agent.api; + +public class RunCustomActionAnswer extends Answer { + + public RunCustomActionAnswer(RunCustomActionCommand cmd, boolean success, String details) { + super(cmd, success, details); + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/RunCustomActionCommand.java b/core/src/main/java/com/cloud/agent/api/RunCustomActionCommand.java new file mode 100644 index 00000000000..36489ad4fa5 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/RunCustomActionCommand.java @@ -0,0 +1,59 @@ +// +// 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.agent.api; + +import java.util.Map; + +public class RunCustomActionCommand extends Command { + + String actionName; + Long vmId; + Map parameters; + + public RunCustomActionCommand(String actionName) { + this.actionName = actionName; + this.setWait(5); + } + + public String getActionName() { + return actionName; + } + + public Long getVmId() { + return vmId; + } + + public void setVmId(Long vmId) { + this.vmId = vmId; + } + + public Map getParameters() { + return parameters; + } + + public void setParameters(Map parameters) { + this.parameters = parameters; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/StopCommand.java b/core/src/main/java/com/cloud/agent/api/StopCommand.java index 3923a35bd0a..d07ffa2e31f 100644 --- a/core/src/main/java/com/cloud/agent/api/StopCommand.java +++ b/core/src/main/java/com/cloud/agent/api/StopCommand.java @@ -19,14 +19,14 @@ package com.cloud.agent.api; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + import com.cloud.agent.api.to.DpdkTO; import com.cloud.agent.api.to.GPUDeviceTO; import com.cloud.vm.VirtualMachine; -import java.util.ArrayList; -import java.util.Map; -import java.util.List; - public class StopCommand extends RebootCommand { private boolean isProxy = false; private String urlPort = null; @@ -37,6 +37,7 @@ public class StopCommand extends RebootCommand { boolean forceStop = false; private Map dpdkInterfaceMapping; Map vlanToPersistenceMap; + boolean expungeVM = false; public Map getDpdkInterfaceMapping() { return dpdkInterfaceMapping; @@ -138,4 +139,12 @@ public class StopCommand extends RebootCommand { public void setVlanToPersistenceMap(Map vlanToPersistenceMap) { this.vlanToPersistenceMap = vlanToPersistenceMap; } + + public boolean isExpungeVM() { + return expungeVM; + } + + public void setExpungeVM(boolean expungeVM) { + this.expungeVM = expungeVM; + } } diff --git a/debian/cloudstack-common.install b/debian/cloudstack-common.install index 08f56d4f117..51fc5bf2a66 100644 --- a/debian/cloudstack-common.install +++ b/debian/cloudstack-common.install @@ -27,6 +27,7 @@ /usr/share/cloudstack-common/scripts/vm/hypervisor/versions.sh /usr/share/cloudstack-common/scripts/vm/hypervisor/vmware/* /usr/share/cloudstack-common/scripts/vm/hypervisor/xenserver/* +/usr/share/cloudstack-common/scripts/vm/hypervisor/external/provisioner/* /usr/share/cloudstack-common/lib/* /usr/share/cloudstack-common/vms/* /usr/bin/cloudstack-set-guest-password diff --git a/debian/cloudstack-management.install b/debian/cloudstack-management.install index 3d0d7e23814..b2a32bd93c1 100644 --- a/debian/cloudstack-management.install +++ b/debian/cloudstack-management.install @@ -22,6 +22,8 @@ /etc/cloudstack/management/java.security.ciphers /etc/cloudstack/management/log4j-cloud.xml /etc/cloudstack/management/config.json +/etc/cloudstack/extensions/Proxmox/proxmox.sh +/etc/cloudstack/extensions/HyperV/hyperv.py /etc/default/cloudstack-management /etc/security/limits.d/cloudstack-limits.conf /etc/sudoers.d/cloudstack diff --git a/debian/cloudstack-management.postinst b/debian/cloudstack-management.postinst index d5d50a4718c..fde3ba96de0 100755 --- a/debian/cloudstack-management.postinst +++ b/debian/cloudstack-management.postinst @@ -59,6 +59,9 @@ if [ "$1" = configure ]; then chown -R cloud:cloud /usr/share/cloudstack-management/templates find /usr/share/cloudstack-management/templates -type d -exec chmod 0770 {} \; + chmod -R 0755 /etc/cloudstack/extensions + chown -R cloud:cloud /etc/cloudstack/extensions + ln -sf ${CONFDIR}/log4j-cloud.xml ${CONFDIR}/log4j2.xml # Add jdbc MySQL driver settings to db.properties if not present diff --git a/debian/rules b/debian/rules index d178afa6730..89943408544 100755 --- a/debian/rules +++ b/debian/rules @@ -64,6 +64,7 @@ override_dh_auto_install: # cloudstack-management mkdir $(DESTDIR)/$(SYSCONFDIR)/$(PACKAGE)/server mkdir $(DESTDIR)/$(SYSCONFDIR)/$(PACKAGE)/management + mkdir -p $(DESTDIR)/$(SYSCONFDIR)/$(PACKAGE)/extensions mkdir -p $(DESTDIR)/$(SYSCONFDIR)/security/limits.d/ mkdir -p $(DESTDIR)/$(SYSCONFDIR)/sudoers.d/ mkdir -p $(DESTDIR)/usr/share/$(PACKAGE)-management @@ -81,6 +82,7 @@ override_dh_auto_install: cp -r client/target/classes/META-INF/webapp $(DESTDIR)/usr/share/$(PACKAGE)-management/webapp cp server/target/conf/* $(DESTDIR)/$(SYSCONFDIR)/$(PACKAGE)/server/ cp client/target/conf/* $(DESTDIR)/$(SYSCONFDIR)/$(PACKAGE)/management/ + cp -r extensions/* $(DESTDIR)/$(SYSCONFDIR)/$(PACKAGE)/extensions/ cp client/target/cloud-client-ui-$(VERSION).jar $(DESTDIR)/usr/share/$(PACKAGE)-management/lib/cloudstack-$(VERSION).jar cp client/target/lib/*jar $(DESTDIR)/usr/share/$(PACKAGE)-management/lib/ cp -r engine/schema/dist/systemvm-templates/* $(DESTDIR)/usr/share/$(PACKAGE)-management/templates/systemvm/ @@ -106,6 +108,7 @@ override_dh_auto_install: # Remove configuration in /ur/share/cloudstack-management/webapps/client/WEB-INF # This should all be in /etc/cloudstack/management ln -s ../../..$(SYSCONFDIR)/$(PACKAGE)/management $(DESTDIR)/usr/share/$(PACKAGE)-management/conf + ln -s ../../..$(SYSCONFDIR)/$(PACKAGE)/extensions $(DESTDIR)/usr/share/$(PACKAGE)-management/extensions ln -s ../../../var/log/$(PACKAGE)/management $(DESTDIR)/usr/share/$(PACKAGE)-management/logs install -d -m0755 debian/$(PACKAGE)-management/lib/systemd/system diff --git a/engine/components-api/src/main/java/com/cloud/hypervisor/ExternalProvisioner.java b/engine/components-api/src/main/java/com/cloud/hypervisor/ExternalProvisioner.java new file mode 100644 index 00000000000..a22ea421113 --- /dev/null +++ b/engine/components-api/src/main/java/com/cloud/hypervisor/ExternalProvisioner.java @@ -0,0 +1,61 @@ +// 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.hypervisor; + +import java.util.Map; + +import com.cloud.agent.api.HostVmStateReportEntry; +import com.cloud.agent.api.PrepareExternalProvisioningAnswer; +import com.cloud.agent.api.PrepareExternalProvisioningCommand; +import com.cloud.agent.api.RebootAnswer; +import com.cloud.agent.api.RebootCommand; +import com.cloud.agent.api.RunCustomActionAnswer; +import com.cloud.agent.api.RunCustomActionCommand; +import com.cloud.agent.api.StartAnswer; +import com.cloud.agent.api.StartCommand; +import com.cloud.agent.api.StopAnswer; +import com.cloud.agent.api.StopCommand; +import com.cloud.utils.component.Manager; + +public interface ExternalProvisioner extends Manager { + + String getExtensionsPath(); + + String getExtensionPath(String relativePath); + + String getChecksumForExtensionPath(String extensionName, String relativePath); + + void prepareExtensionPath(String extensionName, boolean userDefined, String extensionRelativePath); + + void cleanupExtensionPath(String extensionName, String extensionRelativePath); + + void cleanupExtensionData(String extensionName, int olderThanDays, boolean cleanupDirectory); + + PrepareExternalProvisioningAnswer prepareExternalProvisioning(String hostGuid, String extensionName, String extensionRelativePath, PrepareExternalProvisioningCommand cmd); + + StartAnswer startInstance(String hostGuid, String extensionName, String extensionRelativePath, StartCommand cmd); + + StopAnswer stopInstance(String hostGuid, String extensionName, String extensionRelativePath, StopCommand cmd); + + RebootAnswer rebootInstance(String hostGuid, String extensionName, String extensionRelativePath, RebootCommand cmd); + + StopAnswer expungeInstance(String hostGuid, String extensionName, String extensionRelativePath, StopCommand cmd); + + Map getHostVmStateReport(long hostId, String extensionName, String extensionRelativePath); + + RunCustomActionAnswer runCustomAction(String hostGuid, String extensionName, String extensionRelativePath, RunCustomActionCommand cmd); +} diff --git a/engine/components-api/src/main/java/com/cloud/template/TemplateManager.java b/engine/components-api/src/main/java/com/cloud/template/TemplateManager.java index b8912526fdf..28f41c677cb 100644 --- a/engine/components-api/src/main/java/com/cloud/template/TemplateManager.java +++ b/engine/components-api/src/main/java/com/cloud/template/TemplateManager.java @@ -29,6 +29,7 @@ import com.cloud.dc.DataCenterVO; import com.cloud.deploy.DeployDestination; import com.cloud.exception.ResourceAllocationException; import com.cloud.exception.StorageUnavailableException; +import com.cloud.hypervisor.Hypervisor; import com.cloud.storage.DataStoreRole; import com.cloud.storage.Storage.TemplateType; import com.cloud.storage.StoragePool; @@ -141,7 +142,7 @@ public interface TemplateManager { public static final String MESSAGE_REGISTER_PUBLIC_TEMPLATE_EVENT = "Message.RegisterPublicTemplate.Event"; public static final String MESSAGE_RESET_TEMPLATE_PERMISSION_EVENT = "Message.ResetTemplatePermission.Event"; - TemplateType validateTemplateType(BaseCmd cmd, boolean isAdmin, boolean isCrossZones); + TemplateType validateTemplateType(BaseCmd cmd, boolean isAdmin, boolean isCrossZones, Hypervisor.HypervisorType hypervisorType); List getTemplateDisksOnImageStore(VirtualMachineTemplate template, DataStoreRole role, String configurationId); diff --git a/engine/orchestration/pom.xml b/engine/orchestration/pom.xml index 437c98dac87..151f95ff944 100755 --- a/engine/orchestration/pom.xml +++ b/engine/orchestration/pom.xml @@ -73,6 +73,11 @@ cloud-plugin-maintenance ${project.version} + + org.apache.cloudstack + cloud-plugin-hypervisor-external + ${project.version} + diff --git a/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java b/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java index dc7852ed82b..75e9fb20e5a 100644 --- a/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java +++ b/engine/orchestration/src/main/java/com/cloud/agent/manager/AgentManagerImpl.java @@ -676,17 +676,17 @@ public class AgentManagerImpl extends ManagerBase implements AgentManager, Handl protected Status investigate(final AgentAttache agent) { final Long hostId = agent.getId(); final HostVO host = _hostDao.findById(hostId); - if (host != null && host.getType() != null && !host.getType().isVirtual()) { - logger.debug("Checking if agent ({}) is alive", host); - final Answer answer = easySend(hostId, new CheckHealthCommand()); - if (answer != null && answer.getResult()) { - final Status status = Status.Up; - logger.debug("Agent ({}) responded to checkHealthCommand, reporting that agent is {}", host, status); - return status; - } - return _haMgr.investigate(hostId); + if (host == null || host.getType() == null || host.getType().isVirtual()) { + return Status.Alert; } - return Status.Alert; + logger.debug("Checking if agent ({}) is alive", host); + final Answer answer = easySend(hostId, new CheckHealthCommand()); + if (answer != null && answer.getResult()) { + final Status status = Status.Up; + logger.debug("Agent ({}) responded to checkHealthCommand, reporting that agent is {}", host, status); + return status; + } + return _haMgr.investigate(hostId); } protected AgentAttache getAttache(final Long hostId) throws AgentUnavailableException { diff --git a/engine/orchestration/src/main/java/com/cloud/agent/manager/ClusteredAgentManagerImpl.java b/engine/orchestration/src/main/java/com/cloud/agent/manager/ClusteredAgentManagerImpl.java index a7dca34f032..c6448982803 100644 --- a/engine/orchestration/src/main/java/com/cloud/agent/manager/ClusteredAgentManagerImpl.java +++ b/engine/orchestration/src/main/java/com/cloud/agent/manager/ClusteredAgentManagerImpl.java @@ -47,6 +47,8 @@ import org.apache.cloudstack.ca.CAManager; import org.apache.cloudstack.framework.config.ConfigDepot; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.dao.ConfigurationDao; +import org.apache.cloudstack.framework.extensions.command.ExtensionServerActionBaseCommand; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; import org.apache.cloudstack.ha.dao.HAConfigDao; import org.apache.cloudstack.maintenance.ManagementServerMaintenanceManager; import org.apache.cloudstack.maintenance.command.BaseShutdownManagementServerHostCommand; @@ -61,6 +63,7 @@ import org.apache.cloudstack.management.ManagementServerHost; import org.apache.cloudstack.outofbandmanagement.dao.OutOfBandManagementDao; import org.apache.cloudstack.utils.identity.ManagementServerNode; import org.apache.cloudstack.utils.security.SSLUtils; +import org.apache.commons.collections.CollectionUtils; import com.cloud.agent.api.Answer; import com.cloud.agent.api.CancelCommand; @@ -106,8 +109,6 @@ import com.cloud.utils.nio.Link; import com.cloud.utils.nio.Task; import com.google.gson.Gson; -import org.apache.commons.collections.CollectionUtils; - public class ClusteredAgentManagerImpl extends AgentManagerImpl implements ClusterManagerListener, ClusteredAgentRebalanceService { private static ScheduledExecutorService s_transferExecutor = Executors.newScheduledThreadPool(2, new NamedThreadFactory("Cluster-AgentRebalancingExecutor")); private final long rebalanceTimeOut = 300000; // 5 mins - after this time remove the agent from the transfer list @@ -147,6 +148,8 @@ public class ClusteredAgentManagerImpl extends AgentManagerImpl implements Clust private ManagementServerMaintenanceManager managementServerMaintenanceManager; @Inject private DataCenterDao dcDao; + @Inject + ExtensionsManager extensionsManager; protected ClusteredAgentManagerImpl() { super(); @@ -1320,6 +1323,8 @@ public class ClusteredAgentManagerImpl extends AgentManagerImpl implements Clust } else if (cmds.length == 1 && cmds[0] instanceof BaseShutdownManagementServerHostCommand) { final BaseShutdownManagementServerHostCommand cmd = (BaseShutdownManagementServerHostCommand) cmds[0]; return handleShutdownManagementServerHostCommand(cmd); + } else if (cmds.length == 1 && cmds[0] instanceof ExtensionServerActionBaseCommand) { + return extensionsManager.handleExtensionServerCommands((ExtensionServerActionBaseCommand)cmds[0]); } try { diff --git a/engine/orchestration/src/main/java/com/cloud/vm/VirtualMachineManagerImpl.java b/engine/orchestration/src/main/java/com/cloud/vm/VirtualMachineManagerImpl.java index 45dcbd0cb53..b57643323dc 100755 --- a/engine/orchestration/src/main/java/com/cloud/vm/VirtualMachineManagerImpl.java +++ b/engine/orchestration/src/main/java/com/cloud/vm/VirtualMachineManagerImpl.java @@ -72,6 +72,9 @@ import org.apache.cloudstack.engine.subsystem.api.storage.VolumeDataFactory; import org.apache.cloudstack.framework.ca.Certificate; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.Configurable; +import org.apache.cloudstack.framework.extensions.dao.ExtensionDetailsDao; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; +import org.apache.cloudstack.framework.extensions.vo.ExtensionDetailsVO; import org.apache.cloudstack.framework.jobs.AsyncJob; import org.apache.cloudstack.framework.jobs.AsyncJobExecutionContext; import org.apache.cloudstack.framework.jobs.AsyncJobManager; @@ -99,6 +102,7 @@ import org.apache.cloudstack.vm.UnmanagedVMsManager; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.collections.MapUtils; import org.apache.commons.lang.BooleanUtils; +import org.apache.commons.lang3.ObjectUtils; import com.cloud.agent.AgentManager; import com.cloud.agent.Listener; @@ -123,6 +127,8 @@ import com.cloud.agent.api.ModifyTargetsCommand; import com.cloud.agent.api.PingRoutingCommand; import com.cloud.agent.api.PlugNicAnswer; import com.cloud.agent.api.PlugNicCommand; +import com.cloud.agent.api.PrepareExternalProvisioningAnswer; +import com.cloud.agent.api.PrepareExternalProvisioningCommand; import com.cloud.agent.api.PrepareForMigrationAnswer; import com.cloud.agent.api.PrepareForMigrationCommand; import com.cloud.agent.api.RebootAnswer; @@ -203,12 +209,14 @@ import com.cloud.host.Host; import com.cloud.host.HostVO; import com.cloud.host.Status; import com.cloud.host.dao.HostDao; +import com.cloud.host.dao.HostDetailsDao; import com.cloud.hypervisor.Hypervisor.HypervisorType; import com.cloud.hypervisor.HypervisorGuru; import com.cloud.hypervisor.HypervisorGuruBase; import com.cloud.hypervisor.HypervisorGuruManager; import com.cloud.network.Network; import com.cloud.network.NetworkModel; +import com.cloud.network.NetworkService; import com.cloud.network.Networks; import com.cloud.network.dao.NetworkDao; import com.cloud.network.dao.NetworkDetailVO; @@ -335,6 +343,8 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac @Inject private HostDao _hostDao; @Inject + private HostDetailsDao hostDetailsDao; + @Inject private AlertManager _alertMgr; @Inject private GuestOSCategoryDao _guestOsCategoryDao; @@ -417,6 +427,8 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac @Inject private DomainDao domainDao; @Inject + public NetworkService networkService; + @Inject ResourceCleanupService resourceCleanupService; @Inject VmWorkJobDao vmWorkJobDao; @@ -433,6 +445,10 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac @Inject private VolumeDataFactory volumeDataFactory; + @Inject + ExtensionsManager extensionsManager; + @Inject + ExtensionDetailsDao extensionDetailsDao; VmWorkJobHandlerProxy _jobHandlerProxy = new VmWorkJobHandlerProxy(this); @@ -594,8 +610,8 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac if (template.getFormat() == ImageFormat.ISO) { volumeMgr.allocateRawVolume(Type.ROOT, rootVolumeName, rootDiskOfferingInfo.getDiskOffering(), rootDiskOfferingInfo.getSize(), rootDiskOfferingInfo.getMinIops(), rootDiskOfferingInfo.getMaxIops(), vm, template, owner, null); - } else if (template.getFormat() == ImageFormat.BAREMETAL) { - logger.debug("%s has format [{}]. Skipping ROOT volume [{}] allocation.", template.toString(), ImageFormat.BAREMETAL, rootVolumeName); + } else if (Arrays.asList(ImageFormat.BAREMETAL, ImageFormat.EXTERNAL).contains(template.getFormat())) { + logger.debug("{} has format [{}]. Skipping ROOT volume [{}] allocation.", template, template.getFormat(), rootVolumeName); } else { volumeMgr.allocateTemplatedVolumes(Type.ROOT, rootVolumeName, rootDiskOfferingInfo.getDiskOffering(), rootDiskSizeFinal, rootDiskOfferingInfo.getMinIops(), rootDiskOfferingInfo.getMaxIops(), template, vm, owner, volume, snapshot); @@ -655,6 +671,13 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac return; } + if (HypervisorType.External.equals(vm.getHypervisorType())) { + UserVmVO userVM = _userVmDao.findById(vm.getId()); + _userVmDao.loadDetails(userVM); + userVM.setDetail(VmDetailConstants.EXPUNGE_EXTERNAL_VM, Boolean.TRUE.toString()); + _userVmDao.saveDetails(userVM); + } + advanceStop(vm.getUuid(), VmDestroyForcestop.value()); vm = _vmDao.findByUuid(vm.getUuid()); @@ -1147,6 +1170,141 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac vmTO.setMetadataProductName(metadataProduct); } + protected void updateExternalVmDetailsFromPrepareAnswer(VirtualMachineTO vmTO, UserVmVO userVmVO, + Map newDetails) { + if (newDetails == null || newDetails.equals(vmTO.getDetails())) { + return; + } + vmTO.setDetails(newDetails); + userVmVO.setDetails(newDetails); + _userVmDao.saveDetails(userVmVO); + } + + protected void updateExternalVmDataFromPrepareAnswer(VirtualMachineTO vmTO, VirtualMachineTO updatedTO) { + final String vncPassword = updatedTO.getVncPassword(); + final Map details = updatedTO.getDetails(); + if ((vncPassword == null || vncPassword.equals(vmTO.getVncPassword())) && + (details == null || details.equals(vmTO.getDetails()))) { + return; + } + UserVmVO userVmVO = _userVmDao.findById(vmTO.getId()); + if (userVmVO == null) { + return; + } + if (vncPassword != null && !vncPassword.equals(userVmVO.getPassword())) { + userVmVO.setVncPassword(vncPassword); + vmTO.setVncPassword(vncPassword); + } + updateExternalVmDetailsFromPrepareAnswer(vmTO, userVmVO, updatedTO.getDetails()); + } + + protected void updateExternalVmNicsFromPrepareAnswer(VirtualMachineTO vmTO, VirtualMachineTO updatedTO) { + if (ObjectUtils.anyNull(vmTO.getNics(), updatedTO.getNics())) { + return; + } + Map originalNicsByUuid = new HashMap<>(); + for (NicTO nic : vmTO.getNics()) { + originalNicsByUuid.put(nic.getNicUuid(), nic); + } + for (NicTO updatedNicTO : updatedTO.getNics()) { + final String nicUuid = updatedNicTO.getNicUuid(); + NicTO originalNicTO = originalNicsByUuid.get(nicUuid); + if (originalNicTO == null) { + continue; + } + final String mac = updatedNicTO.getMac(); + final String ip4 = updatedNicTO.getIp(); + final String ip6 = updatedNicTO.getIp6Address(); + if (Objects.equals(mac, originalNicTO.getMac()) && + Objects.equals(ip4, originalNicTO.getIp()) && + Objects.equals(ip6, originalNicTO.getIp6Address())) { + continue; + } + NicVO nicVO = _nicsDao.findByUuid(nicUuid); + if (nicVO == null) { + continue; + } + logger.debug("Updating {} during External VM preparation", nicVO); + if (ip4 != null && !ip4.equals(nicVO.getIPv4Address())) { + nicVO.setIPv4Address(ip4); + originalNicTO.setIp(ip4); + } + if (ip6 != null && !ip6.equals(nicVO.getIPv6Address())) { + nicVO.setIPv6Address(ip6); + originalNicTO.setIp6Address(ip6); + } + if (mac != null && !mac.equals(nicVO.getMacAddress())) { + nicVO.setMacAddress(mac); + originalNicTO.setMac(mac); + } + _nicsDao.update(nicVO.getId(), nicVO); + } + } + + protected void updateExternalVmFromPrepareAnswer(VirtualMachineTO vmTO, VirtualMachineTO updatedTO) { + if (updatedTO == null) { + return; + } + updateExternalVmDataFromPrepareAnswer(vmTO, updatedTO); + updateExternalVmNicsFromPrepareAnswer(vmTO, updatedTO); + return; + } + + protected void processPrepareExternalProvisioning(boolean firstStart, Host host, + VirtualMachineProfile vmProfile, DataCenter dataCenter) throws CloudRuntimeException { + VirtualMachineTemplate template = vmProfile.getTemplate(); + if (!firstStart || host == null || !HypervisorType.External.equals(host.getHypervisorType()) || + template.getExtensionId() == null) { + return; + } + ExtensionDetailsVO detailsVO = extensionDetailsDao.findDetail(template.getExtensionId(), + ApiConstants.ORCHESTRATOR_REQUIRES_PREPARE_VM); + if (detailsVO == null || !Boolean.parseBoolean(detailsVO.getValue())) { + return; + } + logger.debug("Sending PrepareExternalProvisioningCommand for {}", vmProfile); + VirtualMachineTO virtualMachineTO = toVmTO(vmProfile); + if (virtualMachineTO.getNics() == null || virtualMachineTO.getNics().length == 0) { + List nics = _nicsDao.listByVmId(vmProfile.getId()); + NicTO[] nicTOs = new NicTO[nics.size()]; + nics.forEach(nicVO -> { + NicTO nicTO = toNicTO(_networkModel.getNicProfile(vmProfile.getVirtualMachine(), nicVO, dataCenter), + HypervisorType.External); + nicTOs[nicTO.getDeviceId()] = nicTO; + }); + virtualMachineTO.setNics(nicTOs); + } + Map vmDetails = virtualMachineTO.getExternalDetails(); + Map> externalDetails = extensionsManager.getExternalAccessDetails(host, + vmDetails); + PrepareExternalProvisioningCommand cmd = new PrepareExternalProvisioningCommand(virtualMachineTO); + cmd.setExternalDetails(externalDetails); + Answer answer = null; + CloudRuntimeException cre = new CloudRuntimeException("Failed to prepare VM"); + try { + answer = _agentMgr.send(host.getId(), cmd); + } catch (AgentUnavailableException | OperationTimedoutException e) { + logger.error("Failed PrepareExternalProvisioningCommand due to : {}", e.getMessage(), e); + throw cre; + } + if (answer == null) { + logger.error("Invalid answer received for PrepareExternalProvisioningCommand"); + throw cre; + } + if (!(answer instanceof PrepareExternalProvisioningAnswer)) { + logger.error("Unexpected answer received for PrepareExternalProvisioningCommand: [result: {}, details: {}]", + answer.getResult(), answer.getDetails()); + throw cre; + } + PrepareExternalProvisioningAnswer prepareAnswer = (PrepareExternalProvisioningAnswer)answer; + if (!prepareAnswer.getResult()) { + logger.error("Unexpected answer received for PrepareExternalProvisioningCommand: [result: {}, details: {}]", + answer.getResult(), answer.getDetails()); + throw cre; + } + updateExternalVmFromPrepareAnswer(virtualMachineTO, prepareAnswer.getVirtualMachineTO()); + } + @Override public void orchestrateStart(final String vmUuid, final Map params, final DeploymentPlan planToDeploy, final DeploymentPlanner planner) throws InsufficientCapacityException, ConcurrentOperationException, ResourceUnavailableException { @@ -1158,6 +1316,8 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac VMInstanceVO vm = _vmDao.findByUuid(vmUuid); + final boolean firstStart = vm.getUpdated() == 0; + final VirtualMachineGuru vmGuru = getVmGuru(vm); final Account owner = _entityMgr.findById(Account.class, vm.getAccountId()); @@ -1257,7 +1417,7 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac } } - final VirtualMachineProfileImpl vmProfile = new VirtualMachineProfileImpl(vm, template, offering, owner, params); + VirtualMachineProfileImpl vmProfile = new VirtualMachineProfileImpl(vm, template, offering, owner, params); logBootModeParameters(params); DeployDestination dest = null; try { @@ -1300,8 +1460,11 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac try { resetVmNicsDeviceId(vm.getId()); + + processPrepareExternalProvisioning(firstStart, dest.getHost(), vmProfile, dest.getDataCenter()); + _networkMgr.prepare(vmProfile, dest, ctx); - if (vm.getHypervisorType() != HypervisorType.BareMetal) { + if (vm.getHypervisorType() != HypervisorType.BareMetal && vm.getHypervisorType() != HypervisorType.External) { checkAndAttemptMigrateVmAcrossCluster(vm, clusterId, dest.getStorageForDisks()); volumeMgr.prepare(vmProfile, dest); } @@ -1320,13 +1483,13 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac handlePath(vmTO.getDisks(), vm.getHypervisorType()); setVmNetworkDetails(vm, vmTO); - Commands cmds = new Commands(Command.OnError.Stop); final Map sshAccessDetails = _networkMgr.getSystemVMAccessDetails(vm); final Map ipAddressDetails = new HashMap<>(sshAccessDetails); ipAddressDetails.remove(NetworkElementCommand.ROUTER_NAME); StartCommand command = new StartCommand(vmTO, dest.getHost(), getExecuteInSequence(vm.getHypervisorType())); + updateStartCommandWithExternalDetails(dest.getHost(), vmTO, command); cmds.addCommand(command); vmGuru.finalizeDeployment(cmds, vmProfile, dest, ctx); @@ -1499,6 +1662,53 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac } } + protected void updateStartCommandWithExternalDetails(Host host, VirtualMachineTO vmTO, StartCommand command) { + if (!HypervisorType.External.equals(host.getHypervisorType())) { + return; + } + Map vmExternalDetails = vmTO.getExternalDetails(); + for (NicTO nic : vmTO.getNics()) { + if (!nic.isDefaultNic()) { + continue; + } + vmExternalDetails.put(VmDetailConstants.CLOUDSTACK_VLAN, networkService.getNicVlanValueForExternalVm(nic)); + } + Map> externalDetails = extensionsManager.getExternalAccessDetails(host, vmExternalDetails); + command.setExternalDetails(externalDetails); + } + + protected void updateStopCommandForExternalHypervisorType(final HypervisorType hypervisorType, + final VirtualMachineProfile vmProfile, final StopCommand stopCommand) { + if (!HypervisorType.External.equals(hypervisorType) || vmProfile.getHostId() == null) { + return; + } + Host host = _hostDao.findById(vmProfile.getHostId()); + if (host == null) { + return; + } + VirtualMachineTO vmTO = ObjectUtils.defaultIfNull(stopCommand.getVirtualMachine(), toVmTO(vmProfile)); + if (MapUtils.isEmpty(vmTO.getGuestOsDetails())) { + vmTO.setGuestOsDetails(null); + } + if (MapUtils.isEmpty(vmTO.getExtraConfig())) { + vmTO.setExtraConfig(null); + } + if (MapUtils.isEmpty(vmTO.getNetworkIdToNetworkNameMap())) { + vmTO.setNetworkIdToNetworkNameMap(null); + } + Map> externalDetails = extensionsManager.getExternalAccessDetails(host, vmTO.getExternalDetails()); + stopCommand.setVirtualMachine(vmTO); + stopCommand.setExternalDetails(externalDetails); + } + + protected void updateRebootCommandWithExternalDetails(Host host, VirtualMachineTO vmTO, RebootCommand rebootCmd) { + if (!HypervisorType.External.equals(host.getHypervisorType())) { + return; + } + Map> externalDetails = extensionsManager.getExternalAccessDetails(host, vmTO.getExternalDetails()); + rebootCmd.setExternalDetails(externalDetails); + } + public void setVmNetworkDetails(VMInstanceVO vm, VirtualMachineTO vmTO) { Map networkToNetworkNameMap = new HashMap<>(); if (VirtualMachine.Type.User.equals(vm.getType())) { @@ -1886,7 +2096,9 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac protected boolean sendStop(final VirtualMachineGuru guru, final VirtualMachineProfile profile, final boolean force, final boolean checkBeforeCleanup) { final VirtualMachine vm = profile.getVirtualMachine(); Map vlanToPersistenceMap = getVlanToPersistenceMapForVM(vm.getId()); + StopCommand stpCmd = new StopCommand(vm, getExecuteInSequence(vm.getHypervisorType()), checkBeforeCleanup); + updateStopCommandForExternalHypervisorType(vm.getHypervisorType(), profile, stpCmd); if (MapUtils.isNotEmpty(vlanToPersistenceMap)) { stpCmd.setVlanToPersistenceMap(vlanToPersistenceMap); } @@ -2017,7 +2229,7 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac } try { - if (vm.getHypervisorType() != HypervisorType.BareMetal) { + if (vm.getHypervisorType() != HypervisorType.BareMetal && vm.getHypervisorType() != HypervisorType.External) { volumeMgr.release(profile); logger.debug("Successfully released storage resources for the VM {} in {} state", vm, state); } @@ -2214,6 +2426,7 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac Map vlanToPersistenceMap = getVlanToPersistenceMapForVM(vm.getId()); final StopCommand stop = new StopCommand(vm, getExecuteInSequence(vm.getHypervisorType()), false, cleanUpEvenIfUnableToStop); stop.setControlIp(getControlNicIpForVM(vm)); + updateStopCommandForExternalHypervisorType(vm.getHypervisorType(), profile, stop); if (MapUtils.isNotEmpty(vlanToPersistenceMap)) { stop.setVlanToPersistenceMap(vlanToPersistenceMap); } @@ -2263,6 +2476,14 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac } else { logger.warn("Unable to actually stop {} but continue with release because it's a force stop", vm); vmGuru.finalizeStop(profile, answer); + if (HypervisorType.External.equals(profile.getHypervisorType())) { + try { + stateTransitTo(vm, VirtualMachine.Event.OperationSucceeded, null); + } catch (final NoTransitionException e) { + logger.warn("Unable to transition the state " + vm, e); + } + } + } } else { if (VirtualMachine.systemVMs.contains(vm.getType())) { @@ -3730,6 +3951,7 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac VirtualMachineTO vmTo = getVmTO(vm.getId()); checkAndSetEnterSetupMode(vmTo, params); rebootCmd.setVirtualMachine(vmTo); + updateRebootCommandWithExternalDetails(host, vmTo, rebootCmd); cmds.addCommand(rebootCmd); _agentMgr.send(host.getId(), cmds); 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 9d07a5c7df6..07d1a60b7f8 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 @@ -16,6 +16,8 @@ // under the License. package org.apache.cloudstack.engine.orchestration; +import static com.cloud.configuration.ConfigurationManager.MESSAGE_DELETE_VLAN_IP_RANGE_EVENT; + import java.net.URI; import java.util.ArrayList; import java.util.Arrays; @@ -38,14 +40,6 @@ import java.util.stream.Collectors; import javax.inject.Inject; import javax.naming.ConfigurationException; -import com.cloud.dc.ASNumberVO; -import com.cloud.bgp.BGPService; -import com.cloud.dc.VlanDetailsVO; -import com.cloud.dc.dao.ASNumberDao; -import com.cloud.dc.dao.VlanDetailsDao; -import com.cloud.network.dao.Ipv6GuestPrefixSubnetNetworkMapDao; -import com.cloud.network.dao.NetrisProviderDao; -import com.cloud.network.dao.NsxProviderDao; import org.apache.cloudstack.acl.ControlledEntity.ACLType; import org.apache.cloudstack.annotation.AnnotationService; import org.apache.cloudstack.annotation.dao.AnnotationDao; @@ -67,6 +61,7 @@ import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; import com.cloud.agent.AgentManager; import com.cloud.agent.Listener; @@ -88,8 +83,10 @@ import com.cloud.agent.api.to.deployasis.OVFNetworkTO; import com.cloud.alert.AlertManager; import com.cloud.api.query.dao.DomainRouterJoinDao; import com.cloud.api.query.vo.DomainRouterJoinVO; +import com.cloud.bgp.BGPService; import com.cloud.configuration.ConfigurationManager; import com.cloud.configuration.Resource.ResourceType; +import com.cloud.dc.ASNumberVO; import com.cloud.dc.ClusterVO; import com.cloud.dc.DataCenter; import com.cloud.dc.DataCenter.NetworkType; @@ -97,12 +94,15 @@ import com.cloud.dc.DataCenterVO; import com.cloud.dc.DataCenterVnetVO; import com.cloud.dc.PodVlanMapVO; import com.cloud.dc.Vlan; +import com.cloud.dc.VlanDetailsVO; import com.cloud.dc.VlanVO; +import com.cloud.dc.dao.ASNumberDao; import com.cloud.dc.dao.ClusterDao; import com.cloud.dc.dao.DataCenterDao; import com.cloud.dc.dao.DataCenterVnetDao; import com.cloud.dc.dao.PodVlanMapDao; import com.cloud.dc.dao.VlanDao; +import com.cloud.dc.dao.VlanDetailsDao; import com.cloud.deploy.DataCenterDeployment; import com.cloud.deploy.DeployDestination; import com.cloud.deploy.DeploymentPlan; @@ -153,6 +153,8 @@ import com.cloud.network.dao.AccountGuestVlanMapVO; import com.cloud.network.dao.FirewallRulesDao; import com.cloud.network.dao.IPAddressDao; import com.cloud.network.dao.IPAddressVO; +import com.cloud.network.dao.Ipv6GuestPrefixSubnetNetworkMapDao; +import com.cloud.network.dao.NetrisProviderDao; import com.cloud.network.dao.NetworkAccountDao; import com.cloud.network.dao.NetworkAccountVO; import com.cloud.network.dao.NetworkDao; @@ -163,6 +165,7 @@ import com.cloud.network.dao.NetworkDomainVO; import com.cloud.network.dao.NetworkServiceMapDao; import com.cloud.network.dao.NetworkServiceMapVO; import com.cloud.network.dao.NetworkVO; +import com.cloud.network.dao.NsxProviderDao; import com.cloud.network.dao.PhysicalNetworkDao; import com.cloud.network.dao.PhysicalNetworkServiceProviderDao; import com.cloud.network.dao.PhysicalNetworkTrafficTypeDao; @@ -250,8 +253,8 @@ import com.cloud.vm.UserVmManager; import com.cloud.vm.UserVmVO; import com.cloud.vm.VMInstanceVO; import com.cloud.vm.VirtualMachine; -import com.cloud.vm.VirtualMachineManager; import com.cloud.vm.VirtualMachine.Type; +import com.cloud.vm.VirtualMachineManager; import com.cloud.vm.VirtualMachineProfile; import com.cloud.vm.dao.DomainRouterDao; import com.cloud.vm.dao.NicDao; @@ -263,9 +266,6 @@ import com.cloud.vm.dao.NicSecondaryIpVO; import com.cloud.vm.dao.UserVmDao; import com.cloud.vm.dao.VMInstanceDao; import com.googlecode.ipv6.IPv6Address; -import org.jetbrains.annotations.NotNull; - -import static com.cloud.configuration.ConfigurationManager.MESSAGE_DELETE_VLAN_IP_RANGE_EVENT; /** * NetworkManagerImpl implements NetworkManager. diff --git a/engine/orchestration/src/test/java/com/cloud/vm/VirtualMachineManagerImplTest.java b/engine/orchestration/src/test/java/com/cloud/vm/VirtualMachineManagerImplTest.java index 6335aae2e0a..a570251cb68 100644 --- a/engine/orchestration/src/test/java/com/cloud/vm/VirtualMachineManagerImplTest.java +++ b/engine/orchestration/src/test/java/com/cloud/vm/VirtualMachineManagerImplTest.java @@ -23,11 +23,14 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.lang.reflect.Field; @@ -40,10 +43,14 @@ import java.util.Random; import java.util.UUID; import java.util.stream.Collectors; +import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.engine.subsystem.api.storage.StoragePoolAllocator; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.impl.ConfigDepotImpl; +import org.apache.cloudstack.framework.extensions.dao.ExtensionDetailsDao; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; +import org.apache.cloudstack.framework.extensions.vo.ExtensionDetailsVO; import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; import org.apache.cloudstack.storage.to.VolumeObjectTO; @@ -65,15 +72,20 @@ import org.springframework.test.util.ReflectionTestUtils; import com.cloud.agent.AgentManager; import com.cloud.agent.api.Command; +import com.cloud.agent.api.PrepareExternalProvisioningAnswer; +import com.cloud.agent.api.RebootCommand; +import com.cloud.agent.api.StartCommand; import com.cloud.agent.api.StopAnswer; import com.cloud.agent.api.StopCommand; import com.cloud.agent.api.routing.NetworkElementCommand; +import com.cloud.agent.api.to.NicTO; import com.cloud.agent.api.to.VirtualMachineTO; import com.cloud.api.query.dao.UserVmJoinDao; import com.cloud.api.query.vo.UserVmJoinVO; import com.cloud.dc.ClusterDetailsDao; import com.cloud.dc.ClusterDetailsVO; import com.cloud.dc.ClusterVO; +import com.cloud.dc.DataCenter; import com.cloud.dc.DataCenterVO; import com.cloud.dc.Pod; import com.cloud.dc.dao.ClusterDao; @@ -94,6 +106,7 @@ import com.cloud.host.HostVO; import com.cloud.host.dao.HostDao; import com.cloud.hypervisor.Hypervisor.HypervisorType; import com.cloud.hypervisor.HypervisorGuruManager; +import com.cloud.network.NetworkService; import com.cloud.network.dao.NetworkDao; import com.cloud.network.dao.NetworkVO; import com.cloud.network.vpc.VpcVO; @@ -130,6 +143,7 @@ import com.cloud.utils.db.EntityManager; import com.cloud.utils.exception.CloudRuntimeException; import com.cloud.utils.fsm.StateMachine2; import com.cloud.vm.VirtualMachine.State; +import com.cloud.vm.dao.NicDao; import com.cloud.vm.dao.UserVmDao; import com.cloud.vm.dao.VMInstanceDetailsDao; import com.cloud.vm.dao.VMInstanceDao; @@ -229,6 +243,14 @@ public class VirtualMachineManagerImplTest { private ItWorkDao _workDao; @Mock protected StateMachine2 _stateMachine; + @Mock + ExtensionsManager extensionsManager; + @Mock + ExtensionDetailsDao extensionDetailsDao; + @Mock + NicDao _nicsDao; + @Mock + NetworkService networkService; private ConfigDepotImpl configDepotImpl; private boolean updatedConfigKeyDepot = false; @@ -458,8 +480,8 @@ public class VirtualMachineManagerImplTest { virtualMachineManagerImpl.executeManagedStorageChecksWhenTargetStoragePoolProvided(storagePoolVoMock, volumeVoMock, Mockito.mock(StoragePoolVO.class)); - Mockito.verify(storagePoolVoMock).isManaged(); - Mockito.verify(storagePoolVoMock, Mockito.times(0)).getId(); + verify(storagePoolVoMock).isManaged(); + verify(storagePoolVoMock, Mockito.times(0)).getId(); } @Test @@ -469,8 +491,8 @@ public class VirtualMachineManagerImplTest { virtualMachineManagerImpl.executeManagedStorageChecksWhenTargetStoragePoolProvided(storagePoolVoMock, volumeVoMock, Mockito.mock(StoragePoolVO.class)); - Mockito.verify(storagePoolVoMock).isManaged(); - Mockito.verify(storagePoolVoMock, Mockito.times(0)).getId(); + verify(storagePoolVoMock).isManaged(); + verify(storagePoolVoMock, Mockito.times(0)).getId(); } @Test @@ -485,8 +507,8 @@ public class VirtualMachineManagerImplTest { virtualMachineManagerImpl.executeManagedStorageChecksWhenTargetStoragePoolProvided(storagePoolVoMock, volumeVoMock, storagePoolVoMock); - Mockito.verify(storagePoolVoMock).isManaged(); - Mockito.verify(storagePoolVoMock, Mockito.times(2)).getId(); + verify(storagePoolVoMock).isManaged(); + verify(storagePoolVoMock, Mockito.times(2)).getId(); } @Test(expected = CloudRuntimeException.class) @@ -510,7 +532,7 @@ public class VirtualMachineManagerImplTest { Assert.assertTrue(volumeToPoolObjectMap.isEmpty()); - Mockito.verify(userDefinedVolumeToStoragePoolMap, times(0)).keySet(); + verify(userDefinedVolumeToStoragePoolMap, times(0)).keySet(); } @Test(expected = CloudRuntimeException.class) @@ -539,7 +561,7 @@ public class VirtualMachineManagerImplTest { assertFalse(volumeToPoolObjectMap.isEmpty()); assertEquals(storagePoolVoMock, volumeToPoolObjectMap.get(volumeVoMock)); - Mockito.verify(userDefinedVolumeToStoragePoolMap, times(1)).keySet(); + verify(userDefinedVolumeToStoragePoolMap, times(1)).keySet(); } @Test @@ -566,8 +588,8 @@ public class VirtualMachineManagerImplTest { virtualMachineManagerImpl.executeManagedStorageChecksWhenTargetStoragePoolNotProvided(hostMock, storagePoolVoMock, volumeVoMock); - Mockito.verify(storagePoolVoMock).isManaged(); - Mockito.verify(storagePoolHostDaoMock, Mockito.times(0)).findByPoolHost(anyLong(), anyLong()); + verify(storagePoolVoMock).isManaged(); + verify(storagePoolHostDaoMock, Mockito.times(0)).findByPoolHost(anyLong(), anyLong()); } @Test @@ -577,8 +599,8 @@ public class VirtualMachineManagerImplTest { virtualMachineManagerImpl.executeManagedStorageChecksWhenTargetStoragePoolNotProvided(hostMock, storagePoolVoMock, volumeVoMock); - Mockito.verify(storagePoolVoMock).isManaged(); - Mockito.verify(storagePoolHostDaoMock, Mockito.times(1)).findByPoolHost(storagePoolVoMockId, hostMockId); + verify(storagePoolVoMock).isManaged(); + verify(storagePoolHostDaoMock, Mockito.times(1)).findByPoolHost(storagePoolVoMockId, hostMockId); } @Test(expected = CloudRuntimeException.class) @@ -677,11 +699,11 @@ public class VirtualMachineManagerImplTest { Assert.assertTrue(poolList.isEmpty()); - Mockito.verify(storagePoolAllocatorMock).allocateToPool(any(DiskProfile.class), any(VirtualMachineProfile.class), any(DeploymentPlan.class), + verify(storagePoolAllocatorMock).allocateToPool(any(DiskProfile.class), any(VirtualMachineProfile.class), any(DeploymentPlan.class), any(ExcludeList.class), Mockito.eq(StoragePoolAllocator.RETURN_UPTO_ALL)); - Mockito.verify(storagePoolAllocatorMock2).allocateToPool(any(DiskProfile.class), any(VirtualMachineProfile.class), any(DeploymentPlan.class), + verify(storagePoolAllocatorMock2).allocateToPool(any(DiskProfile.class), any(VirtualMachineProfile.class), any(DeploymentPlan.class), any(ExcludeList.class), Mockito.eq(StoragePoolAllocator.RETURN_UPTO_ALL)); - Mockito.verify(storagePoolAllocatorMock3).allocateToPool(any(DiskProfile.class), any(VirtualMachineProfile.class), any(DeploymentPlan.class), + verify(storagePoolAllocatorMock3).allocateToPool(any(DiskProfile.class), any(VirtualMachineProfile.class), any(DeploymentPlan.class), any(ExcludeList.class), Mockito.eq(StoragePoolAllocator.RETURN_UPTO_ALL)); } @@ -739,8 +761,8 @@ public class VirtualMachineManagerImplTest { virtualMachineManagerImpl.createStoragePoolMappingsForVolumes(virtualMachineProfileMock, dataCenterDeploymentMock, volumeToPoolObjectMap, allVolumes); Assert.assertTrue(volumeToPoolObjectMap.isEmpty()); - Mockito.verify(virtualMachineManagerImpl).executeManagedStorageChecksWhenTargetStoragePoolNotProvided(hostMock, storagePoolVoMock, volumeVoMock); - Mockito.verify(virtualMachineManagerImpl).createVolumeToStoragePoolMappingIfPossible(virtualMachineProfileMock, dataCenterDeploymentMock, volumeToPoolObjectMap, volumeVoMock, storagePoolVoMock); + verify(virtualMachineManagerImpl).executeManagedStorageChecksWhenTargetStoragePoolNotProvided(hostMock, storagePoolVoMock, volumeVoMock); + verify(virtualMachineManagerImpl).createVolumeToStoragePoolMappingIfPossible(virtualMachineProfileMock, dataCenterDeploymentMock, volumeToPoolObjectMap, volumeVoMock, storagePoolVoMock); } @Test @@ -758,9 +780,9 @@ public class VirtualMachineManagerImplTest { virtualMachineManagerImpl.createStoragePoolMappingsForVolumes(virtualMachineProfileMock, dataCenterDeploymentMock, volumeToPoolObjectMap, allVolumes); Assert.assertTrue(volumeToPoolObjectMap.isEmpty()); - Mockito.verify(virtualMachineManagerImpl).executeManagedStorageChecksWhenTargetStoragePoolNotProvided(hostMock, storagePoolVoMock, volumeVoMock); - Mockito.verify(virtualMachineManagerImpl).createVolumeToStoragePoolMappingIfPossible(virtualMachineProfileMock, dataCenterDeploymentMock, volumeToPoolObjectMap, volumeVoMock, storagePoolVoMock); - Mockito.verify(virtualMachineManagerImpl).isStorageCrossClusterMigration(clusterMockId, storagePoolVoMock); + verify(virtualMachineManagerImpl).executeManagedStorageChecksWhenTargetStoragePoolNotProvided(hostMock, storagePoolVoMock, volumeVoMock); + verify(virtualMachineManagerImpl).createVolumeToStoragePoolMappingIfPossible(virtualMachineProfileMock, dataCenterDeploymentMock, volumeToPoolObjectMap, volumeVoMock, storagePoolVoMock); + verify(virtualMachineManagerImpl).isStorageCrossClusterMigration(clusterMockId, storagePoolVoMock); } @Test @@ -779,9 +801,9 @@ public class VirtualMachineManagerImplTest { assertFalse(volumeToPoolObjectMap.isEmpty()); assertEquals(storagePoolVoMock, volumeToPoolObjectMap.get(volumeVoMock)); - Mockito.verify(virtualMachineManagerImpl).executeManagedStorageChecksWhenTargetStoragePoolNotProvided(hostMock, storagePoolVoMock, volumeVoMock); - Mockito.verify(virtualMachineManagerImpl).isStorageCrossClusterMigration(clusterMockId, storagePoolVoMock); - Mockito.verify(virtualMachineManagerImpl, Mockito.times(0)).createVolumeToStoragePoolMappingIfPossible(virtualMachineProfileMock, dataCenterDeploymentMock, volumeToPoolObjectMap, volumeVoMock, + verify(virtualMachineManagerImpl).executeManagedStorageChecksWhenTargetStoragePoolNotProvided(hostMock, storagePoolVoMock, volumeVoMock); + verify(virtualMachineManagerImpl).isStorageCrossClusterMigration(clusterMockId, storagePoolVoMock); + verify(virtualMachineManagerImpl, Mockito.times(0)).createVolumeToStoragePoolMappingIfPossible(virtualMachineProfileMock, dataCenterDeploymentMock, volumeToPoolObjectMap, volumeVoMock, storagePoolVoMock); } @@ -1318,7 +1340,7 @@ public class VirtualMachineManagerImplTest { virtualMachineManagerImpl.recreateCheckpointsKvmOnVmAfterMigration(vmInstanceMock, 0); - Mockito.verify(volumeDaoMock, Mockito.never()).findByInstance(Mockito.anyLong()); + verify(volumeDaoMock, never()).findByInstance(Mockito.anyLong()); } @Test @@ -1328,7 +1350,7 @@ public class VirtualMachineManagerImplTest { virtualMachineManagerImpl.recreateCheckpointsKvmOnVmAfterMigration(vmInstanceMock, 0); - Mockito.verify(agentManagerMock, Mockito.never()).send(Mockito.anyLong(), (Command) any()); + verify(agentManagerMock, never()).send(Mockito.anyLong(), (Command) any()); } @Test (expected = CloudRuntimeException.class) @@ -1341,7 +1363,7 @@ public class VirtualMachineManagerImplTest { virtualMachineManagerImpl.recreateCheckpointsKvmOnVmAfterMigration(vmInstanceMock, 0); - Mockito.verify(snapshotManagerMock, Mockito.times(1)).endSnapshotChainForVolume(Mockito.anyLong(),any()); + verify(snapshotManagerMock, Mockito.times(1)).endSnapshotChainForVolume(Mockito.anyLong(),any()); } @Test (expected = CloudRuntimeException.class) @@ -1354,7 +1376,7 @@ public class VirtualMachineManagerImplTest { virtualMachineManagerImpl.recreateCheckpointsKvmOnVmAfterMigration(vmInstanceMock, 0); - Mockito.verify(snapshotManagerMock, Mockito.times(1)).endSnapshotChainForVolume(Mockito.anyLong(),any()); + verify(snapshotManagerMock, Mockito.times(1)).endSnapshotChainForVolume(Mockito.anyLong(),any()); } @Test @@ -1367,7 +1389,7 @@ public class VirtualMachineManagerImplTest { virtualMachineManagerImpl.recreateCheckpointsKvmOnVmAfterMigration(vmInstanceMock, 0); - Mockito.verify(snapshotManagerMock, Mockito.times(1)).endSnapshotChainForVolume(Mockito.anyLong(),any()); + verify(snapshotManagerMock, Mockito.times(1)).endSnapshotChainForVolume(Mockito.anyLong(),any()); } @Test @@ -1379,6 +1401,241 @@ public class VirtualMachineManagerImplTest { virtualMachineManagerImpl.recreateCheckpointsKvmOnVmAfterMigration(vmInstanceMock, 0); - Mockito.verify(snapshotManagerMock, Mockito.never()).endSnapshotChainForVolume(Mockito.anyLong(),any()); + verify(snapshotManagerMock, never()).endSnapshotChainForVolume(Mockito.anyLong(),any()); + } + + @Test + public void updateStartCommandWithExternalDetails_nonExternalHypervisor_noAction() { + Host host = mock(Host.class); + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + StartCommand command = mock(StartCommand.class); + + when(host.getHypervisorType()).thenReturn(HypervisorType.KVM); + + virtualMachineManagerImpl.updateStartCommandWithExternalDetails(host, vmTO, command); + + verify(command, never()).setExternalDetails(any()); + } + + @Test + public void updateStartCommandWithExternalDetails_externalHypervisor_setsExternalDetails() { + Host host = mock(Host.class); + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + StartCommand command = mock(StartCommand.class); + NicTO nic = mock(NicTO.class); + + when(host.getHypervisorType()).thenReturn(HypervisorType.External); + when(vmTO.getExternalDetails()).thenReturn(new HashMap<>()); + when(vmTO.getNics()).thenReturn(new NicTO[]{nic}); + when(nic.isDefaultNic()).thenReturn(true); + when(networkService.getNicVlanValueForExternalVm(nic)).thenReturn("segmentName"); + when(extensionsManager.getExternalAccessDetails(eq(host), any())).thenReturn(new HashMap<>()); + + virtualMachineManagerImpl.updateStartCommandWithExternalDetails(host, vmTO, command); + + verify(command).setExternalDetails(any()); + } + + @Test + public void updateStopCommandForExternalHypervisorType_nonExternalHypervisor_noAction() { + VirtualMachineProfile vmProfile = mock(VirtualMachineProfile.class); + StopCommand stopCommand = mock(StopCommand.class); + + virtualMachineManagerImpl.updateStopCommandForExternalHypervisorType(HypervisorType.KVM, vmProfile, stopCommand); + + verify(stopCommand, never()).setExternalDetails(any()); + } + + @Test + public void updateStopCommandForExternalHypervisorType_externalHypervisor_setsExternalDetails() { + VirtualMachineProfile vmProfile = mock(VirtualMachineProfile.class); + StopCommand stopCommand = mock(StopCommand.class); + HostVO host = mock(HostVO.class); + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + when(vmProfile.getHostId()).thenReturn(1L); + when(hostDaoMock.findById(1L)).thenReturn(host); + when(stopCommand.getVirtualMachine()).thenReturn(vmTO); + when(vmTO.getExternalDetails()).thenReturn(new HashMap<>()); + when(extensionsManager.getExternalAccessDetails(eq(host), any())).thenReturn(new HashMap<>()); + doReturn(mock(VirtualMachineTO.class)).when(virtualMachineManagerImpl).toVmTO(any()); + virtualMachineManagerImpl.updateStopCommandForExternalHypervisorType(HypervisorType.External, vmProfile, stopCommand); + verify(stopCommand).setExternalDetails(any()); + } + + @Test + public void updateRebootCommandWithExternalDetails_nonExternalHypervisor_noAction() { + Host host = mock(Host.class); + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + RebootCommand rebootCmd = mock(RebootCommand.class); + when(host.getHypervisorType()).thenReturn(HypervisorType.KVM); + virtualMachineManagerImpl.updateRebootCommandWithExternalDetails(host, vmTO, rebootCmd); + verify(rebootCmd, never()).setExternalDetails(any()); + } + + @Test + public void updateRebootCommandWithExternalDetails_externalHypervisor_setsExternalDetails() { + Host host = mock(Host.class); + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + RebootCommand rebootCmd = mock(RebootCommand.class); + when(host.getHypervisorType()).thenReturn(HypervisorType.External); + when(vmTO.getExternalDetails()).thenReturn(new HashMap<>()); + when(extensionsManager.getExternalAccessDetails(eq(host), any())).thenReturn(new HashMap<>()); + virtualMachineManagerImpl.updateRebootCommandWithExternalDetails(host, vmTO, rebootCmd); + verify(rebootCmd).setExternalDetails(any()); + } + + @Test + public void updateExternalVmDetailsFromPrepareAnswer_nullDetails_noAction() { + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + UserVmVO userVmVO = mock(UserVmVO.class); + virtualMachineManagerImpl.updateExternalVmDetailsFromPrepareAnswer(vmTO, userVmVO, null); + verify(vmTO, never()).setDetails(any()); + verify(userVmVO, never()).setDetails(any()); + verify(userVmDaoMock, never()).saveDetails(any()); + } + + @Test + public void updateExternalVmDetailsFromPrepareAnswer_sameDetails_noAction() { + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + UserVmVO userVmVO = mock(UserVmVO.class); + Map details = new HashMap<>(); + when(vmTO.getDetails()).thenReturn(details); + virtualMachineManagerImpl.updateExternalVmDetailsFromPrepareAnswer(vmTO, userVmVO, details); + verify(vmTO, never()).setDetails(any()); + verify(userVmVO, never()).setDetails(any()); + verify(userVmDaoMock, never()).saveDetails(any()); + } + + @Test + public void updateExternalVmDataFromPrepareAnswer_vncPasswordUpdated_updatesPassword() { + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + VirtualMachineTO updatedTO = mock(VirtualMachineTO.class); + UserVmVO userVmVO = mock(UserVmVO.class); + when(updatedTO.getVncPassword()).thenReturn("newPassword"); + when(vmTO.getVncPassword()).thenReturn("oldPassword"); + when(userVmDaoMock.findById(anyLong())).thenReturn(userVmVO); + virtualMachineManagerImpl.updateExternalVmDataFromPrepareAnswer(vmTO, updatedTO); + verify(userVmVO).setVncPassword("newPassword"); + verify(vmTO).setVncPassword("newPassword"); + } + + @Test + public void updateExternalVmNicsFromPrepareAnswer_nullNics_noAction() { + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + VirtualMachineTO updatedTO = mock(VirtualMachineTO.class); + when(vmTO.getNics()).thenReturn(null); + virtualMachineManagerImpl.updateExternalVmNicsFromPrepareAnswer(vmTO, updatedTO); + verify(_nicsDao, never()).findByUuid(anyString()); + } + + @Test + public void updateExternalVmNicsFromPrepareAnswer_updatesNicsSuccessfully() { + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + VirtualMachineTO updatedTO = mock(VirtualMachineTO.class); + NicTO nicTO = mock(NicTO.class); + NicTO updatedNicTO = mock(NicTO.class); + + when(vmTO.getNics()).thenReturn(new NicTO[]{nicTO}); + when(updatedTO.getNics()).thenReturn(new NicTO[]{updatedNicTO}); + when(nicTO.getNicUuid()).thenReturn("nic-uuid"); + when(nicTO.getMac()).thenReturn("mac-a"); + when(updatedNicTO.getNicUuid()).thenReturn("nic-uuid"); + when(updatedNicTO.getMac()).thenReturn("mac-b"); + when(_nicsDao.findByUuid("nic-uuid")).thenReturn(mock(NicVO.class)); + + virtualMachineManagerImpl.updateExternalVmNicsFromPrepareAnswer(vmTO, updatedTO); + + verify(_nicsDao).findByUuid("nic-uuid"); + verify(_nicsDao).update(anyLong(), any(NicVO.class)); + } + + @Test + public void updateExternalVmNicsFromPrepareAnswer_noMatchingNicUuid_noAction() { + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + VirtualMachineTO updatedTO = mock(VirtualMachineTO.class); + NicTO nicTO = mock(NicTO.class); + NicTO updatedNicTO = mock(NicTO.class); + + when(vmTO.getNics()).thenReturn(new NicTO[]{nicTO}); + when(updatedTO.getNics()).thenReturn(new NicTO[]{updatedNicTO}); + when(nicTO.getNicUuid()).thenReturn("nic-uuid"); + when(updatedNicTO.getNicUuid()).thenReturn("different-uuid"); + + virtualMachineManagerImpl.updateExternalVmNicsFromPrepareAnswer(vmTO, updatedTO); + + verify(_nicsDao, never()).findByUuid(anyString()); + } + + @Test + public void updateExternalVmNicsFromPrepareAnswer_nullUpdatedNics_noAction() { + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + VirtualMachineTO updatedTO = mock(VirtualMachineTO.class); + + when(vmTO.getNics()).thenReturn(new NicTO[]{mock(NicTO.class)}); + when(updatedTO.getNics()).thenReturn(null); + + virtualMachineManagerImpl.updateExternalVmNicsFromPrepareAnswer(vmTO, updatedTO); + + verify(_nicsDao, never()).findByUuid(anyString()); + } + + @Test + public void updateExternalVmNicsFromPrepareAnswer_nullVmNics_noAction() { + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + VirtualMachineTO updatedTO = mock(VirtualMachineTO.class); + + when(vmTO.getNics()).thenReturn(null); + when(updatedTO.getNics()).thenReturn(new NicTO[]{mock(NicTO.class)}); + + virtualMachineManagerImpl.updateExternalVmNicsFromPrepareAnswer(vmTO, updatedTO); + + verify(_nicsDao, never()).findByUuid(anyString()); + } + + @Test + public void updateExternalVmNicsFromPrepareAnswer_emptyNics_noAction() { + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + VirtualMachineTO updatedTO = mock(VirtualMachineTO.class); + + when(vmTO.getNics()).thenReturn(new NicTO[]{}); + when(updatedTO.getNics()).thenReturn(new NicTO[]{}); + + virtualMachineManagerImpl.updateExternalVmNicsFromPrepareAnswer(vmTO, updatedTO); + + verify(_nicsDao, never()).findByUuid(anyString()); + } + + @Test + public void processPrepareExternalProvisioning_nonExternalHypervisor_noAction() throws OperationTimedoutException, AgentUnavailableException { + Host host = mock(Host.class); + VirtualMachineProfile vmProfile = mock(VirtualMachineProfile.class); + VirtualMachineTemplate template = mock(VirtualMachineTemplate.class); + when(vmProfile.getTemplate()).thenReturn(template); + when(host.getHypervisorType()).thenReturn(HypervisorType.KVM); + virtualMachineManagerImpl.processPrepareExternalProvisioning(true, host, vmProfile, mock(DataCenter.class)); + verify(agentManagerMock, never()).send(anyLong(), any(Command.class)); + } + + @Test + public void processPrepareExternalProvisioning_externalHypervisor_sendsCommand() throws OperationTimedoutException, AgentUnavailableException { + Host host = mock(Host.class); + VirtualMachineProfile vmProfile = mock(VirtualMachineProfile.class); + VirtualMachineTemplate template = mock(VirtualMachineTemplate.class); + when(vmProfile.getTemplate()).thenReturn(template); + NicTO[] nics = new NicTO[]{mock(NicTO.class)}; + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + when(vmTO.getNics()).thenReturn(nics); + doReturn(vmTO).when(virtualMachineManagerImpl).toVmTO(vmProfile); + ExtensionDetailsVO detailsVO = mock(ExtensionDetailsVO.class); + when(host.getHypervisorType()).thenReturn(HypervisorType.External); + when(template.getExtensionId()).thenReturn(1L); + when(extensionDetailsDao.findDetail(eq(1L), eq(ApiConstants.ORCHESTRATOR_REQUIRES_PREPARE_VM))).thenReturn(detailsVO); + when(detailsVO.getValue()).thenReturn("true"); + PrepareExternalProvisioningAnswer answer = mock(PrepareExternalProvisioningAnswer.class); + when(answer.getResult()).thenReturn(true); + when(answer.getVirtualMachineTO()).thenReturn(vmTO); + when(agentManagerMock.send(anyLong(), any(Command.class))).thenReturn(answer); + virtualMachineManagerImpl.processPrepareExternalProvisioning(true, host, vmProfile, mock(DataCenter.class)); + verify(agentManagerMock).send(anyLong(), any(Command.class)); } } diff --git a/engine/schema/src/main/java/com/cloud/dc/dao/ClusterDao.java b/engine/schema/src/main/java/com/cloud/dc/dao/ClusterDao.java index a6fe3123c4e..1745f5380e2 100644 --- a/engine/schema/src/main/java/com/cloud/dc/dao/ClusterDao.java +++ b/engine/schema/src/main/java/com/cloud/dc/dao/ClusterDao.java @@ -59,4 +59,6 @@ public interface ClusterDao extends GenericDao { List listClustersByArchAndZoneId(long zoneId, CPU.CPUArch arch); List listDistinctStorageAccessGroups(String name, String keyword); + + List listEnabledClusterIdsByZoneHypervisorArch(Long zoneId, HypervisorType hypervisorType, CPU.CPUArch arch); } diff --git a/engine/schema/src/main/java/com/cloud/dc/dao/ClusterDaoImpl.java b/engine/schema/src/main/java/com/cloud/dc/dao/ClusterDaoImpl.java index 7c0d0c53814..ea82a10f9c9 100644 --- a/engine/schema/src/main/java/com/cloud/dc/dao/ClusterDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/dc/dao/ClusterDaoImpl.java @@ -378,4 +378,29 @@ public class ClusterDaoImpl extends GenericDaoBase implements C return customSearch(sc, null); } + + @Override + public List listEnabledClusterIdsByZoneHypervisorArch(Long zoneId, HypervisorType hypervisorType, CPU.CPUArch arch) { + GenericSearchBuilder sb = createSearchBuilder(Long.class); + sb.selectFields(sb.entity().getId()); + sb.and("zoneId", sb.entity().getDataCenterId(), SearchCriteria.Op.EQ); + sb.and("allocationState", sb.entity().getAllocationState(), Op.EQ); + sb.and("managedState", sb.entity().getManagedState(), Op.EQ); + sb.and("hypervisor", sb.entity().getHypervisorType(), Op.EQ); + sb.and("arch", sb.entity().getArch(), Op.EQ); + sb.done(); + SearchCriteria sc = sb.create(); + sc.setParameters("allocationState", Grouping.AllocationState.Enabled); + sc.setParameters("managedState", Managed.ManagedState.Managed); + if (zoneId != null) { + sc.setParameters("zoneId", zoneId); + } + if (hypervisorType != null) { + sc.setParameters("hypervisor", hypervisorType); + } + if (arch != null) { + sc.setParameters("arch", arch); + } + return customSearch(sc, null); + } } diff --git a/engine/schema/src/main/java/com/cloud/host/dao/HostDetailsDao.java b/engine/schema/src/main/java/com/cloud/host/dao/HostDetailsDao.java index 8dc4efa91f3..5d8bd0a0a3a 100644 --- a/engine/schema/src/main/java/com/cloud/host/dao/HostDetailsDao.java +++ b/engine/schema/src/main/java/com/cloud/host/dao/HostDetailsDao.java @@ -32,4 +32,7 @@ public interface HostDetailsDao extends GenericDao { void deleteDetails(long hostId); List findByName(String name); + + void replaceExternalDetails(long hostId, Map details); + } diff --git a/engine/schema/src/main/java/com/cloud/host/dao/HostDetailsDaoImpl.java b/engine/schema/src/main/java/com/cloud/host/dao/HostDetailsDaoImpl.java index 9c1340592f9..3eb9faeb1c1 100644 --- a/engine/schema/src/main/java/com/cloud/host/dao/HostDetailsDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/host/dao/HostDetailsDaoImpl.java @@ -18,6 +18,7 @@ package com.cloud.host.dao; import java.sql.PreparedStatement; import java.sql.SQLException; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -31,6 +32,7 @@ import com.cloud.utils.db.SearchBuilder; import com.cloud.utils.db.SearchCriteria; import com.cloud.utils.db.TransactionLegacy; import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.vm.VmDetailConstants; @Component public class HostDetailsDaoImpl extends GenericDaoBase implements HostDetailsDao { @@ -130,4 +132,34 @@ public class HostDetailsDaoImpl extends GenericDaoBase implement sc.setParameters("name", name); return listBy(sc); } + + @Override + public void replaceExternalDetails(long hostId, Map details) { + if (details.isEmpty()) { + return; + } + TransactionLegacy txn = TransactionLegacy.currentTxn(); + txn.start(); + List detailVOs = new ArrayList<>(); + for (Map.Entry entry : details.entrySet()) { + String name = entry.getKey(); + String value = entry.getValue(); + if ("password".equals(entry.getKey())) { + value = DBEncryptionUtil.encrypt(value); + } + detailVOs.add(new DetailVO(hostId, name, value)); + } + SearchBuilder sb = createSearchBuilder(); + sb.and("hostId", sb.entity().getHostId(), SearchCriteria.Op.EQ); + sb.and("name", sb.entity().getName(), SearchCriteria.Op.LIKE); + sb.done(); + SearchCriteria sc = sb.create(); + sc.setParameters("hostId", hostId); + sc.setParameters("name", VmDetailConstants.EXTERNAL_DETAIL_PREFIX + "%"); + remove(sc); + for (DetailVO detail : detailVOs) { + persist(detail); + } + txn.commit(); + } } diff --git a/engine/schema/src/main/java/com/cloud/network/dao/PhysicalNetworkTrafficTypeDaoImpl.java b/engine/schema/src/main/java/com/cloud/network/dao/PhysicalNetworkTrafficTypeDaoImpl.java index 4811b59d31e..09d9f1d7fbf 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/PhysicalNetworkTrafficTypeDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/PhysicalNetworkTrafficTypeDaoImpl.java @@ -125,7 +125,7 @@ public class PhysicalNetworkTrafficTypeDaoImpl extends GenericDaoBase details, boolean sshKeyEnabled, boolean isDynamicallyScalable, boolean directDownload, - boolean deployAsIs, CPU.CPUArch arch) { + boolean deployAsIs, CPU.CPUArch arch, Long extensionId) { this(id, name, format, @@ -245,6 +248,7 @@ public class VMTemplateVO implements VirtualMachineTemplate { this.directDownload = directDownload; this.deployAsIs = deployAsIs; this.arch = arch; + this.extensionId = extensionId; } public static VMTemplateVO createPreHostIso(Long id, String uniqueName, String name, ImageFormat format, boolean isPublic, boolean featured, TemplateType type, @@ -702,4 +706,11 @@ public class VMTemplateVO implements VirtualMachineTemplate { this.arch = arch; } + public Long getExtensionId() { + return extensionId; + } + + public void setExtensionId(Long extensionId) { + this.extensionId = extensionId; + } } diff --git a/engine/schema/src/main/java/com/cloud/storage/dao/VMTemplateDao.java b/engine/schema/src/main/java/com/cloud/storage/dao/VMTemplateDao.java index 2835cf3cb3c..d70eeb87653 100644 --- a/engine/schema/src/main/java/com/cloud/storage/dao/VMTemplateDao.java +++ b/engine/schema/src/main/java/com/cloud/storage/dao/VMTemplateDao.java @@ -101,4 +101,6 @@ public interface VMTemplateDao extends GenericDao, StateDao< List listByIds(List ids); List listIdsByTemplateTag(String tag); + + List listIdsByExtensionId(long extensionId); } diff --git a/engine/schema/src/main/java/com/cloud/storage/dao/VMTemplateDaoImpl.java b/engine/schema/src/main/java/com/cloud/storage/dao/VMTemplateDaoImpl.java index 138677927e6..267cef2169a 100644 --- a/engine/schema/src/main/java/com/cloud/storage/dao/VMTemplateDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/storage/dao/VMTemplateDaoImpl.java @@ -837,6 +837,17 @@ public class VMTemplateDaoImpl extends GenericDaoBase implem return customSearchIncludingRemoved(sc, null); } + @Override + public List listIdsByExtensionId(long extensionId) { + GenericSearchBuilder sb = createSearchBuilder(Long.class); + sb.selectFields(sb.entity().getId()); + sb.and("extensionId", sb.entity().getExtensionId(), SearchCriteria.Op.EQ); + sb.done(); + SearchCriteria sc = sb.create(); + sc.setParameters("extensionId", extensionId); + return customSearch(sc, null); + } + @Override public boolean updateState( com.cloud.template.VirtualMachineTemplate.State currentState, diff --git a/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/ResourceDetailsDao.java b/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/ResourceDetailsDao.java index 6b6fe200c10..7c113a10af4 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/ResourceDetailsDao.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/ResourceDetailsDao.java @@ -21,6 +21,7 @@ import java.util.Map; import org.apache.cloudstack.api.ResourceDetail; +import com.cloud.utils.Pair; import com.cloud.utils.db.GenericDao; public interface ResourceDetailsDao extends GenericDao { @@ -94,6 +95,8 @@ public interface ResourceDetailsDao extends GenericDao Map listDetailsVisibility(long resourceId); + Pair, Map> listDetailsKeyPairsWithVisibility(long resourceId); + void saveDetails(List details); void addDetail(long resourceId, String key, String value, boolean display); diff --git a/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/ResourceDetailsDaoBase.java b/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/ResourceDetailsDaoBase.java index 29d3f88fd90..58b60531e5a 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/ResourceDetailsDaoBase.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/ResourceDetailsDaoBase.java @@ -23,6 +23,7 @@ import java.util.stream.Collectors; import org.apache.commons.collections.CollectionUtils; +import com.cloud.utils.Pair; import com.cloud.utils.crypt.DBEncryptionUtil; import com.cloud.utils.db.GenericDaoBase; import com.cloud.utils.db.GenericSearchBuilder; @@ -127,6 +128,19 @@ public abstract class ResourceDetailsDaoBase extends G return details; } + @Override + public Pair, Map> listDetailsKeyPairsWithVisibility(long resourceId) { + SearchCriteria sc = AllFieldsSearch.create(); + sc.setParameters("resourceId", resourceId); + List results = search(sc, null); + Map> partitioned = results.stream() + .collect(Collectors.partitioningBy( + R::isDisplay, + Collectors.toMap(R::getName, R::getValue) + )); + return new Pair<>(partitioned.get(true), partitioned.get(false)); + } + public List listDetails(long resourceId) { SearchCriteria sc = AllFieldsSearch.create(); sc.setParameters("resourceId", resourceId); diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42010to42100.sql b/engine/schema/src/main/resources/META-INF/db/schema-42010to42100.sql index e2389dc9250..275549e5eee 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42010to42100.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42010to42100.sql @@ -282,3 +282,318 @@ ALTER TABLE `cloud`.`vm_instance_details` ADD CONSTRAINT `fk_vm_instance_details CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.backup_schedule', 'uuid', 'VARCHAR(40) NOT NULL'); UPDATE `cloud`.`backup_schedule` SET uuid = UUID(); + +-- Extension framework +UPDATE `cloud`.`configuration` SET value = CONCAT(value, ',External') WHERE name = 'hypervisor.list'; + +CREATE TABLE IF NOT EXISTS `cloud`.`extension` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `uuid` varchar(40) NOT NULL UNIQUE, + `name` varchar(255) NOT NULL, + `description` varchar(4096), + `type` varchar(255) NOT NULL COMMENT 'Type of the extension: Orchestrator, etc', + `relative_path` varchar(2048) NOT NULL COMMENT 'Path for the extension relative to the root extensions directory', + `path_ready` tinyint(1) DEFAULT '0' COMMENT 'True if the extension path is in ready state across management servers', + `is_user_defined` tinyint(1) DEFAULT '0' COMMENT 'True if the extension is added by admin', + `state` char(32) NOT NULL COMMENT 'State of the extension - Enabled or Disabled', + `created` datetime NOT NULL, + `removed` datetime DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `cloud`.`extension_details` ( + `id` bigint unsigned UNIQUE NOT NULL AUTO_INCREMENT COMMENT 'id', + `extension_id` bigint unsigned NOT NULL COMMENT 'extension to which the detail is related to', + `name` varchar(255) NOT NULL COMMENT 'name of the detail', + `value` varchar(255) NOT NULL COMMENT 'value of the detail', + `display` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'True if the detail can be displayed to the end user', + PRIMARY KEY (`id`), + CONSTRAINT `fk_extension_details__extension_id` FOREIGN KEY (`extension_id`) + REFERENCES `extension` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `cloud`.`extension_resource_map` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `extension_id` bigint(20) unsigned NOT NULL, + `resource_id` bigint(20) unsigned NOT NULL, + `resource_type` char(255) NOT NULL, + `created` datetime NOT NULL, + `removed` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + CONSTRAINT `fk_extension_resource_map__extension_id` FOREIGN KEY (`extension_id`) + REFERENCES `cloud`.`extension`(`id`) ON DELETE CASCADE, + INDEX `idx_extension_resource` (`resource_id`, `resource_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `cloud`.`extension_resource_map_details` ( + `id` bigint unsigned UNIQUE NOT NULL AUTO_INCREMENT COMMENT 'id', + `extension_resource_map_id` bigint unsigned NOT NULL COMMENT 'mapping to which the detail is related', + `name` varchar(255) NOT NULL COMMENT 'name of the detail', + `value` varchar(255) NOT NULL COMMENT 'value of the detail', + `display` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'True if the detail can be displayed to the end user', + PRIMARY KEY (`id`), + CONSTRAINT `fk_extension_resource_map_details__map_id` FOREIGN KEY (`extension_resource_map_id`) + REFERENCES `extension_resource_map` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `cloud`.`extension_custom_action` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `uuid` varchar(255) NOT NULL UNIQUE, + `name` varchar(255) NOT NULL, + `description` varchar(4096), + `extension_id` bigint(20) unsigned NOT NULL, + `resource_type` varchar(255), + `allowed_role_types` int unsigned NOT NULL DEFAULT '1', + `success_message` varchar(4096), + `error_message` varchar(4096), + `enabled` boolean DEFAULT true, + `timeout` int unsigned NOT NULL DEFAULT '5' COMMENT 'The timeout in seconds to wait for the action to complete before failing', + `created` datetime NOT NULL, + `removed` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + CONSTRAINT `fk_extension_custom_action__extension_id` FOREIGN KEY (`extension_id`) + REFERENCES `cloud`.`extension`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `cloud`.`extension_custom_action_details` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `extension_custom_action_id` bigint(20) unsigned NOT NULL, + `name` varchar(255) NOT NULL, + `value` TEXT NOT NULL, + `display` tinyint(1) NOT NULL DEFAULT 1, + PRIMARY KEY (`id`), + CONSTRAINT `fk_custom_action_details__action_id` FOREIGN KEY (`extension_custom_action_id`) + REFERENCES `cloud`.`extension_custom_action`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vm_template', 'extension_id', 'bigint unsigned DEFAULT NULL COMMENT "id of the extension"'); + +-- Add built-in Extensions and Custom Actions + +DROP PROCEDURE IF EXISTS `cloud`.`INSERT_EXTENSION_IF_NOT_EXISTS`; +CREATE PROCEDURE `cloud`.`INSERT_EXTENSION_IF_NOT_EXISTS`( + IN ext_name VARCHAR(255), + IN ext_desc VARCHAR(255), + IN ext_path VARCHAR(255) +) +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM `cloud`.`extension` WHERE `name` = ext_name + ) THEN + INSERT INTO `cloud`.`extension` ( + `uuid`, `name`, `description`, `type`, + `relative_path`, `path_ready`, + `is_user_defined`, `state`, `created`, `removed` + ) + VALUES ( + UUID(), ext_name, ext_desc, 'Orchestrator', + ext_path, 1, 0, 'Enabled', NOW(), NULL + ) +; END IF +;END; + +DROP PROCEDURE IF EXISTS `cloud`.`INSERT_EXTENSION_DETAIL_IF_NOT_EXISTS`; +CREATE PROCEDURE `cloud`.`INSERT_EXTENSION_DETAIL_IF_NOT_EXISTS`( + IN ext_name VARCHAR(255), + IN detail_key VARCHAR(255), + IN detail_value TEXT, + IN display TINYINT(1) +) +BEGIN + DECLARE ext_id BIGINT +; SELECT `id` INTO ext_id FROM `cloud`.`extension` WHERE `name` = ext_name LIMIT 1 +; IF NOT EXISTS ( + SELECT 1 FROM `cloud`.`extension_details` + WHERE `extension_id` = ext_id AND `name` = detail_key + ) THEN + INSERT INTO `cloud`.`extension_details` ( + `extension_id`, `name`, `value`, `display` + ) + VALUES ( + ext_id, detail_key, detail_value, display + ) +; END IF +;END; + +CALL `cloud`.`INSERT_EXTENSION_IF_NOT_EXISTS`('Proxmox', 'Sample extension for Proxmox written in bash', 'Proxmox/proxmox.sh'); +CALL `cloud`.`INSERT_EXTENSION_DETAIL_IF_NOT_EXISTS`('Proxmox', 'orchestratorrequirespreparevm', 'true', 0); + +CALL `cloud`.`INSERT_EXTENSION_IF_NOT_EXISTS`('HyperV', 'Sample extension for HyperV written in python', 'HyperV/hyperv.py'); + +DROP PROCEDURE IF EXISTS `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_IF_NOT_EXISTS`; +CREATE PROCEDURE `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_IF_NOT_EXISTS`( + IN ext_name VARCHAR(255), + IN action_name VARCHAR(255), + IN action_desc VARCHAR(4096), + IN resource_type VARCHAR(255), + IN allowed_roles INT UNSIGNED, + IN success_msg VARCHAR(4096), + IN error_msg VARCHAR(4096), + IN timeout_seconds INT UNSIGNED +) +BEGIN + DECLARE ext_id BIGINT +; SELECT `id` INTO ext_id FROM `cloud`.`extension` WHERE `name` = ext_name LIMIT 1 +; IF NOT EXISTS ( + SELECT 1 FROM `cloud`.`extension_custom_action` WHERE `name` = action_name AND `extension_id` = ext_id + ) THEN + INSERT INTO `cloud`.`extension_custom_action` ( + `uuid`, `name`, `description`, `extension_id`, `resource_type`, + `allowed_role_types`, `success_message`, `error_message`, + `enabled`, `timeout`, `created`, `removed` + ) + VALUES ( + UUID(), action_name, action_desc, ext_id, resource_type, + allowed_roles, success_msg, error_msg, + 1, timeout_seconds, NOW(), NULL + ) +; END IF +;END; + +DROP PROCEDURE IF EXISTS `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_DETAILS_IF_NOT_EXISTS`; +CREATE PROCEDURE `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_DETAILS_IF_NOT_EXISTS` ( + IN ext_name VARCHAR(255), + IN action_name VARCHAR(255), + IN param_json TEXT +) +BEGIN + DECLARE action_id BIGINT UNSIGNED +; SELECT `eca`.`id` INTO action_id FROM `cloud`.`extension_custom_action` `eca` + JOIN `cloud`.`extension` `e` ON `e`.`id` = `eca`.`extension_id` + WHERE `eca`.`name` = action_name AND `e`.`name` = ext_name LIMIT 1 +; IF NOT EXISTS ( + SELECT 1 FROM `cloud`.`extension_custom_action_details` + WHERE `extension_custom_action_id` = action_id + AND `name` = 'parameters' + ) THEN + INSERT INTO `cloud`.`extension_custom_action_details` ( + `extension_custom_action_id`, + `name`, + `value`, + `display` + ) VALUES ( + action_id, + 'parameters', + param_json, + 0 + ) +; END IF +;END; + +CALL `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_IF_NOT_EXISTS`('Proxmox', 'ListSnapshots', 'List Instance snapshots', 'VirtualMachine', 15, 'Snapshots fetched for {{resourceName}} in {{extensionName}}', 'List Snapshots failed for {{resourceName}}', 60); +CALL `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_IF_NOT_EXISTS`('Proxmox', 'CreateSnapshot', 'Create an Instance snapshot', 'VirtualMachine', 15, 'Snapshot created for {{resourceName}} in {{extensionName}}', 'Snapshot creation failed for {{resourceName}}', 60); +CALL `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_IF_NOT_EXISTS`('Proxmox', 'RestoreSnapshot', 'Restore Instance to the specific snapshot', 'VirtualMachine', 15, 'Successfully restored snapshot for {{resourceName}} in {{extensionName}}', 'Restore snapshot failed for {{resourceName}}', 60); +CALL `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_IF_NOT_EXISTS`('Proxmox', 'DeleteSnapshot', 'Delete the specified snapshot', 'VirtualMachine', 15, 'Successfully deleted snapshot for {{resourceName}} in {{extensionName}}', 'Delete snapshot failed for {{resourceName}}', 60); + +CALL `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_DETAILS_IF_NOT_EXISTS`( + 'Proxmox', + 'ListSnapshots', + '[]' +); +CALL `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_DETAILS_IF_NOT_EXISTS`( + 'Proxmox', + 'CreateSnapshot', + '[ + { + "name": "snap_name", + "type": "STRING", + "validationformat": "NONE", + "required": true + }, + { + "name": "snap_description", + "type": "STRING", + "validationformat": "NONE", + "required": false + }, + { + "name": "snap_save_memory", + "type": "BOOLEAN", + "validationformat": "NONE", + "required": false + } + ]' +); +CALL `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_DETAILS_IF_NOT_EXISTS`( + 'Proxmox', + 'RestoreSnapshot', + '[ + { + "name": "snap_name", + "type": "STRING", + "validationformat": "NONE", + "required": true + } + ]' +); +CALL `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_DETAILS_IF_NOT_EXISTS`( + 'Proxmox', + 'DeleteSnapshot', + '[ + { + "name": "snap_name", + "type": "STRING", + "validationformat": "NONE", + "required": true + } + ]' +); + +CALL `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_IF_NOT_EXISTS`('HyperV', 'ListSnapshots', 'List checkpoints/snapshots for the Instance', 'VirtualMachine', 15, 'Snapshots fetched for {{resourceName}} in {{extensionName}}', 'List Snapshots failed for {{resourceName}}', 60); +CALL `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_IF_NOT_EXISTS`('HyperV', 'CreateSnapshot', 'Create a checkpoint/snapshot for the Instance', 'VirtualMachine', 15, 'Snapshot created for {{resourceName}} in {{extensionName}}', 'Snapshot creation failed for {{resourceName}}', 60); +CALL `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_IF_NOT_EXISTS`('HyperV', 'RestoreSnapshot', 'Restore Instance to the specified snapshot', 'VirtualMachine', 15, 'Successfully restored snapshot for {{resourceName}} in {{extensionName}}', 'Restore snapshot failed for {{resourceName}}', 60); +CALL `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_IF_NOT_EXISTS`('HyperV', 'DeleteSnapshot', 'Delete the specified snapshot', 'VirtualMachine', 15, 'Successfully deleted snapshot for {{resourceName}} in {{extensionName}}', 'Delete snapshot failed for {{resourceName}}', 60); +CALL `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_IF_NOT_EXISTS`('HyperV', 'Suspend', 'Suspend the Instance by freezing its current state in RAM', 'VirtualMachine', 15, 'Successfully suspended {{resourceName}} in {{extensionName}}', 'Suspend failed for {{resourceName}}', 60); +CALL `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_IF_NOT_EXISTS`('HyperV', 'Resume', 'Resumes a suspended Instance, restoring CPU execution from memory.', 'VirtualMachine', 15, 'Successfully resumed {{resourceName}} in {{extensionName}}', 'Resume failed for {{resourceName}}', 60); + +CALL `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_DETAILS_IF_NOT_EXISTS`( + 'HyperV', + 'ListSnapshots', + '[]' +); +CALL `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_DETAILS_IF_NOT_EXISTS`( + 'HyperV', + 'CreateSnapshot', + '[ + { + "name": "snapshot_name", + "type": "STRING", + "validationformat": "NONE", + "required": true + } + ]' +); +CALL `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_DETAILS_IF_NOT_EXISTS`( + 'HyperV', + 'RestoreSnapshot', + '[ + { + "name": "snapshot_name", + "type": "STRING", + "validationformat": "NONE", + "required": true + } + ]' +); +CALL `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_DETAILS_IF_NOT_EXISTS`( + 'HyperV', + 'DeleteSnapshot', + '[ + { + "name": "snapshot_name", + "type": "STRING", + "validationformat": "NONE", + "required": true + } + ]' +); +CALL `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_DETAILS_IF_NOT_EXISTS`( + 'HyperV', + 'Suspend', + '[]' +); +CALL `cloud`.`INSERT_EXTENSION_CUSTOM_ACTION_DETAILS_IF_NOT_EXISTS`( + 'HyperV', + 'Resume', + '[]' +); diff --git a/engine/schema/src/main/resources/META-INF/db/views/cloud.template_view.sql b/engine/schema/src/main/resources/META-INF/db/views/cloud.template_view.sql index 6bfcdaddbcc..76a8be16bda 100644 --- a/engine/schema/src/main/resources/META-INF/db/views/cloud.template_view.sql +++ b/engine/schema/src/main/resources/META-INF/db/views/cloud.template_view.sql @@ -106,7 +106,10 @@ SELECT `user_data`.`uuid` AS `user_data_uuid`, `user_data`.`name` AS `user_data_name`, `user_data`.`params` AS `user_data_params`, - `vm_template`.`user_data_link_policy` AS `user_data_policy` + `vm_template`.`user_data_link_policy` AS `user_data_policy`, + `extension`.`id` AS `extension_id`, + `extension`.`uuid` AS `extension_uuid`, + `extension`.`name` AS `extension_name` FROM (((((((((((((`vm_template` JOIN `guest_os` ON ((`guest_os`.`id` = `vm_template`.`guest_os_id`))) @@ -129,6 +132,7 @@ FROM OR (`template_zone_ref`.`zone_id` = `data_center`.`id`)))) LEFT JOIN `launch_permission` ON ((`launch_permission`.`template_id` = `vm_template`.`id`))) LEFT JOIN `user_data` ON ((`user_data`.`id` = `vm_template`.`user_data_id`)) + LEFT JOIN `extension` ON ((`extension`.`id` = `vm_template`.`extension_id`)) LEFT JOIN `resource_tags` ON (((`resource_tags`.`resource_id` = `vm_template`.`id`) AND ((`resource_tags`.`resource_type` = 'Template') OR (`resource_tags`.`resource_type` = 'ISO'))))); diff --git a/engine/schema/src/test/java/com/cloud/storage/dao/VMTemplateDaoImplTest.java b/engine/schema/src/test/java/com/cloud/storage/dao/VMTemplateDaoImplTest.java index 96f60547f50..7f151730c9c 100644 --- a/engine/schema/src/test/java/com/cloud/storage/dao/VMTemplateDaoImplTest.java +++ b/engine/schema/src/test/java/com/cloud/storage/dao/VMTemplateDaoImplTest.java @@ -20,6 +20,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.eq; import static org.mockito.Mockito.mock; @@ -299,4 +300,20 @@ public class VMTemplateDaoImplTest { verify(searchBuilder, times(1)).join(eq("templateZoneSearch"), any(), any(), any(), eq(JoinBuilder.JoinType.INNER)); verify(templateDao, times(1)).customSearch(searchCriteria, null); } + + @Test + public void testListIdsByExtensionId_ReturnsIds() { + long extensionId = 42L; + List expectedIds = Arrays.asList(1L, 2L, 3L); + GenericSearchBuilder searchBuilder = mock(GenericSearchBuilder.class); + SearchCriteria searchCriteria = mock(SearchCriteria.class); + when(templateDao.createSearchBuilder(Long.class)).thenReturn(searchBuilder); + when(searchBuilder.entity()).thenReturn(mock(VMTemplateVO.class)); + when(searchBuilder.create()).thenReturn(searchCriteria); + doReturn(expectedIds).when(templateDao).customSearchIncludingRemoved(eq(searchCriteria), isNull()); + List result = templateDao.listIdsByExtensionId(extensionId); + assertEquals(expectedIds, result); + verify(searchCriteria).setParameters("extensionId", extensionId); + verify(templateDao).customSearchIncludingRemoved(eq(searchCriteria), isNull()); + } } diff --git a/engine/storage/image/src/main/java/org/apache/cloudstack/storage/image/TemplateServiceImpl.java b/engine/storage/image/src/main/java/org/apache/cloudstack/storage/image/TemplateServiceImpl.java index 38e0d0d081c..3092b00bac2 100644 --- a/engine/storage/image/src/main/java/org/apache/cloudstack/storage/image/TemplateServiceImpl.java +++ b/engine/storage/image/src/main/java/org/apache/cloudstack/storage/image/TemplateServiceImpl.java @@ -236,6 +236,7 @@ public class TemplateServiceImpl implements TemplateService { } /* Baremetal need not to download any template */ availHypers.remove(HypervisorType.BareMetal); + availHypers.remove(HypervisorType.External); availHypers.add(HypervisorType.None); // bug 9809: resume ISO // download. @@ -526,6 +527,7 @@ public class TemplateServiceImpl implements TemplateService { } /* Baremetal need not to download any template */ availHypers.remove(HypervisorType.BareMetal); + availHypers.remove(HypervisorType.External); availHypers.add(HypervisorType.None); // bug 9809: resume ISO // download. for (VMTemplateVO tmplt : toBeDownloaded) { @@ -817,7 +819,7 @@ public class TemplateServiceImpl implements TemplateService { String templateName = dataDiskTemplate.isIso() ? dataDiskTemplate.getPath().substring(dataDiskTemplate.getPath().lastIndexOf(File.separator) + 1) : template.getName() + suffix + diskCount; VMTemplateVO templateVO = new VMTemplateVO(templateId, templateName, format, false, false, false, ttype, template.getUrl(), template.requiresHvm(), template.getBits(), template.getAccountId(), null, templateName, false, guestOsId, false, template.getHypervisorType(), null, - null, false, false, false, false, template.getArch()); + null, false, false, false, false, template.getArch(), template.getExtensionId()); if (dataDiskTemplate.isIso()){ templateVO.setUniqueName(templateName); } diff --git a/engine/storage/image/src/main/java/org/apache/cloudstack/storage/image/store/TemplateObject.java b/engine/storage/image/src/main/java/org/apache/cloudstack/storage/image/store/TemplateObject.java index 0dbe4fd7246..5cb500f5e6c 100644 --- a/engine/storage/image/src/main/java/org/apache/cloudstack/storage/image/store/TemplateObject.java +++ b/engine/storage/image/src/main/java/org/apache/cloudstack/storage/image/store/TemplateObject.java @@ -358,6 +358,11 @@ public class TemplateObject implements TemplateInfo { return imageVO.getArch(); } + @Override + public Long getExtensionId() { + return imageVO.getExtensionId(); + } + @Override public DataTO getTO() { DataTO to = null; diff --git a/extensions/HyperV/hyperv.py b/extensions/HyperV/hyperv.py new file mode 100755 index 00000000000..83109ebb03a --- /dev/null +++ b/extensions/HyperV/hyperv.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +# 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. + +import json +import sys +import winrm + + +def fail(message): + print(json.dumps({"error": message})) + sys.exit(1) + + +def succeed(data): + print(json.dumps(data)) + sys.exit(0) + + +class HyperVManager: + def __init__(self, config_path): + self.config_path = config_path + self.data = self.parse_json() + self.session = self.init_winrm_session() + + def parse_json(self): + try: + with open(self.config_path, 'r') as f: + json_data = json.load(f) + + external_host_details = json_data["externaldetails"].get("host", []) + data = { + "url": external_host_details["url"], + "username": external_host_details["username"], + "password": external_host_details["password"], + "network_switch": external_host_details["network_switch"], + "vhd_path": external_host_details["vhd_path"], + "vm_path": external_host_details["vm_path"], + "cert_validation": "validate" if external_host_details.get("verify_tls_certificate", "true").lower() == "true" else "ignore" + } + + external_vm_details = json_data["externaldetails"].get("virtualmachine", []) + if external_vm_details: + data["template_type"] = external_vm_details["template_type"] + data["generation"] = external_vm_details.get("generation", 1) + data["template_path"] = external_vm_details.get("template_path", "") + data["iso_path"] = external_vm_details.get("iso_path", "") + data["vhd_size_gb"] = external_vm_details.get("vhd_size_gb", "") + + data["cpus"] = json_data["cloudstack.vm.details"]["cpus"] + data["memory"] = json_data["cloudstack.vm.details"]["minRam"] + data["vmname"] = json_data["cloudstack.vm.details"]["name"] + + nics = json_data["cloudstack.vm.details"].get("nics", []) + data["nics"] = [] + for nic in nics: + data["nics"].append({ + "mac": nic["mac"], + "vlan": nic["broadcastUri"].replace("vlan://", "") + }) + + parameters = json_data.get("parameters", []) + if parameters: + data["snapshot_name"] = parameters.get("snapshot_name", "") + + return data + + except KeyError as e: + fail(f"Missing required field in JSON: {str(e)}") + except Exception as e: + fail(f"Error parsing JSON: {str(e)}") + + def init_winrm_session(self): + return winrm.Session( + f"https://{self.data['url']}:5986/wsman", + auth=(self.data["username"], self.data["password"]), + transport='ntlm', + server_cert_validation=self.data["cert_validation"] + ) + + def run_ps_int(self, command): + r = self.session.run_ps(command) + if r.status_code != 0: + raise Exception(r.std_err.decode()) + return r.std_out.decode() + + def run_ps(self, command): + try: + output = self.run_ps_int(command) + return output + except Exception as e: + fail(str(e)) + + def vm_not_present(self, exception): + vm_not_present_str = f'Hyper-V was unable to find a virtual machine with name "{self.data["vmname"]}"' + return vm_not_present_str in str(exception) + + def create(self): + vm_name = self.data["vmname"] + cpus = self.data["cpus"] + memory = self.data["memory"] + memory_mb = int(memory) / 1024 / 1024 + template_path = self.data["template_path"] + vhd_path = self.data["vhd_path"] + "\\" + vm_name + ".vhdx" + vhd_size_gb = self.data["vhd_size_gb"] + generation = self.data["generation"] + iso_path = self.data["iso_path"] + network_switch = self.data["network_switch"] + vm_path = self.data["vm_path"] + template_type = self.data.get("template_type", "template") + + vhd_created = False + vm_created = False + vm_started = False + try: + command = ( + f'New-VM -Name "{vm_name}" -MemoryStartupBytes {memory_mb}MB ' + f'-Generation {generation} -Path "{vm_path}" ' + ) + if template_type == "iso": + if (iso_path == ""): + fail("Missing required field in JSON: iso_path") + if (vhd_size_gb == ""): + fail("Missing required field in JSON: vhd_size_gb") + command += ( + f'-NewVHDPath "{vhd_path}" -NewVHDSizeBytes {vhd_size_gb}GB; ' + f'Add-VMDvdDrive -VMName "{vm_name}" -Path "{iso_path}"; ' + ) + else: + if (template_path == ""): + fail("Missing required field in JSON: template_path") + self.run_ps_int(f'Copy-Item "{template_path}" "{vhd_path}"') + vhd_created = True + command += f'-VHDPath "{vhd_path}"; ' + + self.run_ps_int(command) + vm_created = True + + command = f'Remove-VMNetworkAdapter -VMName "{vm_name}" -Name "Network Adapter" -ErrorAction SilentlyContinue; ' + self.run_ps_int(command) + + command = f'Set-VMProcessor -VMName "{vm_name}" -Count "{cpus}"; ' + if (generation == 2): + command += f'Set-VMFirmware -VMName "{vm_name}" -EnableSecureBoot Off; ' + + self.run_ps_int(command) + + for idx, nic in enumerate(self.data["nics"]): + adapter_name = f"NIC{idx+1}" + self.run_ps_int(f'Add-VMNetworkAdapter -VMName "{vm_name}" -SwitchName "{network_switch}" -Name "{adapter_name}"') + self.run_ps_int(f'Set-VMNetworkAdapter -VMName "{vm_name}" -Name "{adapter_name}" -StaticMacAddress "{nic["mac"]}"') + self.run_ps_int(f'Set-VMNetworkAdapterVlan -VMName "{vm_name}" -VMNetworkAdapterName "{adapter_name}" -Access -VlanId "{nic["vlan"]}"') + + self.run_ps_int(f'Start-VM -Name "{vm_name}"') + vm_started = True + + succeed({"status": "success", "message": "Instance created"}) + + except Exception as e: + if vm_started: + self.run_ps_int(f'Stop-VM -Name "{vm_name}" -Force -TurnOff') + if vm_created: + self.run_ps_int(f'Remove-VM -Name "{vm_name}" -Force') + if vhd_created: + self.run_ps_int(f'Remove-Item -Path "{vhd_path}" -Force') + fail(str(e)) + + def start(self): + self.run_ps(f'Start-VM -Name "{self.data["vmname"]}"') + succeed({"status": "success", "message": "Instance started"}) + + def stop(self): + try: + self.run_ps_int(f'Stop-VM -Name "{self.data["vmname"]}" -Force') + except Exception as e: + if self.vm_not_present(e): + succeed({"status": "success", "message": "Instance stopped"}) + else: + fail(str(e)) + succeed({"status": "success", "message": "Instance stopped"}) + + def reboot(self): + self.run_ps(f'Restart-VM -Name "{self.data["vmname"]}" -Force') + succeed({"status": "success", "message": "Instance rebooted"}) + + def status(self): + command = f'(Get-VM -Name "{self.data["vmname"]}").State' + state = self.run_ps(command) + if state.lower() == "running": + power_state = "poweron" + elif state.lower() == "off": + power_state = "poweroff" + else: + power_state = "unknown" + succeed({"status": "success", "power_state": power_state}) + + def delete(self): + try: + self.run_ps_int(f'Remove-VM -Name "{self.data["vmname"]}" -Force') + except Exception as e: + if self.vm_not_present(e): + succeed({"status": "success", "message": "Instance deleted"}) + else: + fail(str(e)) + succeed({"status": "success", "message": "Instance deleted"}) + + def suspend(self): + self.run_ps(f'Suspend-VM -Name "{self.data["vmname"]}"') + succeed({"status": "success", "message": "Instance suspended"}) + + def resume(self): + self.run_ps(f'Resume-VM -Name "{self.data["vmname"]}"') + succeed({"status": "success", "message": "Instance resumed"}) + + def create_snapshot(self): + snapshot_name = self.data["snapshot_name"] + if snapshot_name == "": + fail("Missing required field in JSON: snapshot_name") + command = f'Checkpoint-VM -VMName "{self.data["vmname"]}" -SnapshotName "{snapshot_name}"' + self.run_ps(command) + succeed({"status": "success", "message": f"Snapshot '{snapshot_name}' created"}) + + def list_snapshots(self): + command = ( + f'Get-VMSnapshot -VMName "{self.data["vmname"]}" ' + '| Select-Object Name, @{Name="CreationTime";Expression={$_.CreationTime.ToString("s")}} ' + '| ConvertTo-Json' + ) + snapshots = json.loads(self.run_ps(command)) + succeed({"status": "success", "printmessage": "true", "message": snapshots}) + + def restore_snapshot(self): + snapshot_name = self.data["snapshot_name"] + if snapshot_name == "": + fail("Missing required field in JSON: snapshot_name") + command = f'Restore-VMSnapshot -VMName "{self.data["vmname"]}" -Name "{snapshot_name}" -Confirm:$false' + self.run_ps(command) + succeed({"status": "success", "message": f"Snapshot '{snapshot_name}' restored"}) + + def delete_snapshot(self): + snapshot_name = self.data["snapshot_name"] + if snapshot_name == "": + fail("Missing required field in JSON: snapshot_name") + command = f'Remove-VMSnapshot -VMName "{self.data["vmname"]}" -Name "{snapshot_name}" -Confirm:$false' + self.run_ps(command) + succeed({"status": "success", "message": f"Snapshot '{snapshot_name}' deleted"}) + + +def main(): + if len(sys.argv) < 3: + fail("Usage: script.py ''") + + operation = sys.argv[1].lower() + json_file_path = sys.argv[2] + + try: + manager = HyperVManager(json_file_path) + except FileNotFoundError: + fail(f"JSON file not found: {json_file_path}") + except json.JSONDecodeError: + fail("Invalid JSON in file") + + operations = { + "create": manager.create, + "start": manager.start, + "stop": manager.stop, + "reboot": manager.reboot, + "delete": manager.delete, + "status": manager.status, + "suspend": manager.suspend, + "resume": manager.resume, + "listsnapshots": manager.list_snapshots, + "createsnapshot": manager.create_snapshot, + "restoresnapshot": manager.restore_snapshot, + "deletesnapshot": manager.delete_snapshot + } + + if operation not in operations: + fail("Invalid action") + + try: + operations[operation]() + except Exception as e: + fail(str(e)) + + +if __name__ == "__main__": + main() diff --git a/extensions/Proxmox/proxmox.sh b/extensions/Proxmox/proxmox.sh new file mode 100755 index 00000000000..dbfdae1b972 --- /dev/null +++ b/extensions/Proxmox/proxmox.sh @@ -0,0 +1,413 @@ +#!/usr/bin/env bash +# 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. + +parse_json() { + local json_string="$1" + echo "$json_string" | jq '.' > /dev/null || { echo '{"error":"Invalid JSON input"}'; exit 1; } + + local -A details + while IFS="=" read -r key value; do + details[$key]="$value" + done < <(echo "$json_string" | jq -r '{ + "extension_url": (.externaldetails.extension.url // ""), + "extension_user": (.externaldetails.extension.user // ""), + "extension_token": (.externaldetails.extension.token // ""), + "extension_secret": (.externaldetails.extension.secret // ""), + "host_url": (.externaldetails.host.url // ""), + "host_user": (.externaldetails.host.user // ""), + "host_token": (.externaldetails.host.token // ""), + "host_secret": (.externaldetails.host.secret // ""), + "node": (.externaldetails.host.node // ""), + "network_bridge": (.externaldetails.host.network_bridge // ""), + "verify_tls_certificate": (.externaldetails.host.verify_tls_certificate // "true"), + "vm_name": (.externaldetails.virtualmachine.vm_name // ""), + "template_id": (.externaldetails.virtualmachine.template_id // ""), + "template_type": (.externaldetails.virtualmachine.template_type // ""), + "iso_path": (.externaldetails.virtualmachine.iso_path // ""), + "snap_name": (.parameters.snap_name // ""), + "snap_description": (.parameters.snap_description // ""), + "snap_save_memory": (.parameters.snap_save_memory // ""), + "vmid": (."cloudstack.vm.details".details.proxmox_vmid // ""), + "vm_internal_name": (."cloudstack.vm.details".name // ""), + "vmmemory": (."cloudstack.vm.details".minRam // ""), + "vmcpus": (."cloudstack.vm.details".cpus // ""), + "vlans": ([."cloudstack.vm.details".nics[]?.broadcastUri // "" | sub("vlan://"; "")] | join(",")), + "mac_addresses": ([."cloudstack.vm.details".nics[]?.mac // ""] | join(",")) + } | to_entries | .[] | "\(.key)=\(.value)"') + + for key in "${!details[@]}"; do + declare -g "$key=${details[$key]}" + done + + # set url, user, token, secret to host values if present, otherwise use extension values + url="${host_url:-$extension_url}" + user="${host_user:-$extension_user}" + token="${host_token:-$extension_token}" + secret="${host_secret:-$extension_secret}" + + check_required_fields vm_internal_name url user token secret node +} + +urlencode() { + encoded_data=$(python3 -c "import urllib.parse; print(urllib.parse.quote('''$1'''))") + echo "$encoded_data" +} + +check_required_fields() { + local missing=() + for varname in "$@"; do + local value="${!varname}" + if [[ -z "$value" ]]; then + missing+=("$varname") + fi + done + + if [[ ${#missing[@]} -gt 0 ]]; then + echo "{\"error\":\"Missing required fields: ${missing[*]}\"}" + exit 1 + fi +} + +validate_name() { + local entity="$1" + local name="$2" + if [[ ! "$name" =~ ^[a-zA-Z0-9-]+$ ]]; then + echo "{\"error\":\"Invalid $entity name '$name'. Only alphanumeric characters and dashes (-) are allowed.\"}" + exit 1 + fi +} + +call_proxmox_api() { + local method=$1 + local path=$2 + local data=$3 + + curl_opts=( + -s + --fail + -X "$method" + -H "Authorization: PVEAPIToken=${user}!${token}=${secret}" + ) + + if [[ "$verify_tls_certificate" == "false" ]]; then + curl_opts+=(-k) + fi + + if [[ -n "$data" ]]; then + curl_opts+=(-d "$data") + fi + + #echo curl "${curl_opts[@]}" "https://${url}:8006/api2/json${path}" >&2 + response=$(curl "${curl_opts[@]}" "https://${url}:8006/api2/json${path}") + echo "$response" +} + +wait_for_proxmox_task() { + local upid="$1" + local timeout="${2:-$wait_time}" + local interval="${3:-1}" + + local start_time + start_time=$(date +%s) + + while true; do + local now + now=$(date +%s) + if (( now - start_time > timeout )); then + echo '{"error":"Timeout while waiting for async task"}' + exit 1 + fi + + local status_response + status_response=$(call_proxmox_api GET "/nodes/${node}/tasks/$(urlencode "$upid")/status") + + if [[ -z "$status_response" || "$status_response" == *'"errors":'* ]]; then + local msg + msg=$(echo "$status_response" | jq -r '.message // "Unknown error"') + echo "{\"error\":\"$msg\"}" + exit 1 + fi + + local task_status + task_status=$(echo "$status_response" | jq -r '.data.status') + + if [[ "$task_status" == "stopped" ]]; then + local exit_status + exit_status=$(echo "$status_response" | jq -r '.data.exitstatus') + if [[ "$exit_status" != "OK" ]]; then + echo "{\"error\":\"Task failed with exit status: $exit_status\"}" + exit 1 + fi + return 0 + fi + + sleep "$interval" + done +} + +execute_and_wait() { + local method="$1" + local path="$2" + local data="$3" + local response upid msg + + response=$(call_proxmox_api "$method" "$path" "$data") + upid=$(echo "$response" | jq -r '.data // ""') + + if [[ -z "$upid" ]]; then + msg=$(echo "$response" | jq -r '.message // "Unknown error"') + echo "{\"error\":\"Failed to execute API or retrieve UPID. Message: $msg\"}" + exit 1 + fi + + wait_for_proxmox_task "$upid" +} + +vm_not_present() { + response=$(call_proxmox_api GET "/cluster/nextid?vmid=$vmid") + vmid_result=$(echo "$response" | jq -r '.data // empty') + if [[ "$vmid_result" == "$vmid" ]]; then + return 0 + else + return 1 + fi +} + +prepare() { + response=$(call_proxmox_api GET "/cluster/nextid") + vmid=$(echo "$response" | jq -r '.data // ""') + + echo "{\"details\":{\"proxmox_vmid\": \"$vmid\"}}" +} + +create() { + if [[ -z "$vm_name" ]]; then + vm_name="$vm_internal_name" + fi + validate_name "VM" "$vm_name" + check_required_fields vmid network_bridge vmcpus vmmemory + + if [[ "${template_type^^}" == "ISO" ]]; then + check_required_fields iso_path + local data="vmid=$vmid" + data+="&name=$vm_name" + data+="&ide2=$(urlencode "$iso_path,media=cdrom")" + data+="&ostype=l26" + data+="&scsihw=virtio-scsi-single" + data+="&scsi0=$(urlencode "local-lvm:64,iothread=on")" + data+="&sockets=1" + data+="&cores=$vmcpus" + data+="&numa=0" + data+="&cpu=x86-64-v2-AES" + data+="&memory=$((vmmemory / 1024 / 1024))" + + execute_and_wait POST "/nodes/${node}/qemu/" "$data" + cleanup_vm=1 + + else + check_required_fields template_id + local data="newid=$vmid" + data+="&name=$vm_name" + execute_and_wait POST "/nodes/${node}/qemu/${template_id}/clone" "$data" + cleanup_vm=1 + + data="cores=$vmcpus" + data+="&memory=$((vmmemory / 1024 / 1024))" + execute_and_wait POST "/nodes/${node}/qemu/${vmid}/config" "$data" + fi + + IFS=',' read -ra vlan_array <<< "$vlans" + IFS=',' read -ra mac_array <<< "$mac_addresses" + for i in "${!vlan_array[@]}"; do + network="net${i}=$(urlencode "virtio=${mac_array[i]},bridge=${network_bridge},tag=${vlan_array[i]},firewall=0")" + call_proxmox_api PUT "/nodes/${node}/qemu/${vmid}/config/" "$network" > /dev/null + done + + execute_and_wait POST "/nodes/${node}/qemu/${vmid}/status/start" + + cleanup_vm=0 + echo '{"status": "success", "message": "Instance created"}' +} + +start() { + execute_and_wait POST "/nodes/${node}/qemu/${vmid}/status/start" + echo '{"status": "success", "message": "Instance started"}' +} + +delete() { + if vm_not_present; then + echo '{"status": "success", "message": "Instance deleted"}' + return 0 + fi + execute_and_wait DELETE "/nodes/${node}/qemu/${vmid}" + echo '{"status": "success", "message": "Instance deleted"}' +} + +stop() { + if vm_not_present; then + echo '{"status": "success", "message": "Instance stopped"}' + return 0 + fi + execute_and_wait POST "/nodes/${node}/qemu/${vmid}/status/stop" + echo '{"status": "success", "message": "Instance stopped"}' +} + +reboot() { + execute_and_wait POST "/nodes/${node}/qemu/${vmid}/status/reboot" + echo '{"status": "success", "message": "Instance rebooted"}' +} + +status() { + local status_response vm_status powerstate + status_response=$(call_proxmox_api GET "/nodes/${node}/qemu/${vmid}/status/current") + vm_status=$(echo "$status_response" | jq -r '.data.status') + case "$vm_status" in + running) powerstate="poweron" ;; + stopped) powerstate="poweroff" ;; + *) powerstate="unknown" ;; + esac + + echo "{\"status\": \"success\", \"power_state\": \"$powerstate\"}" +} + +list_snapshots() { + snapshot_response=$(call_proxmox_api GET "/nodes/${node}/qemu/${vmid}/snapshot") + echo "$snapshot_response" | jq ' + def to_date: + if . == "-" then "-" + elif . == null then "-" + else (. | tonumber | strftime("%Y-%m-%d %H:%M:%S")) + end; + + { + status: "success", + printmessage: "true", + message: [.data[] | { + name: .name, + snaptime: ((.snaptime // "-") | to_date), + description: .description, + parent: (.parent // "-"), + vmstate: (.vmstate // "-") + }] + } + ' +} + +create_snapshot() { + check_required_fields snap_name + validate_name "Snapshot" "$snap_name" + + local data vmstate + data="snapname=$snap_name" + if [[ -n "$snap_description" ]]; then + data+="&description=$snap_description" + fi + if [[ -n "$snap_save_memory" && "$snap_save_memory" == "true" ]]; then + vmstate="1" + else + vmstate="0" + fi + data+="&vmstate=$vmstate" + + execute_and_wait POST "/nodes/${node}/qemu/${vmid}/snapshot" "$data" + echo '{"status": "success", "message": "Instance Snapshot created"}' +} + +restore_snapshot() { + check_required_fields snap_name + validate_name "Snapshot" "$snap_name" + + execute_and_wait POST "/nodes/${node}/qemu/${vmid}/snapshot/${snap_name}/rollback" + + execute_and_wait POST "/nodes/${node}/qemu/${vmid}/status/start" + + echo '{"status": "success", "message": "Instance Snapshot restored"}' +} + +delete_snapshot() { + check_required_fields snap_name + validate_name "Snapshot" "$snap_name" + + execute_and_wait DELETE "/nodes/${node}/qemu/${vmid}/snapshot/${snap_name}" + echo '{"status": "success", "message": "Instance Snapshot deleted"}' +} + +action=$1 +parameters_file="$2" +wait_time=$3 + +if [[ -z "$action" || -z "$parameters_file" ]]; then + echo '{"error":"Missing required arguments"}' + exit 1 +fi + +# Read file content as parameters (assumes space-separated arguments) +parameters=$(<"$parameters_file") + +parse_json "$parameters" || exit 1 + +cleanup_vm=0 +cleanup() { + if (( cleanup_vm == 1 )); then + execute_and_wait DELETE "/nodes/${node}/qemu/${vmid}" + fi +} + +trap cleanup EXIT + +case $action in + prepare) + prepare + ;; + create) + create + ;; + delete) + delete + ;; + start) + start + ;; + stop) + stop + ;; + reboot) + reboot + ;; + status) + status + ;; + ListSnapshots) + list_snapshots + ;; + CreateSnapshot) + create_snapshot + ;; + RestoreSnapshot) + restore_snapshot + ;; + DeleteSnapshot) + delete_snapshot + ;; + *) + echo '{"error":"Invalid action"}' + exit 1 + ;; +esac + +exit 0 diff --git a/framework/extensions/pom.xml b/framework/extensions/pom.xml new file mode 100644 index 00000000000..d3b8f81bc71 --- /dev/null +++ b/framework/extensions/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + cloud-framework-extensions + Apache CloudStack Framework - Extensions + + org.apache.cloudstack + cloudstack-framework + 4.21.0.0-SNAPSHOT + ../pom.xml + + + + org.apache.cloudstack + cloud-utils + ${project.version} + + + org.apache.cloudstack + cloud-api + ${project.version} + + + org.apache.cloudstack + cloud-engine-schema + 4.21.0.0-SNAPSHOT + compile + + + org.apache.cloudstack + cloud-engine-components-api + 4.21.0.0-SNAPSHOT + compile + + + diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/AddCustomActionCmd.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/AddCustomActionCmd.java new file mode 100644 index 00000000000..dcea754430c --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/AddCustomActionCmd.java @@ -0,0 +1,175 @@ +// 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.api; + +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.response.ExtensionCustomActionResponse; +import org.apache.cloudstack.api.response.ExtensionResponse; +import org.apache.cloudstack.extension.ExtensionCustomAction; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; + +import com.cloud.user.Account; + +@APICommand(name = "addCustomAction", + description = "Add a custom action for an extension", + responseObject = ExtensionCustomActionResponse.class, + responseHasSensitiveInfo = false, + entityType = {ExtensionCustomAction.class}, + authorized = {RoleType.Admin}, + since = "4.21.0") +public class AddCustomActionCmd extends BaseCmd { + + @Inject + ExtensionsManager extensionsManager; + + @Parameter(name = ApiConstants.EXTENSION_ID, type = CommandType.UUID, required = true, + entityType = ExtensionResponse.class, description = "The ID of the extension to associate the action with") + private Long extensionId; + + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, required = true, description = "Name of the action") + private String name; + + @Parameter(name = ApiConstants.DESCRIPTION, type = CommandType.STRING, description = "Description of the action") + private String description; + + @Parameter(name = ApiConstants.RESOURCE_TYPE, + type = CommandType.STRING, + description = "Resource type for which the action is available") + private String resourceType; + + @Parameter(name = ApiConstants.ALLOWED_ROLE_TYPES, + type = CommandType.LIST, + collectionType = CommandType.STRING, + description = "List of role types allowed for the action") + private List allowedRoleTypes; + + @Parameter(name = ApiConstants.PARAMETERS, type = CommandType.MAP, + description = "Parameters mapping for the action using keys - name, type, required. " + + "'name' is mandatory. If 'type' is not specified then STRING will be used. " + + "If 'required' is not specified then false will be used. " + + "Example: parameters[0].name=xxx¶meters[0].type=BOOLEAN¶meters[0].required=true") + protected Map parameters; + + @Parameter(name = ApiConstants.SUCCESS_MESSAGE, type = CommandType.STRING, + description = "Success message that will be used on successful execution of the action. " + + "Name of the action, extension, resource can be used as - actionName, extensionName, resourceName. " + + "Example: Successfully complete {{actionName}} for {{resourceName}} with {{extensionName}}") + protected String successMessage; + + @Parameter(name = ApiConstants.ERROR_MESSAGE, type = CommandType.STRING, + description = "Error message that will be used on failure during execution of the action. " + + "Name of the action, extension, resource can be used as - actionName, extensionName, resourceName. " + + "Example: Failed to complete {{actionName}} for {{resourceName}} with {{extensionName}}") + protected String errorMessage; + + @Parameter(name = ApiConstants.TIMEOUT, + type = CommandType.INTEGER, + description = "Specifies the timeout in seconds to wait for the action to complete before failing. Default value is 5 seconds") + private Integer timeout; + + @Parameter(name = ApiConstants.ENABLED, + type = CommandType.BOOLEAN, + description = "Whether the action is enabled or not. Default is disabled.") + private Boolean enabled; + + @Parameter(name = ApiConstants.DETAILS, + type = CommandType.MAP, + description = "Details in key/value pairs using format details[i].keyname=keyvalue. " + + "Example: details[0].vendor=xxx&&details[0].version=2.0") + protected Map details; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getExtensionId() { + return extensionId; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getResourceType() { + return resourceType; + } + + public List getAllowedRoleTypes() { + return allowedRoleTypes; + } + + public Map getParametersMap() { + return parameters; + } + + public String getSuccessMessage() { + return successMessage; + } + + public String getErrorMessage() { + return errorMessage; + } + + public Integer getTimeout() { + return timeout; + } + + public boolean isEnabled() { + return Boolean.TRUE.equals(enabled); + } + + public Map getDetails() { + return convertDetailsToMap(details); + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() { + ExtensionCustomAction extensionCustomAction = extensionsManager.addCustomAction(this); + ExtensionCustomActionResponse response = extensionsManager.createCustomActionResponse(extensionCustomAction); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.ExtensionCustomAction; + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/CreateExtensionCmd.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/CreateExtensionCmd.java new file mode 100644 index 00000000000..5ab54149645 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/CreateExtensionCmd.java @@ -0,0 +1,140 @@ +// 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.api; + +import java.util.EnumSet; +import java.util.Map; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ExtensionResponse; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.user.Account; + +@APICommand(name = "createExtension", + description = "Create an extension", + responseObject = ExtensionResponse.class, + responseHasSensitiveInfo = false, + entityType = {Extension.class}, + authorized = {RoleType.Admin}, + since = "4.21.0") +public class CreateExtensionCmd extends BaseCmd { + + @Inject + ExtensionsManager extensionsManager; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, required = true, + description = "Name of the extension") + private String name; + + @Parameter(name = ApiConstants.DESCRIPTION, type = CommandType.STRING, + description = "Description of the extension") + private String description; + + @Parameter(name = ApiConstants.TYPE, type = CommandType.STRING, required = true, + description = "Type of the extension") + private String type; + + @Parameter(name = ApiConstants.PATH, type = CommandType.STRING, + description = "Relative path for the extension") + private String path; + + @Parameter(name = ApiConstants.ORCHESTRATOR_REQUIRES_PREPARE_VM, + type = CommandType.BOOLEAN, + description = "Only honored when type is Orchestrator. Whether prepare VM is needed or not") + private Boolean orchestratorRequiresPrepareVm; + + @Parameter(name = ApiConstants.STATE, type = CommandType.STRING, + description = "State of the extension") + private String state; + + @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, + description = "Details in key/value pairs using format details[i].keyname=keyvalue. Example: details[0].endpoint.url=urlvalue") + protected Map details; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getType() { + return type; + } + + public String getPath() { + return path; + } + + public Boolean isOrchestratorRequiresPrepareVm() { + return orchestratorRequiresPrepareVm; + } + + public String getState() { + return state; + } + + public Map getDetails() { + return convertDetailsToMap(details); + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() throws ServerApiException, ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + Extension extension = extensionsManager.createExtension(this); + ExtensionResponse response = extensionsManager.createExtensionResponse(extension, + EnumSet.of(ApiConstants.ExtensionDetails.all)); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.Extension; + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/DeleteCustomActionCmd.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/DeleteCustomActionCmd.java new file mode 100644 index 00000000000..6f2153ad6bc --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/DeleteCustomActionCmd.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.framework.extensions.api; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +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.ExtensionCustomActionResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.extension.ExtensionCustomAction; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.user.Account; + +@APICommand(name = "deleteCustomAction", + description = "Delete the custom action", + responseObject = SuccessResponse.class, + responseHasSensitiveInfo = false, + entityType = {ExtensionCustomAction.class}, + authorized = {RoleType.Admin}, + since = "4.21.0") +public class DeleteCustomActionCmd extends BaseCmd { + + @Inject + ExtensionsManager extensionsManager; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, + entityType = ExtensionCustomActionResponse.class, description = "uuid of the custom action") + private Long id; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getId() { + return id; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() throws ServerApiException, ConcurrentOperationException { + boolean result = extensionsManager.deleteCustomAction(this); + if (result) { + SuccessResponse response = new SuccessResponse(getCommandName()); + response.setSuccess(result); + setResponseObject(response); + } else { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to delete extension custom action"); + } + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.ExtensionCustomAction; + } + + @Override + public Long getApiResourceId() { + return getId(); + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/DeleteExtensionCmd.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/DeleteExtensionCmd.java new file mode 100644 index 00000000000..cdae48fdb3a --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/DeleteExtensionCmd.java @@ -0,0 +1,104 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.framework.extensions.api; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +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.ExtensionResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.user.Account; + +@APICommand(name = "deleteExtension", + description = "Delete the extensions", + responseObject = ExtensionResponse.class, + responseHasSensitiveInfo = false, + entityType = {Extension.class}, + authorized = {RoleType.Admin}, + since = "4.21.0") +public class DeleteExtensionCmd extends BaseCmd { + + @Inject + ExtensionsManager extensionsManager; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, + entityType = ExtensionResponse.class, description = "ID of the extension") + private Long id; + + @Parameter(name = ApiConstants.CLEANUP, type = CommandType.BOOLEAN, + entityType = ExtensionResponse.class, description = "Whether cleanup entry-point files for the extension") + private Boolean cleanup; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getId() { + return id; + } + + public boolean isCleanup() { + return Boolean.TRUE.equals(cleanup); + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() throws ServerApiException, ConcurrentOperationException { + boolean result = extensionsManager.deleteExtension(this); + if (result) { + SuccessResponse response = new SuccessResponse(getCommandName()); + response.setSuccess(result); + setResponseObject(response); + } else { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to delete extension"); + } + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.Extension; + } + + @Override + public Long getApiResourceId() { + return getId(); + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/ListCustomActionCmd.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/ListCustomActionCmd.java new file mode 100644 index 00000000000..4f492bd20a6 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/ListCustomActionCmd.java @@ -0,0 +1,110 @@ +// 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.api; + +import java.util.List; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseListCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ExtensionCustomActionResponse; +import org.apache.cloudstack.api.response.ExtensionResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.extension.ExtensionCustomAction; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; + +@APICommand(name = "listCustomActions", + description = "Lists the custom actions", + responseObject = ExtensionCustomActionResponse.class, + responseHasSensitiveInfo = false, + entityType = {ExtensionCustomAction.class}, + authorized = {RoleType.Admin, RoleType.DomainAdmin, RoleType.ResourceAdmin, RoleType.User}, + since = "4.21.0") +public class ListCustomActionCmd extends BaseListCmd { + + @Inject + ExtensionsManager extensionsManager; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, + entityType = ExtensionCustomActionResponse.class, description = "uuid of the custom action") + private Long id; + + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, description = "Name of the custom action") + private String name; + + @Parameter(name = ApiConstants.EXTENSION_ID, type = CommandType.UUID, + entityType = ExtensionResponse.class, description = "uuid of the extension") + private Long extensionId; + + @Parameter(name = ApiConstants.RESOURCE_TYPE, + type = CommandType.STRING, + description = "Type of the resource for actions") + private String resourceType; + + @Parameter(name = ApiConstants.RESOURCE_ID, + type = CommandType.STRING, + description = "ID of a resource for actions") + private String resourceId; + + @Parameter(name = ApiConstants.ENABLED, + type = CommandType.BOOLEAN, + description = "List actions whether they are enabled or not") + private Boolean enabled; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Long getExtensionId() { + return extensionId; + } + + public String getResourceType() { + return resourceType; + } + + public String getResourceId() { + return resourceId; + } + + public Boolean isEnabled() { + return enabled; + } + + @Override + public void execute() throws ServerApiException { + List responses = extensionsManager.listCustomActions(this); + ListResponse response = new ListResponse<>(); + response.setResponses(responses, responses.size()); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmd.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmd.java new file mode 100644 index 00000000000..4426f259380 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmd.java @@ -0,0 +1,114 @@ +// 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.api; + +import java.util.EnumSet; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseListCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ExtensionResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; +import org.apache.commons.collections.CollectionUtils; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InvalidParameterValueException; + +@APICommand(name = "listExtensions", + description = "Lists extensions", + responseObject = ExtensionResponse.class, + responseHasSensitiveInfo = false, + entityType = {Extension.class}, + authorized = {RoleType.Admin}, + since = "4.21.0") +public class ListExtensionsCmd extends BaseListCmd { + + @Inject + ExtensionsManager extensionsManager; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, description = "Name of the extension") + private String name; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, + entityType = ExtensionResponse.class, description = "uuid of the extension") + private Long extensionId; + + @Parameter(name = ApiConstants.DETAILS, + type = CommandType.LIST, + collectionType = CommandType.STRING, + description = "comma separated list of extension details requested, " + + "value can be a list of [all, resources, external, min]." + + " When no parameters are passed, all the details are returned.") + private List details; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public String getName() { + return name; + } + + public Long getExtensionId() { + return extensionId; + } + + public EnumSet getDetails() throws InvalidParameterValueException { + if (CollectionUtils.isEmpty(details)) { + return EnumSet.of(ApiConstants.ExtensionDetails.all); + } + try { + Set detailsSet = new HashSet<>(); + for (String detail : details) { + detailsSet.add(ApiConstants.ExtensionDetails.valueOf(detail)); + } + return EnumSet.copyOf(detailsSet); + } catch (IllegalArgumentException e) { + throw new InvalidParameterValueException("The details parameter contains a non permitted value." + + "The allowed values are " + EnumSet.allOf(ApiConstants.ExtensionDetails.class)); + } + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() throws ServerApiException, ConcurrentOperationException { + List responses = extensionsManager.listExtensions(this); + + ListResponse response = new ListResponse<>(); + response.setResponses(responses, responses.size()); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/RegisterExtensionCmd.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/RegisterExtensionCmd.java new file mode 100644 index 00000000000..e8f71d7ac8c --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/RegisterExtensionCmd.java @@ -0,0 +1,118 @@ +// 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.api; + +import java.util.EnumSet; +import java.util.Map; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ExtensionResponse; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.user.Account; + +@APICommand(name = "registerExtension", + description = "Register an extension with a resource", + responseObject = ExtensionResponse.class, + responseHasSensitiveInfo = false, + entityType = {Extension.class}, + authorized = {RoleType.Admin}, + since = "4.21.0") +public class RegisterExtensionCmd extends BaseCmd { + + @Inject + ExtensionsManager extensionsManager; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.EXTENSION_ID, type = CommandType.UUID, required = true, + entityType = ExtensionResponse.class, description = "ID of the extension") + private Long extensionId; + + @Parameter(name = ApiConstants.RESOURCE_ID, type = CommandType.STRING, required = true, + description = "ID of the resource to register the extension with") + private String resourceId; + + @Parameter(name = ApiConstants.RESOURCE_TYPE, type = CommandType.STRING, required = true, + description = "Type of the resource") + private String resourceType; + + @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, + description = "Details in key/value pairs using format details[i].keyname=keyvalue. Example: details[0].endpoint.url=urlvalue") + protected Map details; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getExtensionId() { + return extensionId; + } + + public String getResourceId() { + return resourceId; + } + + public String getResourceType() { + return resourceType; + } + + public Map getDetails() { + return convertDetailsToMap(details); + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() throws ServerApiException, ConcurrentOperationException { + Extension extension = extensionsManager.registerExtensionWithResource(this); + ExtensionResponse response = extensionsManager.createExtensionResponse(extension, + EnumSet.of(ApiConstants.ExtensionDetails.all)); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.Extension; + } + + @Override + public Long getApiResourceId() { + return getExtensionId(); + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/RunCustomActionCmd.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/RunCustomActionCmd.java new file mode 100644 index 00000000000..dea09cf1683 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/RunCustomActionCmd.java @@ -0,0 +1,121 @@ +// 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.api; + +import java.util.Map; + +import javax.inject.Inject; + +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.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ExtensionCustomActionResponse; +import org.apache.cloudstack.extension.CustomActionResultResponse; +import org.apache.cloudstack.extension.ExtensionCustomAction; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; + +import com.cloud.event.EventTypes; +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.user.Account; + +@APICommand(name = "runCustomAction", + description = "Run the custom action", + responseObject = CustomActionResultResponse.class, + responseHasSensitiveInfo = false, + entityType = {ExtensionCustomAction.class}, + authorized = {RoleType.Admin, RoleType.DomainAdmin, RoleType.ResourceAdmin, RoleType.User}, + since = "4.21.0") +public class RunCustomActionCmd extends BaseAsyncCmd { + + @Inject + ExtensionsManager extensionsManager; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.CUSTOM_ACTION_ID, type = CommandType.UUID, required = true, + entityType = ExtensionCustomActionResponse.class, description = "ID of the custom action") + private Long customActionId; + + @Parameter(name = ApiConstants.RESOURCE_TYPE, type = CommandType.STRING, + description = "Type of the resource") + private String resourceType; + + @Parameter(name = ApiConstants.RESOURCE_ID, type = CommandType.STRING, required = true, + description = "ID of the instance") + private String resourceId; + + @Parameter(name = ApiConstants.PARAMETERS, type = CommandType.MAP, + description = "Parameters in key/value pairs using format parameters[i].keyname=keyvalue. Example: parameters[0].endpoint.url=urlvalue") + protected Map parameters; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getCustomActionId() { + return customActionId; + } + + public String getResourceType() { + return resourceType; + } + + public String getResourceId() { + return resourceId; + } + + public Map getParameters() { + return convertDetailsToMap(parameters); + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() throws ServerApiException, ConcurrentOperationException { + CustomActionResultResponse response = extensionsManager.runCustomAction(this); + if (response != null) { + response.setResponseName(getCommandName()); + setResponseObject(response); + } else { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to run custom action"); + } + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public String getEventType() { + return EventTypes.EVENT_CUSTOM_ACTION; + } + + @Override + public String getEventDescription() { + return "Running custom action"; + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/UnregisterExtensionCmd.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/UnregisterExtensionCmd.java new file mode 100644 index 00000000000..0edc7a247fd --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/UnregisterExtensionCmd.java @@ -0,0 +1,109 @@ +// 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.api; + +import java.util.EnumSet; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ExtensionResponse; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.user.Account; + +@APICommand(name = "unregisterExtension", + description = "Unregister an extension with a resource", + responseObject = ExtensionResponse.class, + responseHasSensitiveInfo = false, + entityType = {Extension.class}, + authorized = {RoleType.Admin}, + since = "4.21.0") +public class UnregisterExtensionCmd extends BaseCmd { + + @Inject + ExtensionsManager extensionsManager; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.EXTENSION_ID, type = CommandType.UUID, required = true, + entityType = ExtensionResponse.class, description = "ID of the extension") + private Long extensionId; + + @Parameter(name = ApiConstants.RESOURCE_ID, type = CommandType.STRING, required = true, + description = "ID of the resource to register the extension with") + private String resourceId; + + @Parameter(name = ApiConstants.RESOURCE_TYPE, type = CommandType.STRING, required = true, + description = "Type of the resource") + private String resourceType; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getExtensionId() { + return extensionId; + } + + public String getResourceId() { + return resourceId; + } + + public String getResourceType() { + return resourceType; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() throws ServerApiException, ConcurrentOperationException { + Extension extension = extensionsManager.unregisterExtensionWithResource(this); + ExtensionResponse response = extensionsManager.createExtensionResponse(extension, + EnumSet.of(ApiConstants.ExtensionDetails.all)); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.Extension; + } + + @Override + public Long getApiResourceId() { + return getExtensionId(); + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/UpdateCustomActionCmd.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/UpdateCustomActionCmd.java new file mode 100644 index 00000000000..bb03be00c5d --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/UpdateCustomActionCmd.java @@ -0,0 +1,197 @@ +// 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.api; + +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; + +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ExtensionCustomActionResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.extension.ExtensionCustomAction; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; + +import com.cloud.user.Account; + +@APICommand(name = "updateCustomAction", + description = "Update the custom action", + responseObject = SuccessResponse.class, + responseHasSensitiveInfo = false, since = "4.21.0") +public class UpdateCustomActionCmd extends BaseCmd { + + @Inject + ExtensionsManager extensionsManager; + + @Parameter(name = ApiConstants.ID, + type = CommandType.UUID, + required = true, + entityType = ExtensionCustomActionResponse.class, + description = "ID of the custom action") + private Long id; + + @Parameter(name = ApiConstants.DESCRIPTION, + type = CommandType.STRING, + description = "The description of the command") + private String description; + + @Parameter(name = ApiConstants.RESOURCE_TYPE, + type = CommandType.STRING, + description = "Type of the resource for actions") + private String resourceType; + + @Parameter(name = ApiConstants.ALLOWED_ROLE_TYPES, + type = CommandType.LIST, + collectionType = CommandType.STRING, + description = "List of role types allowed for the action") + private List allowedRoleTypes; + + @Parameter(name = ApiConstants.ENABLED, + type = CommandType.BOOLEAN, + description = "Whether the action is enabled or not") + private Boolean enabled; + + @Parameter(name = ApiConstants.PARAMETERS, type = CommandType.MAP, + description = "Parameters mapping for the action using keys - name, type, required. " + + "'name' is mandatory. If 'type' is not specified then STRING will be used. " + + "If 'required' is not specified then false will be used. " + + "Example: parameters[0].name=xxx¶meters[0].type=BOOLEAN¶meters[0].required=true") + protected Map parameters; + + @Parameter(name = ApiConstants.CLEAN_UP_PARAMETERS, + type = CommandType.BOOLEAN, + description = "Optional boolean field, which indicates if parameters should be cleaned up or not " + + "(If set to true, parameters will be removed for this action, parameters field ignored; " + + "if false or not set, no action)") + private Boolean cleanupParameters; + + @Parameter(name = ApiConstants.SUCCESS_MESSAGE, type = CommandType.STRING, + description = "Success message that will be used on successful execution of the action. " + + "Name of the action and and extension can be used in the - actionName, extensionName. " + + "Example: Successfully complete {{actionName}} for {{extensionName") + protected String successMessage; + + @Parameter(name = ApiConstants.ERROR_MESSAGE, type = CommandType.STRING, + description = "Error message that will be used on failure during execution of the action. " + + "Name of the action and and extension can be used in the - actionName, extensionName. " + + "Example: Failed to complete {{actionName}} for {{extensionName") + protected String errorMessage; + + @Parameter(name = ApiConstants.TIMEOUT, + type = CommandType.INTEGER, + description = "Specifies the timeout in seconds to wait for the action to complete before failing. Default value is 3 seconds") + private Integer timeout; + + @Parameter(name = ApiConstants.DETAILS, + type = CommandType.MAP, + description = "Details in key/value pairs using format details[i].keyname=keyvalue. " + + "Example: details[0].vendor=xxx&&details[0].version=2.0") + protected Map details; + + @Parameter(name = ApiConstants.CLEAN_UP_DETAILS, + type = CommandType.BOOLEAN, + description = "Optional boolean field, which indicates if details should be cleaned up or not " + + "(If set to true, details removed for this action, details field ignored; " + + "if false or not set, no action)") + private Boolean cleanupDetails; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public long getId() { + return id; + } + + public String getDescription() { + return description; + } + + public String getResourceType() { + return resourceType; + } + + public List getAllowedRoleTypes() { + return allowedRoleTypes; + } + + public Map getParametersMap() { + return parameters; + } + + public Boolean isCleanupParameters() { + return cleanupParameters; + } + + public String getSuccessMessage() { + return successMessage; + } + + public String getErrorMessage() { + return errorMessage; + } + + public Integer getTimeout() { + return timeout; + } + + public Boolean isEnabled() { + return enabled; + } + + public Map getDetails() { + return convertDetailsToMap(details); + } + + public Boolean isCleanupDetails() { + return cleanupDetails; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() throws ServerApiException { + ExtensionCustomAction extensionCustomAction = extensionsManager.updateCustomAction(this); + ExtensionCustomActionResponse response = extensionsManager.createCustomActionResponse(extensionCustomAction); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.ExtensionCustomAction; + } + + @Override + public Long getApiResourceId() { + return getId(); + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/UpdateExtensionCmd.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/UpdateExtensionCmd.java new file mode 100644 index 00000000000..713e7550a1e --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/api/UpdateExtensionCmd.java @@ -0,0 +1,136 @@ +// 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.api; + +import java.util.EnumSet; +import java.util.Map; + +import javax.inject.Inject; + +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ExtensionResponse; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; + +import com.cloud.user.Account; + +@APICommand(name = "updateExtension", + description = "Update the extension", + responseObject = ExtensionResponse.class, + responseHasSensitiveInfo = false, + since = "4.21.0") +public class UpdateExtensionCmd extends BaseCmd { + + @Inject + ExtensionsManager extensionsManager; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, + entityType = ExtensionResponse.class, + required = true, + description = "The ID of the extension") + private Long id; + + @Parameter(name = ApiConstants.DESCRIPTION, type = CommandType.STRING, + description = "Description of the extension") + private String description; + + @Parameter(name = ApiConstants.ORCHESTRATOR_REQUIRES_PREPARE_VM, + type = CommandType.BOOLEAN, + description = "Only honored when type is Orchestrator. Whether prepare VM is needed or not") + private Boolean orchestratorRequiresPrepareVm; + + @Parameter(name = ApiConstants.STATE, type = CommandType.STRING, + description = "State of the extension") + private String state; + + @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, + description = "Details in key/value pairs using format details[i].keyname=keyvalue. Example: details[0].endpoint.url=urlvalue") + protected Map details; + + @Parameter(name = ApiConstants.CLEAN_UP_DETAILS, + type = CommandType.BOOLEAN, + description = "Optional boolean field, which indicates if details should be cleaned up or not " + + "(If set to true, details removed for this action, details field ignored; " + + "if false or not set, no action)") + private Boolean cleanupDetails; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getId() { + return id; + } + + public String getDescription() { + return description; + } + + public Boolean isOrchestratorRequiresPrepareVm() { + return orchestratorRequiresPrepareVm; + } + + public String getState() { + return state; + } + + public Map getDetails() { + return convertDetailsToMap(details); + } + + public Boolean isCleanupDetails() { + return cleanupDetails; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() throws ServerApiException { + Extension extension = extensionsManager.updateExtension(this); + ExtensionResponse response = extensionsManager.createExtensionResponse(extension, + EnumSet.of(ApiConstants.ExtensionDetails.all)); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_ID_SYSTEM; + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.Extension; + } + + @Override + public Long getApiResourceId() { + return getId(); + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/command/CleanupExtensionFilesCommand.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/command/CleanupExtensionFilesCommand.java new file mode 100644 index 00000000000..ba542d52e85 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/command/CleanupExtensionFilesCommand.java @@ -0,0 +1,27 @@ +// 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.command; + +import org.apache.cloudstack.extension.Extension; + +public class CleanupExtensionFilesCommand extends ExtensionServerActionBaseCommand { + + public CleanupExtensionFilesCommand(long msId, Extension extension) { + super(msId, extension); + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/command/ExtensionBaseCommand.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/command/ExtensionBaseCommand.java new file mode 100644 index 00000000000..ead3c2e4012 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/command/ExtensionBaseCommand.java @@ -0,0 +1,63 @@ +// 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.command; + +import org.apache.cloudstack.extension.Extension; + +import com.cloud.agent.api.Command; + +public class ExtensionBaseCommand extends Command { + private final long extensionId; + private final String extensionName; + private final boolean extensionUserDefined; + private final String extensionRelativePath; + private final Extension.State extensionState; + + protected ExtensionBaseCommand(Extension extension) { + this.extensionId = extension.getId(); + this.extensionName = extension.getName(); + this.extensionUserDefined = extension.isUserDefined(); + this.extensionRelativePath = extension.getRelativePath(); + this.extensionState = extension.getState(); + } + + public long getExtensionId() { + return extensionId; + } + + public String getExtensionName() { + return extensionName; + } + + public boolean isExtensionUserDefined() { + return extensionUserDefined; + } + + public String getExtensionRelativePath() { + return extensionRelativePath; + } + + public Extension.State getExtensionState() { + return extensionState; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/command/ExtensionRoutingUpdateCommand.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/command/ExtensionRoutingUpdateCommand.java new file mode 100644 index 00000000000..3e041112435 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/command/ExtensionRoutingUpdateCommand.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 org.apache.cloudstack.framework.extensions.command; + +import org.apache.cloudstack.extension.Extension; + +public class ExtensionRoutingUpdateCommand extends ExtensionBaseCommand { + + final boolean removed; + + public ExtensionRoutingUpdateCommand(Extension extension, boolean removed) { + super(extension); + this.removed = removed; + } + + public boolean isRemoved() { + return removed; + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/command/ExtensionServerActionBaseCommand.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/command/ExtensionServerActionBaseCommand.java new file mode 100644 index 00000000000..870dc8e2b7e --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/command/ExtensionServerActionBaseCommand.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 org.apache.cloudstack.framework.extensions.command; + +import org.apache.cloudstack.extension.Extension; + +public class ExtensionServerActionBaseCommand extends ExtensionBaseCommand { + private final long msId; + + protected ExtensionServerActionBaseCommand(long msId, Extension extension) { + super(extension); + this.msId = msId; + } + + public long getMsId() { + return msId; + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/command/GetExtensionPathChecksumCommand.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/command/GetExtensionPathChecksumCommand.java new file mode 100644 index 00000000000..13f503d67ba --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/command/GetExtensionPathChecksumCommand.java @@ -0,0 +1,27 @@ +// 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.command; + +import org.apache.cloudstack.extension.Extension; + +public class GetExtensionPathChecksumCommand extends ExtensionServerActionBaseCommand { + + public GetExtensionPathChecksumCommand(long msId, Extension extension) { + super(msId, extension); + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/command/PrepareExtensionPathCommand.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/command/PrepareExtensionPathCommand.java new file mode 100644 index 00000000000..4c8b920b2f3 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/command/PrepareExtensionPathCommand.java @@ -0,0 +1,27 @@ +// 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.command; + +import org.apache.cloudstack.extension.Extension; + +public class PrepareExtensionPathCommand extends ExtensionServerActionBaseCommand { + + public PrepareExtensionPathCommand(long msId, Extension extension) { + super(msId, extension); + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionCustomActionDao.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionCustomActionDao.java new file mode 100644 index 00000000000..6db0a02d976 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionCustomActionDao.java @@ -0,0 +1,27 @@ +// 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.dao; + +import java.util.List; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.framework.extensions.vo.ExtensionCustomActionVO; + +public interface ExtensionCustomActionDao extends GenericDao { + ExtensionCustomActionVO findByNameAndExtensionId(long extensionId, String name); + List listIdsByExtensionId(long extensionId); +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionCustomActionDaoImpl.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionCustomActionDaoImpl.java new file mode 100644 index 00000000000..cd7731d2051 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionCustomActionDaoImpl.java @@ -0,0 +1,59 @@ +// 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.dao; + +import java.util.List; + +import org.apache.cloudstack.framework.extensions.vo.ExtensionCustomActionVO; + +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.GenericSearchBuilder; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; + +public class ExtensionCustomActionDaoImpl extends GenericDaoBase implements ExtensionCustomActionDao { + + private final SearchBuilder AllFieldSearch; + + public ExtensionCustomActionDaoImpl() { + AllFieldSearch = createSearchBuilder(); + AllFieldSearch.and("name", AllFieldSearch.entity().getName(), SearchCriteria.Op.EQ); + AllFieldSearch.and("extensionId", AllFieldSearch.entity().getExtensionId(), SearchCriteria.Op.EQ); + AllFieldSearch.done(); + } + + @Override + public ExtensionCustomActionVO findByNameAndExtensionId(long extensionId, String name) { + SearchCriteria sc = AllFieldSearch.create(); + sc.setParameters("extensionId", extensionId); + sc.setParameters("name", name); + + return findOneBy(sc); + } + + @Override + public List listIdsByExtensionId(long extensionId) { + GenericSearchBuilder sb = createSearchBuilder(Long.class); + sb.selectFields(sb.entity().getId()); + sb.and("extensionId", sb.entity().getExtensionId(), SearchCriteria.Op.EQ); + sb.done(); + SearchCriteria sc = sb.create(); + sc.setParameters("extensionId", extensionId); + return customSearch(sc, null); + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionCustomActionDetailsDao.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionCustomActionDetailsDao.java new file mode 100644 index 00000000000..a34eb0082d1 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionCustomActionDetailsDao.java @@ -0,0 +1,26 @@ +// 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.dao; + + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.framework.extensions.vo.ExtensionCustomActionDetailsVO; +import org.apache.cloudstack.resourcedetail.ResourceDetailsDao; + +public interface ExtensionCustomActionDetailsDao extends GenericDao, ResourceDetailsDao { +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionCustomActionDetailsDaoImpl.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionCustomActionDetailsDaoImpl.java new file mode 100644 index 00000000000..80add008341 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionCustomActionDetailsDaoImpl.java @@ -0,0 +1,28 @@ +// 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.dao; + +import org.apache.cloudstack.framework.extensions.vo.ExtensionCustomActionDetailsVO; +import org.apache.cloudstack.resourcedetail.ResourceDetailsDaoBase; + +public class ExtensionCustomActionDetailsDaoImpl extends ResourceDetailsDaoBase implements ExtensionCustomActionDetailsDao { + @Override + public void addDetail(long resourceId, String key, String value, boolean display) { + super.addDetail(new ExtensionCustomActionDetailsVO(resourceId, key, value, display)); + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDao.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDao.java new file mode 100644 index 00000000000..3355457ed25 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDao.java @@ -0,0 +1,26 @@ +// 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.dao; + +import org.apache.cloudstack.framework.extensions.vo.ExtensionVO; + +import com.cloud.utils.db.GenericDao; + +public interface ExtensionDao extends GenericDao { + + ExtensionVO findByName(String name); +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDaoImpl.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDaoImpl.java new file mode 100644 index 00000000000..8e17199de6c --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDaoImpl.java @@ -0,0 +1,45 @@ +// 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.dao; + +import org.apache.cloudstack.framework.extensions.vo.ExtensionVO; + +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; + +public class ExtensionDaoImpl extends GenericDaoBase implements ExtensionDao { + + private final SearchBuilder AllFieldSearch; + + public ExtensionDaoImpl() { + AllFieldSearch = createSearchBuilder(); + AllFieldSearch.and("name", AllFieldSearch.entity().getName(), SearchCriteria.Op.EQ); + AllFieldSearch.and("type", AllFieldSearch.entity().getType(), SearchCriteria.Op.EQ); + AllFieldSearch.and("state", AllFieldSearch.entity().getState(), SearchCriteria.Op.EQ); + AllFieldSearch.done(); + } + + @Override + public ExtensionVO findByName(String name) { + SearchCriteria sc = AllFieldSearch.create(); + sc.setParameters("name", name); + + return findOneBy(sc); + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDetailsDao.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDetailsDao.java new file mode 100644 index 00000000000..a23a4eb7442 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDetailsDao.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 org.apache.cloudstack.framework.extensions.dao; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.framework.extensions.vo.ExtensionDetailsVO; +import org.apache.cloudstack.resourcedetail.ResourceDetailsDao; + +public interface ExtensionDetailsDao extends GenericDao, ResourceDetailsDao { +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDetailsDaoImpl.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDetailsDaoImpl.java new file mode 100644 index 00000000000..1989db49c24 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDetailsDaoImpl.java @@ -0,0 +1,28 @@ +// 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.dao; + +import org.apache.cloudstack.framework.extensions.vo.ExtensionDetailsVO; +import org.apache.cloudstack.resourcedetail.ResourceDetailsDaoBase; + +public class ExtensionDetailsDaoImpl extends ResourceDetailsDaoBase implements ExtensionDetailsDao { + @Override + public void addDetail(long resourceId, String key, String value, boolean display) { + super.addDetail(new ExtensionDetailsVO(resourceId, key, value, display)); + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDao.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDao.java new file mode 100644 index 00000000000..930ef867553 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDao.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.framework.extensions.dao; + +import org.apache.cloudstack.extension.ExtensionResourceMap; +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.framework.extensions.vo.ExtensionResourceMapVO; + +import java.util.List; + +public interface ExtensionResourceMapDao extends GenericDao { + List listByExtensionId(long extensionId); + + ExtensionResourceMapVO findByResourceIdAndType(long resourceId, ExtensionResourceMap.ResourceType resourceType); + + List listResourceIdsByExtensionIdAndType(long extensionId,ExtensionResourceMap.ResourceType resourceType); +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImpl.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImpl.java new file mode 100644 index 00000000000..6f19ef8b8b6 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImpl.java @@ -0,0 +1,70 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.framework.extensions.dao; + +import org.apache.cloudstack.extension.ExtensionResourceMap; +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.GenericSearchBuilder; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import org.apache.cloudstack.framework.extensions.vo.ExtensionResourceMapVO; + +import java.util.List; + +public class ExtensionResourceMapDaoImpl extends GenericDaoBase implements ExtensionResourceMapDao { + private final SearchBuilder genericSearch; + + public ExtensionResourceMapDaoImpl() { + super(); + + genericSearch = createSearchBuilder(); + genericSearch.and("extensionId", genericSearch.entity().getExtensionId(), SearchCriteria.Op.EQ); + genericSearch.and("resourceId", genericSearch.entity().getResourceId(), SearchCriteria.Op.EQ); + genericSearch.and("resourceType", genericSearch.entity().getResourceType(), SearchCriteria.Op.EQ); + genericSearch.done(); + } + + @Override + public List listByExtensionId(long extensionId) { + SearchCriteria sc = genericSearch.create(); + sc.setParameters("extensionId", extensionId); + return listBy(sc); + } + + @Override + public ExtensionResourceMapVO findByResourceIdAndType(long resourceId, + ExtensionResourceMap.ResourceType resourceType) { + SearchCriteria sc = genericSearch.create(); + sc.setParameters("resourceId", resourceId); + sc.setParameters("resourceType", resourceType); + return findOneBy(sc); + } + + @Override + public List listResourceIdsByExtensionIdAndType(long extensionId, ExtensionResourceMap.ResourceType resourceType) { + GenericSearchBuilder sb = createSearchBuilder(Long.class); + sb.selectFields(sb.entity().getResourceId()); + sb.and("extensionId", sb.entity().getExtensionId(), SearchCriteria.Op.EQ); + sb.and("resourceType", sb.entity().getResourceType(), SearchCriteria.Op.EQ); + sb.done(); + SearchCriteria sc = sb.create(); + sc.setParameters("extensionId", extensionId); + sc.setParameters("resourceType", resourceType); + return customSearch(sc, null); + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDetailsDao.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDetailsDao.java new file mode 100644 index 00000000000..11d445b2242 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDetailsDao.java @@ -0,0 +1,26 @@ +// 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.dao; + + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.framework.extensions.vo.ExtensionResourceMapDetailsVO; +import org.apache.cloudstack.resourcedetail.ResourceDetailsDao; + +public interface ExtensionResourceMapDetailsDao extends GenericDao, ResourceDetailsDao { +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDetailsDaoImpl.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDetailsDaoImpl.java new file mode 100644 index 00000000000..cff01495054 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDetailsDaoImpl.java @@ -0,0 +1,28 @@ +// 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.dao; + +import org.apache.cloudstack.framework.extensions.vo.ExtensionResourceMapDetailsVO; +import org.apache.cloudstack.resourcedetail.ResourceDetailsDaoBase; + +public class ExtensionResourceMapDetailsDaoImpl extends ResourceDetailsDaoBase implements ExtensionResourceMapDetailsDao { + @Override + public void addDetail(long resourceId, String key, String value, boolean display) { + super.addDetail(new ExtensionResourceMapDetailsVO(resourceId, key, value, display)); + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java new file mode 100644 index 00000000000..8b9ad96b3c4 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java @@ -0,0 +1,90 @@ +// +// 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.manager; + + +import java.util.EnumSet; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.response.ExtensionCustomActionResponse; +import org.apache.cloudstack.api.response.ExtensionResponse; +import org.apache.cloudstack.extension.CustomActionResultResponse; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.extension.ExtensionCustomAction; +import org.apache.cloudstack.extension.ExtensionResourceMap; +import org.apache.cloudstack.framework.extensions.api.AddCustomActionCmd; +import org.apache.cloudstack.framework.extensions.api.CreateExtensionCmd; +import org.apache.cloudstack.framework.extensions.api.DeleteCustomActionCmd; +import org.apache.cloudstack.framework.extensions.api.DeleteExtensionCmd; +import org.apache.cloudstack.framework.extensions.api.ListCustomActionCmd; +import org.apache.cloudstack.framework.extensions.api.ListExtensionsCmd; +import org.apache.cloudstack.framework.extensions.api.RegisterExtensionCmd; +import org.apache.cloudstack.framework.extensions.api.RunCustomActionCmd; +import org.apache.cloudstack.framework.extensions.api.UnregisterExtensionCmd; +import org.apache.cloudstack.framework.extensions.api.UpdateCustomActionCmd; +import org.apache.cloudstack.framework.extensions.api.UpdateExtensionCmd; +import org.apache.cloudstack.framework.extensions.command.ExtensionServerActionBaseCommand; + +import com.cloud.host.Host; +import com.cloud.org.Cluster; +import com.cloud.utils.component.Manager; + +public interface ExtensionsManager extends Manager { + + String getExtensionsPath(); + + Extension createExtension(CreateExtensionCmd cmd); + + boolean prepareExtensionPathAcrossServers(Extension extension); + + List listExtensions(ListExtensionsCmd cmd); + + boolean deleteExtension(DeleteExtensionCmd cmd); + + Extension unregisterExtensionWithResource(UnregisterExtensionCmd cmd); + + Extension updateExtension(UpdateExtensionCmd cmd); + + Extension registerExtensionWithResource(RegisterExtensionCmd cmd); + + ExtensionResponse createExtensionResponse(Extension extension, EnumSet viewDetails); + + ExtensionResourceMap registerExtensionWithCluster(Cluster cluster, Extension extension, Map externalDetails); + + void unregisterExtensionWithCluster(Cluster cluster, Long extensionId); + + CustomActionResultResponse runCustomAction(RunCustomActionCmd cmd); + + ExtensionCustomAction addCustomAction(AddCustomActionCmd cmd); + + boolean deleteCustomAction(DeleteCustomActionCmd cmd); + + List listCustomActions(ListCustomActionCmd cmd); + + ExtensionCustomAction updateCustomAction(UpdateCustomActionCmd cmd); + + ExtensionCustomActionResponse createCustomActionResponse(ExtensionCustomAction customAction); + + Map> getExternalAccessDetails(Host host, Map vmDetails); + + String handleExtensionServerCommands(ExtensionServerActionBaseCommand cmd); +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java new file mode 100644 index 00000000000..3087f184dde --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java @@ -0,0 +1,1593 @@ +// +// 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.manager; + +import java.io.File; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.InvalidParameterException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import javax.inject.Inject; +import javax.naming.ConfigurationException; + +import org.apache.cloudstack.acl.Role; +import org.apache.cloudstack.acl.RoleService; +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.response.ExtensionCustomActionParameterResponse; +import org.apache.cloudstack.api.response.ExtensionCustomActionResponse; +import org.apache.cloudstack.api.response.ExtensionResourceResponse; +import org.apache.cloudstack.api.response.ExtensionResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.extension.CustomActionResultResponse; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.extension.ExtensionCustomAction; +import org.apache.cloudstack.extension.ExtensionHelper; +import org.apache.cloudstack.extension.ExtensionResourceMap; +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.config.Configurable; +import org.apache.cloudstack.framework.extensions.api.AddCustomActionCmd; +import org.apache.cloudstack.framework.extensions.api.CreateExtensionCmd; +import org.apache.cloudstack.framework.extensions.api.DeleteCustomActionCmd; +import org.apache.cloudstack.framework.extensions.api.DeleteExtensionCmd; +import org.apache.cloudstack.framework.extensions.api.ListCustomActionCmd; +import org.apache.cloudstack.framework.extensions.api.ListExtensionsCmd; +import org.apache.cloudstack.framework.extensions.api.RegisterExtensionCmd; +import org.apache.cloudstack.framework.extensions.api.RunCustomActionCmd; +import org.apache.cloudstack.framework.extensions.api.UnregisterExtensionCmd; +import org.apache.cloudstack.framework.extensions.api.UpdateCustomActionCmd; +import org.apache.cloudstack.framework.extensions.api.UpdateExtensionCmd; +import org.apache.cloudstack.framework.extensions.command.CleanupExtensionFilesCommand; +import org.apache.cloudstack.framework.extensions.command.ExtensionRoutingUpdateCommand; +import org.apache.cloudstack.framework.extensions.command.ExtensionServerActionBaseCommand; +import org.apache.cloudstack.framework.extensions.command.GetExtensionPathChecksumCommand; +import org.apache.cloudstack.framework.extensions.command.PrepareExtensionPathCommand; +import org.apache.cloudstack.framework.extensions.dao.ExtensionCustomActionDao; +import org.apache.cloudstack.framework.extensions.dao.ExtensionCustomActionDetailsDao; +import org.apache.cloudstack.framework.extensions.dao.ExtensionDao; +import org.apache.cloudstack.framework.extensions.dao.ExtensionDetailsDao; +import org.apache.cloudstack.framework.extensions.dao.ExtensionResourceMapDao; +import org.apache.cloudstack.framework.extensions.dao.ExtensionResourceMapDetailsDao; +import org.apache.cloudstack.framework.extensions.vo.ExtensionCustomActionDetailsVO; +import org.apache.cloudstack.framework.extensions.vo.ExtensionCustomActionVO; +import org.apache.cloudstack.framework.extensions.vo.ExtensionDetailsVO; +import org.apache.cloudstack.framework.extensions.vo.ExtensionResourceMapDetailsVO; +import org.apache.cloudstack.framework.extensions.vo.ExtensionResourceMapVO; +import org.apache.cloudstack.framework.extensions.vo.ExtensionVO; +import org.apache.cloudstack.managed.context.ManagedContextRunnable; +import org.apache.cloudstack.management.ManagementServerHost; +import org.apache.cloudstack.utils.identity.ManagementServerNode; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.EnumUtils; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.agent.AgentManager; +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.Command; +import com.cloud.agent.api.RunCustomActionAnswer; +import com.cloud.agent.api.RunCustomActionCommand; +import com.cloud.alert.AlertManager; +import com.cloud.cluster.ClusterManager; +import com.cloud.cluster.ManagementServerHostVO; +import com.cloud.cluster.dao.ManagementServerHostDao; +import com.cloud.dc.ClusterVO; +import com.cloud.dc.dao.ClusterDao; +import com.cloud.event.ActionEvent; +import com.cloud.event.EventTypes; +import com.cloud.exception.AgentUnavailableException; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.OperationTimedoutException; +import com.cloud.host.Host; +import com.cloud.host.HostVO; +import com.cloud.host.dao.HostDao; +import com.cloud.host.dao.HostDetailsDao; +import com.cloud.hypervisor.ExternalProvisioner; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.org.Cluster; +import com.cloud.serializer.GsonHelper; +import com.cloud.storage.dao.VMTemplateDao; +import com.cloud.user.Account; +import com.cloud.utils.Pair; +import com.cloud.utils.component.ManagerBase; +import com.cloud.utils.component.PluggableService; +import com.cloud.utils.concurrency.NamedThreadFactory; +import com.cloud.utils.db.EntityManager; +import com.cloud.utils.db.Filter; +import com.cloud.utils.db.GlobalLock; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import com.cloud.utils.db.Transaction; +import com.cloud.utils.db.TransactionCallbackWithException; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.VirtualMachineManager; +import com.cloud.vm.VmDetailConstants; +import com.cloud.vm.dao.VMInstanceDao; + +public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsManager, ExtensionHelper, PluggableService, Configurable { + + ConfigKey PathStateCheckInterval = new ConfigKey<>("Advanced", Integer.class, + "extension.path.state.check.interval", "300", + "Interval (in seconds) for checking entry-point state of extensions", + false, ConfigKey.Scope.Global); + + @Inject + ExtensionDao extensionDao; + + @Inject + ExtensionDetailsDao extensionDetailsDao; + + @Inject + ExtensionResourceMapDao extensionResourceMapDao; + + @Inject + ExtensionResourceMapDetailsDao extensionResourceMapDetailsDao; + + @Inject + ClusterDao clusterDao; + + @Inject + AgentManager agentMgr; + + @Inject + HostDao hostDao; + + @Inject + HostDetailsDao hostDetailsDao; + + @Inject + ExternalProvisioner externalProvisioner; + + @Inject + ExtensionCustomActionDao extensionCustomActionDao; + + @Inject + ExtensionCustomActionDetailsDao extensionCustomActionDetailsDao; + + @Inject + VMInstanceDao vmInstanceDao; + + @Inject + VirtualMachineManager virtualMachineManager; + + @Inject + EntityManager entityManager; + + @Inject + ManagementServerHostDao managementServerHostDao; + + @Inject + ClusterManager clusterManager; + + @Inject + AlertManager alertManager; + + @Inject + VMTemplateDao templateDao; + + @Inject + RoleService roleService; + + private ScheduledExecutorService extensionPathStateCheckExecutor; + + protected String getDefaultExtensionRelativePath(String name) { + String safeName = Extension.getDirectoryName(name); + return String.format("%s%s%s.sh", safeName, File.separator, safeName); + } + + protected String getValidatedExtensionRelativePath(String name, String relativePathPath) { + String safeName = Extension.getDirectoryName(name); + String normalizedPath = relativePathPath.replace("\\", "/"); + if (normalizedPath.startsWith("/")) { + normalizedPath = normalizedPath.substring(1); + } + if (normalizedPath.equals(safeName)) { + normalizedPath = safeName + "/" + safeName; + } else if (!normalizedPath.startsWith(safeName + "/")) { + normalizedPath = safeName + "/" + normalizedPath; + } + Path pathObj = Paths.get(normalizedPath); + int subDirCount = pathObj.getNameCount() - 1; + if (subDirCount > 2) { + throw new InvalidParameterException("Entry point path cannot be nested more than two sub-directories deep"); + } + return normalizedPath; + } + + protected Pair getResultFromAnswersString(String answersStr, Extension extension, + ManagementServerHostVO msHost, String op) { + Answer[] answers = null; + try { + answers = GsonHelper.getGson().fromJson(answersStr, Answer[].class); + } catch (Exception e) { + logger.error("Failed to parse answer JSON during {} for {} on {}: {}", + op, extension, msHost, e.getMessage(), e); + return new Pair<>(false, e.getMessage()); + } + Answer answer = answers != null && answers.length > 0 ? answers[0] : null; + boolean result = false; + String details = "Unknown error"; + if (answer != null) { + result = answer.getResult(); + details = answer.getDetails(); + } + if (!result) { + logger.error("Failed to {} for {} on {} due to {}", op, extension, msHost, details); + return new Pair<>(false, details); + } + return new Pair<>(true, details); + } + + protected boolean prepareExtensionPathOnMSPeer(Extension extension, ManagementServerHostVO msHost) { + final String msPeer = Long.toString(msHost.getMsid()); + logger.debug("Sending prepare extension entry-point for {} command to MS: {}", extension, msPeer); + final Command[] commands = new Command[1]; + commands[0] = new PrepareExtensionPathCommand(ManagementServerNode.getManagementServerId(), extension); + String answersStr = clusterManager.execute(msPeer, 0L, GsonHelper.getGson().toJson(commands), true); + return getResultFromAnswersString(answersStr, extension, msHost, "prepare entry-point").first(); + } + + protected Pair prepareExtensionPathOnCurrentServer(String name, boolean userDefined, + String relativePath) { + try { + externalProvisioner.prepareExtensionPath(name, userDefined, relativePath); + } catch (CloudRuntimeException e) { + logger.error("Failed to prepare entry-point for Extension [name: {}, userDefined: {}, relativePath: {}] on this server", + name, userDefined, relativePath, e); + return new Pair<>(false, e.getMessage()); + } + return new Pair<>(true, null); + } + + protected boolean cleanupExtensionFilesOnMSPeer(Extension extension, ManagementServerHostVO msHost) { + final String msPeer = Long.toString(msHost.getMsid()); + logger.debug("Sending cleanup extension entry-point for {} command to MS: {}", extension, msPeer); + final Command[] commands = new Command[1]; + commands[0] = new CleanupExtensionFilesCommand(ManagementServerNode.getManagementServerId(), extension); + String answersStr = clusterManager.execute(msPeer, 0L, GsonHelper.getGson().toJson(commands), true); + return getResultFromAnswersString(answersStr, extension, msHost, "cleanup entry-point").first(); + } + + protected Pair cleanupExtensionFilesOnCurrentServer(String name, String relativePath) { + try { + externalProvisioner.cleanupExtensionPath(name, relativePath); + externalProvisioner.cleanupExtensionData(name, 0, true); + } catch (CloudRuntimeException e) { + logger.error("Failed to cleanup entry-point files for Extension [name: {}, relativePath: {}] on this server", + name, relativePath, e); + return new Pair<>(false, e.getMessage()); + } + return new Pair<>(true, null); + } + + protected void cleanupExtensionFilesAcrossServers(Extension extension) { + boolean cleanup = true; + List msHosts = managementServerHostDao.listBy(ManagementServerHost.State.Up); + for (ManagementServerHostVO msHost : msHosts) { + if (msHost.getMsid() == ManagementServerNode.getManagementServerId()) { + cleanup = cleanup && cleanupExtensionFilesOnCurrentServer(extension.getName(), + extension.getRelativePath()).first(); + continue; + } + cleanup = cleanup && cleanupExtensionFilesOnMSPeer(extension, msHost); + } + if (!cleanup) { + throw new CloudRuntimeException("Extension is deleted but its entry-point files are not cleaned up across servers"); + } + } + + protected Pair getChecksumForExtensionPathOnMSPeer(Extension extension, ManagementServerHostVO msHost) { + final String msPeer = Long.toString(msHost.getMsid()); + logger.debug("Retrieving checksum for {} from MS: {}", extension, msPeer); + final Command[] cmds = new Command[1]; + cmds[0] = new GetExtensionPathChecksumCommand(ManagementServerNode.getManagementServerId(), + extension); + String answersStr = clusterManager.execute(msPeer, 0L, GsonHelper.getGson().toJson(cmds), true); + return getResultFromAnswersString(answersStr, extension, msHost, "prepare entry-point"); + } + + protected List getParametersListFromMap(String actionName, Map parametersMap) { + if (MapUtils.isEmpty(parametersMap)) { + return Collections.emptyList(); + } + List parameters = new ArrayList<>(); + for (Map entry : (Collection>)parametersMap.values()) { + ExtensionCustomAction.Parameter parameter = ExtensionCustomAction.Parameter.fromMap(entry); + logger.debug("Adding {} for custom action [{}]", parameter, actionName); + parameters.add(parameter); + } + return parameters; + } + + protected void unregisterExtensionWithCluster(String clusterUuid, Long extensionId) { + ClusterVO cluster = clusterDao.findByUuid(clusterUuid); + if (cluster == null) { + throw new InvalidParameterValueException("Unable to find cluster with given ID"); + } + unregisterExtensionWithCluster(cluster, extensionId); + } + + protected Extension getExtensionFromResource(ExtensionCustomAction.ResourceType resourceType, String resourceUuid) { + Object object = entityManager.findByUuid(resourceType.getAssociatedClass(), resourceUuid); + if (object == null) { + return null; + } + Long clusterId = null; + if (resourceType == ExtensionCustomAction.ResourceType.VirtualMachine) { + VirtualMachine vm = (VirtualMachine) object; + Pair clusterHostId = virtualMachineManager.findClusterAndHostIdForVm(vm, false); + clusterId = clusterHostId.first(); + } + if (clusterId == null) { + return null; + } + ExtensionResourceMapVO mapVO = + extensionResourceMapDao.findByResourceIdAndType(clusterId, ExtensionResourceMap.ResourceType.Cluster); + if (mapVO == null) { + return null; + } + return extensionDao.findById(mapVO.getExtensionId()); + } + + protected String getActionMessage(boolean success, ExtensionCustomAction action, Extension extension, + ExtensionCustomAction.ResourceType resourceType, Object resource) { + String msg = success ? action.getSuccessMessage() : action.getErrorMessage(); + if (StringUtils.isBlank(msg)) { + return success ? String.format("Successfully completed %s", action.getName()) : + String.format("Failed to complete %s", action.getName()); + } + Map values = new HashMap<>(); + values.put("actionName", action.getName()); + values.put("extensionName", extension.getName()); + if (msg.contains("{{resourceName}}")) { + String resourceName = resourceType.name(); + try { + Method getNameMethod = resource.getClass().getMethod("getName"); + Object result = getNameMethod.invoke(resource); + if (result instanceof String) { + resourceName = (String) result; + } + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + logger.trace("Failed to get name for given resource of type: {}", resourceType, e); + } + values.put("resourceName", resourceName); + } + String result = msg; + for (Map.Entry entry : values.entrySet()) { + result = result.replace("{{" + entry.getKey() + "}}", entry.getValue()); + } + return result; + } + + protected Map getFilteredExternalDetails(Map details) { + if (MapUtils.isEmpty(details)) { + return new HashMap<>(); + } + return details.entrySet().stream() + .filter(entry -> entry.getKey().startsWith(VmDetailConstants.EXTERNAL_DETAIL_PREFIX)) + .collect(Collectors.toMap( + entry -> entry.getKey().substring(VmDetailConstants.EXTERNAL_DETAIL_PREFIX.length()), + Map.Entry::getValue + )); + } + + protected void sendExtensionPathNotReadyAlert(Extension extension) { + String msg = String.format("Path for %s not ready across management servers", + extension); + if (!Extension.State.Enabled.equals(extension.getState())) { + logger.warn(msg); + return; + } + alertManager.sendAlert(AlertManager.AlertType.ALERT_TYPE_EXTENSION_PATH_NOT_READY, 0L, 0L, msg, msg); + } + + protected void updateExtensionPathReady(Extension extension, boolean ready) { + if (!ready) { + sendExtensionPathNotReadyAlert(extension); + } + if (extension.isPathReady() == ready) { + return; + } + ExtensionVO extensionVO = extensionDao.createForUpdate(extension.getId()); + extensionVO.setPathReady(ready); + extensionDao.update(extension.getId(), extensionVO); + updateAllExtensionHosts(extension, null, false); + } + + protected void disableExtension(long extensionId) { + ExtensionVO extensionVO = extensionDao.createForUpdate(extensionId); + extensionVO.setState(Extension.State.Disabled); + extensionDao.update(extensionId, extensionVO); + } + + protected void updateAllExtensionHosts(Extension extension, Long clusterId, boolean remove) { + List hostIds = new ArrayList<>(); + List clusterIds = clusterId == null ? + extensionResourceMapDao.listResourceIdsByExtensionIdAndType(extension.getId(), + ExtensionResourceMap.ResourceType.Cluster) : + Collections.singletonList(clusterId); + for (Long cId : clusterIds) { + hostIds.addAll(hostDao.listIdsByClusterId(cId)); + } + if (CollectionUtils.isEmpty(hostIds)) { + return; + } + ConcurrentHashMap> futures = new ConcurrentHashMap<>(); + ExecutorService executorService = Executors.newFixedThreadPool(3, new NamedThreadFactory("ExtensionHostUpdateWorker")); + for (Long hostId : hostIds) { + futures.put(hostId, executorService.submit(() -> { + ExtensionRoutingUpdateCommand cmd = new ExtensionRoutingUpdateCommand(extension, remove); + agentMgr.send(hostId, cmd); + return null; + })); + } + for (Map.Entry> entry: futures.entrySet()) { + try { + entry.getValue().get(); + } catch (InterruptedException | ExecutionException e) { + logger.error(String.format("Error during updating %s for host: %d due to : %s", + extension, entry.getKey(), e.getMessage()), e); + } + } + executorService.shutdown(); + } + + protected Map> getExternalAccessDetails(Map actionDetails, long hostId, + ExtensionResourceMap resourceMap) { + Map> externalDetails = new HashMap<>(); + if (MapUtils.isNotEmpty(actionDetails)) { + externalDetails.put(ApiConstants.ACTION, actionDetails); + } + Map hostDetails = getFilteredExternalDetails(hostDetailsDao.findDetails(hostId)); + if (MapUtils.isNotEmpty(hostDetails)) { + externalDetails.put(ApiConstants.HOST, hostDetails); + } + if (resourceMap == null) { + return externalDetails; + } + Map resourceDetails = extensionResourceMapDetailsDao.listDetailsKeyPairs(resourceMap.getId(), true); + if (MapUtils.isNotEmpty(resourceDetails)) { + externalDetails.put(ApiConstants.RESOURCE_MAP, resourceDetails); + } + Map extensionDetails = extensionDetailsDao.listDetailsKeyPairs(resourceMap.getExtensionId(), true); + if (MapUtils.isNotEmpty(extensionDetails)) { + externalDetails.put(ApiConstants.EXTENSION, extensionDetails); + } + return externalDetails; + } + + protected void checkOrchestratorTemplates(Long extensionId) { + List extensionTemplateIds = templateDao.listIdsByExtensionId(extensionId); + if (CollectionUtils.isNotEmpty(extensionTemplateIds)) { + throw new CloudRuntimeException("Orchestrator extension has associated templates, remove them to delete the extension"); + } + } + + protected void checkExtensionPathState(Extension extension, List msHosts) { + String checksum = externalProvisioner.getChecksumForExtensionPath(extension.getName(), + extension.getRelativePath()); + if (StringUtils.isBlank(checksum)) { + updateExtensionPathReady(extension, false); + return; + } + if (CollectionUtils.isEmpty(msHosts)) { + updateExtensionPathReady(extension, true); + return; + } + for (ManagementServerHostVO msHost : msHosts) { + final Pair msPeerChecksumResult = getChecksumForExtensionPathOnMSPeer(extension, + msHost); + if (!msPeerChecksumResult.first() || !checksum.equals(msPeerChecksumResult.second())) { + logger.error("Entry-point checksum for {} is different [msid: {}, checksum: {}] and [msid: {}, checksum: {}]", + extension, ManagementServerNode.getManagementServerId(), checksum, msHost.getMsid(), + (msPeerChecksumResult.first() ? msPeerChecksumResult.second() : "unknown")); + updateExtensionPathReady(extension, false); + return; + } + } + updateExtensionPathReady(extension, true); + } + + @Override + public String getExtensionsPath() { + return externalProvisioner.getExtensionsPath(); + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_EXTENSION_CREATE, eventDescription = "creating extension") + public Extension createExtension(CreateExtensionCmd cmd) { + final String name = cmd.getName(); + final String description = cmd.getDescription(); + final String typeStr = cmd.getType(); + String relativePath = cmd.getPath(); + final Boolean orchestratorRequiresPrepareVm = cmd.isOrchestratorRequiresPrepareVm(); + final String stateStr = cmd.getState(); + ExtensionVO extensionByName = extensionDao.findByName(name); + if (extensionByName != null) { + throw new CloudRuntimeException("Extension by name already exists"); + } + final Extension.Type type = EnumUtils.getEnum(Extension.Type.class, typeStr); + if (type == null) { + throw new CloudRuntimeException(String.format("Invalid type specified - %s", typeStr)); + } + if (StringUtils.isBlank(relativePath)) { + relativePath = getDefaultExtensionRelativePath(name); + } else { + relativePath = getValidatedExtensionRelativePath(name, relativePath); + } + Extension.State state = Extension.State.Enabled; + if (StringUtils.isNotEmpty(stateStr)) { + try { + state = Extension.State.valueOf(stateStr); + } catch (IllegalArgumentException iae) { + throw new InvalidParameterValueException("Invalid state specified"); + } + } + if (orchestratorRequiresPrepareVm != null && !Extension.Type.Orchestrator.equals(type)) { + throw new InvalidParameterValueException(String.format("%s is applicable only with %s type", + ApiConstants.ORCHESTRATOR_REQUIRES_PREPARE_VM, type.name())); + } + final String relativePathFinal = relativePath; + final Extension.State stateFinal = state; + ExtensionVO extensionVO = Transaction.execute((TransactionCallbackWithException) status -> { + ExtensionVO extension = new ExtensionVO(name, description, type, + relativePathFinal, stateFinal); + if (!Extension.State.Enabled.equals(stateFinal)) { + extension.setPathReady(false); + } + extension = extensionDao.persist(extension); + + Map details = cmd.getDetails(); + List detailsVOList = new ArrayList<>(); + if (MapUtils.isNotEmpty(details)) { + for (Map.Entry entry : details.entrySet()) { + detailsVOList.add(new ExtensionDetailsVO(extension.getId(), entry.getKey(), entry.getValue())); + } + } + if (orchestratorRequiresPrepareVm != null) { + detailsVOList.add(new ExtensionDetailsVO(extension.getId(), + ApiConstants.ORCHESTRATOR_REQUIRES_PREPARE_VM, String.valueOf(orchestratorRequiresPrepareVm), + false)); + } + if (CollectionUtils.isNotEmpty(detailsVOList)) { + extensionDetailsDao.saveDetails(detailsVOList); + } + CallContext.current().setEventResourceId(extension.getId()); + return extension; + }); + if (Extension.State.Enabled.equals(extensionVO.getState()) && + !prepareExtensionPathAcrossServers(extensionVO)) { + disableExtension(extensionVO.getId()); + throw new CloudRuntimeException(String.format( + "Failed to enable extension: %s as it entry-point is not ready", + extensionVO.getName())); + } + return extensionVO; + } + + @Override + public boolean prepareExtensionPathAcrossServers(Extension extension) { + boolean prepared = true; + List msHosts = managementServerHostDao.listBy(ManagementServerHost.State.Up); + for (ManagementServerHostVO msHost : msHosts) { + if (msHost.getMsid() == ManagementServerNode.getManagementServerId()) { + prepared = prepared && prepareExtensionPathOnCurrentServer(extension.getName(), extension.isUserDefined(), + extension.getRelativePath()).first(); + continue; + } + prepared = prepared && prepareExtensionPathOnMSPeer(extension, msHost); + } + if (extension.isPathReady() != prepared) { + ExtensionVO updateExtension = extensionDao.createForUpdate(extension.getId()); + updateExtension.setPathReady(prepared); + extensionDao.update(extension.getId(), updateExtension); + } + return prepared; + } + + @Override + public List listExtensions(ListExtensionsCmd cmd) { + Long id = cmd.getExtensionId(); + String name = cmd.getName(); + String keyword = cmd.getKeyword(); + final SearchBuilder sb = extensionDao.createSearchBuilder(); + final Filter searchFilter = new Filter(ExtensionVO.class, "id", false, cmd.getStartIndex(), cmd.getPageSizeVal()); + + sb.and("id", sb.entity().getId(), SearchCriteria.Op.EQ); + sb.and("name", sb.entity().getName(), SearchCriteria.Op.EQ); + sb.and("keyword", sb.entity().getName(), SearchCriteria.Op.LIKE); + final SearchCriteria sc = sb.create(); + + if (id != null) { + sc.setParameters("id", id); + } + + if (name != null) { + sc.setParameters("name", name); + } + + if (keyword != null) { + sc.setParameters("keyword", "%" + keyword + "%"); + } + + final Pair, Integer> result = extensionDao.searchAndCount(sc, searchFilter); + List responses = new ArrayList<>(); + for (ExtensionVO extension : result.first()) { + ExtensionResponse response = createExtensionResponse(extension, cmd.getDetails()); + responses.add(response); + } + + return responses; + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_EXTENSION_UPDATE, eventDescription = "updating extension") + public Extension updateExtension(UpdateExtensionCmd cmd) { + final long id = cmd.getId(); + final String description = cmd.getDescription(); + final Boolean orchestratorRequiresPrepareVm = cmd.isOrchestratorRequiresPrepareVm(); + final String stateStr = cmd.getState(); + final Map details = cmd.getDetails(); + final Boolean cleanupDetails = cmd.isCleanupDetails(); + final ExtensionVO extensionVO = extensionDao.findById(id); + if (extensionVO == null) { + throw new InvalidParameterValueException("Failed to find the extension"); + } + boolean updateNeeded = false; + if (description != null && !description.equals(extensionVO.getDescription())) { + extensionVO.setDescription(description); + updateNeeded = true; + } + if (orchestratorRequiresPrepareVm != null && !Extension.Type.Orchestrator.equals(extensionVO.getType())) { + throw new InvalidParameterValueException(String.format("%s is applicable only with %s type", + ApiConstants.ORCHESTRATOR_REQUIRES_PREPARE_VM, extensionVO.getType())); + } + if (StringUtils.isNotBlank(stateStr) && !stateStr.equalsIgnoreCase(extensionVO.getState().name())) { + try { + Extension.State state = Extension.State.valueOf(stateStr); + extensionVO.setState(state); + updateNeeded = true; + } catch (IllegalArgumentException iae) { + throw new InvalidParameterValueException("Invalid state specified"); + } + } + final boolean updateNeededFinal = updateNeeded; + ExtensionVO result = Transaction.execute((TransactionCallbackWithException) status -> { + if (updateNeededFinal && !extensionDao.update(id, extensionVO)) { + throw new CloudRuntimeException(String.format("Failed to updated the extension: %s", + extensionVO.getName())); + } + updateExtensionsDetails(cleanupDetails, details, orchestratorRequiresPrepareVm, id); + return extensionVO; + }); + if (StringUtils.isNotBlank(stateStr)) { + if (Extension.State.Enabled.equals(result.getState()) && + !prepareExtensionPathAcrossServers(result)) { + disableExtension(result.getId()); + throw new CloudRuntimeException(String.format( + "Failed to enable extension: %s as it entry-point is not ready", + extensionVO.getName())); + } + updateAllExtensionHosts(extensionVO, null, false); + } + return result; + } + + protected void updateExtensionsDetails(Boolean cleanupDetails, Map details, Boolean orchestratorRequiresPrepareVm, long id) { + final boolean needToUpdateAllDetails = Boolean.TRUE.equals(cleanupDetails) || MapUtils.isNotEmpty(details); + if (!needToUpdateAllDetails && orchestratorRequiresPrepareVm == null) { + return; + } + if (needToUpdateAllDetails) { + Map hiddenDetails = + extensionDetailsDao.listDetailsKeyPairs(id, false); + List detailsVOList = new ArrayList<>(); + if (orchestratorRequiresPrepareVm != null) { + hiddenDetails.put(ApiConstants.ORCHESTRATOR_REQUIRES_PREPARE_VM, + String.valueOf(orchestratorRequiresPrepareVm)); + } + if (MapUtils.isNotEmpty(hiddenDetails)) { + hiddenDetails.forEach((key, value) -> detailsVOList.add( + new ExtensionDetailsVO(id, key, value, false))); + } + if (!Boolean.TRUE.equals(cleanupDetails) && MapUtils.isNotEmpty(details)) { + details.forEach((key, value) -> detailsVOList.add( + new ExtensionDetailsVO(id, key, value))); + } + if (CollectionUtils.isNotEmpty(detailsVOList)) { + extensionDetailsDao.saveDetails(detailsVOList); + } else if (Boolean.TRUE.equals(cleanupDetails)) { + extensionDetailsDao.removeDetails(id); + } + } else { + ExtensionDetailsVO detailsVO = extensionDetailsDao.findDetail(id, + ApiConstants.ORCHESTRATOR_REQUIRES_PREPARE_VM); + if (detailsVO == null) { + extensionDetailsDao.persist(new ExtensionDetailsVO(id, + ApiConstants.ORCHESTRATOR_REQUIRES_PREPARE_VM, + String.valueOf(orchestratorRequiresPrepareVm), false)); + } else if (Boolean.parseBoolean(detailsVO.getValue()) != orchestratorRequiresPrepareVm) { + detailsVO.setValue(String.valueOf(orchestratorRequiresPrepareVm)); + extensionDetailsDao.update(detailsVO.getId(), detailsVO); + } + } + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_EXTENSION_DELETE, eventDescription = "deleting extension") + public boolean deleteExtension(DeleteExtensionCmd cmd) { + Long extensionId = cmd.getId(); + final boolean cleanup = cmd.isCleanup(); + ExtensionVO extension = extensionDao.findById(extensionId); + if (extension == null) { + throw new InvalidParameterValueException("Unable to find the extension with the specified id"); + } + if (!extension.isUserDefined()) { + throw new InvalidParameterValueException("System extension can not be deleted"); + } + List registeredResources = extensionResourceMapDao.listByExtensionId(extensionId); + if (CollectionUtils.isNotEmpty(registeredResources)) { + throw new CloudRuntimeException("Extension has associated resources, unregister them to delete the extension"); + } + List customActionIds = extensionCustomActionDao.listIdsByExtensionId(extensionId); + if (CollectionUtils.isNotEmpty(customActionIds)) { + throw new CloudRuntimeException(String.format("Extension has %d custom actions, delete them to delete the extension", + customActionIds.size())); + } + checkOrchestratorTemplates(extensionId); + + boolean result = Transaction.execute((TransactionCallbackWithException) status -> { + extensionDetailsDao.removeDetails(extensionId); + extensionDao.remove(extensionId); + return true; + }); + if (result && cleanup) { + cleanupExtensionFilesAcrossServers(extension); + } + return true; + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_EXTENSION_RESOURCE_REGISTER, eventDescription = "registering extension resource") + public Extension registerExtensionWithResource(RegisterExtensionCmd cmd) { + String resourceId = cmd.getResourceId(); + Long extensionId = cmd.getExtensionId(); + String resourceType = cmd.getResourceType(); + if (!EnumUtils.isValidEnum(ExtensionResourceMap.ResourceType.class, resourceType)) { + throw new InvalidParameterValueException( + String.format("Currently only [%s] can be used to register an extension of type Orchestrator", + EnumSet.allOf(ExtensionResourceMap.ResourceType.class))); + } + ClusterVO clusterVO = clusterDao.findByUuid(resourceId); + if (clusterVO == null) { + throw new InvalidParameterValueException("Invalid cluster ID specified"); + } + ExtensionVO extension = extensionDao.findById(extensionId); + if (extension == null) { + throw new InvalidParameterValueException("Invalid extension specified"); + } + ExtensionResourceMap extensionResourceMap = registerExtensionWithCluster(clusterVO, extension, cmd.getDetails()); + return extensionDao.findById(extensionResourceMap.getExtensionId()); + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_EXTENSION_RESOURCE_REGISTER, eventDescription = "registering extension resource") + public ExtensionResourceMap registerExtensionWithCluster(Cluster cluster, Extension extension, + Map details) { + if (!Hypervisor.HypervisorType.External.equals(cluster.getHypervisorType())) { + throw new CloudRuntimeException( + String.format("Cluster ID: %s is not of %s hypervisor type", cluster.getId(), + cluster.getHypervisorType())); + } + final ExtensionResourceMap.ResourceType resourceType = ExtensionResourceMap.ResourceType.Cluster; + ExtensionResourceMapVO existing = + extensionResourceMapDao.findByResourceIdAndType(cluster.getId(), resourceType); + if (existing != null) { + if (existing.getExtensionId() == extension.getId()) { + throw new CloudRuntimeException(String.format( + "Extension: %s is already registered with this cluster: %s", + extension.getName(), cluster.getName())); + } else { + throw new CloudRuntimeException(String.format( + "An extension is already registered with this cluster: %s", cluster.getName())); + } + } + ExtensionResourceMap result = Transaction.execute((TransactionCallbackWithException) status -> { + ExtensionResourceMapVO extensionMap = new ExtensionResourceMapVO(extension.getId(), cluster.getId(), resourceType); + ExtensionResourceMapVO savedExtensionMap = extensionResourceMapDao.persist(extensionMap); + List detailsVOList = new ArrayList<>(); + if (MapUtils.isNotEmpty(details)) { + for (Map.Entry entry : details.entrySet()) { + detailsVOList.add(new ExtensionResourceMapDetailsVO(savedExtensionMap.getId(), + entry.getKey(), entry.getValue())); + } + extensionResourceMapDetailsDao.saveDetails(detailsVOList); + } + return extensionMap; + }); + updateAllExtensionHosts(extension, cluster.getId(), false); + return result; + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_EXTENSION_RESOURCE_UNREGISTER, eventDescription = "unregistering extension resource") + public Extension unregisterExtensionWithResource(UnregisterExtensionCmd cmd) { + final String resourceId = cmd.getResourceId(); + final Long extensionId = cmd.getExtensionId(); + final String resourceType = cmd.getResourceType(); + if (!EnumUtils.isValidEnum(ExtensionResourceMap.ResourceType.class, resourceType)) { + throw new InvalidParameterValueException( + String.format("Currently only [%s] can be used to unregister an extension of type Orchestrator", + EnumSet.allOf(ExtensionResourceMap.ResourceType.class))); + } + unregisterExtensionWithCluster(resourceId, extensionId); + return extensionDao.findById(extensionId); + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_EXTENSION_RESOURCE_UNREGISTER, eventDescription = "unregistering extension resource") + public void unregisterExtensionWithCluster(Cluster cluster, Long extensionId) { + ExtensionResourceMapVO existing = extensionResourceMapDao.findByResourceIdAndType(cluster.getId(), + ExtensionResourceMap.ResourceType.Cluster); + if (existing == null) { + return; + } + extensionResourceMapDao.remove(existing.getId()); + extensionResourceMapDetailsDao.removeDetails(existing.getId()); + ExtensionVO extensionVO = extensionDao.findById(extensionId); + if (extensionVO != null) { + updateAllExtensionHosts(extensionVO, cluster.getId(), true); + } + } + + @Override + public ExtensionResponse createExtensionResponse(Extension extension, + EnumSet viewDetails) { + ExtensionResponse response = new ExtensionResponse(extension.getUuid(), extension.getName(), + extension.getDescription(), extension.getType().name()); + response.setCreated(extension.getCreated()); + response.setPath(externalProvisioner.getExtensionPath(extension.getRelativePath())); + response.setPathReady(extension.isPathReady()); + response.setUserDefined(extension.isUserDefined()); + response.setState(extension.getState().name()); + if (viewDetails.contains(ApiConstants.ExtensionDetails.all) || + viewDetails.contains(ApiConstants.ExtensionDetails.resource)) { + List resourcesResponse = new ArrayList<>(); + List extensionResourceMapVOs = + extensionResourceMapDao.listByExtensionId(extension.getId()); + for (ExtensionResourceMapVO extensionResourceMapVO : extensionResourceMapVOs) { + ExtensionResourceResponse extensionResourceResponse = new ExtensionResourceResponse(); + extensionResourceResponse.setType(extensionResourceMapVO.getResourceType().name()); + extensionResourceResponse.setCreated(extensionResourceMapVO.getCreated()); + if (ExtensionResourceMap.ResourceType.Cluster.equals(extensionResourceMapVO.getResourceType())) { + Cluster cluster = clusterDao.findById(extensionResourceMapVO.getResourceId()); + extensionResourceResponse.setId(cluster.getUuid()); + extensionResourceResponse.setName(cluster.getName()); + } + Map details = extensionResourceMapDetailsDao.listDetailsKeyPairs( + extensionResourceMapVO.getId(), true); + if (MapUtils.isNotEmpty(details)) { + extensionResourceResponse.setDetails(details); + } + resourcesResponse.add(extensionResourceResponse); + } + if (CollectionUtils.isNotEmpty(resourcesResponse)) { + response.setResources(resourcesResponse); + } + } + Map hiddenDetails; + if (viewDetails.contains(ApiConstants.ExtensionDetails.all) || + viewDetails.contains(ApiConstants.ExtensionDetails.external)) { + Pair, Map> extensionDetails = + extensionDetailsDao.listDetailsKeyPairsWithVisibility(extension.getId()); + if (MapUtils.isNotEmpty(extensionDetails.first())) { + response.setDetails(extensionDetails.first()); + } + hiddenDetails = extensionDetails.second(); + } else { + hiddenDetails = extensionDetailsDao.listDetailsKeyPairs(extension.getId(), + List.of(ApiConstants.ORCHESTRATOR_REQUIRES_PREPARE_VM)); + } + if (hiddenDetails.containsKey(ApiConstants.ORCHESTRATOR_REQUIRES_PREPARE_VM)) { + response.setOrchestratorRequiresPrepareVm(Boolean.parseBoolean( + hiddenDetails.get(ApiConstants.ORCHESTRATOR_REQUIRES_PREPARE_VM))); + } + response.setObjectName(Extension.class.getSimpleName().toLowerCase()); + return response; + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_EXTENSION_CUSTOM_ACTION_ADD, eventDescription = "adding extension custom action") + public ExtensionCustomAction addCustomAction(AddCustomActionCmd cmd) { + String name = cmd.getName(); + String description = cmd.getDescription(); + Long extensionId = cmd.getExtensionId(); + String resourceTypeStr = cmd.getResourceType(); + List rolesStrList = cmd.getAllowedRoleTypes(); + final int timeout = ObjectUtils.defaultIfNull(cmd.getTimeout(), 3); + final boolean enabled = cmd.isEnabled(); + Map parametersMap = cmd.getParametersMap(); + final String successMessage = cmd.getSuccessMessage(); + final String errorMessage = cmd.getErrorMessage(); + Map details = cmd.getDetails(); + if (name == null || !name.matches("^[a-zA-Z0-9 _-]+$")) { + throw new InvalidParameterValueException(String.format("Invalid action name: %s. It can contain " + + "only alphabets, numbers, hyphen, underscore and space", name)); + } + ExtensionCustomActionVO existingCustomAction = extensionCustomActionDao.findByNameAndExtensionId(extensionId, name); + if (existingCustomAction != null) { + throw new CloudRuntimeException("Action by name already exists"); + } + ExtensionVO extensionVO = extensionDao.findById(extensionId); + if (extensionVO == null) { + throw new InvalidParameterValueException("Specified extension can not be found"); + } + List parameters = getParametersListFromMap(name, parametersMap); + ExtensionCustomAction.ResourceType resourceType = null; + if (StringUtils.isNotBlank(resourceTypeStr)) { + resourceType = EnumUtils.getEnumIgnoreCase(ExtensionCustomAction.ResourceType.class, resourceTypeStr); + if (resourceType == null) { + throw new InvalidParameterValueException( + String.format("Invalid resource type specified: %s. Valid values are: %s", resourceTypeStr, + EnumSet.allOf(ExtensionCustomAction.ResourceType.class))); + } + } + if (resourceType == null && Extension.Type.Orchestrator.equals(extensionVO.getType())) { + resourceType = ExtensionCustomAction.ResourceType.VirtualMachine; + } + final Set roleTypes = new HashSet<>(); + if (CollectionUtils.isNotEmpty(rolesStrList)) { + for (String roleTypeStr : rolesStrList) { + try { + RoleType roleType = RoleType.fromString(roleTypeStr); + roleTypes.add(roleType); + } catch (IllegalStateException ignored) { + throw new InvalidParameterValueException(String.format("Invalid role specified - %s", roleTypeStr)); + } + } + } + roleTypes.add(RoleType.Admin); + final ExtensionCustomAction.ResourceType resourceTypeFinal = resourceType; + return Transaction.execute((TransactionCallbackWithException) status -> { + ExtensionCustomActionVO customAction = + new ExtensionCustomActionVO(name, description, extensionId, successMessage, errorMessage, timeout, enabled); + if (resourceTypeFinal != null) { + customAction.setResourceType(resourceTypeFinal); + } + customAction.setAllowedRoleTypes(RoleType.toCombinedMask(roleTypes)); + ExtensionCustomActionVO savedAction = extensionCustomActionDao.persist(customAction); + List detailsVOList = new ArrayList<>(); + detailsVOList.add(new ExtensionCustomActionDetailsVO( + savedAction.getId(), + ApiConstants.PARAMETERS, + ExtensionCustomAction.Parameter.toJsonFromList(parameters), + false + )); + if (MapUtils.isNotEmpty(details)) { + details.forEach((key, value) -> detailsVOList.add( + new ExtensionCustomActionDetailsVO(savedAction.getId(), key, value))); + } + if (CollectionUtils.isNotEmpty(detailsVOList)) { + extensionCustomActionDetailsDao.saveDetails(detailsVOList); + } + CallContext.current().setEventResourceId(savedAction.getId()); + return savedAction; + }); + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_EXTENSION_CUSTOM_ACTION_DELETE, eventDescription = "deleting extension custom action") + public boolean deleteCustomAction(DeleteCustomActionCmd cmd) { + Long customActionId = cmd.getId(); + ExtensionCustomActionVO customActionVO = extensionCustomActionDao.findById(customActionId); + if (customActionVO == null) { + throw new InvalidParameterValueException("Unable to find the custom action with the specified id"); + } + return Transaction.execute((TransactionCallbackWithException) status -> { + extensionCustomActionDetailsDao.removeDetails(customActionId); + if (!extensionCustomActionDao.remove(customActionId)) { + throw new CloudRuntimeException("Failed to delete custom action"); + } + return true; + }); + } + + @Override + public List listCustomActions(ListCustomActionCmd cmd) { + Long id = cmd.getId(); + String name = cmd.getName(); + Long extensionId = cmd.getExtensionId(); + String keyword = cmd.getKeyword(); + final String resourceTypeStr = cmd.getResourceType(); + final String resourceId = cmd.getResourceId(); + final Boolean enabled = cmd.isEnabled(); + final SearchBuilder sb = extensionCustomActionDao.createSearchBuilder(); + final Filter searchFilter = new Filter(ExtensionCustomActionVO.class, "id", false, cmd.getStartIndex(), cmd.getPageSizeVal()); + final Account caller = CallContext.current().getCallingAccount(); + + ExtensionCustomAction.ResourceType resourceType = null; + if (StringUtils.isNotBlank(resourceTypeStr)) { + resourceType = EnumUtils.getEnum(ExtensionCustomAction.ResourceType.class, resourceTypeStr); + if (resourceType == null) { + throw new InvalidParameterValueException("Invalid resource type specified"); + } + } + + if (extensionId == null && resourceType != null && StringUtils.isNotBlank(resourceId)) { + Extension extension = getExtensionFromResource(resourceType, resourceId); + if (extension == null) { + logger.error("No extension found for the specified resource [type: {}, id: {}]", resourceTypeStr, resourceId); + throw new InvalidParameterValueException("Internal error listing custom actions with specified resource"); + } + extensionId = extension.getId(); + } + + final Role role = roleService.findRole(caller.getRoleId()); + + sb.and("id", sb.entity().getId(), SearchCriteria.Op.EQ); + sb.and("extensionId", sb.entity().getExtensionId(), SearchCriteria.Op.EQ); + sb.and("name", sb.entity().getName(), SearchCriteria.Op.EQ); + sb.and("keyword", sb.entity().getName(), SearchCriteria.Op.LIKE); + sb.and("enabled", sb.entity().isEnabled(), SearchCriteria.Op.EQ); + if (resourceType != null) { + sb.and().op("resourceTypeNull", sb.entity().getResourceType(), SearchCriteria.Op.NULL); + sb.or("resourceType", sb.entity().getResourceType(), SearchCriteria.Op.EQ); + sb.cp(); + } + if (!RoleType.Admin.equals(role.getRoleType())) { + sb.and("roleType", sb.entity().getAllowedRoleTypes(), SearchCriteria.Op.BINARY_OR); + } + sb.done(); + final SearchCriteria sc = sb.create(); + if (id != null) { + sc.setParameters("id", id); + } + if (extensionId != null) { + sc.setParameters("extensionId", extensionId); + } + if (StringUtils.isNotBlank(name)) { + sc.setParameters("name", name); + } + if (StringUtils.isNotBlank(keyword)) { + sc.setParameters("keyword", "%" + keyword + "%"); + } + if (enabled != null) { + sc.setParameters("enabled", true); + } + if (resourceType != null) { + sc.setParameters("resourceType", resourceType); + } + if (!RoleType.Admin.equals(role.getRoleType())) { + sc.setParameters("roleType", role.getRoleType().getMask()); + } + final Pair, Integer> result = extensionCustomActionDao.searchAndCount(sc, searchFilter); + List responses = new ArrayList<>(); + for (ExtensionCustomActionVO customAction : result.first()) { + responses.add(createCustomActionResponse(customAction)); + } + + return responses; + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_EXTENSION_CUSTOM_ACTION_UPDATE, eventDescription = "updating extension custom action") + public ExtensionCustomAction updateCustomAction(UpdateCustomActionCmd cmd) { + final long id = cmd.getId(); + String description = cmd.getDescription(); + String resourceTypeStr = cmd.getResourceType(); + List rolesStrList = cmd.getAllowedRoleTypes(); + Boolean enabled = cmd.isEnabled(); + Map parametersMap = cmd.getParametersMap(); + Boolean cleanupParameters = cmd.isCleanupParameters(); + final String successMessage = cmd.getSuccessMessage(); + final String errorMessage = cmd.getErrorMessage(); + final Integer timeout = cmd.getTimeout(); + Map details = cmd.getDetails(); + Boolean cleanupDetails = cmd.isCleanupDetails(); + + ExtensionCustomActionVO customAction = extensionCustomActionDao.findById(id); + if (customAction == null) { + throw new CloudRuntimeException("Action not found"); + } + + boolean needUpdate = false; + if (StringUtils.isNotBlank(description)) { + customAction.setDescription(description); + needUpdate = true; + } + if (resourceTypeStr != null) { + ExtensionCustomAction.ResourceType resourceType = + EnumUtils.getEnumIgnoreCase(ExtensionCustomAction.ResourceType.class, resourceTypeStr); + if (resourceType == null) { + throw new InvalidParameterValueException( + String.format("Invalid resource type specified: %s. Valid values are: %s", resourceTypeStr, + EnumSet.allOf(ExtensionCustomAction.ResourceType.class))); + } + customAction.setResourceType(resourceType); + needUpdate = true; + } + if (CollectionUtils.isNotEmpty(rolesStrList)) { + Set roles = new HashSet<>(); + for (String roleTypeStr : rolesStrList) { + try { + RoleType roleType = RoleType.fromString(roleTypeStr); + roles.add(roleType); + } catch (IllegalStateException ignored) { + throw new InvalidParameterValueException(String.format("Invalid role specified - %s", roleTypeStr)); + } + } + customAction.setAllowedRoleTypes(RoleType.toCombinedMask(roles)); + needUpdate = true; + } + if (successMessage != null) { + customAction.setSuccessMessage(successMessage); + needUpdate = true; + } + if (errorMessage != null) { + customAction.setErrorMessage(errorMessage); + needUpdate = true; + } + if (timeout != null) { + customAction.setTimeout(timeout); + needUpdate = true; + } + if (enabled != null) { + customAction.setEnabled(enabled); + needUpdate = true; + } + + List parameters = null; + if (!Boolean.TRUE.equals(cleanupParameters) && MapUtils.isNotEmpty(parametersMap)) { + parameters = getParametersListFromMap(customAction.getName(), parametersMap); + } + + final boolean needUpdateFinal = needUpdate; + final List parametersFinal = parameters; + return Transaction.execute((TransactionCallbackWithException) status -> { + if (needUpdateFinal) { + boolean result = extensionCustomActionDao.update(id, customAction); + if (!result) { + throw new CloudRuntimeException(String.format("Failed to update custom action: %s", + customAction.getName())); + } + } + updatedCustomActionDetails(id, cleanupDetails, details, cleanupParameters, parametersFinal); + return customAction; + }); + } + + protected void updatedCustomActionDetails(long id, Boolean cleanupDetails, Map details, + Boolean cleanupParameters, List parametersFinal) { + final boolean needToUpdateAllDetails = Boolean.TRUE.equals(cleanupDetails) || MapUtils.isNotEmpty(details); + final boolean needToUpdateParameters = Boolean.TRUE.equals(cleanupParameters) || CollectionUtils.isNotEmpty(parametersFinal); + if (!needToUpdateAllDetails && !needToUpdateParameters) { + return; + } + if (needToUpdateAllDetails) { + Map hiddenDetails = + extensionCustomActionDetailsDao.listDetailsKeyPairs(id, false); + List detailsVOList = new ArrayList<>(); + if (Boolean.TRUE.equals(cleanupParameters)) { + hiddenDetails.remove(ApiConstants.PARAMETERS); + } else if (CollectionUtils.isNotEmpty(parametersFinal)) { + hiddenDetails.put(ApiConstants.PARAMETERS, + ExtensionCustomAction.Parameter.toJsonFromList(parametersFinal)); + } + if (MapUtils.isNotEmpty(hiddenDetails)) { + hiddenDetails.forEach((key, value) -> detailsVOList.add( + new ExtensionCustomActionDetailsVO(id, key, value, false))); + } + if (!Boolean.TRUE.equals(cleanupDetails) && MapUtils.isNotEmpty(details)) { + details.forEach((key, value) -> detailsVOList.add( + new ExtensionCustomActionDetailsVO(id, key, value))); + } + if (CollectionUtils.isNotEmpty(detailsVOList)) { + extensionCustomActionDetailsDao.saveDetails(detailsVOList); + } else if (Boolean.TRUE.equals(cleanupDetails)) { + extensionCustomActionDetailsDao.removeDetails(id); + } + } else { + if (Boolean.TRUE.equals(cleanupParameters)) { + extensionCustomActionDetailsDao.removeDetail(id, ApiConstants.PARAMETERS); + } else if (CollectionUtils.isNotEmpty(parametersFinal)) { + ExtensionCustomActionDetailsVO detailsVO = extensionCustomActionDetailsDao.findDetail(id, + ApiConstants.PARAMETERS); + if (detailsVO == null) { + extensionCustomActionDetailsDao.persist(new ExtensionCustomActionDetailsVO(id, + ApiConstants.PARAMETERS, + ExtensionCustomAction.Parameter.toJsonFromList(parametersFinal), false)); + } else { + detailsVO.setValue(ExtensionCustomAction.Parameter.toJsonFromList(parametersFinal)); + extensionCustomActionDetailsDao.update(detailsVO.getId(), detailsVO); + } + } + } + } + + @Override + public CustomActionResultResponse runCustomAction(RunCustomActionCmd cmd) { + final Long id = cmd.getCustomActionId(); + final String resourceTypeStr = cmd.getResourceType(); + final String resourceUuid = cmd.getResourceId(); + Map cmdParameters = cmd.getParameters(); + final Account caller = CallContext.current().getCallingAccount(); + + String error = "Internal error running action"; + ExtensionCustomActionVO customActionVO = extensionCustomActionDao.findById(id); + if (customActionVO == null) { + logger.error("Invalid custom action specified with ID: {}", id); + throw new InvalidParameterValueException(error); + } + final Role role = roleService.findRole(caller.getRoleId()); + if (!RoleType.Admin.equals(role.getRoleType())) { + final Set allowedRoles = RoleType.fromCombinedMask(customActionVO.getAllowedRoleTypes()); + if (!allowedRoles.contains(role.getRoleType())) { + logger.error("Caller does not have permission to run {} with {} having role: {}", + customActionVO, caller, role.getRoleType().name()); + throw new InvalidParameterValueException(error); + } + } + if (!customActionVO.isEnabled()) { + logger.error("Failed to run {} as it is not enabled", customActionVO); + throw new InvalidParameterValueException(error); + } + final String actionName = customActionVO.getName(); + RunCustomActionCommand runCustomActionCommand = new RunCustomActionCommand(actionName); + final long extensionId = customActionVO.getExtensionId(); + final ExtensionVO extensionVO = extensionDao.findById(extensionId); + if (extensionVO == null) { + logger.error("Unable to find extension for {}", customActionVO); + throw new CloudRuntimeException(error); + } + if (!Extension.State.Enabled.equals(extensionVO.getState())) { + logger.error("{} is not in enabled state for running {}", extensionVO, customActionVO); + throw new CloudRuntimeException(error); + } + ExtensionCustomAction.ResourceType actionResourceType = customActionVO.getResourceType(); + if (actionResourceType == null && StringUtils.isBlank(resourceTypeStr)) { + throw new InvalidParameterValueException("Resource type not specified for the action"); + } + boolean validType = true; + if (StringUtils.isNotBlank(resourceTypeStr)) { + ExtensionCustomAction.ResourceType cmdResourceType = + EnumUtils.getEnumIgnoreCase(ExtensionCustomAction.ResourceType.class, resourceTypeStr); + validType = cmdResourceType != null && (actionResourceType == null || actionResourceType.equals(cmdResourceType)); + actionResourceType = cmdResourceType; + } + if (!validType || actionResourceType == null) { + logger.error("Invalid resource type - {} specified for {}", resourceTypeStr, customActionVO); + throw new CloudRuntimeException(error); + } + Object entity = entityManager.findByUuid(actionResourceType.getAssociatedClass(), resourceUuid); + if (entity == null) { + logger.error("Specified resource does not exist for running {}", customActionVO); + throw new CloudRuntimeException(error); + } + Long clusterId = null; + Long hostId = null; + if (entity instanceof Cluster) { + clusterId = ((Cluster)entity).getId(); + List hosts = hostDao.listByClusterAndHypervisorType(clusterId, Hypervisor.HypervisorType.External); + if (CollectionUtils.isEmpty(hosts)) { + logger.error("No hosts found for {} for running {}", entity, customActionVO); + throw new CloudRuntimeException(error); + } + hostId = hosts.get(0).getId(); + } else if (entity instanceof Host) { + Host host = (Host)entity; + if (!Hypervisor.HypervisorType.External.equals(host.getHypervisorType())) { + logger.error("Invalid {} specified as host resource for running {}", entity, customActionVO); + throw new InvalidParameterValueException(error); + } + hostId = host.getId(); + clusterId = host.getClusterId(); + } else if (entity instanceof VirtualMachine) { + VirtualMachine virtualMachine = (VirtualMachine)entity; + runCustomActionCommand.setVmId(virtualMachine.getId()); + if (!Hypervisor.HypervisorType.External.equals(virtualMachine.getHypervisorType())) { + logger.error("Invalid {} specified as VM resource for running {}", entity, customActionVO); + throw new InvalidParameterValueException(error); + } + Pair clusterAndHostId = virtualMachineManager.findClusterAndHostIdForVm(virtualMachine, false); + clusterId = clusterAndHostId.first(); + hostId = clusterAndHostId.second(); + } + + if (clusterId == null || hostId == null) { + logger.error( + "Unable to find cluster or host with the specified resource - cluster ID: {}, host ID: {}", + clusterId, hostId); + throw new CloudRuntimeException(error); + } + + ExtensionResourceMapVO extensionResource = extensionResourceMapDao.findByResourceIdAndType(clusterId, + ExtensionResourceMap.ResourceType.Cluster); + if (extensionResource == null) { + logger.error("No extension registered with cluster ID: {}", clusterId); + throw new CloudRuntimeException(error); + } + + List actionParameters = null; + Pair, Map> allDetails = + extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(customActionVO.getId()); + if (allDetails.second().containsKey(ApiConstants.PARAMETERS)) { + actionParameters = + ExtensionCustomAction.Parameter.toListFromJson(allDetails.second().get(ApiConstants.PARAMETERS)); + } + Map parameters = null; + if (CollectionUtils.isNotEmpty(actionParameters)) { + parameters = ExtensionCustomAction.Parameter.validateParameterValues(actionParameters, cmdParameters); + } + + CustomActionResultResponse response = new CustomActionResultResponse(); + response.setId(customActionVO.getUuid()); + response.setName(actionName); + response.setObjectName("customactionresult"); + Map result = new HashMap<>(); + response.setSuccess(false); + result.put(ApiConstants.MESSAGE, getActionMessage(false, customActionVO, extensionVO, + actionResourceType, entity)); + Map> externalDetails = + getExternalAccessDetails(allDetails.first(), hostId, extensionResource); + runCustomActionCommand.setParameters(parameters); + runCustomActionCommand.setExternalDetails(externalDetails); + runCustomActionCommand.setWait(customActionVO.getTimeout()); + try { + logger.info("Running custom action: {} with {} parameters", actionName, + (parameters != null ? parameters.keySet().size() : 0)); + Answer answer = agentMgr.send(hostId, runCustomActionCommand); + if (!(answer instanceof RunCustomActionAnswer)) { + logger.error("Unexpected answer [{}] received for {}", answer.getClass().getSimpleName(), + RunCustomActionCommand.class.getSimpleName()); + result.put(ApiConstants.DETAILS, error); + } else { + RunCustomActionAnswer customActionAnswer = (RunCustomActionAnswer) answer; + response.setSuccess(answer.getResult()); + result.put(ApiConstants.MESSAGE, getActionMessage(answer.getResult(), customActionVO, extensionVO, + actionResourceType, entity)); + result.put(ApiConstants.DETAILS, customActionAnswer.getDetails()); + } + } catch (AgentUnavailableException e) { + String msg = "Unable to run custom action"; + logger.error("{} due to {}", msg, e.getMessage(), e); + result.put(ApiConstants.DETAILS, msg); + } catch (OperationTimedoutException e) { + String msg = "Running custom action timed out, please try again"; + logger.error(msg, e); + result.put(ApiConstants.DETAILS, msg); + } + response.setResult(result); + return response; + } + + @Override + public ExtensionCustomActionResponse createCustomActionResponse(ExtensionCustomAction customAction) { + ExtensionCustomActionResponse response = new ExtensionCustomActionResponse(customAction.getUuid(), + customAction.getName(), customAction.getDescription()); + if (customAction.getResourceType() != null) { + response.setResourceType(customAction.getResourceType().name()); + } + Integer roles = ObjectUtils.defaultIfNull(customAction.getAllowedRoleTypes(), RoleType.Admin.getMask()); + response.setAllowedRoleTypes(RoleType.fromCombinedMask(roles) + .stream() + .map(Enum::name) + .collect(Collectors.toList())); + response.setSuccessMessage(customAction.getSuccessMessage()); + response.setErrorMessage(customAction.getErrorMessage()); + response.setTimeout(customAction.getTimeout()); + response.setEnabled(customAction.isEnabled()); + response.setCreated(customAction.getCreated()); + Optional.ofNullable(extensionDao.findById(customAction.getExtensionId())).ifPresent(extensionVO -> { + response.setExtensionId(extensionVO.getUuid()); + response.setExtensionName(extensionVO.getName()); + }); + Pair, Map> allDetails = + extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(customAction.getId()); + Optional.ofNullable(allDetails.second().get(ApiConstants.PARAMETERS)) + .map(ExtensionCustomAction.Parameter::toListFromJson) + .ifPresent(parameters -> { + List paramResponses = parameters.stream() + .map(p -> new ExtensionCustomActionParameterResponse(p.getName(), + p.getType().name(), p.getValidationFormat().name(), p.getValueOptions(), p.isRequired())) + .collect(Collectors.toList()); + response.setParameters(paramResponses); + }); + response.setDetails(allDetails.first()); + response.setObjectName(ExtensionCustomAction.class.getSimpleName().toLowerCase()); + return response; + } + + @Override + public Map> getExternalAccessDetails(Host host, Map vmDetails) { + long clusterId = host.getClusterId(); + ExtensionResourceMapVO resourceMap = extensionResourceMapDao.findByResourceIdAndType(clusterId, + ExtensionResourceMap.ResourceType.Cluster); + Map> details = getExternalAccessDetails(null, host.getId(), resourceMap); + if (MapUtils.isNotEmpty(vmDetails)) { + details.put(ApiConstants.VIRTUAL_MACHINE, vmDetails); + } + return details; + } + + @Override + public String handleExtensionServerCommands(ExtensionServerActionBaseCommand command) { + final String extensionName = command.getExtensionName(); + final String extensionRelativePath = command.getExtensionRelativePath(); + logger.debug("Received {} from MS: {} for extension [id: {}, name: {}, relativePath: {}]", + command.getClass().getSimpleName(), command.getMsId(), command.getExtensionId(), + extensionName, extensionRelativePath); + Answer answer = new Answer(command, false, "Unsupported command"); + if (command instanceof GetExtensionPathChecksumCommand) { + final GetExtensionPathChecksumCommand cmd = (GetExtensionPathChecksumCommand)command; + String checksum = externalProvisioner.getChecksumForExtensionPath(extensionName, + extensionRelativePath); + answer = new Answer(cmd, StringUtils.isNotBlank(checksum), checksum); + } else if (command instanceof PrepareExtensionPathCommand) { + final PrepareExtensionPathCommand cmd = (PrepareExtensionPathCommand)command; + Pair result = prepareExtensionPathOnCurrentServer( + extensionName, cmd.isExtensionUserDefined(), extensionRelativePath); + answer = new Answer(cmd, result.first(), result.second()); + } else if (command instanceof CleanupExtensionFilesCommand) { + final CleanupExtensionFilesCommand cmd = (CleanupExtensionFilesCommand)command; + Pair result = cleanupExtensionFilesOnCurrentServer(extensionName, + extensionRelativePath); + answer = new Answer(cmd, result.first(), result.second()); + } + final Answer[] answers = new Answer[1]; + answers[0] = answer; + return GsonHelper.getGson().toJson(answers); + } + + @Override + public Long getExtensionIdForCluster(long clusterId) { + ExtensionResourceMapVO map = extensionResourceMapDao.findByResourceIdAndType(clusterId, + ExtensionResourceMap.ResourceType.Cluster); + if (map == null) { + return null; + } + return map.getExtensionId(); + } + + @Override + public Extension getExtension(long id) { + return extensionDao.findById(id); + } + + @Override + public Extension getExtensionForCluster(long clusterId) { + Long extensionId = getExtensionIdForCluster(clusterId); + if (extensionId == null) { + return null; + } + return extensionDao.findById(extensionId); + } + + @Override + public boolean start() { + long pathStateCheckInterval = PathStateCheckInterval.value(); + long pathStateCheckInitialDelay = Math.min(60, pathStateCheckInterval); + logger.debug("Scheduling extensions path state check task with initial delay={}s and interval={}s", + pathStateCheckInitialDelay, pathStateCheckInterval); + extensionPathStateCheckExecutor.scheduleWithFixedDelay(new PathStateCheckWorker(), + pathStateCheckInitialDelay, pathStateCheckInterval, TimeUnit.SECONDS); + return true; + } + + @Override + public boolean configure(String name, Map params) throws ConfigurationException { + try { + extensionPathStateCheckExecutor = Executors.newScheduledThreadPool(1, + new NamedThreadFactory("Extension-Path-State-Check")); + } catch (final Exception e) { + throw new ConfigurationException("Unable to to configure ExtensionsManagerImpl"); + } + return true; + } + + @Override + public List> getCommands() { + List> cmds = new ArrayList<>(); + cmds.add(AddCustomActionCmd.class); + cmds.add(ListCustomActionCmd.class); + cmds.add(DeleteCustomActionCmd.class); + cmds.add(UpdateCustomActionCmd.class); + cmds.add(RunCustomActionCmd.class); + + cmds.add(CreateExtensionCmd.class); + cmds.add(ListExtensionsCmd.class); + cmds.add(DeleteExtensionCmd.class); + cmds.add(UpdateExtensionCmd.class); + cmds.add(RegisterExtensionCmd.class); + cmds.add(UnregisterExtensionCmd.class); + return cmds; + } + + @Override + public String getConfigComponentName() { + return ExtensionsManager.class.getSimpleName(); + } + + @Override + public ConfigKey[] getConfigKeys() { + return new ConfigKey[]{ + PathStateCheckInterval + }; + } + + public class PathStateCheckWorker extends ManagedContextRunnable { + + protected void runCheckUsingLongestRunningManagementServer() { + try { + List msHosts = managementServerHostDao.listBy(ManagementServerHost.State.Up); + msHosts.sort(Comparator.comparingLong(ManagementServerHostVO::getRunid)); + ManagementServerHostVO msHost = msHosts.remove(0); + if (msHost == null || (msHost.getMsid() != ManagementServerNode.getManagementServerId())) { + logger.debug("Skipping the extensions path state check on this management server"); + return; + } + List extensions = extensionDao.listAll(); + for (ExtensionVO extension : extensions) { + checkExtensionPathState(extension, msHosts); + } + } catch (Exception e) { + logger.warn("Extensions path state check failed", e); + } + } + + @Override + protected void runInContext() { + GlobalLock gcLock = GlobalLock.getInternLock("ExtensionPathStateCheck"); + try { + if (gcLock.lock(3)) { + try { + runCheckUsingLongestRunningManagementServer(); + } finally { + gcLock.unlock(); + } + } + } finally { + gcLock.releaseRef(); + } + } + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionCustomActionDetailsVO.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionCustomActionDetailsVO.java new file mode 100644 index 00000000000..15a5af4f60c --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionCustomActionDetailsVO.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.framework.extensions.vo; + +import org.apache.cloudstack.api.ResourceDetail; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; + +@Entity +@Table(name = "extension_custom_action_details") +public class ExtensionCustomActionDetailsVO implements ResourceDetail { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "extension_custom_action_id", nullable = false) + private long resourceId; + + @Column(name = "name", nullable = false, length = 255) + private String name; + + @Column(name = "value", nullable = false, length = 65535) + private String value; + + @Column(name = "display") + private boolean display = true; + + public ExtensionCustomActionDetailsVO() { + } + + public ExtensionCustomActionDetailsVO(long resourceId, String name, String value) { + this.resourceId = resourceId; + this.name = name; + this.value = value; + } + + public ExtensionCustomActionDetailsVO(long id, String name, String value, boolean display) { + this.resourceId = id; + this.name = name; + this.value = value; + this.display = display; + } + + @Override + public long getId() { + return id; + } + + @Override + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public String getValue() { + return value; + } + + @Override + public boolean isDisplay() { + return display; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public long getResourceId() { + return resourceId; + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionCustomActionVO.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionCustomActionVO.java new file mode 100644 index 00000000000..c5ab288d853 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionCustomActionVO.java @@ -0,0 +1,215 @@ +// 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.vo; + +import org.apache.cloudstack.extension.ExtensionCustomAction; +import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; + +import com.cloud.utils.db.GenericDao; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import java.util.Date; +import java.util.UUID; + +@Entity +@Table(name = "extension_custom_action") +public class ExtensionCustomActionVO implements ExtensionCustomAction { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "uuid", nullable = false, unique = true) + private String uuid; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "description", length = 4096) + private String description; + + @Column(name = "extension_id", nullable = false) + private Long extensionId; + + @Column(name = "resource_type") + @Enumerated(value = EnumType.STRING) + private ResourceType resourceType; + + @Column(name = "allowed_role_types") + private Integer allowedRoleTypes; + + @Column(name = "success_message", length = 4096) + private String successMessage; + + @Column(name = "error_message", length = 4096) + private String errorMessage; + + @Column(name = "timeout", nullable = false) + private int timeout; + + @Column(name = "enabled") + private boolean enabled; + + @Column(name = "created", nullable = false, updatable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date created; + + @Column(name = GenericDao.REMOVED_COLUMN) + private Date removed; + + public ExtensionCustomActionVO() { + this.uuid = UUID.randomUUID().toString(); + this.created = new Date(); + } + + public ExtensionCustomActionVO(String name, String description, long extensionId, String successMessage, + String errorMessage, int timeout, boolean enabled) { + this.uuid = UUID.randomUUID().toString(); + this.created = new Date(); + this.name = name; + this.description = description; + this.extensionId = extensionId; + this.successMessage = successMessage; + this.errorMessage = errorMessage; + this.timeout = timeout; + this.enabled = enabled; + } + + @Override + public long getId() { + return id; + } + + @Override + public String getUuid() { + return uuid; + } + + public void setAllowedRoleTypes(int allowedRoleTypes) { + this.allowedRoleTypes = allowedRoleTypes; + } + + @Override + public Integer getAllowedRoleTypes() { + return allowedRoleTypes; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + @Override + public long getExtensionId() { + return extensionId; + } + + public void setExtensionId(Long extensionId) { + this.extensionId = extensionId; + } + + @Override + public ResourceType getResourceType() { + return resourceType; + } + + public void setResourceType(ResourceType resourceType) { + this.resourceType = resourceType; + } + + public String getSuccessMessage() { + return successMessage; + } + + public void setSuccessMessage(String successMessage) { + this.successMessage = successMessage; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + @Override + public int getTimeout() { + return timeout; + } + + public void setTimeout(int timeout) { + this.timeout = timeout; + } + + @Override + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public void setCreated(Date created) { + this.created = created; + } + + @Override + public Date getCreated() { + return created; + } + + public Date getRemoved() { + return removed; + } + + public void setRemoved(Date removed) { + this.removed = removed; + } + + @Override + public String toString() { + return String.format("Extension Custom Action %s", ReflectionToStringBuilderUtils.reflectOnlySelectedFields(this, "id", "uuid", "name", "extensionId", "resourceType")); + } + +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionDetailsVO.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionDetailsVO.java new file mode 100644 index 00000000000..535a0f70395 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionDetailsVO.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.framework.extensions.vo; + +import org.apache.cloudstack.api.ResourceDetail; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; + +@Entity +@Table(name = "extension_details") +public class ExtensionDetailsVO implements ResourceDetail { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "extension_id", nullable = false) + private long resourceId; + + @Column(name = "name", nullable = false, length = 255) + private String name; + + @Column(name = "value", nullable = false, length = 255) + private String value; + + @Column(name = "display") + private boolean display = true; + + public ExtensionDetailsVO() { + } + + public ExtensionDetailsVO(long resourceId, String name, String value) { + this.resourceId = resourceId; + this.name = name; + this.value = value; + } + + public ExtensionDetailsVO(long id, String name, String value, boolean display) { + this.resourceId = id; + this.name = name; + this.value = value; + this.display = display; + } + + @Override + public long getId() { + return id; + } + + @Override + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public String getValue() { + return value; + } + + @Override + public boolean isDisplay() { + return display; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public long getResourceId() { + return resourceId; + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionResourceMapDetailsVO.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionResourceMapDetailsVO.java new file mode 100644 index 00000000000..5cb6f7b8511 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionResourceMapDetailsVO.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.framework.extensions.vo; + +import org.apache.cloudstack.api.ResourceDetail; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; + +@Entity +@Table(name = "extension_resource_map_details") +public class ExtensionResourceMapDetailsVO implements ResourceDetail { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "extension_resource_map_id", nullable = false) + private long resourceId; + + @Column(name = "name", nullable = false, length = 255) + private String name; + + @Column(name = "value", nullable = false, length = 255) + private String value; + + @Column(name = "display") + private boolean display = true; + + public ExtensionResourceMapDetailsVO() { + } + + public ExtensionResourceMapDetailsVO(long resourceId, String name, String value) { + this.resourceId = resourceId; + this.name = name; + this.value = value; + } + + public ExtensionResourceMapDetailsVO(long id, String name, String value, boolean display) { + this.resourceId = id; + this.name = name; + this.value = value; + this.display = display; + } + + @Override + public long getId() { + return id; + } + + @Override + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public String getValue() { + return value; + } + + @Override + public boolean isDisplay() { + return display; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public long getResourceId() { + return resourceId; + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionResourceMapVO.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionResourceMapVO.java new file mode 100644 index 00000000000..48d70b937e3 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionResourceMapVO.java @@ -0,0 +1,122 @@ +// +// 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.vo; + +import org.apache.cloudstack.extension.ExtensionResourceMap; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import java.util.Date; + +@Entity +@Table(name = "extension_resource_map") +public class ExtensionResourceMapVO implements ExtensionResourceMap { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "extension_id", nullable = false) + private Long extensionId; + + @Column(name = "resource_id", nullable = false) + private Long resourceId; + + @Column(name = "resource_type", nullable = false) + @Enumerated(value = EnumType.STRING) + private ResourceType resourceType; + + @Column(name = "created", nullable = false, updatable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date created; + + @Column(name = "removed") + @Temporal(TemporalType.TIMESTAMP) + private Date removed; + + public ExtensionResourceMapVO() { + } + + public ExtensionResourceMapVO(long extensionId, long resourceId, ResourceType resourceType) { + this.extensionId = extensionId; + this.resourceId = resourceId; + this.resourceType = resourceType; + } + + @Override + public long getExtensionId() { + return extensionId; + } + + + @Override + public long getResourceId() { + return resourceId; + } + + @Override + public ResourceType getResourceType() { + return resourceType; + } + + @Override + public Date getCreated() { + return created; + } + + public Date getRemoved() { + return removed; + } + + public void setExtensionId(Long extensionId) { + this.extensionId = extensionId; + } + + public void setResourceId(Long resourceId) { + this.resourceId = resourceId; + } + + public void setResourceType(ResourceType resourceType) { + this.resourceType = resourceType; + } + + public void setRemoved(Date removed) { + this.removed = removed; + } + + @Override + public long getId() { + return id; + } + + @Override + public String getUuid() { + return null; + } +} diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionVO.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionVO.java new file mode 100644 index 00000000000..20423764c1c --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionVO.java @@ -0,0 +1,179 @@ +//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.vo; + +import java.util.Date; +import java.util.UUID; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; + +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; + +import com.cloud.utils.db.GenericDao; + +@Entity +@Table(name = "extension") +public class ExtensionVO implements Extension { + + public ExtensionVO() { + this.uuid = UUID.randomUUID().toString(); + this.created = new Date(); + } + + public ExtensionVO(String name, String description, Type type, String relativePath, State state) { + this.uuid = UUID.randomUUID().toString(); + this.name = name; + this.description = description; + this.type = type; + this.relativePath = relativePath; + this.userDefined = true; + this.pathReady = true; + this.state = state; + this.created = new Date(); + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "uuid", nullable = false, unique = true) + private String uuid; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "description", length = 4096) + private String description; + + @Column(name = "type", nullable = false) + @Enumerated(value = EnumType.STRING) + private Type type; + + @Column(name = "relative_path", nullable = false, length = 2048) + private String relativePath; + + @Column(name = "path_ready") + private boolean pathReady; + + @Column(name = "is_user_defined") + private boolean userDefined; + + @Column(name = "state") + @Enumerated(value = EnumType.STRING) + private State state; + + @Column(name = "created", nullable = false, updatable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date created; + + @Column(name = GenericDao.REMOVED_COLUMN) + private Date removed; + + @Override + public long getId() { + return id; + } + + public String getUuid() { + return uuid; + } + + @Override + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + @Override + public Type getType() { + return type; + } + + @Override + public String getRelativePath() { + return relativePath; + } + + public void setRelativePath(String relativePath) { + this.relativePath = relativePath; + } + + @Override + public boolean isPathReady() { + return pathReady; + } + + public void setPathReady(boolean pathReady) { + this.pathReady = pathReady; + } + + @Override + public boolean isUserDefined() { + return userDefined; + } + + @Override + public State getState() { + return state; + } + + public void setState(State state) { + this.state = state; + } + + @Override + public Date getCreated() { + return created; + } + + public Date getRemoved() { + return removed; + } + + public void setRemoved(Date removed) { + this.removed = removed; + } + + @Override + public String toString() { + return String.format("Extension %s", ReflectionToStringBuilderUtils.reflectOnlySelectedFields(this, "id", "uuid", "name", "type")); + } +} diff --git a/framework/extensions/src/main/resources/META-INF/cloudstack/core/spring-framework-extensions-core-context.xml b/framework/extensions/src/main/resources/META-INF/cloudstack/core/spring-framework-extensions-core-context.xml new file mode 100644 index 00000000000..9d44d8ff7f3 --- /dev/null +++ b/framework/extensions/src/main/resources/META-INF/cloudstack/core/spring-framework-extensions-core-context.xml @@ -0,0 +1,36 @@ + + + + + + + + + + diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/AddCustomActionCmdTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/AddCustomActionCmdTest.java new file mode 100644 index 00000000000..b25de85a69d --- /dev/null +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/AddCustomActionCmdTest.java @@ -0,0 +1,224 @@ +// 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.api; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.response.ExtensionCustomActionResponse; +import org.apache.cloudstack.extension.ExtensionCustomAction; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; +import org.apache.commons.collections.MapUtils; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.test.util.ReflectionTestUtils; + +import com.cloud.user.Account; + +@RunWith(MockitoJUnitRunner.class) +public class AddCustomActionCmdTest { + + private AddCustomActionCmd cmd; + private ExtensionsManager extensionsManager; + + @Before + public void setUp() throws Exception { + cmd = new AddCustomActionCmd(); + extensionsManager = mock(ExtensionsManager.class); + cmd.extensionsManager = extensionsManager; + } + + private void setField(String fieldName, Object value) { + ReflectionTestUtils.setField(cmd, fieldName, value); + } + + @Test + public void testGetters() { + Long extensionId = 42L; + String name = "actionName"; + String description = "desc"; + String resourceType = "VM"; + List allowedRoleTypes = Arrays.asList(RoleType.Admin.name(), RoleType.User.name()); + Map parameters = new HashMap<>(); + parameters.put("name", "param1"); + String successMessage = "Success!"; + String errorMessage = "Error!"; + Integer timeout = 10; + Boolean enabled = true; + Map> details = new HashMap<>(); + Map inner = new HashMap<>(); + inner.put("vendor", "acme"); + details.put("details", inner); + setField("details", details); + + setField("extensionId", extensionId); + setField("name", name); + setField("description", description); + setField("resourceType", resourceType); + setField("allowedRoleTypes", allowedRoleTypes); + setField("parameters", parameters); + setField("successMessage", successMessage); + setField("errorMessage", errorMessage); + setField("timeout", timeout); + setField("enabled", enabled); + setField("details", details); + + assertEquals(extensionId, cmd.getExtensionId()); + assertEquals(name, cmd.getName()); + assertEquals(description, cmd.getDescription()); + assertEquals(resourceType, cmd.getResourceType()); + assertEquals(allowedRoleTypes, cmd.getAllowedRoleTypes()); + assertEquals(parameters, cmd.getParametersMap()); + assertEquals(successMessage, cmd.getSuccessMessage()); + assertEquals(errorMessage, cmd.getErrorMessage()); + assertEquals(timeout, cmd.getTimeout()); + assertTrue(cmd.isEnabled()); + assertTrue(MapUtils.isNotEmpty(cmd.getDetails())); + } + + @Test + public void testIsEnabledReturnsFalseWhenNull() { + setField("enabled", null); + assertFalse(cmd.isEnabled()); + } + + @Test + public void testIsEnabledReturnsFalseWhenFalse() { + setField("enabled", Boolean.FALSE); + assertFalse(cmd.isEnabled()); + } + + @Test + public void testIsEnabledReturnsTrueWhenTrue() { + setField("enabled", Boolean.TRUE); + assertTrue(cmd.isEnabled()); + } + + @Test + public void testGetAllowedRoleTypesReturnsNullWhenUnset() { + setField("allowedRoleTypes", null); + assertNull(cmd.getAllowedRoleTypes()); + } + + @Test + public void testGetAllowedRoleTypesReturnsEmptyList() { + setField("allowedRoleTypes", Collections.emptyList()); + assertEquals(0, cmd.getAllowedRoleTypes().size()); + } + + @Test + public void testGetParametersMapReturnsNullWhenUnset() { + setField("parameters", null); + assertNull(cmd.getParametersMap()); + } + + @Test + public void testGetParametersMapReturnsMap() { + Map parameters = new HashMap<>(); + parameters.put("foo", "bar"); + setField("parameters", parameters); + assertEquals(parameters, cmd.getParametersMap()); + } + + @Test + public void testGetDetailsReturnsNullWhenUnset() { + setField("details", null); + assertTrue(MapUtils.isEmpty(cmd.getDetails())); + } + + @Test + public void testGetDetailsReturnsMap() { + Map> details = new HashMap<>(); + Map inner = new HashMap<>(); + inner.put("key", "value"); + details.put("details", inner); + setField("details", details); + assertTrue(MapUtils.isNotEmpty(cmd.getDetails())); + } + + @Test + public void testGetDescriptionReturnsNullWhenUnset() { + setField("description", null); + assertNull(cmd.getDescription()); + } + + @Test + public void testGetSuccessMessageReturnsNullWhenUnset() { + setField("successMessage", null); + assertNull(cmd.getSuccessMessage()); + } + + @Test + public void testGetErrorMessageReturnsNullWhenUnset() { + setField("errorMessage", null); + assertNull(cmd.getErrorMessage()); + } + + @Test + public void testGetTimeoutReturnsNullWhenUnset() { + setField("timeout", null); + assertNull(cmd.getTimeout()); + } + + @Test + public void testExecuteCallsExtensionsManagerAndSetsResponse() { + ExtensionCustomAction extensionCustomAction = mock(ExtensionCustomAction.class); + ExtensionCustomActionResponse response = mock(ExtensionCustomActionResponse.class); + + when(extensionsManager.addCustomAction(any(AddCustomActionCmd.class))).thenReturn(extensionCustomAction); + when(extensionsManager.createCustomActionResponse(extensionCustomAction)).thenReturn(response); + + AddCustomActionCmd spyCmd = spy(cmd); + doNothing().when(spyCmd).setResponseObject(any()); + + spyCmd.execute(); + + verify(extensionsManager).addCustomAction(spyCmd); + verify(extensionsManager).createCustomActionResponse(extensionCustomAction); + verify(response).setResponseName(spyCmd.getCommandName()); + verify(spyCmd).setResponseObject(response); + } + + @Test + public void testGetEntityOwnerIdReturnsSystemAccount() { + assertEquals(Account.ACCOUNT_ID_SYSTEM, cmd.getEntityOwnerId()); + } + + @Test + public void testGetApiResourceTypeReturnsExtensionCustomAction() { + assertEquals(ApiCommandResourceType.ExtensionCustomAction, cmd.getApiResourceType()); + } +} diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/CreateExtensionCmdTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/CreateExtensionCmdTest.java new file mode 100644 index 00000000000..2edb6ea48e3 --- /dev/null +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/CreateExtensionCmdTest.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.framework.extensions.api; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.springframework.test.util.ReflectionTestUtils.setField; + +import java.util.HashMap; +import java.util.Map; + +import org.apache.commons.collections.MapUtils; +import org.junit.Test; + +public class CreateExtensionCmdTest { + CreateExtensionCmd cmd = new CreateExtensionCmd(); + + @Test + public void testGetNameReturnsNullWhenUnset() { + assertNull(cmd.getName()); + } + + @Test + public void testGetNameReturnsValueWhenSet() { + String name = "name"; + setField(cmd, "name", name); + assertEquals(name, cmd.getName()); + } + + @Test + public void testGetTypeReturnsNullWhenUnset() { + setField(cmd, "type", null); + assertNull(cmd.getType()); + } + + @Test + public void testGetDescriptionReturnsValueWhenSet() { + String description = "description"; + setField(cmd, "description", description); + assertEquals(description, cmd.getDescription()); + } + + @Test + public void testGetPathReturnsValueWhenSet() { + String path = "/entry"; + setField(cmd, "path", path); + assertEquals(path, cmd.getPath()); + } + + @Test + public void testGetStateReturnsNullWhenUnset() { + setField(cmd, "state", null); + assertNull(cmd.getState()); + } + + @Test + public void testIsOrchestratorRequiresPrepareVm() { + assertNull(cmd.isOrchestratorRequiresPrepareVm()); + setField(cmd, "orchestratorRequiresPrepareVm", true); + assertTrue(cmd.isOrchestratorRequiresPrepareVm()); + setField(cmd, "orchestratorRequiresPrepareVm", false); + assertFalse(cmd.isOrchestratorRequiresPrepareVm()); + } + + @Test + public void testGetDetailsReturnsNullWhenUnset() { + setField(cmd, "details", null); + assertTrue(MapUtils.isEmpty(cmd.getDetails())); + } + + @Test + public void testGetDetailsReturnsMap() { + Map> details = new HashMap<>(); + Map inner = new HashMap<>(); + inner.put("key", "value"); + details.put("details", inner); + setField(cmd, "details", details); + assertTrue(MapUtils.isNotEmpty(cmd.getDetails())); + } +} diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/DeleteCustomActionCmdTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/DeleteCustomActionCmdTest.java new file mode 100644 index 00000000000..3a217d009fa --- /dev/null +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/DeleteCustomActionCmdTest.java @@ -0,0 +1,81 @@ +// 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.api; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.fail; + +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +public class DeleteCustomActionCmdTest { + + private DeleteCustomActionCmd cmd; + private ExtensionsManager extensionsManager; + + @Before + public void setUp() throws Exception { + cmd = Mockito.spy(new DeleteCustomActionCmd()); + extensionsManager = Mockito.mock(ExtensionsManager.class); + java.lang.reflect.Field field = DeleteCustomActionCmd.class.getDeclaredField("extensionsManager"); + field.setAccessible(true); + field.set(cmd, extensionsManager); + } + + @Test + public void getIdReturnsNullWhenUnset() throws Exception { + java.lang.reflect.Field field = DeleteCustomActionCmd.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(cmd, null); + assertNull(cmd.getId()); + } + + @Test + public void getIdReturnsValueWhenSet() throws Exception { + Long id = 12345L; + java.lang.reflect.Field field = DeleteCustomActionCmd.class.getDeclaredField("id"); + field.setAccessible(true); + field.set(cmd, id); + assertEquals(id, cmd.getId()); + } + + @Test + public void executeSetsSuccessResponseWhenManagerReturnsTrue() throws Exception { + Mockito.when(extensionsManager.deleteCustomAction(cmd)).thenReturn(true); + Mockito.doNothing().when(cmd).setResponseObject(Mockito.any()); + cmd.execute(); + Mockito.verify(extensionsManager).deleteCustomAction(cmd); + Mockito.verify(cmd).setResponseObject(Mockito.any(SuccessResponse.class)); + } + + @Test + public void executeThrowsServerApiExceptionWhenManagerReturnsFalse() throws Exception { + Mockito.when(extensionsManager.deleteCustomAction(cmd)).thenReturn(false); + try { + cmd.execute(); + fail("Expected ServerApiException"); + } catch (ServerApiException e) { + assertEquals("Failed to delete extension custom action", e.getDescription()); + } + } +} diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/DeleteExtensionCmdTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/DeleteExtensionCmdTest.java new file mode 100644 index 00000000000..9078a05dfda --- /dev/null +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/DeleteExtensionCmdTest.java @@ -0,0 +1,90 @@ +// 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.api; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +public class DeleteExtensionCmdTest { + + private DeleteExtensionCmd cmd; + private ExtensionsManager extensionsManager; + + @Before + public void setUp() { + cmd = Mockito.spy(new DeleteExtensionCmd()); + extensionsManager = Mockito.mock(ExtensionsManager.class); + cmd.extensionsManager = extensionsManager; + } + + @Test + public void getIdReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "id", null); + assertNull(cmd.getId()); + } + + @Test + public void getIdReturnsValueWhenSet() { + Long id = 12345L; + ReflectionTestUtils.setField(cmd, "id", id); + assertEquals(id, cmd.getId()); + } + + @Test + public void isCleanupReturnsFalseWhenUnset() { + ReflectionTestUtils.setField(cmd, "cleanup", null); + assertFalse(cmd.isCleanup()); + } + + @Test + public void isCleanupReturnsTrueWhenSetTrue() { + ReflectionTestUtils.setField(cmd, "cleanup", true); + assertTrue(cmd.isCleanup()); + } + + @Test + public void executeSetsSuccessResponseWhenManagerReturnsTrue() { + Mockito.when(extensionsManager.deleteExtension(cmd)).thenReturn(true); + Mockito.doNothing().when(cmd).setResponseObject(Mockito.any()); + cmd.execute(); + Mockito.verify(extensionsManager).deleteExtension(cmd); + Mockito.verify(cmd).setResponseObject(Mockito.any(SuccessResponse.class)); + } + + @Test + public void executeThrowsServerApiExceptionWhenManagerReturnsFalse() { + Mockito.when(extensionsManager.deleteExtension(cmd)).thenReturn(false); + try { + cmd.execute(); + fail("Expected ServerApiException"); + } catch (ServerApiException e) { + assertEquals("Failed to delete extension", e.getDescription()); + } + } +} diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/ListCustomActionCmdTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/ListCustomActionCmdTest.java new file mode 100644 index 00000000000..6648b840c1c --- /dev/null +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/ListCustomActionCmdTest.java @@ -0,0 +1,153 @@ +// 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.api; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.List; + +import org.apache.cloudstack.api.response.ExtensionCustomActionResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +public class ListCustomActionCmdTest { + + private ListCustomActionCmd cmd; + private ExtensionsManager extensionsManager; + + @Before + public void setUp() { + cmd = new ListCustomActionCmd(); + extensionsManager = mock(ExtensionsManager.class); + ReflectionTestUtils.setField(cmd, "extensionsManager", extensionsManager); + } + + private void setField(String fieldName, Object value) { + ReflectionTestUtils.setField(cmd, fieldName, value); + } + + @Test + public void getIdReturnsNullWhenUnset() { + setField("id", null); + assertNull(cmd.getId()); + } + + @Test + public void getIdReturnsValueWhenSet() { + Long id = 42L; + setField("id", id); + assertEquals(id, cmd.getId()); + } + + @Test + public void getNameReturnsNullWhenUnset() { + setField("name", null); + assertNull(cmd.getName()); + } + + @Test + public void getNameReturnsValueWhenSet() { + String name = "customAction"; + setField("name", name); + assertEquals(name, cmd.getName()); + } + + @Test + public void getExtensionIdReturnsNullWhenUnset() { + setField("extensionId", null); + assertNull(cmd.getExtensionId()); + } + + @Test + public void getExtensionIdReturnsValueWhenSet() { + Long extensionId = 99L; + setField("extensionId", extensionId); + assertEquals(extensionId, cmd.getExtensionId()); + } + + @Test + public void getResourceTypeReturnsNullWhenUnset() { + setField("resourceType", null); + assertNull(cmd.getResourceType()); + } + + @Test + public void getResourceTypeReturnsValueWhenSet() { + String resourceType = "VM"; + setField("resourceType", resourceType); + assertEquals(resourceType, cmd.getResourceType()); + } + + @Test + public void getResourceIdReturnsNullWhenUnset() { + setField("resourceId", null); + assertNull(cmd.getResourceId()); + } + + @Test + public void getResourceIdReturnsValueWhenSet() { + String resourceId = "abc-123"; + setField("resourceId", resourceId); + assertEquals(resourceId, cmd.getResourceId()); + } + + @Test + public void isEnabledReturnsNullWhenUnset() { + setField("enabled", null); + assertNull(cmd.isEnabled()); + } + + @Test + public void isEnabledReturnsTrueWhenSetTrue() { + setField("enabled", Boolean.TRUE); + assertTrue(cmd.isEnabled()); + } + + @Test + public void isEnabledReturnsFalseWhenSetFalse() { + setField("enabled", Boolean.FALSE); + assertFalse(cmd.isEnabled()); + } + + @Test + public void executeSetsListResponse() { + List responses = Arrays.asList(mock(ExtensionCustomActionResponse.class)); + when(extensionsManager.listCustomActions(cmd)).thenReturn(responses); + + ListCustomActionCmd spyCmd = Mockito.spy(cmd); + doNothing().when(spyCmd).setResponseObject(any()); + + spyCmd.execute(); + + verify(extensionsManager).listCustomActions(spyCmd); + verify(spyCmd).setResponseObject(any(ListResponse.class)); + } +} diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmdTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmdTest.java new file mode 100644 index 00000000000..1ca601293a3 --- /dev/null +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/ListExtensionsCmdTest.java @@ -0,0 +1,92 @@ +// 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.api; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; + +import org.apache.cloudstack.api.ApiConstants; +import org.junit.Before; +import org.junit.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import com.cloud.exception.InvalidParameterValueException; + +public class ListExtensionsCmdTest { + + private ListExtensionsCmd cmd; + + @Before + public void setUp() { + cmd = new ListExtensionsCmd(); + } + + private void setPrivateField(String fieldName, Object value) { + ReflectionTestUtils.setField(cmd, fieldName, value); + } + + @Test + public void testGetName() { + String testName = "testExtension"; + setPrivateField("name", testName); + assertEquals(testName, cmd.getName()); + } + + @Test + public void testGetExtensionId() { + Long testId = 123L; + setPrivateField("extensionId", testId); + assertEquals(testId, cmd.getExtensionId()); + } + + @Test + public void testGetDetailsReturnsAllWhenNull() { + setPrivateField("details", null); + EnumSet result = cmd.getDetails(); + assertEquals(EnumSet.of(ApiConstants.ExtensionDetails.all), result); + } + + @Test + public void testGetDetailsReturnsAllWhenEmpty() { + setPrivateField("details", Collections.emptyList()); + EnumSet result = cmd.getDetails(); + assertEquals(EnumSet.of(ApiConstants.ExtensionDetails.all), result); + } + + @Test + public void testGetDetailsWithValidValues() { + List detailsList = Arrays.asList("all", "resource"); + setPrivateField("details", detailsList); + EnumSet result = cmd.getDetails(); + assertTrue(result.contains(ApiConstants.ExtensionDetails.all)); + assertTrue(result.contains(ApiConstants.ExtensionDetails.resource)); + assertEquals(2, result.size()); + } + + @Test(expected = InvalidParameterValueException.class) + public void testGetDetailsWithInvalidValueThrowsException() { + List detailsList = Arrays.asList("invalidValue"); + setPrivateField("details", detailsList); + cmd.getDetails(); + } +} diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/RegisterExtensionCmdTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/RegisterExtensionCmdTest.java new file mode 100644 index 00000000000..b5281342d4c --- /dev/null +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/RegisterExtensionCmdTest.java @@ -0,0 +1,133 @@ +// 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.api; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.EnumSet; +import java.util.Map; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ExtensionResponse; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +public class RegisterExtensionCmdTest { + + private RegisterExtensionCmd cmd; + private ExtensionsManager extensionsManager; + + @Before + public void setUp() { + cmd = Mockito.spy(new RegisterExtensionCmd()); + extensionsManager = mock(ExtensionsManager.class); + ReflectionTestUtils.setField(cmd, "extensionsManager", extensionsManager); + } + + @Test + public void extensionIdReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "extensionId", null); + assertNull(cmd.getExtensionId()); + } + + @Test + public void extensionIdReturnsValueWhenSet() { + Long extensionId = 12345L; + ReflectionTestUtils.setField(cmd, "extensionId", extensionId); + assertEquals(extensionId, cmd.getExtensionId()); + } + + @Test + public void resourceIdReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "resourceId", null); + assertNull(cmd.getResourceId()); + } + + @Test + public void resourceIdReturnsValueWhenSet() { + String resourceId = "resource-123"; + ReflectionTestUtils.setField(cmd, "resourceId", resourceId); + assertEquals(resourceId, cmd.getResourceId()); + } + + @Test + public void resourceTypeReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "resourceType", null); + assertNull(cmd.getResourceType()); + } + + @Test + public void resourceTypeReturnsValueWhenSet() { + String resourceType = "VM"; + ReflectionTestUtils.setField(cmd, "resourceType", resourceType); + assertEquals(resourceType, cmd.getResourceType()); + } + + @Test + public void detailsReturnsEmptyMapWhenUnset() { + ReflectionTestUtils.setField(cmd, "details", null); + Map details = cmd.getDetails(); + assertNotNull(details); + assertTrue(details.isEmpty()); + } + + @Test + public void executeSetsExtensionResponseWhenManagerSucceeds() { + Extension extension = mock(Extension.class); + ExtensionResponse response = mock(ExtensionResponse.class); + when(extensionsManager.registerExtensionWithResource(cmd)).thenReturn(extension); + when(extensionsManager.createExtensionResponse(extension, EnumSet.of(ApiConstants.ExtensionDetails.all))) + .thenReturn(response); + + doNothing().when(cmd).setResponseObject(any()); + + cmd.execute(); + + verify(extensionsManager).registerExtensionWithResource(cmd); + verify(extensionsManager).createExtensionResponse(extension, EnumSet.of(ApiConstants.ExtensionDetails.all)); + verify(cmd).setResponseObject(response); + } + + @Test + public void executeThrowsServerApiExceptionWhenManagerFails() { + when(extensionsManager.registerExtensionWithResource(cmd)) + .thenThrow(new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to register extension")); + + try { + cmd.execute(); + fail("Expected ServerApiException"); + } catch (ServerApiException e) { + assertEquals("Failed to register extension", e.getDescription()); + } + } +} diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/RunCustomActionCmdTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/RunCustomActionCmdTest.java new file mode 100644 index 00000000000..0fb4f628d27 --- /dev/null +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/RunCustomActionCmdTest.java @@ -0,0 +1,127 @@ +// 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.api; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Map; + +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.extension.CustomActionResultResponse; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +public class RunCustomActionCmdTest { + + private RunCustomActionCmd cmd; + private ExtensionsManager extensionsManager; + + @Before + public void setUp() { + cmd = Mockito.spy(new RunCustomActionCmd()); + extensionsManager = mock(ExtensionsManager.class); + ReflectionTestUtils.setField(cmd, "extensionsManager", extensionsManager); + } + + @Test + public void customActionIdReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "customActionId", null); + assertNull(cmd.getCustomActionId()); + } + + @Test + public void customActionIdReturnsValueWhenSet() { + Long customActionId = 12345L; + ReflectionTestUtils.setField(cmd, "customActionId", customActionId); + assertEquals(customActionId, cmd.getCustomActionId()); + } + + @Test + public void resourceTypeReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "resourceType", null); + assertNull(cmd.getResourceType()); + } + + @Test + public void resourceTypeReturnsValueWhenSet() { + String resourceType = "VM"; + ReflectionTestUtils.setField(cmd, "resourceType", resourceType); + assertEquals(resourceType, cmd.getResourceType()); + } + + @Test + public void resourceIdReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "resourceId", null); + assertNull(cmd.getResourceId()); + } + + @Test + public void resourceIdReturnsValueWhenSet() { + String resourceId = "resource-123"; + ReflectionTestUtils.setField(cmd, "resourceId", resourceId); + assertEquals(resourceId, cmd.getResourceId()); + } + + @Test + public void parametersReturnsEmptyMapWhenUnset() { + ReflectionTestUtils.setField(cmd, "parameters", null); + Map parameters = cmd.getParameters(); + assertNotNull(parameters); + assertTrue(parameters.isEmpty()); + } + + @Test + public void executeSetsCustomActionResultResponseWhenManagerSucceeds() { + CustomActionResultResponse response = mock(CustomActionResultResponse.class); + when(extensionsManager.runCustomAction(cmd)).thenReturn(response); + + doNothing().when(cmd).setResponseObject(any()); + + cmd.execute(); + + verify(extensionsManager).runCustomAction(cmd); + verify(cmd).setResponseObject(response); + } + + @Test + public void executeThrowsServerApiExceptionWhenManagerFails() { + when(extensionsManager.runCustomAction(cmd)) + .thenThrow(new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to run custom action")); + + try { + cmd.execute(); + fail("Expected ServerApiException"); + } catch (ServerApiException e) { + assertEquals(ApiErrorCode.INTERNAL_ERROR, e.getErrorCode()); + assertEquals("Failed to run custom action", e.getDescription()); + } + } +} diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UnregisterExtensionCmdTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UnregisterExtensionCmdTest.java new file mode 100644 index 00000000000..f3c26f71f70 --- /dev/null +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UnregisterExtensionCmdTest.java @@ -0,0 +1,124 @@ +// 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.api; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.EnumSet; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ExtensionResponse; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +public class UnregisterExtensionCmdTest { + + private UnregisterExtensionCmd cmd; + private ExtensionsManager extensionsManager; + + @Before + public void setUp() { + cmd = Mockito.spy(new UnregisterExtensionCmd()); + extensionsManager = mock(ExtensionsManager.class); + ReflectionTestUtils.setField(cmd, "extensionsManager", extensionsManager); + } + + @Test + public void extensionIdReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "extensionId", null); + assertNull(cmd.getExtensionId()); + } + + @Test + public void extensionIdReturnsValueWhenSet() { + Long extensionId = 12345L; + ReflectionTestUtils.setField(cmd, "extensionId", extensionId); + assertEquals(extensionId, cmd.getExtensionId()); + } + + @Test + public void resourceIdReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "resourceId", null); + assertNull(cmd.getResourceId()); + } + + @Test + public void resourceIdReturnsValueWhenSet() { + String resourceId = "resource-123"; + ReflectionTestUtils.setField(cmd, "resourceId", resourceId); + assertEquals(resourceId, cmd.getResourceId()); + } + + @Test + public void resourceTypeReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "resourceType", null); + assertNull(cmd.getResourceType()); + } + + @Test + public void resourceTypeReturnsValueWhenSet() { + String resourceType = "VM"; + ReflectionTestUtils.setField(cmd, "resourceType", resourceType); + assertEquals(resourceType, cmd.getResourceType()); + } + + @Test + public void executeSetsExtensionResponseWhenManagerSucceeds() { + Extension extension = mock(Extension.class); + ExtensionResponse response = mock(ExtensionResponse.class); + when(extensionsManager.unregisterExtensionWithResource(cmd)).thenReturn(extension); + when(extensionsManager.createExtensionResponse(extension, EnumSet.of(ApiConstants.ExtensionDetails.all))) + .thenReturn(response); + + doNothing().when(cmd).setResponseObject(any()); + + cmd.execute(); + + verify(extensionsManager).unregisterExtensionWithResource(cmd); + verify(extensionsManager).createExtensionResponse(extension, EnumSet.of(ApiConstants.ExtensionDetails.all)); + verify(response).setResponseName(cmd.getCommandName()); + verify(cmd).setResponseObject(response); + } + + @Test + public void executeThrowsServerApiExceptionWhenManagerFails() { + when(extensionsManager.unregisterExtensionWithResource(cmd)) + .thenThrow(new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to unregister extension")); + + try { + cmd.execute(); + fail("Expected ServerApiException"); + } catch (ServerApiException e) { + assertEquals(ApiErrorCode.INTERNAL_ERROR, e.getErrorCode()); + assertEquals("Failed to unregister extension", e.getDescription()); + } + } +} diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UpdateCustomActionCmdTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UpdateCustomActionCmdTest.java new file mode 100644 index 00000000000..5ba17111c1c --- /dev/null +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UpdateCustomActionCmdTest.java @@ -0,0 +1,240 @@ +// 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.api; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ExtensionCustomActionResponse; +import org.apache.cloudstack.extension.ExtensionCustomAction; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; +import org.apache.commons.collections.MapUtils; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +public class UpdateCustomActionCmdTest { + + private UpdateCustomActionCmd cmd; + private ExtensionsManager extensionsManager; + + @Before + public void setUp() { + cmd = Mockito.spy(new UpdateCustomActionCmd()); + extensionsManager = mock(ExtensionsManager.class); + ReflectionTestUtils.setField(cmd, "extensionsManager", extensionsManager); + } + + @Test + public void idReturnsValueWhenSet() { + long id = 12345L; + ReflectionTestUtils.setField(cmd, "id", id); + assertEquals(id, cmd.getId()); + } + + @Test + public void descriptionReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "description", null); + assertNull(cmd.getDescription()); + } + + @Test + public void descriptionReturnsValueWhenSet() { + String description = "Custom action description"; + ReflectionTestUtils.setField(cmd, "description", description); + assertEquals(description, cmd.getDescription()); + } + + @Test + public void resourceTypeReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "resourceType", null); + assertNull(cmd.getResourceType()); + } + + @Test + public void resourceTypeReturnsValueWhenSet() { + String resourceType = "VM"; + ReflectionTestUtils.setField(cmd, "resourceType", resourceType); + assertEquals(resourceType, cmd.getResourceType()); + } + + @Test + public void allowedRoleTypesReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "allowedRoleTypes", null); + assertNull(cmd.getAllowedRoleTypes()); + } + + @Test + public void allowedRoleTypesReturnsValueWhenSet() { + List roles = Arrays.asList("Admin", "User"); + ReflectionTestUtils.setField(cmd, "allowedRoleTypes", roles); + assertEquals(roles, cmd.getAllowedRoleTypes()); + } + + @Test + public void parametersMapReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "parameters", null); + assertNull(cmd.getParametersMap()); + } + + @Test + public void parametersMapReturnsValueWhenSet() { + Map params = new HashMap<>(); + params.put("name", "param1"); + ReflectionTestUtils.setField(cmd, "parameters", params); + assertEquals(params, cmd.getParametersMap()); + } + + @Test + public void isCleanupParametersReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "cleanupParameters", null); + assertNull(cmd.isCleanupParameters()); + } + + @Test + public void isCleanupParametersReturnsValueWhenSet() { + ReflectionTestUtils.setField(cmd, "cleanupParameters", Boolean.TRUE); + assertTrue(cmd.isCleanupParameters()); + } + + @Test + public void successMessageReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "successMessage", null); + assertNull(cmd.getSuccessMessage()); + } + + @Test + public void successMessageReturnsValueWhenSet() { + String msg = "Success!"; + ReflectionTestUtils.setField(cmd, "successMessage", msg); + assertEquals(msg, cmd.getSuccessMessage()); + } + + @Test + public void errorMessageReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "errorMessage", null); + assertNull(cmd.getErrorMessage()); + } + + @Test + public void errorMessageReturnsValueWhenSet() { + String msg = "Error!"; + ReflectionTestUtils.setField(cmd, "errorMessage", msg); + assertEquals(msg, cmd.getErrorMessage()); + } + + @Test + public void timeoutReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "timeout", null); + assertNull(cmd.getTimeout()); + } + + @Test + public void timeoutReturnsValueWhenSet() { + Integer timeout = 10; + ReflectionTestUtils.setField(cmd, "timeout", timeout); + assertEquals(timeout, cmd.getTimeout()); + } + + @Test + public void isEnabledReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "enabled", null); + assertNull(cmd.isEnabled()); + } + + @Test + public void isEnabledReturnsValueWhenSet() { + ReflectionTestUtils.setField(cmd, "enabled", Boolean.FALSE); + assertFalse(cmd.isEnabled()); + } + + @Test + public void detailsReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "details", null); + assertTrue(MapUtils.isEmpty(cmd.getDetails())); + } + + @Test + public void detailsReturnsValueWhenSet() { + Map> details = new HashMap<>(); + Map inner = new HashMap<>(); + inner.put("vendor", "acme"); + details.put("details", inner); + ReflectionTestUtils.setField(cmd, "details", details); + assertTrue(MapUtils.isNotEmpty(cmd.getDetails())); + } + + @Test + public void isCleanupDetailsReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "cleanupDetails", null); + assertNull(cmd.isCleanupDetails()); + } + + @Test + public void isCleanupDetailsReturnsValueWhenSet() { + ReflectionTestUtils.setField(cmd, "cleanupDetails", Boolean.TRUE); + assertTrue(cmd.isCleanupDetails()); + } + + @Test + public void executeSetsCustomActionResponseWhenManagerSucceeds() { + ExtensionCustomAction customAction = mock(ExtensionCustomAction.class); + ExtensionCustomActionResponse response = mock(ExtensionCustomActionResponse.class); + when(extensionsManager.updateCustomAction(cmd)).thenReturn(customAction); + when(extensionsManager.createCustomActionResponse(customAction)).thenReturn(response); + + doNothing().when(cmd).setResponseObject(any()); + + cmd.execute(); + + verify(extensionsManager).updateCustomAction(cmd); + verify(extensionsManager).createCustomActionResponse(customAction); + verify(response).setResponseName(cmd.getCommandName()); + verify(cmd).setResponseObject(response); + } + + @Test + public void executeThrowsServerApiExceptionWhenManagerFails() { + when(extensionsManager.updateCustomAction(cmd)) + .thenThrow(new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to update custom action")); + + try { + cmd.execute(); + fail("Expected ServerApiException"); + } catch (ServerApiException e) { + assertEquals(ApiErrorCode.INTERNAL_ERROR, e.getErrorCode()); + assertEquals("Failed to update custom action", e.getDescription()); + } + } +} diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UpdateExtensionCmdTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UpdateExtensionCmdTest.java new file mode 100644 index 00000000000..f0a3a6fcf21 --- /dev/null +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/api/UpdateExtensionCmdTest.java @@ -0,0 +1,168 @@ +// 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.api; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Map; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ExtensionResponse; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; +import org.apache.commons.collections.MapUtils; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +public class UpdateExtensionCmdTest { + + private UpdateExtensionCmd cmd; + private ExtensionsManager extensionsManager; + + @Before + public void setUp() { + cmd = Mockito.spy(new UpdateExtensionCmd()); + extensionsManager = mock(ExtensionsManager.class); + ReflectionTestUtils.setField(cmd, "extensionsManager", extensionsManager); + } + + @Test + public void idReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "id", null); + assertNull(cmd.getId()); + } + + @Test + public void idReturnsValueWhenSet() { + Long id = 12345L; + ReflectionTestUtils.setField(cmd, "id", id); + assertEquals(id, cmd.getId()); + } + + @Test + public void descriptionReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "description", null); + assertNull(cmd.getDescription()); + } + + @Test + public void descriptionReturnsValueWhenSet() { + String description = "Extension description"; + ReflectionTestUtils.setField(cmd, "description", description); + assertEquals(description, cmd.getDescription()); + } + + @Test + public void orchestratorRequiresPrepareVmReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "orchestratorRequiresPrepareVm", null); + assertNull(cmd.isOrchestratorRequiresPrepareVm()); + } + + @Test + public void orchestratorRequiresPrepareVmReturnsValueWhenSet() { + ReflectionTestUtils.setField(cmd, "orchestratorRequiresPrepareVm", Boolean.TRUE); + assertTrue(cmd.isOrchestratorRequiresPrepareVm()); + } + + @Test + public void stateReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "state", null); + assertNull(cmd.getState()); + } + + @Test + public void stateReturnsValueWhenSet() { + String state = "Active"; + ReflectionTestUtils.setField(cmd, "state", state); + assertEquals(state, cmd.getState()); + } + + @Test + public void detailsReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "details", null); + assertTrue(MapUtils.isEmpty(cmd.getDetails())); + } + + @Test + public void detailsReturnsValueWhenSet() { + Map> details = new HashMap<>(); + Map inner = new HashMap<>(); + inner.put("vendor", "acme"); + details.put("details", inner); + ReflectionTestUtils.setField(cmd, "details", details); + assertTrue(MapUtils.isNotEmpty(cmd.getDetails())); + } + + @Test + public void isCleanupDetailsReturnsNullWhenUnset() { + ReflectionTestUtils.setField(cmd, "cleanupDetails", null); + assertNull(cmd.isCleanupDetails()); + } + + @Test + public void isCleanupDetailsReturnsValueWhenSet() { + ReflectionTestUtils.setField(cmd, "cleanupDetails", Boolean.TRUE); + assertTrue(cmd.isCleanupDetails()); + } + + @Test + public void executeSetsExtensionResponseWhenManagerSucceeds() { + Extension extension = mock(Extension.class); + ExtensionResponse response = mock(ExtensionResponse.class); + when(extensionsManager.updateExtension(cmd)).thenReturn(extension); + when(extensionsManager.createExtensionResponse(extension, EnumSet.of(ApiConstants.ExtensionDetails.all))) + .thenReturn(response); + + doNothing().when(cmd).setResponseObject(any()); + + cmd.execute(); + + verify(extensionsManager).updateExtension(cmd); + verify(extensionsManager).createExtensionResponse(extension, EnumSet.of(ApiConstants.ExtensionDetails.all)); + verify(response).setResponseName(cmd.getCommandName()); + verify(cmd).setResponseObject(response); + } + + @Test + public void executeThrowsServerApiExceptionWhenManagerFails() { + when(extensionsManager.updateExtension(cmd)) + .thenThrow(new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to update extension")); + + try { + cmd.execute(); + fail("Expected ServerApiException"); + } catch (ServerApiException e) { + assertEquals(ApiErrorCode.INTERNAL_ERROR, e.getErrorCode()); + assertEquals("Failed to update extension", e.getDescription()); + } + } +} diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/command/ExtensionBaseCommandTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/command/ExtensionBaseCommandTest.java new file mode 100644 index 00000000000..ba95cc2da97 --- /dev/null +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/command/ExtensionBaseCommandTest.java @@ -0,0 +1,78 @@ +// 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.command; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.extension.Extension; +import org.junit.Test; + +public class ExtensionBaseCommandTest { + + @Test + public void extensionIdReturnsCorrectValue() { + Extension extension = mock(Extension.class); + when(extension.getId()).thenReturn(12345L); + ExtensionBaseCommand command = new ExtensionBaseCommand(extension); + assertEquals(12345L, command.getExtensionId()); + } + + @Test + public void extensionNameReturnsCorrectValue() { + Extension extension = mock(Extension.class); + when(extension.getName()).thenReturn("TestExtension"); + ExtensionBaseCommand command = new ExtensionBaseCommand(extension); + assertEquals("TestExtension", command.getExtensionName()); + } + + @Test + public void extensionUserDefinedReturnsTrueWhenSet() { + Extension extension = mock(Extension.class); + when(extension.isUserDefined()).thenReturn(true); + ExtensionBaseCommand command = new ExtensionBaseCommand(extension); + assertTrue(command.isExtensionUserDefined()); + } + + @Test + public void extensionRelativePathReturnsCorrectValue() { + Extension extension = mock(Extension.class); + when(extension.getRelativePath()).thenReturn("/entry/point"); + ExtensionBaseCommand command = new ExtensionBaseCommand(extension); + assertEquals("/entry/point", command.getExtensionRelativePath()); + } + + @Test + public void extensionStateReturnsCorrectValue() { + Extension extension = mock(Extension.class); + Extension.State state = Extension.State.Enabled; + when(extension.getState()).thenReturn(state); + ExtensionBaseCommand command = new ExtensionBaseCommand(extension); + assertEquals(state, command.getExtensionState()); + } + + @Test + public void executeInSequenceReturnsFalse() { + Extension extension = mock(Extension.class); + ExtensionBaseCommand command = new ExtensionBaseCommand(extension); + assertFalse(command.executeInSequence()); + } +} diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/dao/ExtensionCustomActionDaoImplTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/dao/ExtensionCustomActionDaoImplTest.java new file mode 100644 index 00000000000..dccd1ebf1ed --- /dev/null +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/dao/ExtensionCustomActionDaoImplTest.java @@ -0,0 +1,68 @@ +// 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.dao; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.apache.cloudstack.framework.extensions.vo.ExtensionCustomActionVO; +import org.junit.Before; +import org.junit.Test; + +public class ExtensionCustomActionDaoImplTest { + + private ExtensionCustomActionDaoImpl dao; + + @Before + public void setUp() { + dao = mock(ExtensionCustomActionDaoImpl.class); + } + + @Test + public void findByNameAndExtensionIdReturnsNullWhenNoMatch() { + when(dao.findByNameAndExtensionId(1L, "nonexistent")).thenReturn(null); + assertNull(dao.findByNameAndExtensionId(1L, "nonexistent")); + } + + @Test + public void findByNameAndExtensionIdReturnsCorrectEntity() { + ExtensionCustomActionVO expected = new ExtensionCustomActionVO(); + expected.setName("actionName"); + expected.setExtensionId(1L); + when(dao.findByNameAndExtensionId(1L, "actionName")).thenReturn(expected); + assertEquals(expected, dao.findByNameAndExtensionId(1L, "actionName")); + } + + @Test + public void listIdsByExtensionIdReturnsEmptyListWhenNoMatch() { + when(dao.listIdsByExtensionId(999L)).thenReturn(List.of()); + assertTrue(dao.listIdsByExtensionId(999L).isEmpty()); + } + + @Test + public void listIdsByExtensionIdReturnsCorrectIds() { + List expectedIds = List.of(1L, 2L, 3L); + when(dao.listIdsByExtensionId(1L)).thenReturn(expectedIds); + assertEquals(expectedIds, dao.listIdsByExtensionId(1L)); + } +} diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDaoImplTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDaoImplTest.java new file mode 100644 index 00000000000..545feba0b3d --- /dev/null +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/dao/ExtensionDaoImplTest.java @@ -0,0 +1,51 @@ +// 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.dao; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.apache.cloudstack.framework.extensions.vo.ExtensionVO; +import org.junit.Before; +import org.junit.Test; + +public class ExtensionDaoImplTest { + + private ExtensionDaoImpl dao; + + @Before + public void setUp() { + dao = mock(ExtensionDaoImpl.class); + } + + @Test + public void findByNameReturnsNullWhenNoMatch() { + when(dao.findByName("nonexistent")).thenReturn(null); + assertNull(dao.findByName("nonexistent")); + } + + @Test + public void findByNameReturnsCorrectEntity() { + ExtensionVO expected = new ExtensionVO(); + expected.setName("extensionName"); + when(dao.findByName("extensionName")).thenReturn(expected); + assertEquals(expected, dao.findByName("extensionName")); + } +} diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImplTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImplTest.java new file mode 100644 index 00000000000..76a0175e757 --- /dev/null +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/dao/ExtensionResourceMapDaoImplTest.java @@ -0,0 +1,86 @@ +// 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.dao; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.apache.cloudstack.extension.ExtensionResourceMap; +import org.apache.cloudstack.framework.extensions.vo.ExtensionResourceMapVO; +import org.junit.Before; +import org.junit.Test; + +public class ExtensionResourceMapDaoImplTest { + + private ExtensionResourceMapDaoImpl dao; + + @Before + public void setUp() { + dao = mock(ExtensionResourceMapDaoImpl.class); + } + + @Test + public void listByExtensionIdReturnsEmptyListWhenNoMatch() { + when(dao.listByExtensionId(999L)).thenReturn(List.of()); + assertTrue(dao.listByExtensionId(999L).isEmpty()); + } + + @Test + public void listByExtensionIdReturnsCorrectEntities() { + ExtensionResourceMapVO entity1 = new ExtensionResourceMapVO(); + entity1.setExtensionId(1L); + ExtensionResourceMapVO entity2 = new ExtensionResourceMapVO(); + entity2.setExtensionId(1L); + List expected = List.of(entity1, entity2); + when(dao.listByExtensionId(1L)).thenReturn(expected); + assertEquals(expected, dao.listByExtensionId(1L)); + } + + @Test + public void findByResourceIdAndTypeReturnsNullWhenNoMatch() { + when(dao.findByResourceIdAndType(999L, ExtensionResourceMap.ResourceType.Cluster)).thenReturn(null); + assertNull(dao.findByResourceIdAndType(999L, ExtensionResourceMap.ResourceType.Cluster)); + } + + @Test + public void findByResourceIdAndTypeReturnsCorrectEntity() { + ExtensionResourceMapVO expected = new ExtensionResourceMapVO(); + expected.setResourceId(123L); + expected.setResourceType(ExtensionResourceMap.ResourceType.Cluster); + when(dao.findByResourceIdAndType(123L, ExtensionResourceMap.ResourceType.Cluster)).thenReturn(expected); + assertEquals(expected, dao.findByResourceIdAndType(123L, ExtensionResourceMap.ResourceType.Cluster)); + } + + @Test + public void listResourceIdsByExtensionIdAndTypeReturnsEmptyListWhenNoMatch() { + when(dao.listResourceIdsByExtensionIdAndType(999L, ExtensionResourceMap.ResourceType.Cluster)).thenReturn(List.of()); + assertTrue(dao.listResourceIdsByExtensionIdAndType(999L, ExtensionResourceMap.ResourceType.Cluster).isEmpty()); + } + + @Test + public void listResourceIdsByExtensionIdAndTypeReturnsCorrectIds() { + List expectedIds = List.of(1L, 2L, 3L); + when(dao.listResourceIdsByExtensionIdAndType(1L, ExtensionResourceMap.ResourceType.Cluster)).thenReturn(expectedIds); + assertEquals(expectedIds, dao.listResourceIdsByExtensionIdAndType(1L, ExtensionResourceMap.ResourceType.Cluster)); + } +} diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java new file mode 100644 index 00000000000..00bf915831b --- /dev/null +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java @@ -0,0 +1,1809 @@ +// 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.manager; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyBoolean; +import static org.mockito.Mockito.anyLong; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.security.InvalidParameterException; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.apache.cloudstack.acl.Role; +import org.apache.cloudstack.acl.RoleService; +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.response.ExtensionCustomActionResponse; +import org.apache.cloudstack.api.response.ExtensionResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.extension.CustomActionResultResponse; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.extension.ExtensionCustomAction; +import org.apache.cloudstack.extension.ExtensionResourceMap; +import org.apache.cloudstack.framework.extensions.api.AddCustomActionCmd; +import org.apache.cloudstack.framework.extensions.api.CreateExtensionCmd; +import org.apache.cloudstack.framework.extensions.api.DeleteCustomActionCmd; +import org.apache.cloudstack.framework.extensions.api.DeleteExtensionCmd; +import org.apache.cloudstack.framework.extensions.api.ListCustomActionCmd; +import org.apache.cloudstack.framework.extensions.api.ListExtensionsCmd; +import org.apache.cloudstack.framework.extensions.api.RegisterExtensionCmd; +import org.apache.cloudstack.framework.extensions.api.RunCustomActionCmd; +import org.apache.cloudstack.framework.extensions.api.UnregisterExtensionCmd; +import org.apache.cloudstack.framework.extensions.api.UpdateCustomActionCmd; +import org.apache.cloudstack.framework.extensions.api.UpdateExtensionCmd; +import org.apache.cloudstack.framework.extensions.command.CleanupExtensionFilesCommand; +import org.apache.cloudstack.framework.extensions.command.ExtensionServerActionBaseCommand; +import org.apache.cloudstack.framework.extensions.command.GetExtensionPathChecksumCommand; +import org.apache.cloudstack.framework.extensions.command.PrepareExtensionPathCommand; +import org.apache.cloudstack.framework.extensions.dao.ExtensionCustomActionDao; +import org.apache.cloudstack.framework.extensions.dao.ExtensionCustomActionDetailsDao; +import org.apache.cloudstack.framework.extensions.dao.ExtensionDao; +import org.apache.cloudstack.framework.extensions.dao.ExtensionDetailsDao; +import org.apache.cloudstack.framework.extensions.dao.ExtensionResourceMapDao; +import org.apache.cloudstack.framework.extensions.dao.ExtensionResourceMapDetailsDao; +import org.apache.cloudstack.framework.extensions.vo.ExtensionCustomActionDetailsVO; +import org.apache.cloudstack.framework.extensions.vo.ExtensionCustomActionVO; +import org.apache.cloudstack.framework.extensions.vo.ExtensionResourceMapVO; +import org.apache.cloudstack.framework.extensions.vo.ExtensionVO; +import org.apache.cloudstack.utils.identity.ManagementServerNode; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.test.util.ReflectionTestUtils; + +import com.cloud.agent.AgentManager; +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.Command; +import com.cloud.agent.api.RunCustomActionAnswer; +import com.cloud.alert.AlertManager; +import com.cloud.cluster.ClusterManager; +import com.cloud.cluster.ManagementServerHostVO; +import com.cloud.cluster.dao.ManagementServerHostDao; +import com.cloud.dc.ClusterVO; +import com.cloud.dc.dao.ClusterDao; +import com.cloud.exception.AgentUnavailableException; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.OperationTimedoutException; +import com.cloud.host.dao.HostDao; +import com.cloud.host.dao.HostDetailsDao; +import com.cloud.hypervisor.ExternalProvisioner; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.org.Cluster; +import com.cloud.serializer.GsonHelper; +import com.cloud.storage.dao.VMTemplateDao; +import com.cloud.user.Account; +import com.cloud.utils.Pair; +import com.cloud.utils.db.EntityManager; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.VirtualMachineManager; +import com.cloud.vm.VmDetailConstants; +import com.cloud.vm.dao.VMInstanceDao; + +@RunWith(MockitoJUnitRunner.class) +public class ExtensionsManagerImplTest { + + @Spy + @InjectMocks + private ExtensionsManagerImpl extensionsManager; + + @Mock + private ExtensionDao extensionDao; + @Mock + private ExtensionDetailsDao extensionDetailsDao; + @Mock + private ExtensionResourceMapDao extensionResourceMapDao; + @Mock + private ExtensionResourceMapDetailsDao extensionResourceMapDetailsDao; + @Mock + private ClusterDao clusterDao; + @Mock + private AgentManager agentMgr; + @Mock + private HostDao hostDao; + @Mock + private HostDetailsDao hostDetailsDao; + @Mock + private ExternalProvisioner externalProvisioner; + @Mock + private ExtensionCustomActionDao extensionCustomActionDao; + @Mock + private ExtensionCustomActionDetailsDao extensionCustomActionDetailsDao; + @Mock + private VMInstanceDao vmInstanceDao; + @Mock + private VirtualMachineManager virtualMachineManager; + @Mock + private EntityManager entityManager; + @Mock + private ManagementServerHostDao managementServerHostDao; + @Mock + private ClusterManager clusterManager; + @Mock + private AlertManager alertManager; + @Mock + private VMTemplateDao templateDao; + @Mock + private RoleService roleService; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + public void getDefaultExtensionRelativePathReturnsExpectedPath() { + String name = "testExtension"; + String expected = Extension.getDirectoryName(name) + File.separator + Extension.getDirectoryName(name) + ".sh"; + String result = extensionsManager.getDefaultExtensionRelativePath(name); + assertEquals(expected, result); + } + + @Test + public void getValidatedExtensionRelativePathReturnsNormalizedPath() { + String name = "ext"; + String path = "ext/entry.sh"; + String result = extensionsManager.getValidatedExtensionRelativePath(name, path); + assertTrue(result.startsWith("ext/")); + } + + @Test(expected = InvalidParameterException.class) + public void getValidatedExtensionRelativePathThrowsForDeepPath() { + String name = "ext"; + String path = "ext/a/b/c/entry.sh"; + extensionsManager.getValidatedExtensionRelativePath(name, path); + } + + @Test + public void getResultFromAnswersStringReturnsSuccess() { + Extension ext = mock(Extension.class); + Answer[] answers = new Answer[]{new Answer(mock(PrepareExtensionPathCommand.class), true, "ok")}; + String json = GsonHelper.getGson().toJson(answers); + ManagementServerHostVO msHost = mock(ManagementServerHostVO.class); + Pair result = extensionsManager.getResultFromAnswersString(json, ext, msHost, "op"); + assertTrue(result.first()); + assertEquals("ok", result.second()); + } + + @Test + public void getResultFromAnswersStringReturnsFailure() { + Extension ext = mock(Extension.class); + Answer[] answers = new Answer[]{new Answer(mock(PrepareExtensionPathCommand.class), false, "fail")}; + String json = GsonHelper.getGson().toJson(answers); + ManagementServerHostVO msHost = mock(ManagementServerHostVO.class); + Pair result = extensionsManager.getResultFromAnswersString(json, ext, msHost, "op"); + assertFalse(result.first()); + assertEquals("fail", result.second()); + } + + @Test + public void prepareExtensionPathOnMSPeerReturnsTrueOnSuccess() { + Extension ext = mock(Extension.class); + ManagementServerHostVO msHost = mock(ManagementServerHostVO.class); + when(msHost.getMsid()).thenReturn(1L); + when(clusterManager.execute(anyString(), anyLong(), anyString(), eq(true))) + .thenReturn("answer"); + doReturn(new Pair<>(true, "ok")).when(extensionsManager).getResultFromAnswersString(anyString(), eq(ext), eq(msHost), anyString()); + assertTrue(extensionsManager.prepareExtensionPathOnMSPeer(ext, msHost)); + } + + @Test + public void prepareExtensionPathOnCurrentServerReturnsSuccess() { + doNothing().when(externalProvisioner).prepareExtensionPath(anyString(), anyBoolean(), anyString()); + Pair result = extensionsManager.prepareExtensionPathOnCurrentServer("name", true, "entry"); + assertTrue(result.first()); + assertNull(result.second()); + } + + @Test + public void cleanupExtensionFilesOnMSPeerReturnsTrueOnSuccess() { + Extension ext = mock(Extension.class); + ManagementServerHostVO msHost = mock(ManagementServerHostVO.class); + when(msHost.getMsid()).thenReturn(1L); + when(clusterManager.execute(anyString(), anyLong(), anyString(), eq(true))) + .thenReturn("answer"); + doReturn(new Pair<>(true, "ok")).when(extensionsManager).getResultFromAnswersString(anyString(), eq(ext), eq(msHost), anyString()); + assertTrue(extensionsManager.cleanupExtensionFilesOnMSPeer(ext, msHost)); + } + + @Test + public void cleanupExtensionFilesOnCurrentServerReturnsSuccess() { + Pair result = extensionsManager.cleanupExtensionFilesOnCurrentServer("name", "entry"); + assertTrue(result.first()); + } + + @Test + public void getParametersListFromMapReturnsEmptyListForNull() { + List result = extensionsManager.getParametersListFromMap("action", null); + assertTrue(result.isEmpty()); + } + + @Test(expected = InvalidParameterValueException.class) + public void unregisterExtensionWithClusterThrowsIfClusterNotFound() { + when(clusterDao.findByUuid(anyString())).thenReturn(null); + extensionsManager.unregisterExtensionWithCluster("uuid", 1L); + } + + @Test + public void getExtensionFromResourceReturnsNullIfEntityNotFound() { + when(entityManager.findByUuid(any(), anyString())).thenReturn(null); + assertNull(extensionsManager.getExtensionFromResource(ExtensionCustomAction.ResourceType.VirtualMachine, "uuid")); + } + + @Test + public void getActionMessageReturnsDefaultOnBlank() { + ExtensionCustomAction action = mock(ExtensionCustomAction.class); + Extension ext = mock(Extension.class); + when(action.getSuccessMessage()).thenReturn(null); + String msg = extensionsManager.getActionMessage(true, action, ext, ExtensionCustomAction.ResourceType.VirtualMachine, null); + assertTrue(msg.contains("Successfully completed")); + } + + @Test + public void getActionMessageReturnsDefaultMessageForSuccessWithoutCustomMessage() { + ExtensionCustomAction action = mock(ExtensionCustomAction.class); + Extension extension = mock(Extension.class); + when(action.getSuccessMessage()).thenReturn(null); + + String result = extensionsManager.getActionMessage(true, action, extension, ExtensionCustomAction.ResourceType.VirtualMachine, null); + + assertTrue(result.contains("Successfully completed")); + } + + @Test + public void getActionMessageReturnsCustomSuccessMessage() { + ExtensionCustomAction action = mock(ExtensionCustomAction.class); + when(action.getName()).thenReturn("actionName"); + Extension extension = mock(Extension.class); + when(extension.getName()).thenReturn("extension"); + when(action.getSuccessMessage()).thenReturn("Custom success message"); + String result = extensionsManager.getActionMessage(true, action, extension, ExtensionCustomAction.ResourceType.VirtualMachine, null); + assertEquals("Custom success message", result); + } + + @Test + public void getActionMessageReturnsDefaultMessageForFailureWithoutCustomMessage() { + ExtensionCustomAction action = mock(ExtensionCustomAction.class); + Extension extension = mock(Extension.class); + when(action.getErrorMessage()).thenReturn(null); + + String result = extensionsManager.getActionMessage(false, action, extension, ExtensionCustomAction.ResourceType.VirtualMachine, null); + + assertTrue(result.contains("Failed to complete")); + } + + @Test + public void getActionMessageReturnsCustomFailureMessage() { + ExtensionCustomAction action = mock(ExtensionCustomAction.class); + when(action.getName()).thenReturn("actionName"); + Extension extension = mock(Extension.class); + when(extension.getName()).thenReturn("extension"); + when(action.getErrorMessage()).thenReturn("Custom failure message"); + String result = extensionsManager.getActionMessage(false, action, extension, ExtensionCustomAction.ResourceType.VirtualMachine, null); + assertEquals("Custom failure message", result); + } + + @Test + public void getActionMessageHandlesNullActionMessage() { + ExtensionCustomAction action = mock(ExtensionCustomAction.class); + when(action.getSuccessMessage()).thenReturn(null); + Extension extension = mock(Extension.class); + String result = extensionsManager.getActionMessage(true, action, extension, ExtensionCustomAction.ResourceType.VirtualMachine, null); + assertTrue(result.contains("Successfully completed")); + } + + @Test + public void getFilteredExternalDetailsReturnsFilteredMap() { + Map details = new HashMap<>(); + String key = "detail.key"; + details.put(VmDetailConstants.EXTERNAL_DETAIL_PREFIX + key, "value"); + details.put("other.key", "value2"); + Map filtered = extensionsManager.getFilteredExternalDetails(details); + assertTrue(filtered.containsKey(key)); + assertFalse(filtered.containsKey("other.key")); + } + + @Test + public void sendExtensionPathNotReadyAlertCallsAlertManager() { + Extension ext = mock(Extension.class); + when(ext.getState()).thenReturn(Extension.State.Enabled); + extensionsManager.sendExtensionPathNotReadyAlert(ext); + verify(alertManager, atLeastOnce()).sendAlert(eq(AlertManager.AlertType.ALERT_TYPE_EXTENSION_PATH_NOT_READY), + anyLong(), anyLong(), anyString(), anyString()); + } + + @Test + public void sendExtensionPathNotReadyAlertDoesNotCallsAlertManager() { + Extension ext = mock(Extension.class); + when(ext.getState()).thenReturn(Extension.State.Disabled); + extensionsManager.sendExtensionPathNotReadyAlert(ext); + verify(alertManager, never()).sendAlert(eq(AlertManager.AlertType.ALERT_TYPE_EXTENSION_PATH_NOT_READY), + anyLong(), anyLong(), anyString(), anyString()); + } + + @Test + public void updateExtensionPathReadyUpdatesWhenStateDiffers() { + Extension ext = mock(Extension.class); + when(ext.getId()).thenReturn(1L); + when(ext.isPathReady()).thenReturn(false); + ExtensionVO vo = mock(ExtensionVO.class); + when(extensionDao.createForUpdate(1L)).thenReturn(vo); + when(extensionDao.update(1L, vo)).thenReturn(true); + extensionsManager.updateExtensionPathReady(ext, true); + verify(extensionDao).update(1L, vo); + } + + @Test + public void disableExtensionUpdatesState() { + ExtensionVO vo = mock(ExtensionVO.class); + when(extensionDao.createForUpdate(1L)).thenReturn(vo); + when(extensionDao.update(1L, vo)).thenReturn(true); + extensionsManager.disableExtension(1L); + verify(extensionDao).update(1L, vo); + } + + @Test + public void getExtensionFromResourceReturnsExtensionForValidResource() { + VirtualMachine vm = mock(VirtualMachine.class); + when(entityManager.findByUuid(eq(VirtualMachine.class), eq("vm-uuid"))).thenReturn(vm); + when(virtualMachineManager.findClusterAndHostIdForVm(vm, false)).thenReturn(new Pair<>(1L, 1L)); + ExtensionResourceMapVO mapVO = mock(ExtensionResourceMapVO.class); + when(mapVO.getExtensionId()).thenReturn(100L); + when(extensionResourceMapDao.findByResourceIdAndType(1L, ExtensionResourceMap.ResourceType.Cluster)).thenReturn(mapVO); + ExtensionVO extension = mock(ExtensionVO.class); + when(extensionDao.findById(100L)).thenReturn(extension); + + Extension result = extensionsManager.getExtensionFromResource(ExtensionCustomAction.ResourceType.VirtualMachine, "vm-uuid"); + + assertEquals(extension, result); + } + + @Test + public void getExtensionFromResourceReturnsNullForInvalidResourceUuid() { + when(entityManager.findByUuid(eq(VirtualMachine.class), eq("invalid-uuid"))).thenReturn(null); + + Extension result = extensionsManager.getExtensionFromResource(ExtensionCustomAction.ResourceType.VirtualMachine, "invalid-uuid"); + + assertNull(result); + } + + @Test + public void getExtensionFromResourceReturnsNullForMissingClusterMapping() { + VirtualMachine vm = mock(VirtualMachine.class); + when(entityManager.findByUuid(eq(VirtualMachine.class), eq("vm-uuid"))).thenReturn(vm); + when(virtualMachineManager.findClusterAndHostIdForVm(vm, false)).thenReturn(new Pair<>(null, null)); + + Extension result = extensionsManager.getExtensionFromResource(ExtensionCustomAction.ResourceType.VirtualMachine, "vm-uuid"); + + assertNull(result); + } + + @Test + public void getExtensionFromResourceReturnsNullForMissingExtensionMapping() { + VirtualMachine vm = mock(VirtualMachine.class); + when(entityManager.findByUuid(eq(VirtualMachine.class), eq("vm-uuid"))).thenReturn(vm); + when(virtualMachineManager.findClusterAndHostIdForVm(vm, false)).thenReturn(new Pair<>(1L, 1L)); + when(extensionResourceMapDao.findByResourceIdAndType(1L, ExtensionResourceMap.ResourceType.Cluster)).thenReturn(null); + + Extension result = extensionsManager.getExtensionFromResource(ExtensionCustomAction.ResourceType.VirtualMachine, "vm-uuid"); + + assertNull(result); + } + + @Test + public void updateExtensionPathReadyUpdatesStateWhenNotReady() { + Extension ext = mock(Extension.class); + when(ext.getId()).thenReturn(1L); + when(ext.isPathReady()).thenReturn(true); + ExtensionVO vo = mock(ExtensionVO.class); + when(extensionDao.createForUpdate(1L)).thenReturn(vo); + when(extensionDao.update(1L, vo)).thenReturn(true); + + extensionsManager.updateExtensionPathReady(ext, false); + + verify(extensionDao).update(1L, vo); + } + + @Test + public void updateExtensionPathReadyDoesNotUpdateWhenStateUnchanged() { + Extension ext = mock(Extension.class); + when(ext.isPathReady()).thenReturn(true); + extensionsManager.updateExtensionPathReady(ext, true); + verify(extensionDao, never()).update(anyLong(), any()); + } + + @Test + public void disableExtensionChangesStateToDisabled() { + ExtensionVO vo = mock(ExtensionVO.class); + when(extensionDao.createForUpdate(1L)).thenReturn(vo); + when(extensionDao.update(1L, vo)).thenReturn(true); + + extensionsManager.disableExtension(1L); + + verify(vo).setState(Extension.State.Disabled); + verify(extensionDao).update(1L, vo); + } + + @Test + public void updateAllExtensionHostsRemovesHostsSuccessfully() throws OperationTimedoutException, AgentUnavailableException { + Extension extension = mock(Extension.class); + when(extension.getId()).thenReturn(1L); + Long clusterId = 100L; + Long hostId = 200L; + when(hostDao.listIdsByClusterId(clusterId)).thenReturn(List.of(hostId)); + extensionsManager.updateAllExtensionHosts(extension, clusterId, true); + verify(agentMgr).send(eq(hostId), any(Command.class)); + } + + @Test + public void updateAllExtensionHostsAddsHostsSuccessfully() throws OperationTimedoutException, AgentUnavailableException { + Extension extension = mock(Extension.class); + when(extension.getId()).thenReturn(1L); + Long clusterId = 100L; + Long hostId = 200L; + when(hostDao.listIdsByClusterId(clusterId)).thenReturn(List.of(hostId)); + extensionsManager.updateAllExtensionHosts(extension, clusterId, false); + verify(agentMgr).send(eq(hostId), any(Command.class)); + } + + @Test + public void updateAllExtensionHostsHandlesEmptyHostListGracefully() throws OperationTimedoutException, AgentUnavailableException { + Extension extension = mock(Extension.class); + Long clusterId = 100L; + when(hostDao.listIdsByClusterId(clusterId)).thenReturn(Collections.emptyList()); + extensionsManager.updateAllExtensionHosts(extension, clusterId, false); + verify(agentMgr, never()).send(anyLong(), any(Command.class)); + } + + @Test + public void updateAllExtensionHostsHandlesNullClusterId() throws OperationTimedoutException, AgentUnavailableException { + Extension extension = mock(Extension.class); + when(extension.getId()).thenReturn(1L); + when(extensionResourceMapDao.listResourceIdsByExtensionIdAndType(eq(1L), any())).thenReturn(Collections.emptyList()); + extensionsManager.updateAllExtensionHosts(extension, null, false); + verify(agentMgr, never()).send(anyLong(), any(Command.class)); + } + + @Test + public void getExternalAccessDetailsReturnsMapWithHostAndExtension() { + Map map = new HashMap<>(); + map.put("external.detail.key", "value"); + long hostId = 1L; + ExtensionResourceMap resourceMap = mock(ExtensionResourceMap.class); + when(resourceMap.getId()).thenReturn(2L); + when(resourceMap.getExtensionId()).thenReturn(3L); + when(hostDetailsDao.findDetails(hostId)).thenReturn(null); + when(extensionResourceMapDetailsDao.listDetailsKeyPairs(2L, true)).thenReturn(Collections.emptyMap()); + when(extensionDetailsDao.listDetailsKeyPairs(3L, true)).thenReturn(map); + Map> result = extensionsManager.getExternalAccessDetails(map, hostId, resourceMap); + assertTrue(result.containsKey(ApiConstants.ACTION)); + assertFalse(result.containsKey(ApiConstants.HOST)); + assertFalse(result.containsKey(ApiConstants.RESOURCE_MAP)); + assertTrue(result.containsKey(ApiConstants.EXTENSION)); + } + + @Test(expected = CloudRuntimeException.class) + public void checkOrchestratorTemplatesThrowsIfTemplatesExist() { + when(templateDao.listIdsByExtensionId(1L)).thenReturn(Arrays.asList(1L, 2L)); + extensionsManager.checkOrchestratorTemplates(1L); + } + + @Test + public void getExtensionsPathReturnsProvisionerPath() { + when(externalProvisioner.getExtensionsPath()).thenReturn("/tmp/extensions"); + assertEquals("/tmp/extensions", extensionsManager.getExtensionsPath()); + } + + @Test + public void getExtensionIdForClusterReturnsNullIfNoMap() { + when(extensionResourceMapDao.findByResourceIdAndType(anyLong(), any())).thenReturn(null); + assertNull(extensionsManager.getExtensionIdForCluster(1L)); + } + + @Test + public void getExtensionIdForClusterReturnsIdIfMapExists() { + ExtensionResourceMapVO map = mock(ExtensionResourceMapVO.class); + when(map.getExtensionId()).thenReturn(5L); + when(extensionResourceMapDao.findByResourceIdAndType(anyLong(), any())).thenReturn(map); + assertEquals(Long.valueOf(5L), extensionsManager.getExtensionIdForCluster(1L)); + } + + @Test + public void getExtensionReturnsExtension() { + ExtensionVO ext = mock(ExtensionVO.class); + when(extensionDao.findById(1L)).thenReturn(ext); + assertEquals(ext, extensionsManager.getExtension(1L)); + } + + @Test + public void getExtensionForClusterReturnsNullIfNoId() { + when(extensionResourceMapDao.findByResourceIdAndType(anyLong(), any())).thenReturn(null); + assertNull(extensionsManager.getExtensionForCluster(1L)); + } + + @Test + public void getExtensionForClusterReturnsExtensionIfIdExists() { + ExtensionResourceMapVO map = mock(ExtensionResourceMapVO.class); + when(map.getExtensionId()).thenReturn(5L); + when(extensionResourceMapDao.findByResourceIdAndType(anyLong(), any())).thenReturn(map); + ExtensionVO ext = mock(ExtensionVO.class); + when(extensionDao.findById(5L)).thenReturn(ext); + assertEquals(ext, extensionsManager.getExtensionForCluster(1L)); + } + + @Test + public void checkExtensionPathSyncUpdatesReadyWhenChecksumIsBlank() { + Extension ext = mock(Extension.class); + when(ext.getName()).thenReturn("ext"); + when(ext.getRelativePath()).thenReturn("entry.sh"); + when(externalProvisioner.getChecksumForExtensionPath("ext", "entry.sh")).thenReturn(""); + + extensionsManager.checkExtensionPathState(ext, Collections.emptyList()); + + verify(extensionsManager).updateExtensionPathReady(ext, false); + } + + @Test + public void checkExtensionPathSyncUpdatesReadyWhenNoHostsProvided() { + ExtensionVO ext = mock(ExtensionVO.class); + when(ext.getName()).thenReturn("ext"); + when(ext.getRelativePath()).thenReturn("entry.sh"); + when(externalProvisioner.getChecksumForExtensionPath("ext", "entry.sh")).thenReturn("checksum123"); + when(extensionDao.createForUpdate(anyLong())).thenReturn(ext); + extensionsManager.checkExtensionPathState(ext, Collections.emptyList()); + verify(extensionsManager).updateExtensionPathReady(ext, true); + } + + @Test + public void checkExtensionPathSyncUpdatesReadyWhenChecksumsMatchAcrossHosts() { + ExtensionVO ext = mock(ExtensionVO.class); + when(ext.getName()).thenReturn("ext"); + when(ext.getRelativePath()).thenReturn("entry.sh"); + when(externalProvisioner.getChecksumForExtensionPath("ext", "entry.sh")).thenReturn("checksum123"); + when(extensionDao.createForUpdate(anyLong())).thenReturn(ext); + ManagementServerHostVO msHost = mock(ManagementServerHostVO.class); + doReturn(new Pair<>(true, "checksum123")).when(extensionsManager).getChecksumForExtensionPathOnMSPeer(ext, msHost); + extensionsManager.checkExtensionPathState(ext, Collections.singletonList(msHost)); + verify(extensionsManager).updateExtensionPathReady(ext, true); + } + + @Test + public void checkExtensionPathStateUpdatesNotReadyWhenChecksumsDifferAcrossHosts() { + Extension ext = mock(Extension.class); + when(ext.getName()).thenReturn("ext"); + when(ext.getRelativePath()).thenReturn("entry.sh"); + when(externalProvisioner.getChecksumForExtensionPath("ext", "entry.sh")).thenReturn("checksum123"); + ManagementServerHostVO msHost = mock(ManagementServerHostVO.class); + when(msHost.getMsid()).thenReturn(1L); + doReturn(new Pair<>(true, "checksum456")).when(extensionsManager).getChecksumForExtensionPathOnMSPeer(ext, msHost); + extensionsManager.checkExtensionPathState(ext, Collections.singletonList(msHost)); + verify(extensionsManager).updateExtensionPathReady(ext, false); + } + + @Test + public void checkExtensionPathStateUpdatesNotReadyWhenPeerChecksumFails() { + Extension ext = mock(Extension.class); + when(ext.getName()).thenReturn("ext"); + when(ext.getRelativePath()).thenReturn("entry.sh"); + when(externalProvisioner.getChecksumForExtensionPath("ext", "entry.sh")).thenReturn("checksum123"); + + ManagementServerHostVO msHost = mock(ManagementServerHostVO.class); + when(msHost.getMsid()).thenReturn(1L); + doReturn(new Pair<>(false, null)).when(extensionsManager).getChecksumForExtensionPathOnMSPeer(ext, msHost); + + extensionsManager.checkExtensionPathState(ext, Collections.singletonList(msHost)); + + verify(extensionsManager).updateExtensionPathReady(ext, false); + } + + @Test + public void testCreateExtension_Success() { + CreateExtensionCmd cmd = mock(CreateExtensionCmd.class); + when(cmd.getName()).thenReturn("ext1"); + when(cmd.getDescription()).thenReturn("desc"); + when(cmd.getType()).thenReturn("Orchestrator"); + when(cmd.getPath()).thenReturn(null); + when(cmd.isOrchestratorRequiresPrepareVm()).thenReturn(null); + when(cmd.getState()).thenReturn(null); + when(extensionDao.findByName("ext1")).thenReturn(null); + when(extensionDao.persist(any())).thenAnswer(inv -> { + ExtensionVO extensionVO = inv.getArgument(0); + ReflectionTestUtils.setField(extensionVO, "id", 1L); + return extensionVO; + }); + when(managementServerHostDao.listBy(any())).thenReturn(Collections.emptyList()); + + Extension ext = extensionsManager.createExtension(cmd); + + assertEquals("ext1", ext.getName()); + verify(extensionDao).persist(any()); + } + + @Test + public void testCreateExtension_DuplicateName() { + CreateExtensionCmd cmd = mock(CreateExtensionCmd.class); + when(cmd.getName()).thenReturn("ext1"); + when(extensionDao.findByName("ext1")).thenReturn(mock(ExtensionVO.class)); + + assertThrows(CloudRuntimeException.class, () -> extensionsManager.createExtension(cmd)); + } + + @Test + public void prepareExtensionPathAcrossServersReturnsTrueWhenAllServersSucceed() { + Extension ext = mock(Extension.class); + when(ext.getName()).thenReturn("ext"); + when(ext.isUserDefined()).thenReturn(true); + when(ext.getRelativePath()).thenReturn("entry.sh"); + when(ext.getId()).thenReturn(1L); + when(ext.isPathReady()).thenReturn(false); + + ManagementServerHostVO msHost1 = mock(ManagementServerHostVO.class); + ManagementServerHostVO msHost2 = mock(ManagementServerHostVO.class); + when(msHost1.getMsid()).thenReturn(100L); + when(msHost2.getMsid()).thenReturn(200L); + + when(managementServerHostDao.listBy(any())).thenReturn(Arrays.asList(msHost1, msHost2)); + + try (MockedStatic managementServerNodeMockedStatic = mockStatic(ManagementServerNode.class)) { + managementServerNodeMockedStatic.when(ManagementServerNode::getManagementServerId).thenReturn(101L); + doReturn(new Pair<>(true, "ok")).when(extensionsManager).prepareExtensionPathOnCurrentServer(anyString(), anyBoolean(), anyString()); + doReturn(true).when(extensionsManager).prepareExtensionPathOnMSPeer(eq(ext), eq(msHost2)); + + // Simulate current server is msHost1 + when(msHost1.getMsid()).thenReturn(101L); + + // Extension entry point ready state should be updated + ExtensionVO updateExt = mock(ExtensionVO.class); + when(extensionDao.createForUpdate(1L)).thenReturn(updateExt); + when(extensionDao.update(1L, updateExt)).thenReturn(true); + + boolean result = extensionsManager.prepareExtensionPathAcrossServers(ext); + assertTrue(result); + verify(extensionDao).update(1L, updateExt); + } + } + + @Test + public void prepareExtensionPathAcrossServersReturnsFalseWhenAnyServerFails() { + Extension ext = mock(Extension.class); + when(ext.getName()).thenReturn("ext"); + when(ext.isUserDefined()).thenReturn(true); + when(ext.getRelativePath()).thenReturn("entry.sh"); + when(ext.getId()).thenReturn(1L); + when(ext.isPathReady()).thenReturn(true); + + ManagementServerHostVO msHost1 = mock(ManagementServerHostVO.class); + ManagementServerHostVO msHost2 = mock(ManagementServerHostVO.class); + when(msHost1.getMsid()).thenReturn(101L); + when(msHost2.getMsid()).thenReturn(200L); + + when(managementServerHostDao.listBy(any())).thenReturn(Arrays.asList(msHost1, msHost2)); + + try (MockedStatic managementServerNodeMockedStatic = mockStatic(ManagementServerNode.class)) { + managementServerNodeMockedStatic.when(ManagementServerNode::getManagementServerId).thenReturn(101L); + doReturn(new Pair<>(true, "ok")).when(extensionsManager).prepareExtensionPathOnCurrentServer(anyString(), anyBoolean(), anyString()); + doReturn(false).when(extensionsManager).prepareExtensionPathOnMSPeer(eq(ext), eq(msHost2)); + + ExtensionVO updateExt = mock(ExtensionVO.class); + when(extensionDao.createForUpdate(1L)).thenReturn(updateExt); + when(extensionDao.update(1L, updateExt)).thenReturn(true); + + boolean result = extensionsManager.prepareExtensionPathAcrossServers(ext); + assertFalse(result); + verify(extensionDao).update(1L, updateExt); + } + } + + @Test + public void prepareExtensionPathAcrossServersDoesNotUpdateIfStateUnchanged() { + Extension ext = mock(Extension.class); + when(ext.getName()).thenReturn("ext"); + when(ext.isUserDefined()).thenReturn(true); + when(ext.getRelativePath()).thenReturn("entry.sh"); + when(ext.isPathReady()).thenReturn(true); + + ManagementServerHostVO msHost = mock(ManagementServerHostVO.class); + when(msHost.getMsid()).thenReturn(101L); + + when(managementServerHostDao.listBy(any())).thenReturn(Collections.singletonList(msHost)); + + try (MockedStatic managementServerNodeMockedStatic = mockStatic(ManagementServerNode.class)) { + managementServerNodeMockedStatic.when(ManagementServerNode::getManagementServerId).thenReturn(101L); + doReturn(new Pair<>(true, "ok")).when(extensionsManager).prepareExtensionPathOnCurrentServer(anyString(), anyBoolean(), anyString()); + + boolean result = extensionsManager.prepareExtensionPathAcrossServers(ext); + assertTrue(result); + verify(extensionDao, never()).update(anyLong(), any()); + } + } + + @Test + public void testListExtensionsReturnsResponses() { + ListExtensionsCmd cmd = mock(ListExtensionsCmd.class); + when(cmd.getExtensionId()).thenReturn(null); + when(cmd.getName()).thenReturn(null); + when(cmd.getKeyword()).thenReturn(null); + when(cmd.getStartIndex()).thenReturn(0L); + when(cmd.getPageSizeVal()).thenReturn(10L); + when(cmd.getDetails()).thenReturn(null); + + ExtensionVO ext1 = mock(ExtensionVO.class); + ExtensionVO ext2 = mock(ExtensionVO.class); + List extList = Arrays.asList(ext1, ext2); + SearchBuilder sb = mock(SearchBuilder.class); + when(sb.create()).thenReturn(mock(SearchCriteria.class)); + when(sb.entity()).thenReturn(mock(ExtensionVO.class)); + when(extensionDao.createSearchBuilder()).thenReturn(sb); + when(extensionDao.searchAndCount(any(), any())).thenReturn(new Pair<>(extList, 2)); + + // Spy createExtensionResponse to return a dummy response + ExtensionResponse resp1 = mock(ExtensionResponse.class); + ExtensionResponse resp2 = mock(ExtensionResponse.class); + doReturn(resp1).when(extensionsManager).createExtensionResponse(eq(ext1), any()); + doReturn(resp2).when(extensionsManager).createExtensionResponse(eq(ext2), any()); + + List result = extensionsManager.listExtensions(cmd); + + assertEquals(2, result.size()); + assertTrue(result.contains(resp1)); + assertTrue(result.contains(resp2)); + } + + @Test + public void testListExtensionsWithId() { + ListExtensionsCmd cmd = mock(ListExtensionsCmd.class); + when(cmd.getExtensionId()).thenReturn(42L); + when(cmd.getName()).thenReturn(null); + when(cmd.getKeyword()).thenReturn(null); + when(cmd.getStartIndex()).thenReturn(0L); + when(cmd.getPageSizeVal()).thenReturn(10L); + when(cmd.getDetails()).thenReturn(null); + + ExtensionVO ext = mock(ExtensionVO.class); + SearchBuilder sb = mock(SearchBuilder.class); + when(sb.create()).thenReturn(mock(SearchCriteria.class)); + when(sb.entity()).thenReturn(mock(ExtensionVO.class)); + when(extensionDao.createSearchBuilder()).thenReturn(sb); + when(extensionDao.searchAndCount(any(), any())).thenReturn(new Pair<>(Collections.singletonList(ext), 1)); + ExtensionResponse resp = mock(ExtensionResponse.class); + doReturn(resp).when(extensionsManager).createExtensionResponse(eq(ext), any()); + + List result = extensionsManager.listExtensions(cmd); + + assertEquals(1, result.size()); + assertEquals(resp, result.get(0)); + } + + @Test + public void testListExtensionsWithNameAndKeyword() { + ListExtensionsCmd cmd = mock(ListExtensionsCmd.class); + when(cmd.getExtensionId()).thenReturn(null); + when(cmd.getName()).thenReturn("testName"); + when(cmd.getKeyword()).thenReturn("key"); + when(cmd.getStartIndex()).thenReturn(0L); + when(cmd.getPageSizeVal()).thenReturn(10L); + when(cmd.getDetails()).thenReturn(null); + + ExtensionVO ext = mock(ExtensionVO.class); + SearchBuilder sb = mock(SearchBuilder.class); + when(sb.create()).thenReturn(mock(SearchCriteria.class)); + when(sb.entity()).thenReturn(mock(ExtensionVO.class)); + when(extensionDao.createSearchBuilder()).thenReturn(sb); + when(extensionDao.searchAndCount(any(), any())).thenReturn(new Pair<>(Collections.singletonList(ext), 1)); + ExtensionResponse resp = mock(ExtensionResponse.class); + doReturn(resp).when(extensionsManager).createExtensionResponse(eq(ext), any()); + + List result = extensionsManager.listExtensions(cmd); + + assertEquals(1, result.size()); + assertEquals(resp, result.get(0)); + } + + @Test + public void testUpdateExtension_SuccessfulDescriptionUpdate() { + UpdateExtensionCmd cmd = mock(UpdateExtensionCmd.class); + when(cmd.getId()).thenReturn(1L); + when(cmd.getDescription()).thenReturn("new desc"); + when(cmd.isOrchestratorRequiresPrepareVm()).thenReturn(null); + when(cmd.getState()).thenReturn(null); + when(cmd.getDetails()).thenReturn(null); + when(cmd.isCleanupDetails()).thenReturn(false); + + ExtensionVO ext = mock(ExtensionVO.class); + when(ext.getDescription()).thenReturn("old desc"); + when(extensionDao.findById(1L)).thenReturn(ext); + when(extensionDao.update(1L, ext)).thenReturn(true); + + Extension result = extensionsManager.updateExtension(cmd); + + assertEquals(ext, result); + verify(ext).setDescription("new desc"); + verify(extensionDao, atLeastOnce()).update(1L, ext); + } + + @Test(expected = InvalidParameterValueException.class) + public void testUpdateExtension_NotFound() { + UpdateExtensionCmd cmd = mock(UpdateExtensionCmd.class); + when(cmd.getId()).thenReturn(2L); + when(extensionDao.findById(2L)).thenReturn(null); + + extensionsManager.updateExtension(cmd); + } + + @Test(expected = InvalidParameterValueException.class) + public void testUpdateExtension_InvalidOrchestratorFlag() { + UpdateExtensionCmd cmd = mock(UpdateExtensionCmd.class); + when(cmd.getId()).thenReturn(3L); + when(cmd.isOrchestratorRequiresPrepareVm()).thenReturn(true); + + ExtensionVO ext = mock(ExtensionVO.class); + when(ext.getType()).thenReturn(null); + when(extensionDao.findById(3L)).thenReturn(ext); + + extensionsManager.updateExtension(cmd); + } + + @Test(expected = CloudRuntimeException.class) + public void testUpdateExtension_UpdateFails() { + UpdateExtensionCmd cmd = mock(UpdateExtensionCmd.class); + when(cmd.getId()).thenReturn(4L); + when(cmd.getDescription()).thenReturn("desc"); + when(cmd.isOrchestratorRequiresPrepareVm()).thenReturn(null); + when(cmd.getState()).thenReturn(null); + when(cmd.getDetails()).thenReturn(null); + when(cmd.isCleanupDetails()).thenReturn(false); + + ExtensionVO ext = mock(ExtensionVO.class); + when(ext.getDescription()).thenReturn("old"); + when(extensionDao.findById(4L)).thenReturn(ext); + when(extensionDao.update(4L, ext)).thenReturn(false); + + extensionsManager.updateExtension(cmd); + } + + @Test(expected = InvalidParameterValueException.class) + public void testUpdateExtension_InvalidState() { + UpdateExtensionCmd cmd = mock(UpdateExtensionCmd.class); + when(cmd.getId()).thenReturn(5L); + when(cmd.getState()).thenReturn("NonExistentState"); + + ExtensionVO ext = mock(ExtensionVO.class); + when(ext.getType()).thenReturn(Extension.Type.Orchestrator); + when(ext.getState()).thenReturn(Extension.State.Enabled); + when(extensionDao.findById(5L)).thenReturn(ext); + + extensionsManager.updateExtension(cmd); + } + + @Test + public void updateExtensionsDetails_SavesDetails_WhenDetailsProvided() { + long extensionId = 10L; + Map details = Map.of("foo", "bar", "baz", "qux"); + extensionsManager.updateExtensionsDetails(false, details, null, extensionId); + verify(extensionDetailsDao).saveDetails(any()); + } + + @Test + public void updateExtensionsDetails_DoesNothing_WhenDetailsAndCleanupAreNull() { + long extensionId = 11L; + extensionsManager.updateExtensionsDetails(null, null, null, extensionId); + verify(extensionDetailsDao, never()).removeDetails(anyLong()); + verify(extensionDetailsDao, never()).saveDetails(any()); + } + + @Test + public void updateExtensionsDetails_RemovesDetailsOnly_WhenCleanupIsTrue() { + long extensionId = 12L; + extensionsManager.updateExtensionsDetails(true, null, null, extensionId); + verify(extensionDetailsDao).removeDetails(extensionId); + verify(extensionDetailsDao, never()).saveDetails(any()); + } + + @Test + public void updateExtensionsDetails_PersistsOrchestratorFlag_WhenFlagIsNotNull() { + long extensionId = 13L; + extensionsManager.updateExtensionsDetails(false, null, true, extensionId); + verify(extensionDetailsDao).persist(any()); + } + + @Test(expected = CloudRuntimeException.class) + public void updateExtensionsDetails_ThrowsException_WhenPersistFails() { + long extensionId = 14L; + Map details = Map.of("foo", "bar"); + doThrow(CloudRuntimeException.class).when(extensionDetailsDao).saveDetails(any()); + extensionsManager.updateExtensionsDetails(false, details, null, extensionId); + } + + @Test + public void testDeleteExtension_Success() { + DeleteExtensionCmd cmd = mock(DeleteExtensionCmd.class); + when(cmd.getId()).thenReturn(1L); + when(cmd.isCleanup()).thenReturn(false); + ExtensionVO ext = mock(ExtensionVO.class); + when(ext.isUserDefined()).thenReturn(true); + when(extensionDao.findById(1L)).thenReturn(ext); + when(extensionResourceMapDao.listByExtensionId(1L)).thenReturn(Collections.emptyList()); + when(extensionCustomActionDao.listIdsByExtensionId(1L)).thenReturn(Collections.emptyList()); + doNothing().when(extensionDetailsDao).removeDetails(1L); + when(extensionDao.remove(1L)).thenReturn(true); + + assertTrue(extensionsManager.deleteExtension(cmd)); + verify(extensionDao).remove(1L); + } + + @Test + public void testRegisterExtensionWithResource_InvalidResourceType() { + RegisterExtensionCmd cmd = mock(RegisterExtensionCmd.class); + when(cmd.getResourceType()).thenReturn("InvalidType"); + + assertThrows(InvalidParameterValueException.class, () -> extensionsManager.registerExtensionWithResource(cmd)); + } + + @Test + public void registerExtensionWithResourceRegistersSuccessfullyForValidResourceType() { + RegisterExtensionCmd cmd = mock(RegisterExtensionCmd.class); + when(cmd.getResourceType()).thenReturn(ExtensionResourceMap.ResourceType.Cluster.name()); + when(cmd.getResourceId()).thenReturn(UUID.randomUUID().toString()); + when(cmd.getExtensionId()).thenReturn(1L); + ExtensionVO extension = mock(ExtensionVO.class); + ClusterVO clusterVO = mock(ClusterVO.class); + when(clusterVO.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.External); + when(clusterDao.findByUuid(anyString())).thenReturn(clusterVO); + ExtensionResourceMapVO resourceMap = mock(ExtensionResourceMapVO.class); + when(extensionResourceMapDao.persist(any())).thenReturn(resourceMap); + when(extensionDao.findById(anyLong())).thenReturn(extension); + Extension result = extensionsManager.registerExtensionWithResource(cmd); + assertEquals(extension, result); + verify(extensionResourceMapDao).persist(any()); + } + + @Test(expected = InvalidParameterValueException.class) + public void registerExtensionWithResourceThrowsForInvalidResourceType() { + RegisterExtensionCmd cmd = mock(RegisterExtensionCmd.class); + when(cmd.getResourceType()).thenReturn("InvalidType"); + + extensionsManager.registerExtensionWithResource(cmd); + } + + @Test(expected = InvalidParameterValueException.class) + public void registerExtensionWithResourceThrowsForMissingExtension() { + RegisterExtensionCmd cmd = mock(RegisterExtensionCmd.class); + when(cmd.getResourceType()).thenReturn(ExtensionResourceMap.ResourceType.Cluster.name()); + when(cmd.getResourceId()).thenReturn(UUID.randomUUID().toString()); + ClusterVO clusterVO = mock(ClusterVO.class); + when(clusterDao.findByUuid(anyString())).thenReturn(clusterVO); + extensionsManager.registerExtensionWithResource(cmd); + } + + @Test(expected = CloudRuntimeException.class) + public void registerExtensionWithResourceThrowsForPersistFailure() { + RegisterExtensionCmd cmd = mock(RegisterExtensionCmd.class); + when(cmd.getResourceType()).thenReturn(ExtensionResourceMap.ResourceType.Cluster.name()); + when(cmd.getResourceId()).thenReturn(UUID.randomUUID().toString()); + when(cmd.getExtensionId()).thenReturn(1L); + ClusterVO clusterVO = mock(ClusterVO.class); + when(clusterVO.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.External); + when(clusterDao.findByUuid(anyString())).thenReturn(clusterVO); + ExtensionVO extension = mock(ExtensionVO.class); + when(extensionDao.findById(1L)).thenReturn(extension); + when(extensionResourceMapDao.persist(any())).thenThrow(CloudRuntimeException.class); + extensionsManager.registerExtensionWithResource(cmd); + } + + @Test + public void registerExtensionWithClusterRegistersSuccessfullyForValidCluster() { + Cluster cluster = mock(Cluster.class); + when(cluster.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.External); + Extension extension = mock(Extension.class); + Map details = Map.of("key1", "value1"); + ExtensionResourceMapVO resourceMap = mock(ExtensionResourceMapVO.class); + when(extensionResourceMapDao.persist(any())).thenReturn(resourceMap); + ExtensionResourceMap result = extensionsManager.registerExtensionWithCluster(cluster, extension, details); + assertNotNull(result); + verify(extensionResourceMapDao).persist(any()); + } + + @Test + public void registerExtensionWithClusterHandlesNullDetails() { + Cluster cluster = mock(Cluster.class); + when(cluster.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.External); + Extension extension = mock(Extension.class); + ExtensionResourceMapVO resourceMap = mock(ExtensionResourceMapVO.class); + when(extensionResourceMapDao.persist(any())).thenReturn(resourceMap); + ExtensionResourceMap result = extensionsManager.registerExtensionWithCluster(cluster, extension, null); + assertNotNull(result); + verify(extensionResourceMapDao).persist(any()); + } + + @Test + public void testUnregisterExtensionWithResource_InvalidResourceType() { + UnregisterExtensionCmd cmd = mock(UnregisterExtensionCmd.class); + when(cmd.getResourceType()).thenReturn("InvalidType"); + + assertThrows(InvalidParameterValueException.class, () -> extensionsManager.unregisterExtensionWithResource(cmd)); + } + + @Test + public void unregisterExtensionWithClusterRemovesMappingSuccessfully() { + Cluster cluster = mock(Cluster.class); + when(cluster.getId()).thenReturn(100L); + Long extensionId = 1L; + ExtensionResourceMapVO resourceMap = mock(ExtensionResourceMapVO.class); + when(extensionResourceMapDao.findByResourceIdAndType(eq(100L), eq(ExtensionResourceMap.ResourceType.Cluster))) + .thenReturn(resourceMap); + extensionsManager.unregisterExtensionWithCluster(cluster, extensionId); + verify(extensionResourceMapDao).remove(resourceMap.getId()); + } + + @Test + public void unregisterExtensionWithClusterHandlesMissingMappingGracefully() { + Cluster cluster = mock(Cluster.class); + when(cluster.getId()).thenReturn(100L); + Long extensionId = 1L; + when(extensionResourceMapDao.findByResourceIdAndType(eq(100L), eq(ExtensionResourceMap.ResourceType.Cluster))) + .thenReturn(null); + extensionsManager.unregisterExtensionWithCluster(cluster, extensionId); + verify(extensionResourceMapDao, never()).remove(anyLong()); + } + + @Test + public void testCreateExtensionResponse_BasicFields() { + Extension extension = mock(Extension.class); + when(extension.getUuid()).thenReturn("uuid-1"); + when(extension.getName()).thenReturn("ext1"); + when(extension.getDescription()).thenReturn("desc"); + when(extension.getType()).thenReturn(Extension.Type.Orchestrator); + when(extension.getCreated()).thenReturn(new Date()); + when(extension.getRelativePath()).thenReturn("entry.sh"); + when(extension.isPathReady()).thenReturn(true); + when(extension.isUserDefined()).thenReturn(true); + when(extension.getState()).thenReturn(Extension.State.Enabled); + when(extension.getId()).thenReturn(1L); + + // Mock externalProvisioner + when(externalProvisioner.getExtensionPath("entry.sh")).thenReturn("/some/path/entry.sh"); + + // Mock detailsDao + Pair, Map> detailsPair = new Pair<>(Map.of("foo", "bar"), + Map.of(ApiConstants.ORCHESTRATOR_REQUIRES_PREPARE_VM, "true")); + when(extensionDetailsDao.listDetailsKeyPairsWithVisibility(1L)).thenReturn(detailsPair); + + EnumSet viewDetails = EnumSet.of(ApiConstants.ExtensionDetails.all); + + ExtensionResponse response = extensionsManager.createExtensionResponse(extension, viewDetails); + + assertEquals("uuid-1", response.getId()); + assertEquals("ext1", response.getName()); + assertEquals("desc", response.getDescription()); + assertEquals("Orchestrator", response.getType()); + assertEquals("/some/path/entry.sh", response.getPath()); + assertTrue(response.isPathReady()); + assertTrue(response.isUserDefined()); + assertEquals("Enabled", response.getState()); + assertEquals("bar", response.getDetails().get("foo")); + assertTrue(response.isOrchestratorRequiresPrepareVm()); + assertEquals("extension", response.getObjectName()); + } + + @Test + public void testCreateExtensionResponse_HiddenDetailsOnly() { + Extension extension = mock(Extension.class); + when(extension.getUuid()).thenReturn("uuid-2"); + when(extension.getName()).thenReturn("ext2"); + when(extension.getDescription()).thenReturn("desc2"); + when(extension.getType()).thenReturn(Extension.Type.Orchestrator); + when(extension.getCreated()).thenReturn(new Date()); + when(extension.getRelativePath()).thenReturn("entry2.sh"); + when(extension.isPathReady()).thenReturn(false); + when(extension.isUserDefined()).thenReturn(false); + when(extension.getState()).thenReturn(Extension.State.Disabled); + when(extension.getId()).thenReturn(2L); + + when(externalProvisioner.getExtensionPath("entry2.sh")).thenReturn("/some/path/entry2.sh"); + + Map hiddenDetails = Map.of(ApiConstants.ORCHESTRATOR_REQUIRES_PREPARE_VM, "false"); + when(extensionDetailsDao.listDetailsKeyPairs(2L, List.of(ApiConstants.ORCHESTRATOR_REQUIRES_PREPARE_VM))) + .thenReturn(hiddenDetails); + + EnumSet viewDetails = EnumSet.noneOf(ApiConstants.ExtensionDetails.class); + + ExtensionResponse response = extensionsManager.createExtensionResponse(extension, viewDetails); + + assertEquals("uuid-2", response.getId()); + assertEquals("ext2", response.getName()); + assertEquals("desc2", response.getDescription()); + assertEquals("Orchestrator", response.getType()); + assertEquals("/some/path/entry2.sh", response.getPath()); + assertFalse(response.isPathReady()); + assertFalse(response.isUserDefined()); + assertEquals("Disabled", response.getState()); + assertFalse(response.isOrchestratorRequiresPrepareVm()); + assertEquals("extension", response.getObjectName()); + } + + @Test + public void testAddCustomAction_Success() { + AddCustomActionCmd cmd = mock(AddCustomActionCmd.class); + when(cmd.getName()).thenReturn("action1"); + when(cmd.getDescription()).thenReturn("desc"); + when(cmd.getExtensionId()).thenReturn(1L); + when(cmd.getResourceType()).thenReturn("VirtualMachine"); + when(cmd.getAllowedRoleTypes()).thenReturn(List.of("Admin")); + when(cmd.getTimeout()).thenReturn(5); + when(cmd.isEnabled()).thenReturn(true); + when(cmd.getParametersMap()).thenReturn(null); + when(cmd.getSuccessMessage()).thenReturn("ok"); + when(cmd.getErrorMessage()).thenReturn("fail"); + when(cmd.getDetails()).thenReturn(null); + + when(extensionCustomActionDao.findByNameAndExtensionId(1L, "action1")).thenReturn(null); + ExtensionVO ext = mock(ExtensionVO.class); + when(extensionDao.findById(1L)).thenReturn(ext); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(extensionCustomActionDao.persist(any())).thenReturn(actionVO); + + ExtensionCustomAction result = extensionsManager.addCustomAction(cmd); + + assertEquals(actionVO, result); + verify(extensionCustomActionDao).persist(any()); + } + + @Test(expected = CloudRuntimeException.class) + public void testAddCustomAction_DuplicateName() { + AddCustomActionCmd cmd = mock(AddCustomActionCmd.class); + when(cmd.getName()).thenReturn("action1"); + when(cmd.getExtensionId()).thenReturn(1L); + when(extensionCustomActionDao.findByNameAndExtensionId(1L, "action1")).thenReturn(mock(ExtensionCustomActionVO.class)); + + extensionsManager.addCustomAction(cmd); + } + + @Test(expected = InvalidParameterValueException.class) + public void testAddCustomAction_ExtensionNotFound() { + AddCustomActionCmd cmd = mock(AddCustomActionCmd.class); + when(cmd.getName()).thenReturn("action1"); + when(cmd.getExtensionId()).thenReturn(2L); + when(extensionCustomActionDao.findByNameAndExtensionId(2L, "action1")).thenReturn(null); + when(extensionDao.findById(2L)).thenReturn(null); + + extensionsManager.addCustomAction(cmd); + } + + @Test(expected = CloudRuntimeException.class) + public void testAddCustomAction_InvalidResourceType() { + AddCustomActionCmd cmd = mock(AddCustomActionCmd.class); + when(cmd.getName()).thenReturn("action1"); + when(cmd.getExtensionId()).thenReturn(1L); + when(cmd.getResourceType()).thenReturn("InvalidType"); + when(extensionCustomActionDao.findByNameAndExtensionId(1L, "action1")).thenReturn(null); + ExtensionVO ext = mock(ExtensionVO.class); + when(extensionDao.findById(1L)).thenReturn(ext); + + extensionsManager.addCustomAction(cmd); + } + + @Test(expected = InvalidParameterValueException.class) + public void testAddCustomAction_InvalidName() { + AddCustomActionCmd cmd = mock(AddCustomActionCmd.class); + when(cmd.getName()).thenReturn("action;1"); + extensionsManager.addCustomAction(cmd); + } + + @Test + public void deleteCustomAction_RemovesActionAndDetails_ReturnsTrue() { + long actionId = 10L; + DeleteCustomActionCmd cmd = mock(DeleteCustomActionCmd.class); + when(cmd.getId()).thenReturn(actionId); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(extensionCustomActionDao.findById(actionId)).thenReturn(actionVO); + when(extensionCustomActionDao.remove(actionId)).thenReturn(true); + + boolean result = extensionsManager.deleteCustomAction(cmd); + + assertTrue(result); + verify(extensionCustomActionDetailsDao).removeDetails(actionId); + verify(extensionCustomActionDao).remove(actionId); + } + + @Test(expected = InvalidParameterValueException.class) + public void deleteCustomAction_ActionNotFound() { + long actionId = 20L; + DeleteCustomActionCmd cmd = mock(DeleteCustomActionCmd.class); + when(cmd.getId()).thenReturn(actionId); + when(extensionCustomActionDao.findById(actionId)).thenReturn(null); + extensionsManager.deleteCustomAction(cmd); + verify(extensionCustomActionDetailsDao, never()).removeDetails(anyLong()); + verify(extensionCustomActionDao, never()).remove(anyLong()); + } + + @Test(expected = CloudRuntimeException.class) + public void deleteCustomAction_RemoveFails() { + long actionId = 30L; + DeleteCustomActionCmd cmd = mock(DeleteCustomActionCmd.class); + when(cmd.getId()).thenReturn(actionId); + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(extensionCustomActionDao.findById(actionId)).thenReturn(actionVO); + when(extensionCustomActionDao.remove(actionId)).thenReturn(false); + extensionsManager.deleteCustomAction(cmd); + verify(extensionCustomActionDetailsDao).removeDetails(actionId); + verify(extensionCustomActionDao).remove(actionId); + } + + private void mockCallerRole(RoleType roleType) { + CallContext callContextMock = Mockito.mock(CallContext.class); + when(CallContext.current()).thenReturn(callContextMock); + Account accountMock = mock(Account.class); + when(accountMock.getRoleId()).thenReturn(1L); + Role role = mock(Role.class); + when(role.getRoleType()).thenReturn(roleType); + when(roleService.findRole(1L)).thenReturn(role); + when(callContextMock.getCallingAccount()).thenReturn(accountMock); + } + + @Test + public void testListCustomActions_ReturnsResponses() { + ListCustomActionCmd cmd = mock(ListCustomActionCmd.class); + when(cmd.getId()).thenReturn(null); + when(cmd.getName()).thenReturn(null); + when(cmd.getExtensionId()).thenReturn(1L); + when(cmd.getKeyword()).thenReturn(null); + when(cmd.getResourceType()).thenReturn(null); + when(cmd.getResourceId()).thenReturn(null); + when(cmd.isEnabled()).thenReturn(null); + when(cmd.getStartIndex()).thenReturn(0L); + when(cmd.getPageSizeVal()).thenReturn(10L); + + ExtensionCustomActionVO action1 = mock(ExtensionCustomActionVO.class); + ExtensionCustomActionVO action2 = mock(ExtensionCustomActionVO.class); + List actions = Arrays.asList(action1, action2); + SearchBuilder sb = mock(SearchBuilder.class); + when(sb.create()).thenReturn(mock(SearchCriteria.class)); + when(sb.entity()).thenReturn(mock(ExtensionCustomActionVO.class)); + when(extensionCustomActionDao.createSearchBuilder()).thenReturn(sb); + when(extensionCustomActionDao.searchAndCount(any(), any())).thenReturn(new Pair<>(actions, 2)); + + ExtensionCustomActionResponse resp1 = mock(ExtensionCustomActionResponse.class); + ExtensionCustomActionResponse resp2 = mock(ExtensionCustomActionResponse.class); + doReturn(resp1).when(extensionsManager).createCustomActionResponse(eq(action1)); + doReturn(resp2).when(extensionsManager).createCustomActionResponse(eq(action2)); + + + try (MockedStatic ignored = mockStatic(CallContext.class)) { + mockCallerRole(RoleType.Admin); + List result = extensionsManager.listCustomActions(cmd); + + assertEquals(2, result.size()); + assertTrue(result.contains(resp1)); + assertTrue(result.contains(resp2)); + } + } + + @Test + public void testUpdateCustomAction_UpdatesFields() { + long actionId = 1L; + String newDescription = "Updated description"; + String newResourceType = "VirtualMachine"; + List newRoles = List.of("Admin", "User"); + Boolean enabled = true; + int timeout = 10; + String successMsg = "Success!"; + String errorMsg = "Error!"; + Map details = Map.of("key", "value"); + + UpdateCustomActionCmd cmd = mock(UpdateCustomActionCmd.class); + when(cmd.getId()).thenReturn(actionId); + when(cmd.getDescription()).thenReturn(newDescription); + when(cmd.getResourceType()).thenReturn(newResourceType); + when(cmd.getAllowedRoleTypes()).thenReturn(newRoles); + when(cmd.isEnabled()).thenReturn(enabled); + when(cmd.getTimeout()).thenReturn(timeout); + when(cmd.getSuccessMessage()).thenReturn(successMsg); + when(cmd.getErrorMessage()).thenReturn(errorMsg); + when(cmd.getParametersMap()).thenReturn(null); + when(cmd.isCleanupParameters()).thenReturn(false); + when(cmd.getDetails()).thenReturn(details); + when(cmd.isCleanupDetails()).thenReturn(false); + + ExtensionCustomActionVO actionVO = new ExtensionCustomActionVO(); + ReflectionTestUtils.setField(actionVO, "id", 1L); + when(extensionCustomActionDao.findById(actionId)).thenReturn(actionVO); + when(extensionCustomActionDao.update(eq(actionId), any())).thenReturn(true); + + when(extensionCustomActionDetailsDao.listDetailsKeyPairs(eq(actionId), eq(false))) + .thenReturn(new HashMap<>()); + + ExtensionCustomAction result = extensionsManager.updateCustomAction(cmd); + + assertEquals(newDescription, result.getDescription()); + assertEquals(successMsg, result.getSuccessMessage()); + assertEquals(errorMsg, result.getErrorMessage()); + assertEquals(timeout, result.getTimeout()); + assertTrue(result.isEnabled()); + assertEquals(ExtensionCustomAction.ResourceType.VirtualMachine, result.getResourceType()); + } + + @Test(expected = CloudRuntimeException.class) + public void testUpdateCustomAction_ActionNotFound_ThrowsException() { + UpdateCustomActionCmd cmd = mock(UpdateCustomActionCmd.class); + when(cmd.getId()).thenReturn(99L); + when(extensionCustomActionDao.findById(99L)).thenReturn(null); + + extensionsManager.updateCustomAction(cmd); + } + + @Test(expected = InvalidParameterValueException.class) + public void testUpdateCustomAction_InvalidResourceType_ThrowsException() { + UpdateCustomActionCmd cmd = mock(UpdateCustomActionCmd.class); + when(cmd.getId()).thenReturn(1L); + ExtensionCustomActionVO action = mock(ExtensionCustomActionVO.class); + when(extensionCustomActionDao.findById(1L)).thenReturn(action); + when(cmd.getResourceType()).thenReturn("InvalidType"); + + extensionsManager.updateCustomAction(cmd); + } + + @Test(expected = InvalidParameterValueException.class) + public void testUpdateCustomAction_InvalidRoleType_ThrowsException() { + UpdateCustomActionCmd cmd = mock(UpdateCustomActionCmd.class); + when(cmd.getId()).thenReturn(1L); + ExtensionCustomActionVO action = mock(ExtensionCustomActionVO.class); + when(extensionCustomActionDao.findById(1L)).thenReturn(action); + when(cmd.getAllowedRoleTypes()).thenReturn(List.of("NotARole")); + + extensionsManager.updateCustomAction(cmd); + } + + @Test(expected = CloudRuntimeException.class) + public void testUpdateCustomAction_DaoUpdateFails_ThrowsException() { + UpdateCustomActionCmd cmd = mock(UpdateCustomActionCmd.class); + when(cmd.getId()).thenReturn(1L); + when(cmd.getDescription()).thenReturn("desc"); + ExtensionCustomActionVO action = mock(ExtensionCustomActionVO.class); + when(extensionCustomActionDao.findById(1L)).thenReturn(action); + when(extensionCustomActionDao.update(eq(1L), any())).thenReturn(false); + + extensionsManager.updateCustomAction(cmd); + } + + @Test + public void updatedCustomActionDetails_RemovesDetails_WhenCleanupDetailsIsTrue() { + long actionId = 1L; + Boolean cleanupDetails = true; + extensionsManager.updatedCustomActionDetails(actionId, cleanupDetails, null, false, null); + verify(extensionCustomActionDetailsDao).removeDetails(actionId); + verify(extensionCustomActionDetailsDao, never()).saveDetails(any()); + } + + @Test + public void updatedCustomActionDetails_SavesDetails_WhenDetailsProvided() { + long actionId = 2L; + Map details = Map.of("key1", "value1", "key2", "value2"); + extensionsManager.updatedCustomActionDetails(actionId, false, details, false, null); + verify(extensionCustomActionDetailsDao).saveDetails(any()); + verify(extensionCustomActionDetailsDao, never()).removeDetails(anyLong()); + } + + @Test + public void updatedCustomActionDetails_DoesNothing_WhenDetailsAndCleanupDetailsAreNull() { + long actionId = 3L; + extensionsManager.updatedCustomActionDetails(actionId, null, null, false, null); + verify(extensionCustomActionDetailsDao, never()).removeDetails(anyLong()); + verify(extensionCustomActionDetailsDao, never()).saveDetails(any()); + } + + @Test + public void updatedCustomActionDetails_HandlesEmptyDetailsGracefully() { + long actionId = 4L; + Map details = Collections.emptyMap(); + extensionsManager.updatedCustomActionDetails(actionId, false, details, false, null); + verify(extensionCustomActionDetailsDao, never()).saveDetails(any()); + verify(extensionCustomActionDetailsDao, never()).removeDetails(anyLong()); + } + + @Test(expected = CloudRuntimeException.class) + public void updatedCustomActionDetails_ThrowsException_WhenSaveDetailsFails() { + long actionId = 5L; + Map details = Map.of("key1", "value1"); + doThrow(CloudRuntimeException.class).when(extensionCustomActionDetailsDao).saveDetails(any()); + extensionsManager.updatedCustomActionDetails(actionId, false, details, false, null); + } + + @Test + public void updatedCustomActionDetails_RemovesDetails_WhenCleanupDetailsParametersAreTrue() { + long actionId = 1L; + Map hiddenDetails = new HashMap<>(); + hiddenDetails.put(ApiConstants.PARAMETERS, "Test"); + when(extensionCustomActionDetailsDao.listDetailsKeyPairs(actionId, false)).thenReturn(hiddenDetails); + extensionsManager.updatedCustomActionDetails(actionId, true, null, true, null); + verify(extensionCustomActionDetailsDao).removeDetails(actionId); + verify(extensionCustomActionDetailsDao, never()).saveDetails(any()); + } + + @Test + public void updatedCustomActionDetails_RemovesDetails_WhenCleanupDetailsTrueCleanupParametersFalse() { + long actionId = 1L; + Map hiddenDetails = new HashMap<>(); + hiddenDetails.put(ApiConstants.PARAMETERS, "Test"); + when(extensionCustomActionDetailsDao.listDetailsKeyPairs(actionId, false)).thenReturn(hiddenDetails); + extensionsManager.updatedCustomActionDetails(actionId, true, null, false, null); + verify(extensionCustomActionDetailsDao, never()).removeDetails(actionId); + verify(extensionCustomActionDetailsDao).saveDetails(any()); + } + + @Test + public void updatedCustomActionDetails_RemovesDetails_WhenParameterGiven() { + long actionId = 1L; + extensionsManager.updatedCustomActionDetails(actionId, false, null, false, + List.of(mock(ExtensionCustomAction.Parameter.class))); + verify(extensionCustomActionDetailsDao, never()).removeDetails(actionId); + verify(extensionCustomActionDetailsDao, never()).saveDetails(any()); + verify(extensionCustomActionDetailsDao).persist(any(ExtensionCustomActionDetailsVO.class)); + } + + @Test + public void runCustomAction_SuccessfulExecution_ReturnsExpectedResult() throws Exception { + RunCustomActionCmd cmd = mock(RunCustomActionCmd.class); + when(cmd.getCustomActionId()).thenReturn(1L); + when(cmd.getResourceId()).thenReturn("vm-123"); + when(cmd.getParameters()).thenReturn(Map.of("param1", "value1")); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(extensionCustomActionDao.findById(1L)).thenReturn(actionVO); + when(actionVO.isEnabled()).thenReturn(true); + when(actionVO.getResourceType()).thenReturn(ExtensionCustomAction.ResourceType.VirtualMachine); + when(actionVO.getAllowedRoleTypes()).thenReturn( + RoleType.toCombinedMask(List.of(RoleType.Admin, RoleType.DomainAdmin, RoleType.User))); + + ExtensionVO extensionVO = mock(ExtensionVO.class); + when(extensionDao.findById(anyLong())).thenReturn(extensionVO); + when(extensionVO.getState()).thenReturn(Extension.State.Enabled); + + RunCustomActionAnswer answer = mock(RunCustomActionAnswer.class); + when(answer.getResult()).thenReturn(true); + + VirtualMachine vm = mock(VirtualMachine.class); + when(vm.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.External); + when(entityManager.findByUuid(eq(VirtualMachine.class), anyString())).thenReturn(vm); + when(virtualMachineManager.findClusterAndHostIdForVm(vm, false)).thenReturn(new Pair<>(1L, 1L)); + + when(extensionResourceMapDao.findByResourceIdAndType(1L, ExtensionResourceMap.ResourceType.Cluster)).thenReturn(mock(ExtensionResourceMapVO.class)); + when(extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(anyLong())).thenReturn(new Pair<>(new HashMap<>(), new HashMap<>())); + + when(agentMgr.send(anyLong(), any(Command.class))).thenReturn(answer); + + try (MockedStatic ignored = mockStatic(CallContext.class)) { + mockCallerRole(RoleType.User); + CustomActionResultResponse result = extensionsManager.runCustomAction(cmd); + + assertTrue(result.getSuccess()); + } + } + + @Test(expected = InvalidParameterValueException.class) + public void runCustomAction_ActionNotFound_ThrowsException() { + RunCustomActionCmd cmd = mock(RunCustomActionCmd.class); + when(cmd.getCustomActionId()).thenReturn(99L); + when(extensionCustomActionDao.findById(99L)).thenReturn(null); + + + try (MockedStatic ignored = mockStatic(CallContext.class)) { + mockCallerRole(RoleType.Admin); + extensionsManager.runCustomAction(cmd); + } + } + + @Test(expected = CloudRuntimeException.class) + public void runCustomAction_ActionNotAllowedForRole_ThrowsException() { + RunCustomActionCmd cmd = mock(RunCustomActionCmd.class); + when(cmd.getCustomActionId()).thenReturn(2L); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(extensionCustomActionDao.findById(2L)).thenReturn(actionVO); + when(actionVO.getAllowedRoleTypes()).thenReturn( + RoleType.toCombinedMask(List.of(RoleType.Admin, RoleType.DomainAdmin))); + + try (MockedStatic ignored = mockStatic(CallContext.class)) { + mockCallerRole(RoleType.User); + extensionsManager.runCustomAction(cmd); + } + } + + @Test(expected = CloudRuntimeException.class) + public void runCustomAction_ActionDisabled_ThrowsException() { + RunCustomActionCmd cmd = mock(RunCustomActionCmd.class); + when(cmd.getCustomActionId()).thenReturn(2L); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(extensionCustomActionDao.findById(2L)).thenReturn(actionVO); + when(actionVO.isEnabled()).thenReturn(false); + + try (MockedStatic ignored = mockStatic(CallContext.class)) { + mockCallerRole(RoleType.Admin); + extensionsManager.runCustomAction(cmd); + } + } + + @Test(expected = InvalidParameterValueException.class) + public void runCustomAction_InvalidResourceType_ThrowsException() { + RunCustomActionCmd cmd = mock(RunCustomActionCmd.class); + when(cmd.getCustomActionId()).thenReturn(3L); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(extensionCustomActionDao.findById(3L)).thenReturn(actionVO); + when(actionVO.isEnabled()).thenReturn(true); + when(actionVO.getResourceType()).thenReturn(null); + when(actionVO.getExtensionId()).thenReturn(1L); + ExtensionVO extensionVO = mock(ExtensionVO.class); + when(extensionVO.getState()).thenReturn(Extension.State.Enabled); + when(extensionDao.findById(1L)).thenReturn(extensionVO); + + + try (MockedStatic ignored = mockStatic(CallContext.class)) { + mockCallerRole(RoleType.Admin); + extensionsManager.runCustomAction(cmd); + } + } + + @Test + public void runCustomAction_ExecutionThrowsException() throws Exception { + RunCustomActionCmd cmd = mock(RunCustomActionCmd.class); + when(cmd.getCustomActionId()).thenReturn(1L); + when(cmd.getResourceId()).thenReturn("vm-123"); + when(cmd.getParameters()).thenReturn(Map.of("param1", "value1")); + + ExtensionCustomActionVO actionVO = mock(ExtensionCustomActionVO.class); + when(extensionCustomActionDao.findById(1L)).thenReturn(actionVO); + when(actionVO.isEnabled()).thenReturn(true); + when(actionVO.getResourceType()).thenReturn(ExtensionCustomAction.ResourceType.VirtualMachine); + + ExtensionVO extensionVO = mock(ExtensionVO.class); + when(extensionDao.findById(anyLong())).thenReturn(extensionVO); + when(extensionVO.getState()).thenReturn(Extension.State.Enabled); + + VirtualMachine vm = mock(VirtualMachine.class); + when(vm.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.External); + when(entityManager.findByUuid(eq(VirtualMachine.class), anyString())).thenReturn(vm); + when(virtualMachineManager.findClusterAndHostIdForVm(vm, false)).thenReturn(new Pair<>(1L, 1L)); + + when(extensionResourceMapDao.findByResourceIdAndType(1L, ExtensionResourceMap.ResourceType.Cluster)).thenReturn(mock(ExtensionResourceMapVO.class)); + when(extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(anyLong())).thenReturn(new Pair<>(new HashMap<>(), new HashMap<>())); + + when(agentMgr.send(anyLong(), any(Command.class))).thenThrow(OperationTimedoutException.class); + + try (MockedStatic ignored = mockStatic(CallContext.class)) { + mockCallerRole(RoleType.Admin); + CustomActionResultResponse result = extensionsManager.runCustomAction(cmd); + + assertFalse(result.getSuccess()); + } + } + + @Test + public void createCustomActionResponse_SetsBasicFields() { + ExtensionCustomAction action = mock(ExtensionCustomAction.class); + when(action.getUuid()).thenReturn("uuid-1"); + when(action.getName()).thenReturn("action1"); + when(action.getDescription()).thenReturn("desc"); + when(action.getResourceType()).thenReturn(ExtensionCustomAction.ResourceType.VirtualMachine); + + when(extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(anyLong())) + .thenReturn(new Pair<>(Map.of("foo", "bar"), Map.of())); + + ExtensionCustomActionResponse response = extensionsManager.createCustomActionResponse(action); + + assertEquals("uuid-1", response.getId()); + assertEquals("action1", response.getName()); + assertEquals("desc", response.getDescription()); + assertEquals("VirtualMachine", response.getResourceType()); + assertEquals("bar", response.getDetails().get("foo")); + } + + @Test + public void createCustomActionResponse_HandlesNullResourceType() { + ExtensionCustomAction action = mock(ExtensionCustomAction.class); + when(action.getUuid()).thenReturn("uuid-2"); + when(action.getName()).thenReturn("action2"); + when(action.getDescription()).thenReturn("desc2"); + when(action.getResourceType()).thenReturn(null); + + when(extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(anyLong())) + .thenReturn(new Pair<>(Collections.emptyMap(), Collections.emptyMap())); + + ExtensionCustomActionResponse response = extensionsManager.createCustomActionResponse(action); + + assertEquals("uuid-2", response.getId()); + assertNull(response.getResourceType()); + assertTrue(response.getDetails().isEmpty()); + } + + @Test + public void createCustomActionResponse_ParametersAreSetIfPresent() { + ExtensionCustomAction action = mock(ExtensionCustomAction.class); + when(action.getUuid()).thenReturn("uuid-3"); + when(action.getName()).thenReturn("action3"); + when(action.getDescription()).thenReturn("desc3"); + when(action.getResourceType()).thenReturn(ExtensionCustomAction.ResourceType.VirtualMachine); + + Map details = Map.of("foo", "bar"); + ExtensionCustomAction.Parameter param = new ExtensionCustomAction.Parameter("param1", + ExtensionCustomAction.Parameter.Type.STRING, ExtensionCustomAction.Parameter.ValidationFormat.NONE, + null, false); + Map hidden = Map.of(ApiConstants.PARAMETERS, + ExtensionCustomAction.Parameter.toJsonFromList(List.of(param))); + when(extensionCustomActionDetailsDao.listDetailsKeyPairsWithVisibility(anyLong())) + .thenReturn(new Pair<>(details, hidden)); + + ExtensionCustomActionResponse response = extensionsManager.createCustomActionResponse(action); + + assertEquals(ExtensionCustomAction.ResourceType.VirtualMachine.name(), response.getResourceType()); + assertEquals("bar", response.getDetails().get("foo")); + assertNotNull(response.getParameters()); + assertFalse(response.getParameters().isEmpty()); + } + + @Test + public void handleExtensionServerCommands_GetChecksumCommand_ReturnsChecksumAnswer() { + GetExtensionPathChecksumCommand cmd = mock(GetExtensionPathChecksumCommand.class); + when(cmd.getExtensionName()).thenReturn("ext"); + when(cmd.getExtensionRelativePath()).thenReturn("ext/entry.sh"); + when(extensionsManager.externalProvisioner.getChecksumForExtensionPath(anyString(), anyString())) + .thenReturn("checksum123"); + String json = extensionsManager.handleExtensionServerCommands(cmd); + assertTrue(json.contains("checksum123")); + assertTrue(json.contains("\"result\":true")); + } + + @Test + public void handleExtensionServerCommands_PreparePathCommand_ReturnsSuccessAnswer() { + PrepareExtensionPathCommand cmd = mock(PrepareExtensionPathCommand.class); + when(cmd.getExtensionName()).thenReturn("ext"); + when(cmd.getExtensionRelativePath()).thenReturn("ext/entry.sh"); + when(cmd.isExtensionUserDefined()).thenReturn(true); + doReturn(new Pair<>(true, "ok")).when(extensionsManager) + .prepareExtensionPathOnCurrentServer(anyString(), anyBoolean(), anyString()); + + String json = extensionsManager.handleExtensionServerCommands(cmd); + assertTrue(json.contains("\"result\":true")); + assertTrue(json.contains("ok")); + } + + @Test + public void handleExtensionServerCommands_CleanupFilesCommand_ReturnsSuccessAnswer() { + CleanupExtensionFilesCommand cmd = mock(CleanupExtensionFilesCommand.class); + when(cmd.getExtensionName()).thenReturn("ext"); + when(cmd.getExtensionRelativePath()).thenReturn("ext/entry.sh"); + doReturn(new Pair<>(true, "cleaned")).when(extensionsManager) + .cleanupExtensionFilesOnCurrentServer(anyString(), anyString()); + + String json = extensionsManager.handleExtensionServerCommands(cmd); + assertTrue(json.contains("\"result\":true")); + assertTrue(json.contains("cleaned")); + } + + @Test + public void handleExtensionServerCommands_UnsupportedCommand_ReturnsUnsupportedAnswer() { + ExtensionServerActionBaseCommand cmd = mock(ExtensionServerActionBaseCommand.class); + when(cmd.getExtensionName()).thenReturn("ext"); + when(cmd.getExtensionRelativePath()).thenReturn("ext/entry.sh"); + + String json = extensionsManager.handleExtensionServerCommands(cmd); + assertTrue(json.contains("Unsupported command")); + assertTrue(json.contains("\"result\":false")); + } + + @Test + public void getExtensionIdForCluster_WhenMappingExists_ReturnsExtensionId() { + long clusterId = 1L; + long extensionId = 100L; + ExtensionResourceMapVO mapVO = mock(ExtensionResourceMapVO.class); + when(extensionResourceMapDao.findByResourceIdAndType(eq(clusterId), any())) + .thenReturn(mapVO); + when(mapVO.getExtensionId()).thenReturn(extensionId); + + Long result = extensionsManager.getExtensionIdForCluster(clusterId); + + assertEquals(Long.valueOf(extensionId), result); + } + + @Test + public void getExtensionIdForCluster_WhenNoMappingExists_ReturnsNull() { + long clusterId = 42L; + when(extensionResourceMapDao.findByResourceIdAndType(eq(clusterId), any())) + .thenReturn(null); + + Long result = extensionsManager.getExtensionIdForCluster(clusterId); + + assertNull(result); + } + + @Test + public void getExtension_WhenExtensionExists_ReturnsExtension() { + long id = 1L; + ExtensionVO ext = mock(ExtensionVO.class); + when(extensionDao.findById(id)).thenReturn(ext); + + Extension result = extensionsManager.getExtension(id); + + assertEquals(ext, result); + } + + @Test + public void getExtension_WhenExtensionDoesNotExist_ReturnsNull() { + long id = 2L; + when(extensionDao.findById(id)).thenReturn(null); + + Extension result = extensionsManager.getExtension(id); + + assertNull(result); + } + + @Test + public void getExtensionForCluster_WhenMappingExists_ReturnsExtension() { + long clusterId = 10L; + long extensionId = 20L; + ExtensionVO ext = mock(ExtensionVO.class); + when(extensionsManager.getExtensionIdForCluster(clusterId)).thenReturn(extensionId); + when(extensionDao.findById(extensionId)).thenReturn(ext); + Extension result = extensionsManager.getExtensionForCluster(clusterId); + assertEquals(ext, result); + } + + @Test + public void getExtensionForCluster_WhenNoMappingExists_ReturnsNull() { + long clusterId = 10L; + when(extensionsManager.getExtensionIdForCluster(clusterId)).thenReturn(null); + Extension result = extensionsManager.getExtensionForCluster(clusterId); + assertNull(result); + } +} diff --git a/framework/pom.xml b/framework/pom.xml index 77a2710c335..3b534a4bb5a 100644 --- a/framework/pom.xml +++ b/framework/pom.xml @@ -51,6 +51,7 @@ db direct-download events + extensions ipc jobs managed-context diff --git a/packaging/debian/replace.properties b/packaging/debian/replace.properties index db88310d81c..5ea4a03b275 100644 --- a/packaging/debian/replace.properties +++ b/packaging/debian/replace.properties @@ -58,3 +58,4 @@ USAGECLASSPATH= USAGELOG=/var/log/cloudstack/usage/usage.log USAGESYSCONFDIR=/etc/cloudstack/usage PACKAGE=cloudstack +EXTENSIONSDEPLOYMENTMODE=production diff --git a/packaging/el8/cloud.spec b/packaging/el8/cloud.spec index 2c6898cac7c..995f758033a 100644 --- a/packaging/el8/cloud.spec +++ b/packaging/el8/cloud.spec @@ -319,6 +319,11 @@ mkdir -p ${RPM_BUILD_ROOT}%{_datadir}/%{name}-management/templates/systemvm cp -r engine/schema/dist/systemvm-templates/* ${RPM_BUILD_ROOT}%{_datadir}/%{name}-management/templates/systemvm rm -rf ${RPM_BUILD_ROOT}%{_datadir}/%{name}-management/templates/systemvm/md5sum.txt +# Sample Extensions +mkdir -p ${RPM_BUILD_ROOT}%{_sysconfdir}/%{name}/extensions +cp -r extensions/* ${RPM_BUILD_ROOT}%{_sysconfdir}/%{name}/extensions +ln -sf %{_sysconfdir}/%{name}/extensions ${RPM_BUILD_ROOT}%{_datadir}/%{name}-management/extensions + # UI mkdir -p ${RPM_BUILD_ROOT}%{_sysconfdir}/%{name}/ui mkdir -p ${RPM_BUILD_ROOT}%{_datadir}/%{name}-ui/ @@ -607,6 +612,7 @@ pip3 install --upgrade /usr/share/cloudstack-marvin/Marvin-*.tar.gz %{_datadir}/%{name}-management/lib/*.jar %{_datadir}/%{name}-management/logs %{_datadir}/%{name}-management/templates +%{_datadir}/%{name}-management/extensions %attr(0755,root,root) %{_bindir}/%{name}-setup-databases %attr(0755,root,root) %{_bindir}/%{name}-migrate-databases %attr(0755,root,root) %{_bindir}/%{name}-set-guest-password @@ -628,6 +634,8 @@ pip3 install --upgrade /usr/share/cloudstack-marvin/Marvin-*.tar.gz %{_defaultdocdir}/%{name}-management-%{version}/LICENSE %{_defaultdocdir}/%{name}-management-%{version}/NOTICE %{_datadir}/%{name}-management/setup/wheel/*.whl +%dir %attr(0755,cloud,cloud) %{_sysconfdir}/%{name}/extensions +%attr(0755,cloud,cloud) %{_sysconfdir}/%{name}/extensions/* %files agent %attr(0755,root,root) %{_bindir}/%{name}-setup-agent diff --git a/packaging/el8/replace.properties b/packaging/el8/replace.properties index efeab01166e..a6094b59c73 100644 --- a/packaging/el8/replace.properties +++ b/packaging/el8/replace.properties @@ -57,3 +57,4 @@ SYSTEMJARS= USAGECLASSPATH= USAGELOG=/var/log/cloudstack/usage/usage.log USAGESYSCONFDIR=/etc/sysconfig +EXTENSIONSDEPLOYMENTMODE=production diff --git a/plugins/hypervisors/external/pom.xml b/plugins/hypervisors/external/pom.xml new file mode 100644 index 00000000000..05a22cd2f9d --- /dev/null +++ b/plugins/hypervisors/external/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + + org.apache.cloudstack + cloudstack-plugins + 4.21.0.0-SNAPSHOT + ../../pom.xml + + cloud-plugin-hypervisor-external + Apache CloudStack Plugin - Hypervisor External + External Hypervisor for Cloudstack + + + org.apache.cloudstack + cloud-agent + ${project.version} + + + com.fasterxml.jackson.core + jackson-databind + ${cs.jackson.version} + compile + + + diff --git a/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/agent/manager/ExternalAgentManager.java b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/agent/manager/ExternalAgentManager.java new file mode 100644 index 00000000000..24f0816868c --- /dev/null +++ b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/agent/manager/ExternalAgentManager.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 org.apache.cloudstack.agent.manager; + +import com.cloud.utils.component.Manager; + +public interface ExternalAgentManager extends Manager { + +} diff --git a/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/agent/manager/ExternalAgentManagerImpl.java b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/agent/manager/ExternalAgentManagerImpl.java new file mode 100644 index 00000000000..c33ce479885 --- /dev/null +++ b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/agent/manager/ExternalAgentManagerImpl.java @@ -0,0 +1,55 @@ +// +// 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.agent.manager; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.config.Configurable; + +import com.cloud.utils.component.ManagerBase; +import com.cloud.utils.component.PluggableService; + +public class ExternalAgentManagerImpl extends ManagerBase implements ExternalAgentManager, Configurable, PluggableService { + + public static final ConfigKey expectMacAddressFromExternalProvisioner = new ConfigKey<>(Boolean.class, "expect.macaddress.from.external.provisioner", "Advanced", "false", + "Sample external provisioning config, any value that has to be sent", true, ConfigKey.Scope.Cluster, null); + + @Override + public boolean start() { + return true; + } + + @Override + public List> getCommands() { + return new ArrayList<>(); + } + + @Override + public String getConfigComponentName() { + return ExternalAgentManagerImpl.class.getSimpleName(); + } + + @Override + public ConfigKey[] getConfigKeys() { + return new ConfigKey[] {expectMacAddressFromExternalProvisioner}; + } +} diff --git a/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/agent/manager/ExternalServerPlanner.java b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/agent/manager/ExternalServerPlanner.java new file mode 100644 index 00000000000..33da0373b6a --- /dev/null +++ b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/agent/manager/ExternalServerPlanner.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.agent.manager; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import javax.inject.Inject; +import javax.naming.ConfigurationException; + +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.extension.ExtensionResourceMap; +import org.apache.cloudstack.framework.extensions.dao.ExtensionDao; +import org.apache.cloudstack.framework.extensions.dao.ExtensionResourceMapDao; +import org.apache.cloudstack.framework.extensions.vo.ExtensionVO; +import org.apache.commons.collections.CollectionUtils; + +import com.cloud.dc.DataCenter; +import com.cloud.dc.Pod; +import com.cloud.dc.dao.ClusterDao; +import com.cloud.dc.dao.DataCenterDao; +import com.cloud.dc.dao.HostPodDao; +import com.cloud.deploy.DeployDestination; +import com.cloud.deploy.DeploymentPlan; +import com.cloud.deploy.DeploymentPlanner; +import com.cloud.exception.InsufficientServerCapacityException; +import com.cloud.host.Host; +import com.cloud.host.HostVO; +import com.cloud.host.dao.HostDao; +import com.cloud.hypervisor.Hypervisor.HypervisorType; +import com.cloud.offering.ServiceOffering; +import com.cloud.org.Cluster; +import com.cloud.resource.ResourceManager; +import com.cloud.template.VirtualMachineTemplate; +import com.cloud.utils.component.AdapterBase; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.VirtualMachineProfile; + +public class ExternalServerPlanner extends AdapterBase implements DeploymentPlanner { + + @Inject + protected DataCenterDao dcDao; + @Inject + protected HostPodDao podDao; + @Inject + protected ClusterDao clusterDao; + @Inject + protected HostDao hostDao; + @Inject + protected ResourceManager resourceMgr; + @Inject + ExtensionDao extensionDao; + @Inject + ExtensionResourceMapDao extensionResourceMapDao; + + @Override + public DeployDestination plan(VirtualMachineProfile vmProfile, DeploymentPlan plan, ExcludeList avoid) throws InsufficientServerCapacityException { + VirtualMachine vm = vmProfile.getVirtualMachine(); + ServiceOffering offering = vmProfile.getServiceOffering(); + VirtualMachineTemplate template = vmProfile.getTemplate(); + Long extensionId = template.getExtensionId(); + final ExtensionVO extensionVO = extensionDao.findById(extensionId); + if (extensionVO == null) { + logger.error("Extension associated with {} cannot be found during deployment of external instance {}", + template, vmProfile.getInstanceName()); + return null; + } + if (!Extension.State.Enabled.equals(extensionVO.getState())) { + logger.error("{} is not in enabled state therefore planning can not be done for deployment of external instance {}", + extensionVO, vmProfile.getInstanceName()); + return null; + } + if (!extensionVO.isPathReady()) { + logger.error("{} path is not in ready state therefore planning can not be done for deployment of external instance {}", + extensionVO, vmProfile.getInstanceName()); + return null; + } + + String haVmTag = (String)vmProfile.getParameter(VirtualMachineProfile.Param.HaTag); + + if (vm.getLastHostId() != null) { + HostVO h = hostDao.findById(vm.getLastHostId()); + DataCenter dc = dcDao.findById(h.getDataCenterId()); + Pod pod = podDao.findById(h.getPodId()); + Cluster c = clusterDao.findById(h.getClusterId()); + logger.debug("Start external {} on last used {}", vm, h); + return new DeployDestination(dc, pod, c, h); + } + + String hostTag = null; + if (haVmTag != null) { + hostTag = haVmTag; + } else if (offering.getHostTag() != null) { + String[] tags = offering.getHostTag().split(","); + if (tags.length > 0) { + hostTag = tags[0]; + } + } + + List clusterIds = clusterDao.listEnabledClusterIdsByZoneHypervisorArch(vm.getDataCenterId(), + HypervisorType.External, vmProfile.getTemplate().getArch()); + List extensionClusterIds = extensionResourceMapDao.listResourceIdsByExtensionIdAndType(extensionId, + ExtensionResourceMap.ResourceType.Cluster); + if (CollectionUtils.isEmpty(extensionClusterIds)) { + logger.error("No clusters associated with {} to plan deployment of external instance {}", + vmProfile.getInstanceName()); + return null; + } + clusterIds = clusterIds.stream() + .filter(extensionClusterIds::contains) + .collect(Collectors.toList()); + logger.debug("Found {} clusters associated with {}", clusterIds.size(), extensionVO); + HostVO target = null; + List hosts; + for (Long clusterId : clusterIds) { + hosts = resourceMgr.listAllUpAndEnabledHosts(Host.Type.Routing, clusterId, null, + vm.getDataCenterId()); + if (hostTag != null) { + for (HostVO host : hosts) { + hostDao.loadHostTags(host); + List hostTags = host.getHostTags(); + if (hostTags.contains(hostTag)) { + target = host; + break; + } + } + } else { + if (CollectionUtils.isNotEmpty(hosts)) { + Collections.shuffle(hosts); + target = hosts.get(0); + break; + } + } + } + + if (target != null) { + DataCenter dc = dcDao.findById(target.getDataCenterId()); + Pod pod = podDao.findById(target.getPodId()); + Cluster cluster = clusterDao.findById(target.getClusterId()); + return new DeployDestination(dc, pod, cluster, target); + } + + logger.warn("Cannot find suitable host for deploying external instance {}", vmProfile.getInstanceName()); + return null; + } + + @Override + public boolean canHandle(VirtualMachineProfile vm, DeploymentPlan plan, ExcludeList avoid) { + return vm.getHypervisorType() == HypervisorType.External; + } + + @Override + public boolean configure(String name, Map params) throws ConfigurationException { + return true; + } + + @Override + public boolean start() { + return true; + } + + @Override + public boolean stop() { + return true; + } + +} diff --git a/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/agent/manager/ExternalTemplateAdapter.java b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/agent/manager/ExternalTemplateAdapter.java new file mode 100644 index 00000000000..aefe9d0d180 --- /dev/null +++ b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/agent/manager/ExternalTemplateAdapter.java @@ -0,0 +1,279 @@ +// 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.agent.manager; + +import java.util.Arrays; +import java.util.Date; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import org.apache.cloudstack.api.command.user.iso.DeleteIsoCmd; +import org.apache.cloudstack.api.command.user.iso.GetUploadParamsForIsoCmd; +import org.apache.cloudstack.api.command.user.iso.RegisterIsoCmd; +import org.apache.cloudstack.api.command.user.template.RegisterTemplateCmd; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; +import org.apache.cloudstack.storage.command.TemplateOrVolumePostUploadCommand; +import org.apache.cloudstack.storage.datastore.db.ImageStoreVO; +import org.apache.cloudstack.storage.datastore.db.TemplateDataStoreVO; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.configuration.Resource; +import com.cloud.dc.DataCenterVO; +import com.cloud.event.EventTypes; +import com.cloud.event.UsageEventVO; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.storage.Storage; +import com.cloud.storage.TemplateProfile; +import com.cloud.storage.VMTemplateStorageResourceAssoc; +import com.cloud.storage.VMTemplateVO; +import com.cloud.storage.VMTemplateZoneVO; +import com.cloud.template.TemplateAdapter; +import com.cloud.template.TemplateAdapterBase; +import com.cloud.template.VirtualMachineTemplate; +import com.cloud.user.Account; +import com.cloud.utils.db.DB; +import com.cloud.utils.db.Transaction; +import com.cloud.utils.db.TransactionCallback; +import com.cloud.utils.exception.CloudRuntimeException; + +public class ExternalTemplateAdapter extends TemplateAdapterBase implements TemplateAdapter { + + @Override + public String getName() { + return TemplateAdapterType.External.getName(); + } + + @Override + public TemplateProfile prepare(RegisterTemplateCmd cmd) throws ResourceAllocationException { + Account caller = CallContext.current().getCallingAccount(); + Account owner = _accountMgr.getAccount(cmd.getEntityOwnerId()); + _accountMgr.checkAccess(caller, null, true, owner); + Storage.TemplateType templateType = templateMgr.validateTemplateType(cmd, _accountMgr.isAdmin(caller.getAccountId()), + CollectionUtils.isEmpty(cmd.getZoneIds()), Hypervisor.HypervisorType.External); + + List zoneId = cmd.getZoneIds(); + // ignore passed zoneId if we are using region wide image store + List stores = _imgStoreDao.findRegionImageStores(); + if (stores != null && stores.size() > 0) { + zoneId = null; + } + + Hypervisor.HypervisorType hypervisorType = Hypervisor.HypervisorType.getType(cmd.getHypervisor()); + if(hypervisorType == Hypervisor.HypervisorType.None) { + throw new InvalidParameterValueException(String.format( + "Hypervisor Type: %s is invalid. Supported Hypervisor types are: %s", + cmd.getHypervisor(), + StringUtils.join(Arrays.stream(Hypervisor.HypervisorType.values()).filter(h -> h != Hypervisor.HypervisorType.None).map(Hypervisor.HypervisorType::name).toArray(), ", "))); + } + + Map details = cmd.getDetails(); + Map externalDetails = cmd.getExternalDetails(); + if (details != null) { + details.putAll(externalDetails); + } else { + details = externalDetails; + } + + return prepare(false, CallContext.current().getCallingUserId(), cmd.getTemplateName(), cmd.getDisplayText(), cmd.getArch(), cmd.getBits(), cmd.isPasswordEnabled(), cmd.getRequiresHvm(), + cmd.getUrl(), cmd.isPublic(), cmd.isFeatured(), cmd.isExtractable(), cmd.getFormat(), cmd.getOsTypeId(), zoneId, hypervisorType, cmd.getChecksum(), true, + cmd.getTemplateTag(), owner, details, cmd.isSshKeyEnabled(), null, cmd.isDynamicallyScalable(), templateType, + cmd.isDirectDownload(), cmd.isDeployAsIs(), cmd.isForCks(), cmd.getExtensionId()); + } + + @Override + public TemplateProfile prepare(RegisterIsoCmd cmd) throws ResourceAllocationException { + throw new CloudRuntimeException("External hypervisor doesn't support ISO template"); + } + + @Override + public TemplateProfile prepare(GetUploadParamsForIsoCmd cmd) throws ResourceAllocationException { + throw new CloudRuntimeException("External hypervisor doesn't support ISO template"); + } + + private void templateCreateUsage(VMTemplateVO template, long dcId) { + if (template.getAccountId() != Account.ACCOUNT_ID_SYSTEM) { + UsageEventVO usageEvent = + new UsageEventVO(EventTypes.EVENT_TEMPLATE_CREATE, template.getAccountId(), dcId, template.getId(), template.getName(), null, + template.getSourceTemplateId(), 0L); + _usageEventDao.persist(usageEvent); + } + } + + @Override + public VMTemplateVO create(TemplateProfile profile) { + VMTemplateVO template = persistTemplate(profile, VirtualMachineTemplate.State.Active); + List zones = profile.getZoneIdList(); + + // create an entry at template_store_ref with store_id = null to represent that this template is ready for use. + TemplateDataStoreVO vmTemplateHost = + new TemplateDataStoreVO(null, template.getId(), new Date(), 100, VMTemplateStorageResourceAssoc.Status.DOWNLOADED, null, null, null, null, template.getUrl()); + this._tmpltStoreDao.persist(vmTemplateHost); + + if (zones == null) { + List dcs = _dcDao.listAllIncludingRemoved(); + if (dcs != null && dcs.size() > 0) { + templateCreateUsage(template, dcs.get(0).getId()); + } + } else { + for (Long zoneId: zones) { + templateCreateUsage(template, zoneId); + } + } + + _resourceLimitMgr.incrementResourceCount(profile.getAccountId(), Resource.ResourceType.template); + return template; + } + + @Override + public List createTemplateForPostUpload(TemplateProfile profile) { + return Transaction.execute((TransactionCallback>) status -> { + if (Storage.ImageFormat.ISO.equals(profile.getFormat())) { + throw new CloudRuntimeException("ISO upload is not supported for External hypervisor"); + } + List zoneIdList = profile.getZoneIdList(); + if (zoneIdList == null) { + throw new CloudRuntimeException("Zone ID is null, cannot upload template."); + } + if (zoneIdList.size() > 1) { + throw new CloudRuntimeException("Operation is not supported for more than one zone id at a time."); + } + VMTemplateVO template = persistTemplate(profile, VirtualMachineTemplate.State.NotUploaded); + if (template == null) { + throw new CloudRuntimeException("Unable to persist the template " + profile.getTemplate()); + } + // Set Event Details for Template/ISO Upload + String eventResourceId = template.getUuid(); + CallContext.current().setEventDetails(String.format("Template Id: %s", eventResourceId)); + CallContext.current().putContextParameter(VirtualMachineTemplate.class, eventResourceId); + Long zoneId = zoneIdList.get(0); + DataStore imageStore = verifyHeuristicRulesForZone(template, zoneId); + List payloads = new LinkedList<>(); + if (imageStore == null) { + List imageStores = getImageStoresThrowsExceptionIfNotFound(zoneId, profile); + postUploadAllocation(imageStores, template, payloads); + } else { + postUploadAllocation(List.of(imageStore), template, payloads); + } + if (payloads.isEmpty()) { + throw new CloudRuntimeException("Unable to find zone or an image store with enough capacity"); + } + _resourceLimitMgr.incrementResourceCount(profile.getAccountId(), Resource.ResourceType.template); + return payloads; + }); + } + + @Override + public TemplateProfile prepareDelete(DeleteIsoCmd cmd) { + throw new CloudRuntimeException("External hypervisor doesn't support ISO, how the delete get here???"); + } + + @Override + @DB + public boolean delete(TemplateProfile profile) { + VMTemplateVO template = profile.getTemplate(); + Long templateId = template.getId(); + boolean success = true; + String zoneName; + + if (profile.getZoneIdList() != null && profile.getZoneIdList().size() > 1) + throw new CloudRuntimeException("Operation is not supported for more than one zone id at a time"); + + if (!template.isCrossZones() && profile.getZoneIdList() != null) { + //get the first element in the list + zoneName = profile.getZoneIdList().get(0).toString(); + } else { + zoneName = "all zones"; + } + + logger.debug("Attempting to mark template host refs for {} as destroyed in zone: {}", template, zoneName); + Account account = _accountDao.findByIdIncludingRemoved(template.getAccountId()); + String eventType = EventTypes.EVENT_TEMPLATE_DELETE; + List templateHostVOs = this._tmpltStoreDao.listByTemplate(templateId); + + for (TemplateDataStoreVO vo : templateHostVOs) { + TemplateDataStoreVO lock = null; + try { + lock = _tmpltStoreDao.acquireInLockTable(vo.getId()); + if (lock == null) { + logger.debug("Failed to acquire lock when deleting templateDataStoreVO with ID: {}", vo.getId()); + success = false; + break; + } + + vo.setDestroyed(true); + _tmpltStoreDao.update(vo.getId(), vo); + + } finally { + if (lock != null) { + _tmpltStoreDao.releaseFromLockTable(lock.getId()); + } + } + } + + if (profile.getZoneIdList() != null) { + UsageEventVO usageEvent = new UsageEventVO(eventType, account.getId(), profile.getZoneIdList().get(0), + templateId, null); + _usageEventDao.persist(usageEvent); + + VMTemplateZoneVO templateZone = _tmpltZoneDao.findByZoneTemplate(profile.getZoneIdList().get(0), templateId); + + if (templateZone != null) { + _tmpltZoneDao.remove(templateZone.getId()); + } + } else { + List dcs = _dcDao.listAllIncludingRemoved(); + for (DataCenterVO dc : dcs) { + UsageEventVO usageEvent = new UsageEventVO(eventType, account.getId(), dc.getId(), templateId, null); + _usageEventDao.persist(usageEvent); + } + } + + logger.debug("Successfully marked template host refs for {}} as destroyed in zone: {}", template, zoneName); + + // If there are no more non-destroyed template host entries for this template, delete it + if (success && _tmpltStoreDao.listByTemplate(templateId).isEmpty()) { + long accountId = template.getAccountId(); + + VMTemplateVO lock = _tmpltDao.acquireInLockTable(templateId); + + try { + if (lock == null) { + logger.debug("Failed to acquire lock when deleting template with ID: {}", templateId); + success = false; + } else if (_tmpltDao.remove(templateId)) { + // Decrement the number of templates and total secondary storage space used by the account. + _resourceLimitMgr.decrementResourceCount(accountId, Resource.ResourceType.template); + _resourceLimitMgr.recalculateResourceCount(accountId, _accountMgr.getAccount(accountId).getDomainId(), Resource.ResourceType.secondary_storage.getOrdinal()); + } + + } finally { + if (lock != null) { + _tmpltDao.releaseFromLockTable(lock.getId()); + } + } + logger.debug("Removed template: {} because all of its template host refs were marked as destroyed.", template.getName()); + } + + return success; + } +} diff --git a/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/guru/ExternalHypervisorGuru.java b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/guru/ExternalHypervisorGuru.java new file mode 100644 index 00000000000..cd6a2cf996a --- /dev/null +++ b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/guru/ExternalHypervisorGuru.java @@ -0,0 +1,114 @@ +// 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.guru; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; + +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; +import org.apache.commons.collections.MapUtils; + +import com.cloud.agent.api.Command; +import com.cloud.agent.api.StopCommand; +import com.cloud.agent.api.to.VirtualMachineTO; +import com.cloud.host.Host; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.hypervisor.HypervisorGuru; +import com.cloud.hypervisor.HypervisorGuruBase; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.VirtualMachineManager; +import com.cloud.vm.VirtualMachineProfile; +import com.cloud.vm.VirtualMachineProfileImpl; +import com.cloud.vm.dao.UserVmDao; + +public class ExternalHypervisorGuru extends HypervisorGuruBase implements HypervisorGuru { + + @Inject + private VirtualMachineManager virtualMachineManager; + @Inject + private UserVmDao userVmDao; + @Inject + ExtensionsManager extensionsManager; + + protected ExternalHypervisorGuru() { + super(); + } + + @Override + public Hypervisor.HypervisorType getHypervisorType() { + return Hypervisor.HypervisorType.External; + } + + @Override + public VirtualMachineTO implement(VirtualMachineProfile vm) { + VirtualMachineTO to = toVirtualMachineTO(vm); + return to; + } + + @Override + public boolean trackVmHostChange() { + return false; + } + + @Override + protected VirtualMachineTO toVirtualMachineTO(VirtualMachineProfile vmProfile) { + VirtualMachineTO to = super.toVirtualMachineTO(vmProfile); + + Map newDetails = new HashMap<>(); + Map toDetails = to.getDetails(); + Map serviceOfferingDetails = _serviceOfferingDetailsDao.listDetailsKeyPairs(vmProfile.getServiceOfferingId()); + if (MapUtils.isNotEmpty(serviceOfferingDetails)) { + newDetails.putAll(serviceOfferingDetails); + } + newDetails.putAll(toDetails); + if (MapUtils.isNotEmpty(newDetails)) { + to.setDetails(newDetails); + } + + return to; + } + + protected void updateStopCommandForExternalHypervisorType(final Hypervisor.HypervisorType hypervisorType, + final Long hostId, final Map vmExternalDetails, final StopCommand stopCommand) { + if (!Hypervisor.HypervisorType.External.equals(hypervisorType) || hostId == null) { + return; + } + Host host = hostDao.findById(hostId); + if (host == null) { + return; + } + stopCommand.setExternalDetails(extensionsManager.getExternalAccessDetails(host, vmExternalDetails)); + stopCommand.setExpungeVM(true); + } + + public List finalizeExpunge(VirtualMachine vm) { + List commands = new ArrayList<>(); + final StopCommand stop = new StopCommand(vm, virtualMachineManager.getExecuteInSequence(vm.getHypervisorType()), false, false); + VirtualMachineProfile profile = new VirtualMachineProfileImpl(vm); + VirtualMachineTO virtualMachineTO = toVirtualMachineTO(profile); + stop.setVirtualMachine(virtualMachineTO); + final Long hostId = vm.getHostId() != null ? vm.getHostId() : vm.getLastHostId(); + updateStopCommandForExternalHypervisorType(vm.getHypervisorType(), hostId, + virtualMachineTO.getExternalDetails(), stop); + commands.add(stop); + return commands; + } +} diff --git a/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/discoverer/ExternalServerDiscoverer.java b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/discoverer/ExternalServerDiscoverer.java new file mode 100644 index 00000000000..643a29fe3ee --- /dev/null +++ b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/discoverer/ExternalServerDiscoverer.java @@ -0,0 +1,294 @@ +// 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.hypervisor.external.discoverer; + +import java.net.URI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import javax.inject.Inject; +import javax.naming.ConfigurationException; + +import org.apache.cloudstack.extension.ExtensionResourceMap; +import org.apache.cloudstack.framework.extensions.dao.ExtensionDao; +import org.apache.cloudstack.framework.extensions.dao.ExtensionResourceMapDao; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; +import org.apache.cloudstack.framework.extensions.vo.ExtensionResourceMapVO; +import org.apache.cloudstack.framework.extensions.vo.ExtensionVO; +import org.apache.cloudstack.hypervisor.external.resource.ExternalResource; + +import com.cloud.agent.AgentManager; +import com.cloud.agent.Listener; +import com.cloud.agent.api.AgentControlAnswer; +import com.cloud.agent.api.AgentControlCommand; +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.Command; +import com.cloud.agent.api.StartupCommand; +import com.cloud.agent.api.StartupRoutingCommand; +import com.cloud.dc.ClusterVO; +import com.cloud.exception.ConnectionException; +import com.cloud.exception.DiscoveryException; +import com.cloud.host.Host; +import com.cloud.host.HostVO; +import com.cloud.host.Status; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.resource.Discoverer; +import com.cloud.resource.DiscovererBase; +import com.cloud.resource.ResourceStateAdapter; +import com.cloud.resource.ServerResource; +import com.cloud.resource.UnableDeleteHostException; + +public class ExternalServerDiscoverer extends DiscovererBase implements Discoverer, Listener, ResourceStateAdapter { + + @Inject + AgentManager agentManager; + + @Inject + ExtensionDao extensionDao; + + @Inject + ExtensionResourceMapDao extensionResourceMapDao; + + @Inject + ExtensionsManager extensionsManager; + + @Override + public boolean processAnswers(long agentId, long seq, Answer[] answers) { + return false; + } + + @Override + public boolean processCommands(long agentId, long seq, Command[] commands) { + return false; + } + + @Override + public AgentControlAnswer processControlCommand(long agentId, AgentControlCommand cmd) { + return null; + } + + @Override + public void processHostAdded(long hostId) { + + } + + @Override + public void processConnect(Host host, StartupCommand cmd, boolean forRebalance) throws ConnectionException { + + } + + @Override + public boolean processDisconnect(long agentId, Status state) { + return false; + } + + @Override + public void processHostAboutToBeRemoved(long hostId) { + + } + + @Override + public void processHostRemoved(long hostId, long clusterId) { + + } + + @Override + public boolean isRecurring() { + return false; + } + + @Override + public int getTimeout() { + return 0; + } + + @Override + public boolean processTimeout(long agentId, long seq) { + return false; + } + + @Override + public boolean configure(String name, Map params) throws ConfigurationException { + super.configure(name, params); + agentManager.registerForHostEvents(this, true, false, true); + _resourceMgr.registerResourceStateAdapter(this.getClass().getSimpleName(), this); + return true; + } + + protected String getResourceGuidFromName(String name) { + return "External:" + UUID.nameUUIDFromBytes(name.getBytes()); + } + + protected void addExtensionDataToResourceParams(ExtensionVO extension, Map params) { + params.put("extensionName", extension.getName()); + params.put("extensionRelativePath", extension.getRelativePath()); + params.put("extensionState", extension.getState()); + params.put("extensionPathReady", extension.isPathReady()); + } + + @Override + public Map> find(long dcId, Long podId, Long clusterId, URI uri, String username, String password, List hostTags) throws DiscoveryException { + Map> resources; + String errorMessage; + if (clusterId == null) { + errorMessage = "Must specify cluster Id when adding host"; + logger.error(errorMessage); + throw new DiscoveryException(errorMessage); + } + ClusterVO cluster = _clusterDao.findById(clusterId); + if (cluster == null || (cluster.getHypervisorType() != Hypervisor.HypervisorType.External)) { + errorMessage = "Invalid cluster id or cluster is not for External hypervisors"; + logger.error(errorMessage); + throw new DiscoveryException(errorMessage); + } + if (podId == null) { + errorMessage = "Must specify pod when adding host"; + logger.error(errorMessage); + throw new DiscoveryException(errorMessage); + } + ExtensionResourceMapVO extensionResourceMapVO = extensionResourceMapDao.findByResourceIdAndType(clusterId, + ExtensionResourceMap.ResourceType.Cluster); + if (extensionResourceMapVO == null) { + logger.error("External hypervisor {} must be registered with an extension when adding host", + cluster); + throw new DiscoveryException(String.format("Cluster: %s is not registered with an extension", + cluster.getName())); + } + ExtensionVO extensionVO = extensionDao.findById(extensionResourceMapVO.getExtensionId()); + if (extensionVO == null) { + logger.error("Extension ID: {} to which {} cluster is registered is not found", + extensionResourceMapVO.getExtensionId(), cluster); + throw new DiscoveryException(String.format("Cluster: %s is registered with an inexistent extension", + cluster.getName())); + } + if (cluster.getGuid() == null) { + cluster.setGuid(UUID.randomUUID().toString()); + _clusterDao.update(clusterId, cluster); + } + Map params = new HashMap<>(); + params.put("username", username); + params.put("password", password); + params.put("zone", Long.toString(dcId)); + params.put("pod", Long.toString(podId)); + params.put("cluster", Long.toString(clusterId)); + String name = uri.toString(); + params.put("guid", getResourceGuidFromName(name)); + addExtensionDataToResourceParams(extensionVO, params); + resources = createAgentResource(name, params); + if (resources == null) { + throw new DiscoveryException("Failed to create external agent"); + } + return resources; + } + + @Override + protected HashMap buildConfigParams(HostVO host) { + HashMap params = super.buildConfigParams(host); + long clusterId = Long.parseLong((String) params.get("cluster")); + ExtensionResourceMapVO extensionResourceMapVO = + extensionResourceMapDao.findByResourceIdAndType(clusterId, + ExtensionResourceMap.ResourceType.Cluster); + if (extensionResourceMapVO == null) { + logger.debug("Cluster ID: {} not registered with any extension", clusterId); + return params; + } + ExtensionVO extensionVO = extensionDao.findById(extensionResourceMapVO.getExtensionId()); + if (extensionVO == null) { + logger.error("Extension with ID: {} not found", extensionResourceMapVO.getExtensionId()); + return params; + } + addExtensionDataToResourceParams(extensionVO, params); + return params; + } + + private Map> createAgentResource(String name, Map params) { + try { + logger.info("Creating external server resource: {}", name); + Map args = new HashMap<>(); + Map> newResources = new HashMap<>(); + ExternalResource agentResource; + synchronized (this) { + agentResource = new ExternalResource(); + try { + agentResource.start(); + agentResource.configure(name, params); + args.put("guid", (String)params.get("guid")); + newResources.put(agentResource, args); + } catch (ConfigurationException e) { + logger.error("Error while configuring server resource {}", e.getMessage()); + } + } + return newResources; + } catch (Exception ex) { + logger.warn("Caught creating external server resources {}", name, ex); + } + return null; + } + + @Override + public void postDiscovery(List hosts, long msId) { + } + + @Override + public boolean matchHypervisor(String hypervisor) { + if (hypervisor == null) + return true; + + return getHypervisorType().toString().equalsIgnoreCase(hypervisor); + } + + @Override + public Hypervisor.HypervisorType getHypervisorType() { + return Hypervisor.HypervisorType.External; + } + + @Override + public HostVO createHostVOForConnectedAgent(HostVO host, StartupCommand[] cmd) { + return null; + } + + @Override + public HostVO createHostVOForDirectConnectAgent(HostVO host, StartupCommand[] startup, ServerResource resource, + Map details, List hostTags) { + StartupCommand firstCmd = startup[0]; + if (!(firstCmd instanceof StartupRoutingCommand)) { + return null; + } + StartupRoutingCommand ssCmd = (StartupRoutingCommand)firstCmd; + if (ssCmd.getHypervisorType() != Hypervisor.HypervisorType.External) { + return null; + } + ExtensionResourceMapVO extensionResourceMapVO = extensionResourceMapDao.findByResourceIdAndType( + host.getClusterId(), ExtensionResourceMap.ResourceType.Cluster); + if (extensionResourceMapVO != null) { + ExtensionVO extension = extensionDao.findById(extensionResourceMapVO.getExtensionId()); + logger.debug("Creating {} for {}", host, extension); + extensionsManager.prepareExtensionPathAcrossServers(extension); + } else { + logger.debug("Creating {}. No extension registered for cluster ID: {}", host, host.getClusterId()); + } + return _resourceMgr.fillRoutingHostVO(host, ssCmd, Hypervisor.HypervisorType.External, details, hostTags); + } + + @Override + public DeleteHostAnswer deleteHost(HostVO host, boolean isForced, boolean isForceDeleteStorage) throws UnableDeleteHostException { + return new DeleteHostAnswer(true); + } +} diff --git a/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisioner.java b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisioner.java new file mode 100644 index 00000000000..5a1632ce977 --- /dev/null +++ b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisioner.java @@ -0,0 +1,832 @@ +// +// 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.hypervisor.external.provisioner; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +import javax.inject.Inject; +import javax.naming.ConfigurationException; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; +import org.apache.cloudstack.utils.security.DigestHelper; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.collections.MapUtils; + +import com.cloud.agent.AgentManager; +import com.cloud.agent.api.HostVmStateReportEntry; +import com.cloud.agent.api.PrepareExternalProvisioningAnswer; +import com.cloud.agent.api.PrepareExternalProvisioningCommand; +import com.cloud.agent.api.RebootAnswer; +import com.cloud.agent.api.RebootCommand; +import com.cloud.agent.api.RunCustomActionAnswer; +import com.cloud.agent.api.RunCustomActionCommand; +import com.cloud.agent.api.StartAnswer; +import com.cloud.agent.api.StartCommand; +import com.cloud.agent.api.StopAnswer; +import com.cloud.agent.api.StopCommand; +import com.cloud.agent.api.to.VirtualMachineTO; +import com.cloud.host.HostVO; +import com.cloud.host.dao.HostDao; +import com.cloud.hypervisor.ExternalProvisioner; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.hypervisor.HypervisorGuru; +import com.cloud.hypervisor.HypervisorGuruManager; +import com.cloud.serializer.GsonHelper; +import com.cloud.utils.FileUtil; +import com.cloud.utils.Pair; +import com.cloud.utils.PropertiesUtil; +import com.cloud.utils.StringUtils; +import com.cloud.utils.component.ManagerBase; +import com.cloud.utils.component.PluggableService; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.utils.json.JsonMergeUtil; +import com.cloud.utils.script.Script; +import com.cloud.vm.UserVmVO; +import com.cloud.vm.VMInstanceVO; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.VirtualMachineProfile; +import com.cloud.vm.VirtualMachineProfileImpl; +import com.cloud.vm.VmDetailConstants; +import com.cloud.vm.dao.UserVmDao; +import com.cloud.vm.dao.VMInstanceDao; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +public class ExternalPathPayloadProvisioner extends ManagerBase implements ExternalProvisioner, PluggableService { + + public static final String BASE_EXTERNAL_PROVISIONER_SCRIPTS_DIR = "scripts/vm/hypervisor/external/provisioner"; + public static final String BASE_EXTERNAL_PROVISIONER_SHELL_SCRIPT = + BASE_EXTERNAL_PROVISIONER_SCRIPTS_DIR + "/provisioner.sh"; + + private static final String PROPERTIES_FILE = "server.properties"; + private static final String EXTENSIONS_DEPLOYMENT_MODE_NAME = "extensions.deployment.mode"; + private static final String EXTENSIONS_DIRECTORY_PROD = "/usr/share/cloudstack-management/extensions"; + private static final String EXTENSIONS_DATA_DIRECTORY_PROD = "/var/lib/cloudstack/management/extensions"; + private static final String EXTENSIONS_DIRECTORY_DEV = "extensions"; + private static final String EXTENSIONS_DATA_DIRECTORY_DEV = "client/target/extensions-data"; + + @Inject + UserVmDao _uservmDao; + + @Inject + HostDao hostDao; + + @Inject + VMInstanceDao vmInstanceDao; + + @Inject + HypervisorGuruManager hypervisorGuruManager; + + @Inject + ExtensionsManager extensionsManager; + + private static final AtomicReference propertiesRef = new AtomicReference<>(); + private String extensionsDirectory; + private String extensionsDataDirectory; + private ExecutorService payloadCleanupExecutor; + private ScheduledExecutorService payloadCleanupScheduler; + private static final List TRIVIAL_ACTIONS = Arrays.asList( + "status" + ); + + @Override + public String getName() { + return getClass().getSimpleName(); + } + + protected Map loadAccessDetails(Map> externalDetails, + VirtualMachineTO virtualMachineTO) { + Map modifiedDetails = new HashMap<>(); + if (MapUtils.isNotEmpty(externalDetails)) { + modifiedDetails.put(ApiConstants.EXTERNAL_DETAILS, externalDetails); + } + if (virtualMachineTO != null) { + modifiedDetails.put(ApiConstants.VIRTUAL_MACHINE_ID, virtualMachineTO.getUuid()); + modifiedDetails.put(ApiConstants.VIRTUAL_MACHINE_NAME, virtualMachineTO.getName()); + modifiedDetails.put(VmDetailConstants.CLOUDSTACK_VM_DETAILS, virtualMachineTO); + } + return modifiedDetails; + } + + protected String getExtensionCheckedPath(String extensionName, String extensionRelativePath) { + String path = getExtensionPath(extensionRelativePath); + File file = new File(path); + String errorSuffix = String.format("Entry point [%s] for extension: %s", path, extensionName); + if (!file.exists()) { + logger.error("{} does not exist", errorSuffix); + return null; + } + if (!file.isFile()) { + logger.error("{} is not a file", errorSuffix); + return null; + } + if (!file.canRead()) { + logger.error("{} is not readable", errorSuffix); + return null; + } + if (!file.canExecute()) { + logger.error("{} is not executable", errorSuffix); + return null; + } + return path; + + } + + protected boolean checkExtensionsDirectory() { + File dir = new File(extensionsDirectory); + if (!dir.exists() || !dir.isDirectory() || !dir.canWrite()) { + logger.error("Extension directory [{}] is not properly set up. It must exist, be a directory, and be writeable", + dir.getAbsolutePath()); + return false; + } + if (!extensionsDirectory.equals(dir.getAbsolutePath())) { + extensionsDirectory = dir.getAbsolutePath(); + } + logger.info("Extensions directory path: {}", extensionsDirectory); + return true; + } + + protected void createOrCheckExtensionsDataDirectory() throws ConfigurationException { + File dir = new File(extensionsDataDirectory); + if (!dir.exists()) { + try { + Files.createDirectories(dir.toPath()); + } catch (IOException e) { + logger.error("Unable to create extensions data directory [{}]", dir.getAbsolutePath(), e); + throw new ConfigurationException("Unable to create extensions data directory path"); + } + } + if (!dir.isDirectory() || !dir.canWrite()) { + logger.error("Extensions data directory [{}] is not properly set up. It must exist, be a directory, and be writeable", + dir.getAbsolutePath()); + throw new ConfigurationException("Extensions data directory path is not accessible"); + } + extensionsDataDirectory = dir.getAbsolutePath(); + logger.info("Extensions data directory path: {}", extensionsDataDirectory); + } + + private String getServerProperty(String name) { + Properties props = propertiesRef.get(); + if (props == null) { + File propsFile = PropertiesUtil.findConfigFile(PROPERTIES_FILE); + if (propsFile == null) { + logger.error("{} file not found", PROPERTIES_FILE); + return null; + } + Properties tempProps = new Properties(); + try (FileInputStream is = new FileInputStream(propsFile)) { + tempProps.load(is); + } catch (IOException e) { + logger.error("Error loading {}: {}", PROPERTIES_FILE, e.getMessage(), e); + return null; + } + if (!propertiesRef.compareAndSet(null, tempProps)) { + tempProps = propertiesRef.get(); + } + props = tempProps; + } + return props.getProperty(name); + } + + @Override + public boolean configure(String name, Map params) throws ConfigurationException { + super.configure(name, params); + + initializeExtensionDirectories(); + checkExtensionsDirectory(); + createOrCheckExtensionsDataDirectory(); + return true; + } + + private void initializeExtensionDirectories() { + String deploymentMode = getServerProperty(EXTENSIONS_DEPLOYMENT_MODE_NAME); + if ("developer".equals(deploymentMode)) { + extensionsDirectory = EXTENSIONS_DIRECTORY_DEV; + extensionsDataDirectory = EXTENSIONS_DATA_DIRECTORY_DEV; + } else { + extensionsDirectory = EXTENSIONS_DIRECTORY_PROD; + extensionsDataDirectory = EXTENSIONS_DATA_DIRECTORY_PROD; + } + } + + @Override + public boolean start() { + payloadCleanupExecutor = Executors.newSingleThreadExecutor(); + payloadCleanupScheduler = Executors.newSingleThreadScheduledExecutor(); + return true; + } + + @Override + public boolean stop() { + payloadCleanupExecutor.shutdown(); + payloadCleanupScheduler.shutdown(); + return true; + } + + @Override + public String getExtensionsPath() { + return extensionsDirectory; + } + + @Override + public String getExtensionPath(String relativePath) { + return String.format("%s%s%s", extensionsDirectory, File.separator, relativePath); + } + + @Override + public String getChecksumForExtensionPath(String extensionName, String relativePath) { + String path = getExtensionCheckedPath(extensionName, relativePath); + if (StringUtils.isBlank(path)) { + return null; + } + try { + return DigestHelper.calculateChecksum(new File(path)); + } catch (CloudRuntimeException ignored) { + return null; + } + } + + @Override + public PrepareExternalProvisioningAnswer prepareExternalProvisioning(String hostGuid, + String extensionName, String extensionRelativePath, PrepareExternalProvisioningCommand cmd) { + String extensionPath = getExtensionCheckedPath(extensionName, extensionRelativePath); + if (StringUtils.isEmpty(extensionPath)) { + return new PrepareExternalProvisioningAnswer(cmd, false, "Extension not configured"); + } + VirtualMachineTO vmTO = cmd.getVirtualMachineTO(); + String vmUUID = vmTO.getUuid(); + logger.debug("Executing PrepareExternalProvisioningCommand in the external provisioner " + + "for the VM {} as part of VM deployment", vmUUID); + Map accessDetails = loadAccessDetails(cmd.getExternalDetails(), vmTO); + Pair result = prepareExternalProvisioningInternal(extensionName, extensionPath, + vmUUID, accessDetails, cmd.getWait()); + String output = result.second(); + if (!result.first()) { + return new PrepareExternalProvisioningAnswer(cmd, false, output); + } + if (StringUtils.isEmpty(output)) { + return new PrepareExternalProvisioningAnswer(cmd, result.first(), ""); + } + try { + String merged = JsonMergeUtil.mergeJsonPatch(GsonHelper.getGson().toJson(vmTO), result.second()); + VirtualMachineTO virtualMachineTO = GsonHelper.getGson().fromJson(merged, VirtualMachineTO.class); + return new PrepareExternalProvisioningAnswer(cmd, null, virtualMachineTO, null); + } catch (Exception e) { + logger.warn("Failed to parse the output from preparing external provisioning operation as " + + "part of VM deployment: {}", e.getMessage(), e); + return new PrepareExternalProvisioningAnswer(cmd, false, "Failed to parse VM"); + } + } + + @Override + public StartAnswer startInstance(String hostGuid, String extensionName, String extensionRelativePath, + StartCommand cmd) { + String extensionPath = getExtensionCheckedPath(extensionName, extensionRelativePath); + if (StringUtils.isEmpty(extensionPath)) { + return new StartAnswer(cmd, "Extension not configured"); + } + VirtualMachineTO virtualMachineTO = cmd.getVirtualMachine(); + Map accessDetails = loadAccessDetails(cmd.getExternalDetails(), virtualMachineTO); + String vmUUID = virtualMachineTO.getUuid(); + + logger.debug(String.format("Executing StartCommand in the external provisioner for VM %s", vmUUID)); + + Object deployvm = virtualMachineTO.getDetails().get("deployvm"); + boolean isDeploy = (deployvm != null && Boolean.parseBoolean((String)deployvm)); + String operation = isDeploy ? "Deploying" : "Starting"; + try { + Pair result = executeStartCommandOnExternalSystem(extensionName, isDeploy, + extensionPath, vmUUID, accessDetails, cmd.getWait()); + + if (!result.first()) { + String errMsg = String.format("%s VM %s on the external system failed: %s", operation, vmUUID, result.second()); + logger.debug(errMsg); + return new StartAnswer(cmd, result.second()); + } + logger.debug(String.format("%s VM %s on the external system", operation, vmUUID)); + return new StartAnswer(cmd); + + } catch (CloudRuntimeException e) { + String errMsg = String.format("%s VM %s on the external system failed: %s", operation, vmUUID, e.getMessage()); + logger.debug(errMsg); + return new StartAnswer(cmd, errMsg); + } + } + + private Pair executeStartCommandOnExternalSystem(String extensionName, boolean isDeploy, + String filename, String vmUUID, Map accessDetails, int wait) { + if (isDeploy) { + return deployInstanceOnExternalSystem(extensionName, filename, vmUUID, accessDetails, wait); + } else { + return startInstanceOnExternalSystem(extensionName, filename, vmUUID, accessDetails, wait); + } + } + + @Override + public StopAnswer stopInstance(String hostGuid, String extensionName, String extensionRelativePath, + StopCommand cmd) { + String extensionPath = getExtensionCheckedPath(extensionName, extensionRelativePath); + if (StringUtils.isEmpty(extensionPath)) { + return new StopAnswer(cmd, "Extension not configured", false); + } + logger.debug("Executing stop command on the external provisioner"); + VirtualMachineTO virtualMachineTO = cmd.getVirtualMachine(); + String vmUUID = cmd.getVirtualMachine().getUuid(); + logger.debug("Executing stop command in the external system for the VM {}", vmUUID); + Map accessDetails = loadAccessDetails(cmd.getExternalDetails(), virtualMachineTO); + Pair result = stopInstanceOnExternalSystem(extensionName, extensionPath, vmUUID, + accessDetails, cmd.getWait()); + if (result.first()) { + return new StopAnswer(cmd, null, true); + } else { + return new StopAnswer(cmd, result.second(), false); + } + } + + @Override + public RebootAnswer rebootInstance(String hostGuid, String extensionName, String extensionRelativePath, + RebootCommand cmd) { + String extensionPath = getExtensionCheckedPath(extensionName, extensionRelativePath); + if (StringUtils.isEmpty(extensionPath)) { + return new RebootAnswer(cmd, "Extension not configured", false); + } + logger.debug("Executing reboot command using IPMI in the external provisioner"); + VirtualMachineTO virtualMachineTO = cmd.getVirtualMachine(); + String vmUUID = virtualMachineTO.getUuid(); + logger.debug("Executing reboot command in the external system for the VM {}", vmUUID); + Map accessDetails = loadAccessDetails(cmd.getExternalDetails(), virtualMachineTO); + Pair result = rebootInstanceOnExternalSystem(extensionName, extensionPath, vmUUID, + accessDetails, cmd.getWait()); + if (result.first()) { + return new RebootAnswer(cmd, null, true); + } else { + return new RebootAnswer(cmd, result.second(), false); + } + } + + @Override + public StopAnswer expungeInstance(String hostGuid, String extensionName, String extensionRelativePath, + StopCommand cmd) { + String extensionPath = getExtensionCheckedPath(extensionName, extensionRelativePath); + if (StringUtils.isEmpty(extensionPath)) { + return new StopAnswer(cmd, "Extension not configured", false); + } + VirtualMachineTO virtualMachineTO = cmd.getVirtualMachine(); + String vmUUID = virtualMachineTO.getUuid(); + logger.debug("Executing stop command as part of expunge in the external system for the VM {}", vmUUID); + Map accessDetails = loadAccessDetails(cmd.getExternalDetails(), virtualMachineTO); + Pair result = deleteInstanceOnExternalSystem(extensionName, extensionPath, vmUUID, + accessDetails, cmd.getWait()); + if (result.first()) { + return new StopAnswer(cmd, null, true); + } else { + return new StopAnswer(cmd, result.second(), false); + } + } + + @Override + public Map getHostVmStateReport(long hostId, String extensionName, + String extensionRelativePath) { + final Map vmStates = new HashMap<>(); + String extensionPath = getExtensionCheckedPath(extensionName, extensionRelativePath); + if (StringUtils.isEmpty(extensionPath)) { + return vmStates; + } + HostVO host = hostDao.findById(hostId); + if (host == null) { + logger.error("Host with ID: {} not found", hostId); + return vmStates; + } + List allVms = _uservmDao.listByHostId(hostId); + allVms.addAll(_uservmDao.listByLastHostId(hostId)); + if (CollectionUtils.isEmpty(allVms)) { + logger.debug("No VMs found for the {}", host); + return vmStates; + } + Map> accessDetails = + extensionsManager.getExternalAccessDetails(host, null); + for (UserVmVO vm: allVms) { + VirtualMachine.PowerState powerState = getVmPowerState(vm, accessDetails, extensionName, extensionPath); + vmStates.put(vm.getInstanceName(), new HostVmStateReportEntry(powerState, "host-" + hostId)); + } + return vmStates; + } + + @Override + public RunCustomActionAnswer runCustomAction(String hostGuid, String extensionName, + String extensionRelativePath, RunCustomActionCommand cmd) { + String extensionPath = getExtensionCheckedPath(extensionName, extensionRelativePath); + if (StringUtils.isEmpty(extensionPath)) { + return new RunCustomActionAnswer(cmd, false, "Extension not configured"); + } + final String actionName = cmd.getActionName(); + final Map parameters = cmd.getParameters(); + logger.debug("Executing custom action '{}' in the external provisioner", actionName); + VirtualMachineTO virtualMachineTO = null; + if (cmd.getVmId() != null) { + VMInstanceVO vm = vmInstanceDao.findById(cmd.getVmId()); + final HypervisorGuru hvGuru = hypervisorGuruManager.getGuru(Hypervisor.HypervisorType.External); + VirtualMachineProfile profile = new VirtualMachineProfileImpl(vm); + virtualMachineTO = hvGuru.implement(profile); + } + logger.debug("Executing custom action '{}' in the external system", actionName); + Map accessDetails = loadAccessDetails(cmd.getExternalDetails(), virtualMachineTO); + accessDetails.put(ApiConstants.ACTION, actionName); + if (MapUtils.isNotEmpty(parameters)) { + accessDetails.put(ApiConstants.PARAMETERS, parameters); + } + Pair result = runCustomActionOnExternalSystem(extensionName, extensionPath, + actionName, accessDetails, cmd.getWait()); + return new RunCustomActionAnswer(cmd, result.first(), result.second()); + } + + protected boolean createExtensionPath(String extensionName, Path destinationPathObj) throws IOException { + String sourceScriptPath = Script.findScript("", BASE_EXTERNAL_PROVISIONER_SHELL_SCRIPT); + if(sourceScriptPath == null) { + logger.error("Failed to find base script for preparing extension: {}", + extensionName); + return false; + } + Path sourcePath = Paths.get(sourceScriptPath); + Files.copy(sourcePath, destinationPathObj, StandardCopyOption.REPLACE_EXISTING); + return true; + } + + @Override + public void prepareExtensionPath(String extensionName, boolean userDefined, + String extensionRelativePath) { + logger.debug("Preparing entry point for Extension [name: {}, user-defined: {}]", extensionName, userDefined); + if (!userDefined) { + logger.debug("Skipping preparing entry point for inbuilt extension: {}", extensionName); + return; + } + String destinationPath = getExtensionPath(extensionRelativePath); + if (!destinationPath.endsWith(".sh")) { + logger.info("File {} for extension: {} is not a bash script, skipping copy.", destinationPath, + extensionName); + return; + } + File destinationFile = new File(destinationPath); + if (destinationFile.exists()) { + logger.info("File already exists at {} for extension: {}, skipping copy.", destinationPath, + extensionName); + return; + } + CloudRuntimeException exception = + new CloudRuntimeException(String.format("Failed to prepare scripts for extension: %s", extensionName)); + if (!checkExtensionsDirectory()) { + throw exception; + } + Path destinationPathObj = Paths.get(destinationPath); + Path destinationDirPath = destinationPathObj.getParent(); + if (destinationDirPath == null) { + logger.error("Failed to find parent directory for extension: {} script path {}", + extensionName, destinationPath); + throw exception; + } + try { + Files.createDirectories(destinationDirPath); + } catch (IOException e) { + logger.error("Failed to create directory: {} for extension: {}", destinationDirPath, + extensionName, e); + throw exception; + } + try { + if (!createExtensionPath(extensionName, destinationPathObj)) { + throw exception; + } + } catch (IOException e) { + logger.error("Failed to copy entry point file to [{}] for extension: {}", + destinationPath, extensionName, e); + throw exception; + } + logger.debug("Successfully prepared entry point [{}] for extension: {}", destinationPath, + extensionName); + } + + @Override + public void cleanupExtensionPath(String extensionName, String extensionRelativePath) { + String normalizedPath = extensionRelativePath; + if (normalizedPath.startsWith("/")) { + normalizedPath = normalizedPath.substring(1); + } + try { + Path rootPath = Paths.get(extensionsDirectory).toAbsolutePath().normalize(); + String extensionDirName = Extension.getDirectoryName(extensionName); + Path filePath = rootPath + .resolve(normalizedPath.startsWith(extensionDirName) ? extensionDirName : normalizedPath) + .normalize(); + if (!Files.exists(filePath)) { + return; + } + if (!Files.isDirectory(filePath) && !Files.isRegularFile(filePath)) { + throw new CloudRuntimeException( + String.format("Failed to cleanup extension entry-point: %s for extension: %s as it either " + + "does not exist or is not a regular file/directory", + extensionName, extensionRelativePath)); + } + if (!FileUtil.deleteRecursively(filePath)) { + throw new CloudRuntimeException( + String.format("Failed to delete extension entry-point: %s for extension: %s", + extensionName, filePath)); + } + } catch (IOException e) { + throw new CloudRuntimeException( + String.format("Failed to cleanup extension entry-point: %s for extension: %s due to: %s", + extensionName, normalizedPath, e.getMessage()), e); + } + } + + @Override + public void cleanupExtensionData(String extensionName, int olderThanDays, boolean cleanupDirectory) { + String extensionPayloadDirPath = extensionsDataDirectory + File.separator + extensionName; + Path dirPath = Paths.get(extensionPayloadDirPath); + if (!Files.exists(dirPath)) { + return; + } + try { + if (cleanupDirectory) { + try (Stream paths = Files.walk(dirPath)) { + paths.sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + } + return; + } + long cutoffMillis = System.currentTimeMillis() - (olderThanDays * 24L * 60 * 60 * 1000); + long lastModified = Files.getLastModifiedTime(dirPath).toMillis(); + if (lastModified < cutoffMillis) { + return; + } + try (Stream paths = Files.walk(dirPath)) { + paths.filter(path -> !path.equals(dirPath)) + .filter(path -> { + try { + return Files.getLastModifiedTime(path).toMillis() < cutoffMillis; + } catch (IOException e) { + return false; + } + }) + .sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + } + } catch (IOException e) { + logger.warn("Failed to clean up extension payloads for {}: {}", extensionName, e.getMessage()); + } + } + + public Pair runCustomActionOnExternalSystem(String extensionName, String filename, + String actionName, Map accessDetails, int wait) { + return executeExternalCommand(extensionName, actionName, accessDetails, wait, + String.format("Failed to execute custom action '%s' on external system", actionName), filename); + } + + protected VirtualMachine.PowerState getPowerStateFromString(String powerStateStr) { + if (StringUtils.isBlank(powerStateStr)) { + return VirtualMachine.PowerState.PowerUnknown; + } + if (powerStateStr.equalsIgnoreCase(VirtualMachine.PowerState.PowerOn.toString())) { + return VirtualMachine.PowerState.PowerOn; + } else if (powerStateStr.equalsIgnoreCase(VirtualMachine.PowerState.PowerOff.toString())) { + return VirtualMachine.PowerState.PowerOff; + } + return VirtualMachine.PowerState.PowerUnknown; + } + + protected VirtualMachine.PowerState parsePowerStateFromResponse(UserVmVO userVmVO, String response) { + logger.debug("Power status response from the external system for {} : {}", userVmVO, response); + if (StringUtils.isBlank(response)) { + logger.warn("Empty response while trying to fetch the power status of the {}", userVmVO); + return VirtualMachine.PowerState.PowerUnknown; + } + if (!response.trim().startsWith("{")) { + return getPowerStateFromString(response); + } + try { + JsonObject jsonObj = new JsonParser().parse(response).getAsJsonObject(); + String powerState = jsonObj.has("power_state") ? jsonObj.get("power_state").getAsString() : null; + return getPowerStateFromString(powerState); + } catch (Exception e) { + logger.warn("Failed to parse power status response: {} for {} as JSON: {}", + response, userVmVO, e.getMessage()); + return VirtualMachine.PowerState.PowerUnknown; + } + } + + private VirtualMachine.PowerState getVmPowerState(UserVmVO userVmVO, Map> accessDetails, + String extensionName, String extensionPath) { + final HypervisorGuru hvGuru = hypervisorGuruManager.getGuru(Hypervisor.HypervisorType.External); + VirtualMachineProfile profile = new VirtualMachineProfileImpl(userVmVO); + VirtualMachineTO virtualMachineTO = hvGuru.implement(profile); + accessDetails.put(ApiConstants.VIRTUAL_MACHINE, virtualMachineTO.getExternalDetails()); + Map modifiedDetails = loadAccessDetails(accessDetails, virtualMachineTO); + String vmUUID = userVmVO.getUuid(); + logger.debug("Trying to get VM power status from the external system for {}", userVmVO); + Pair result = getInstanceStatusOnExternalSystem(extensionName, extensionPath, vmUUID, + modifiedDetails, AgentManager.Wait.value()); + if (!result.first()) { + logger.warn("Failure response received while trying to fetch the power status of the {} : {}", + userVmVO, result.second()); + return VirtualMachine.PowerState.PowerUnknown; + } + return parsePowerStateFromResponse(userVmVO, result.second()); + } + public Pair prepareExternalProvisioningInternal(String extensionName, String filename, + String vmUUID, Map accessDetails, int wait) { + return executeExternalCommand(extensionName, "prepare", accessDetails, wait, + String.format("Failed to prepare external provisioner for deploying VM %s on external system", vmUUID), + filename); + } + + public Pair deployInstanceOnExternalSystem(String extensionName, String filename, String vmUUID, + Map accessDetails, int wait) { + return executeExternalCommand(extensionName, "create", accessDetails, wait, + String.format("Failed to create the instance %s on external system", vmUUID), filename); + } + + public Pair startInstanceOnExternalSystem(String extensionName, String filename, String vmUUID, + Map accessDetails, int wait) { + return executeExternalCommand(extensionName, "start", accessDetails, wait, + String.format("Failed to start the instance %s on external system", vmUUID), filename); + } + + public Pair stopInstanceOnExternalSystem(String extensionName, String filename, String vmUUID, + Map accessDetails, int wait) { + return executeExternalCommand(extensionName, "stop", accessDetails, wait, + String.format("Failed to stop the instance %s on external system", vmUUID), filename); + } + + public Pair rebootInstanceOnExternalSystem(String extensionName, String filename, String vmUUID, + Map accessDetails, int wait) { + return executeExternalCommand(extensionName, "reboot", accessDetails, wait, + String.format("Failed to reboot the instance %s on external system", vmUUID), filename); + } + + public Pair deleteInstanceOnExternalSystem(String extensionName, String filename, String vmUUID, + Map accessDetails, int wait) { + return executeExternalCommand(extensionName, "delete", accessDetails, wait, + String.format("Failed to delete the instance %s on external system", vmUUID), filename); + } + + public Pair getInstanceStatusOnExternalSystem(String extensionName, String filename, String vmUUID, + Map accessDetails, int wait) { + return executeExternalCommand(extensionName, "status", accessDetails, wait, + String.format("Failed to get the instance power status %s on external system", vmUUID), filename); + } + + public Pair executeExternalCommand(String extensionName, String action, + Map accessDetails, int wait, String errorLogPrefix, String file) { + try { + Path executablePath = Paths.get(file).toAbsolutePath().normalize(); + if (!Files.isExecutable(executablePath)) { + logger.error("{}: File is not executable: {}", errorLogPrefix, executablePath); + return new Pair<>(false, "File is not executable"); + } + if (wait == 0) { + wait = AgentManager.Wait.value(); + } + List command = new ArrayList<>(); + command.add(executablePath.toString()); + command.add(action); + String dataFile = prepareExternalPayload(extensionName, accessDetails); + command.add(dataFile); + command.add(Integer.toString(wait)); + ProcessBuilder builder = new ProcessBuilder(command); + builder.redirectErrorStream(true); + + logger.debug("Executing {} for command: {} with wait: {} and data file: {}", executablePath, + action, wait, dataFile); + + Process process = builder.start(); + boolean finished = process.waitFor(wait, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + logger.error("{}: External API execution timed out after {} seconds", errorLogPrefix, wait); + return new Pair<>(false, "Timeout"); + } + StringBuilder output = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append(System.lineSeparator()); + } + } + int exitCode = process.exitValue(); + if (exitCode != 0) { + logger.warn("{}: External API execution failed with exit code {}", errorLogPrefix, exitCode); + return new Pair<>(false, "Exit code: " + exitCode + ", Output: " + output.toString().trim()); + } + deleteExtensionPayloadFile(extensionName, action, dataFile); + return new Pair<>(true, output.toString().trim()); + + } catch (IOException | InterruptedException e) { + logger.error("{}: External operation failed", errorLogPrefix, e); + throw new CloudRuntimeException(String.format("%s: External operation failed", errorLogPrefix), e); + } + } + + protected void deleteExtensionPayloadFile(String extensionName, String action, String payloadFileName) { + if (!TRIVIAL_ACTIONS.contains(action)) { + return; + } + logger.trace("Deleting payload file: {} for extension: {}, action: {}, file: {}", + payloadFileName, extensionName, action); + FileUtil.deletePath(payloadFileName); + } + + protected void scheduleExtensionPayloadDirectoryCleanup(String extensionName) { + try { + Future future = payloadCleanupExecutor.submit(() -> { + try { + cleanupExtensionData(extensionName, 1, false); + logger.trace("Cleaned up payload directory for extension: {}", extensionName); + } catch (Exception e) { + logger.warn("Exception during payload cleanup for extension: {} due to {}", extensionName, + e.getMessage()); + logger.trace(e); + } + }); + payloadCleanupScheduler.schedule(() -> { + try { + if (!future.isDone()) { + future.cancel(true); + logger.trace("Cancelled cleaning up payload directory for extension: {} as it " + + "running for more than 3 seconds", extensionName); + } + } catch (Exception e) { + logger.warn("Failed to cancel payload cleanup task for extension: {} due to {}", + extensionName, e.getMessage()); + logger.trace(e); + } + }, 3, TimeUnit.SECONDS); + } catch (RejectedExecutionException e) { + logger.warn("Payload cleanup task for extension: {} was rejected due to: {}", extensionName, + e.getMessage()); + logger.trace(e); + } + } + + protected String prepareExternalPayload(String extensionName, Map details) throws IOException { + String json = GsonHelper.getGson().toJson(details); + String fileName = UUID.randomUUID() + ".json"; + String extensionPayloadDir = extensionsDataDirectory + File.separator + extensionName; + Path payloadDirPath = Paths.get(extensionPayloadDir); + if (!Files.exists(payloadDirPath)) { + Files.createDirectories(payloadDirPath); + } else { + scheduleExtensionPayloadDirectoryCleanup(extensionName); + } + Path payloadFile = payloadDirPath.resolve(fileName); + Files.writeString(payloadFile, json, StandardOpenOption.CREATE_NEW); + return payloadFile.toAbsolutePath().toString(); + } + + @Override + public List> getCommands() { + return new ArrayList<>(); + } +} diff --git a/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/resource/ExternalResource.java b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/resource/ExternalResource.java new file mode 100644 index 00000000000..ab70c880a81 --- /dev/null +++ b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/resource/ExternalResource.java @@ -0,0 +1,376 @@ +// 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.hypervisor.external.resource; + +import java.util.HashMap; +import java.util.Map; + +import javax.naming.ConfigurationException; + +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.framework.extensions.command.ExtensionRoutingUpdateCommand; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; + +import com.cloud.agent.IAgentControl; +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.CheckHealthAnswer; +import com.cloud.agent.api.CheckHealthCommand; +import com.cloud.agent.api.CheckNetworkAnswer; +import com.cloud.agent.api.CheckNetworkCommand; +import com.cloud.agent.api.CleanupNetworkRulesCmd; +import com.cloud.agent.api.Command; +import com.cloud.agent.api.GetHostStatsAnswer; +import com.cloud.agent.api.GetHostStatsCommand; +import com.cloud.agent.api.GetVmStatsCommand; +import com.cloud.agent.api.HostVmStateReportEntry; +import com.cloud.agent.api.MaintainAnswer; +import com.cloud.agent.api.MaintainCommand; +import com.cloud.agent.api.PingCommand; +import com.cloud.agent.api.PingRoutingCommand; +import com.cloud.agent.api.PingTestCommand; +import com.cloud.agent.api.PrepareExternalProvisioningAnswer; +import com.cloud.agent.api.PrepareExternalProvisioningCommand; +import com.cloud.agent.api.ReadyAnswer; +import com.cloud.agent.api.ReadyCommand; +import com.cloud.agent.api.RebootAnswer; +import com.cloud.agent.api.RebootCommand; +import com.cloud.agent.api.RunCustomActionAnswer; +import com.cloud.agent.api.RunCustomActionCommand; +import com.cloud.agent.api.StartAnswer; +import com.cloud.agent.api.StartCommand; +import com.cloud.agent.api.StartupCommand; +import com.cloud.agent.api.StartupRoutingCommand; +import com.cloud.agent.api.StopAnswer; +import com.cloud.agent.api.StopCommand; +import com.cloud.host.Host; +import com.cloud.hypervisor.ExternalProvisioner; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.network.Networks; +import com.cloud.resource.ServerResource; +import com.cloud.utils.StringUtils; +import com.cloud.utils.component.ComponentContext; + +public class ExternalResource implements ServerResource { + protected Logger logger = LogManager.getLogger(getClass()); + protected static final int CPU = 0; + protected static final long CPU_SPEED = 0; + protected static final long RAM = 0; + protected static final long DOM0_RAM = 0; + protected static final String CAPABILITIES = "hvm"; + + protected ExternalProvisioner externalProvisioner; + + protected String url; + protected String dcId; + protected String pod; + protected String cluster; + protected String name; + protected String guid; + private final Host.Type type; + + private String extensionName; + private String extensionRelativePath; + private Extension.State extensionState; + private boolean extensionPathReady; + + protected boolean isExtensionDisconnected() { + return StringUtils.isAnyBlank(extensionName, extensionRelativePath); + } + + protected boolean isExtensionNotEnabled() { + return !Extension.State.Enabled.equals(extensionState); + } + + protected boolean isExtensionPathNotReady() { + return !extensionPathReady; + } + + public ExternalResource() { + type = Host.Type.Routing; + } + + @Override + public Host.Type getType() { + return type; + } + + @Override + public StartupCommand[] initialize() { + final StartupRoutingCommand cmd = + new StartupRoutingCommand(CPU, CPU_SPEED, RAM, DOM0_RAM, CAPABILITIES, + Hypervisor.HypervisorType.External,Networks.RouterPrivateIpStrategy.HostLocal); + cmd.setDataCenter(dcId); + cmd.setPod(pod); + cmd.setCluster(cluster); + cmd.setHostType(type); + cmd.setName(name); + cmd.setPrivateIpAddress(Hypervisor.HypervisorType.External.toString()); + cmd.setGuid(guid); + cmd.setIqn(guid); + cmd.setVersion(ExternalResource.class.getPackage().getImplementationVersion()); + return new StartupCommand[] {cmd}; + } + + @Override + public PingCommand getCurrentStatus(long id) { + if (isExtensionDisconnected()) { + return new PingRoutingCommand(Host.Type.Routing, id, new HashMap<>()); + } + final Map vmStates = externalProvisioner.getHostVmStateReport(id, extensionName, + extensionRelativePath); + return new PingRoutingCommand(Host.Type.Routing, id, vmStates); + } + + @Override + public Answer executeRequest(Command cmd) { + try { + if (cmd instanceof ExtensionRoutingUpdateCommand) { + return execute((ExtensionRoutingUpdateCommand)cmd); + } else if (cmd instanceof PingTestCommand) { + return execute((PingTestCommand) cmd); + } else if (cmd instanceof ReadyCommand) { + return execute((ReadyCommand)cmd); + } else if (cmd instanceof CheckHealthCommand) { + return execute((CheckHealthCommand) cmd); + } else if (cmd instanceof CheckNetworkCommand) { + return execute((CheckNetworkCommand)cmd); + }else if (cmd instanceof CleanupNetworkRulesCmd) { + return execute((CleanupNetworkRulesCmd) cmd); + } else if (cmd instanceof GetVmStatsCommand) { + return execute((GetVmStatsCommand) cmd); + } else if (cmd instanceof MaintainCommand) { + return execute((MaintainCommand) cmd); + } else if (cmd instanceof StartCommand) { + return execute((StartCommand) cmd); + } else if (cmd instanceof StopCommand) { + return execute((StopCommand) cmd); + } else if (cmd instanceof RebootCommand) { + return execute((RebootCommand) cmd); + } else if (cmd instanceof PrepareExternalProvisioningCommand) { + return execute((PrepareExternalProvisioningCommand) cmd); + } else if (cmd instanceof GetHostStatsCommand) { + return execute((GetHostStatsCommand) cmd); + } else if (cmd instanceof RunCustomActionCommand) { + return execute((RunCustomActionCommand) cmd); + } else { + return execute(cmd); + } + } catch (IllegalArgumentException e) { + return new Answer(cmd, false, e.getMessage()); + } + } + + protected String logAndGetExtensionNotConnectedOrDisabledError() { + if (isExtensionDisconnected()) { + logger.error("Extension not connected to host: {}", name); + return "Extension not connected"; + } + if (isExtensionNotEnabled()) { + logger.error("Extension: {} connected to host: {} is not in Enabled state", extensionName, name); + return "Extension is disabled"; + } + logger.error("Extension: {} connected to host: {} is not having path in Ready state", extensionName, name); + return "Extension is not ready"; + } + + private Answer execute(ExtensionRoutingUpdateCommand cmd) { + if (StringUtils.isNotBlank(extensionName) && !extensionName.equals(cmd.getExtensionName())) { + return new Answer(cmd, false, "Not same extension"); + } + if (cmd.isRemoved()) { + extensionName = null; + extensionRelativePath = null; + extensionState = Extension.State.Disabled; + return new Answer(cmd); + } + extensionName = cmd.getExtensionName(); + extensionRelativePath = cmd.getExtensionRelativePath(); + extensionState = cmd.getExtensionState(); + return new Answer(cmd); + } + + private Answer execute(PingTestCommand cmd) { + return new Answer(cmd); + } + + private Answer execute(ReadyCommand cmd) { + return new ReadyAnswer(cmd); + } + + private Answer execute(CheckHealthCommand cmd) { + if (isExtensionDisconnected()) { + logAndGetExtensionNotConnectedOrDisabledError(); + } + return new CheckHealthAnswer(cmd, !isExtensionDisconnected()); + } + + private Answer execute(CheckNetworkCommand cmd) { + return new CheckNetworkAnswer(cmd, true, "Network Setup check by names is done"); + } + + private Answer execute(CleanupNetworkRulesCmd cmd) { + if (isExtensionDisconnected()) { + return new Answer(cmd, false, logAndGetExtensionNotConnectedOrDisabledError()); + } + return new Answer(cmd, false, "Not supported"); + } + + private Answer execute(GetVmStatsCommand cmd) { + if (isExtensionDisconnected()) { + return new Answer(cmd, false, logAndGetExtensionNotConnectedOrDisabledError()); + } + return new Answer(cmd, false, "Not supported"); + } + + private MaintainAnswer execute(MaintainCommand cmd) { + return new MaintainAnswer(cmd, false); + } + + public GetHostStatsAnswer execute(GetHostStatsCommand cmd) { + if (isExtensionDisconnected()) { + logAndGetExtensionNotConnectedOrDisabledError(); + } + return new GetHostStatsAnswer(cmd, null); + } + + public StartAnswer execute(StartCommand cmd) { + if (isExtensionDisconnected() || isExtensionNotEnabled() || isExtensionPathNotReady()) { + return new StartAnswer(cmd, logAndGetExtensionNotConnectedOrDisabledError()); + } + return externalProvisioner.startInstance(guid, extensionName, extensionRelativePath, cmd); + } + + public StopAnswer execute(StopCommand cmd) { + if (isExtensionDisconnected() || isExtensionNotEnabled() || isExtensionPathNotReady()) { + return new StopAnswer(cmd, logAndGetExtensionNotConnectedOrDisabledError(), false); + } + if (cmd.isExpungeVM()) { + return externalProvisioner.expungeInstance(guid, extensionName, extensionRelativePath, cmd); + } + return externalProvisioner.stopInstance(guid, extensionName, extensionRelativePath, cmd); + } + + public RebootAnswer execute(RebootCommand cmd) { + if (isExtensionDisconnected() || isExtensionNotEnabled() || isExtensionPathNotReady()) { + return new RebootAnswer(cmd, logAndGetExtensionNotConnectedOrDisabledError(), false); + } + return externalProvisioner.rebootInstance(guid, extensionName, extensionRelativePath, cmd); + } + + public PrepareExternalProvisioningAnswer execute(PrepareExternalProvisioningCommand cmd) { + if (isExtensionDisconnected() || isExtensionNotEnabled() || isExtensionPathNotReady()) { + return new PrepareExternalProvisioningAnswer(cmd, false, logAndGetExtensionNotConnectedOrDisabledError()); + } + return externalProvisioner.prepareExternalProvisioning(guid, extensionName, extensionRelativePath, cmd); + } + + public RunCustomActionAnswer execute(RunCustomActionCommand cmd) { + if (isExtensionDisconnected() || isExtensionNotEnabled() || isExtensionPathNotReady()) { + return new RunCustomActionAnswer(cmd, false, logAndGetExtensionNotConnectedOrDisabledError()); + } + return externalProvisioner.runCustomAction(guid, extensionName, extensionRelativePath, cmd); + } + + public Answer execute(Command cmd) { + if (isExtensionDisconnected() || isExtensionNotEnabled() || isExtensionPathNotReady()) { + return new Answer(cmd, false, logAndGetExtensionNotConnectedOrDisabledError()); + } + RunCustomActionCommand runCustomActionCommand = new RunCustomActionCommand(cmd.toString()); + RunCustomActionAnswer customActionAnswer = externalProvisioner.runCustomAction(guid, extensionName, + extensionRelativePath, runCustomActionCommand); + return new Answer(cmd, customActionAnswer.getResult(), customActionAnswer.getDetails()); + } + + @Override + public void disconnected() { + + } + + @Override + public IAgentControl getAgentControl() { + return null; + } + + @Override + public void setAgentControl(IAgentControl agentControl) { + + } + + @Override + public String getName() { + return null; + } + + @Override + public void setName(String name) { + + } + + @Override + public void setConfigParams(Map params) { + + } + + @Override + public Map getConfigParams() { + return null; + } + + @Override + public int getRunLevel() { + return 0; + } + + @Override + public void setRunLevel(int level) { + + } + + @Override + public boolean configure(String name, Map params) throws ConfigurationException { + try { + externalProvisioner = ComponentContext.getDelegateComponentOfType(ExternalProvisioner.class); + } catch (NoSuchBeanDefinitionException e) { + throw new ConfigurationException( + String.format("Unable to find an ExternalProvisioner for the external resource: %s", name) + ); + } + externalProvisioner.configure(name, params); + dcId = (String)params.get("zone"); + pod = (String)params.get("pod"); + cluster = (String)params.get("cluster"); + this.name = name; + guid = (String)params.get("guid"); + extensionName = (String)params.get("extensionName"); + extensionRelativePath = (String)params.get("extensionRelativePath"); + extensionState = (Extension.State)params.get("extensionState"); + extensionPathReady = (boolean)params.get("extensionPathReady"); + return true; + } + + @Override + public boolean start() { + return true; + } + + @Override + public boolean stop() { + return true; + } +} diff --git a/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/core/spring-external-core-context.xml b/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/core/spring-external-core-context.xml new file mode 100644 index 00000000000..abc704c9947 --- /dev/null +++ b/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/core/spring-external-core-context.xml @@ -0,0 +1,31 @@ + + + + + diff --git a/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-compute/module.properties b/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-compute/module.properties new file mode 100644 index 00000000000..726cb6d197d --- /dev/null +++ b/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-compute/module.properties @@ -0,0 +1,18 @@ +# 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=external-compute +parent=compute diff --git a/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-compute/spring-external-compute-context.xml b/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-compute/spring-external-compute-context.xml new file mode 100644 index 00000000000..7dd21d6f7a9 --- /dev/null +++ b/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-compute/spring-external-compute-context.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-discoverer/module.properties b/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-discoverer/module.properties new file mode 100644 index 00000000000..2220b459b44 --- /dev/null +++ b/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-discoverer/module.properties @@ -0,0 +1,18 @@ +# 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=external-discoverer +parent=discoverer diff --git a/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-discoverer/spring-external-discoverer-context.xml b/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-discoverer/spring-external-discoverer-context.xml new file mode 100644 index 00000000000..6a7181bdcd2 --- /dev/null +++ b/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-discoverer/spring-external-discoverer-context.xml @@ -0,0 +1,34 @@ + + + + + + + + diff --git a/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-planner/module.properties b/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-planner/module.properties new file mode 100644 index 00000000000..3d9d10b8537 --- /dev/null +++ b/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-planner/module.properties @@ -0,0 +1,18 @@ +# 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=external-planner +parent=planner diff --git a/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-planner/spring-external-planner-context.xml b/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-planner/spring-external-planner-context.xml new file mode 100644 index 00000000000..da915b1557d --- /dev/null +++ b/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-planner/spring-external-planner-context.xml @@ -0,0 +1,34 @@ + + + + + + + + diff --git a/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-storage/module.properties b/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-storage/module.properties new file mode 100644 index 00000000000..c040872c19c --- /dev/null +++ b/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-storage/module.properties @@ -0,0 +1,18 @@ +# 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=external-storage +parent=storage diff --git a/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-storage/spring-external-storage-context.xml b/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-storage/spring-external-storage-context.xml new file mode 100644 index 00000000000..b3f5b6c306b --- /dev/null +++ b/plugins/hypervisors/external/src/main/resources/META-INF/cloudstack/external-storage/spring-external-storage-context.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/plugins/hypervisors/external/src/test/java/org/apache/cloudstack/agent/manager/ExternalTemplateAdapterTest.java b/plugins/hypervisors/external/src/test/java/org/apache/cloudstack/agent/manager/ExternalTemplateAdapterTest.java new file mode 100644 index 00000000000..3349b61c1d9 --- /dev/null +++ b/plugins/hypervisors/external/src/test/java/org/apache/cloudstack/agent/manager/ExternalTemplateAdapterTest.java @@ -0,0 +1,173 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.agent.manager; + +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyBoolean; +import static org.mockito.Mockito.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; + +import org.apache.cloudstack.api.command.user.template.RegisterTemplateCmd; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; +import org.apache.cloudstack.storage.datastore.db.ImageStoreDao; +import org.apache.cloudstack.storage.datastore.db.ImageStoreVO; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.test.util.ReflectionTestUtils; + +import com.cloud.cpu.CPU; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.storage.TemplateProfile; +import com.cloud.storage.dao.VMTemplateDao; +import com.cloud.template.TemplateManager; +import com.cloud.user.Account; +import com.cloud.user.AccountManager; +import com.cloud.user.AccountVO; +import com.cloud.user.ResourceLimitService; +import com.cloud.user.UserVO; +import com.cloud.user.dao.UserDao; +import com.cloud.utils.exception.CloudRuntimeException; + +@RunWith(MockitoJUnitRunner.class) +public class ExternalTemplateAdapterTest { + @Mock + ImageStoreDao _imgStoreDao; + @Mock + AccountManager _accountMgr; + @Mock + TemplateManager templateMgr; + @Mock + ResourceLimitService _resourceLimitMgr; + @Mock + UserDao _userDao; + @Mock + VMTemplateDao _tmpltDao; + + @Spy + @InjectMocks + ExternalTemplateAdapter adapter; + + private RegisterTemplateCmd cmd; + private TemplateProfile profile; + private DataStore dataStore; + + @Before + public void setUp() { + cmd = mock(RegisterTemplateCmd.class); + profile = mock(TemplateProfile.class); + dataStore = mock(DataStore.class); + } + + @Test(expected = InvalidParameterValueException.class) + public void prepare_WhenHypervisorTypeIsNone_ThrowsInvalidParameterValueException() throws ResourceAllocationException { + Account adminAccount = new AccountVO("system", 1L, "", Account.Type.ADMIN, "uuid"); + ReflectionTestUtils.setField(adminAccount, "id", 1L); + CallContext callContext = Mockito.mock(CallContext.class); + when(callContext.getCallingAccount()).thenReturn(adminAccount); + try (MockedStatic mockedCallContext = Mockito.mockStatic(CallContext.class)) { + mockedCallContext.when(CallContext::current).thenReturn(callContext); + when(cmd.getHypervisor()).thenReturn("None"); + when(cmd.getZoneIds()).thenReturn(List.of(1L)); + when(_imgStoreDao.findRegionImageStores()).thenReturn(Collections.emptyList()); + + adapter.prepare(cmd); + } + } + + @Test + public void prepare_WhenRegionImageStoresExist_ZoneIdsAreIgnored() throws ResourceAllocationException { + Account adminAccount = new AccountVO("system", 1L, "", Account.Type.ADMIN, "uuid"); + ReflectionTestUtils.setField(adminAccount, "id", 1L); + CallContext callContext = Mockito.mock(CallContext.class); + when(callContext.getCallingAccount()).thenReturn(adminAccount); + try (MockedStatic mockedCallContext = Mockito.mockStatic(CallContext.class)) { + mockedCallContext.when(CallContext::current).thenReturn(callContext); + when(cmd.getZoneIds()).thenReturn(List.of(1L, 2L)); + when(_imgStoreDao.findRegionImageStores()).thenReturn(List.of(mock(ImageStoreVO.class))); + when(cmd.getHypervisor()).thenReturn("KVM"); + when(cmd.getDetails()).thenReturn(null); + when(cmd.getExternalDetails()).thenReturn(Collections.emptyMap()); + when(_accountMgr.getAccount(anyLong())).thenReturn(mock(com.cloud.user.Account.class)); + when(_accountMgr.isAdmin(anyLong())).thenReturn(true); + when(templateMgr.validateTemplateType(any(), anyBoolean(), anyBoolean(), eq(Hypervisor.HypervisorType.External))).thenReturn(com.cloud.storage.Storage.TemplateType.USER); + when(_userDao.findById(any())).thenReturn(mock(UserVO.class)); + when(cmd.getEntityOwnerId()).thenReturn(1L); + when(cmd.getTemplateName()).thenReturn("t"); + when(cmd.getDisplayText()).thenReturn("d"); + when(cmd.getArch()).thenReturn(CPU.CPUArch.amd64); + when(cmd.getBits()).thenReturn(64); + when(cmd.isPasswordEnabled()).thenReturn(false); + when(cmd.getRequiresHvm()).thenReturn(false); + when(cmd.getUrl()).thenReturn("http://example.com"); + when(cmd.isPublic()).thenReturn(false); + when(cmd.isFeatured()).thenReturn(false); + when(cmd.isExtractable()).thenReturn(false); + when(cmd.getFormat()).thenReturn("QCOW2"); + when(cmd.getOsTypeId()).thenReturn(1L); + when(cmd.getChecksum()).thenReturn("abc"); + when(cmd.getTemplateTag()).thenReturn(null); + when(cmd.isSshKeyEnabled()).thenReturn(false); + when(cmd.isDynamicallyScalable()).thenReturn(false); + when(cmd.isDirectDownload()).thenReturn(false); + when(cmd.isDeployAsIs()).thenReturn(false); + when(cmd.isForCks()).thenReturn(false); + when(cmd.getExtensionId()).thenReturn(null); + + TemplateProfile result = adapter.prepare(cmd); + + assertNull(result.getZoneIdList()); + } + } + + @Test(expected = CloudRuntimeException.class) + public void createTemplateForPostUpload_WhenZoneIdListIsNull_ThrowsCloudRuntimeException() { + when(profile.getFormat()).thenReturn(com.cloud.storage.Storage.ImageFormat.QCOW2); + when(profile.getZoneIdList()).thenReturn(null); + + adapter.createTemplateForPostUpload(profile); + } + + @Test(expected = CloudRuntimeException.class) + public void createTemplateForPostUpload_WhenMultipleZoneIds_ThrowsCloudRuntimeException() { + when(profile.getFormat()).thenReturn(com.cloud.storage.Storage.ImageFormat.QCOW2); + when(profile.getZoneIdList()).thenReturn(List.of(1L, 2L)); + + adapter.createTemplateForPostUpload(profile); + } + + @Test(expected = CloudRuntimeException.class) + public void prepareDelete_AlwaysThrowsCloudRuntimeException() { + adapter.prepareDelete(mock(org.apache.cloudstack.api.command.user.iso.DeleteIsoCmd.class)); + } +} diff --git a/plugins/hypervisors/external/src/test/java/org/apache/cloudstack/hypervisor/external/discoverer/ExternalServerDiscovererTest.java b/plugins/hypervisors/external/src/test/java/org/apache/cloudstack/hypervisor/external/discoverer/ExternalServerDiscovererTest.java new file mode 100644 index 00000000000..367ec58fcec --- /dev/null +++ b/plugins/hypervisors/external/src/test/java/org/apache/cloudstack/hypervisor/external/discoverer/ExternalServerDiscovererTest.java @@ -0,0 +1,182 @@ +// 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.hypervisor.external.discoverer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.anyLong; +import static org.mockito.Mockito.when; + +import java.net.URI; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.apache.cloudstack.extension.ExtensionResourceMap; +import org.apache.cloudstack.framework.config.dao.ConfigurationDao; +import org.apache.cloudstack.framework.extensions.dao.ExtensionDao; +import org.apache.cloudstack.framework.extensions.dao.ExtensionResourceMapDao; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; +import org.apache.cloudstack.framework.extensions.vo.ExtensionResourceMapVO; +import org.apache.cloudstack.framework.extensions.vo.ExtensionVO; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.agent.AgentManager; +import com.cloud.dc.ClusterVO; +import com.cloud.dc.dao.ClusterDao; +import com.cloud.exception.DiscoveryException; +import com.cloud.host.HostVO; +import com.cloud.hypervisor.Hypervisor; + +@RunWith(MockitoJUnitRunner.class) +public class ExternalServerDiscovererTest { + + @Mock + private AgentManager agentManager; + @Mock + private ExtensionDao extensionDao; + @Mock + private ExtensionResourceMapDao extensionResourceMapDao; + @Mock + private ExtensionsManager extensionsManager; + @Mock + ClusterDao _clusterDao; + @Mock + ConfigurationDao _configDao; + @Mock + private ClusterVO clusterVO; + @Mock + private ExtensionResourceMapVO extensionResourceMapVO; + @Mock + private ExtensionVO extensionVO; + @Mock + private HostVO hostVO; + + @InjectMocks + private ExternalServerDiscoverer discoverer; + + @Before + public void setUp() { + } + + @Test + public void testGetResourceGuidFromName() { + String name = "test-resource"; + String guid = discoverer.getResourceGuidFromName(name); + assertTrue(guid.startsWith("External:")); + assertTrue(guid.length() > "External:".length()); + } + + @Test + public void testAddExtensionDataToResourceParams() { + Map params = new HashMap<>(); + when(extensionVO.getName()).thenReturn("ext"); + when(extensionVO.getRelativePath()).thenReturn("entry.sh"); + when(extensionVO.getState()).thenReturn(ExtensionVO.State.Enabled); + + discoverer.addExtensionDataToResourceParams(extensionVO, params); + + assertEquals("ext", params.get("extensionName")); + assertEquals("entry.sh", params.get("extensionRelativePath")); + assertEquals(ExtensionVO.State.Enabled, params.get("extensionState")); + } + + @Test(expected = DiscoveryException.class) + public void testFindThrowsWhenClusterIdNull() throws Exception { + discoverer.find(1L, 2L, null, new URI("http://host"), "user", "pass", Collections.emptyList()); + } + + @Test(expected = DiscoveryException.class) + public void testFindThrowsWhenClusterNotExternal() throws Exception { + when(clusterVO.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.KVM); + when(_clusterDao.findById(1L)).thenReturn(clusterVO); + discoverer.find(1L, 2L, 1L, new URI("http://host"), "user", "pass", Collections.emptyList()); + } + + @Test(expected = DiscoveryException.class) + public void testFindThrowsWhenPodIdNull() throws Exception { + when(clusterVO.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.External); + when(_clusterDao.findById(1L)).thenReturn(clusterVO); + discoverer.find(1L, null, 1L, new URI("http://host"), "user", "pass", Collections.emptyList()); + } + + @Test(expected = DiscoveryException.class) + public void testFindThrowsWhenNoExtensionResourceMap() throws Exception { + when(clusterVO.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.External); + when(_clusterDao.findById(1L)).thenReturn(clusterVO); + when(extensionResourceMapDao.findByResourceIdAndType(1L, ExtensionResourceMap.ResourceType.Cluster)).thenReturn(null); + discoverer.find(1L, 2L, 1L, new URI("http://host"), "user", "pass", Collections.emptyList()); + } + + @Test(expected = DiscoveryException.class) + public void testFindThrowsWhenNoExtensionVO() throws Exception { + when(clusterVO.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.External); + when(_clusterDao.findById(1L)).thenReturn(clusterVO); + when(extensionResourceMapDao.findByResourceIdAndType(1L, ExtensionResourceMap.ResourceType.Cluster)).thenReturn(extensionResourceMapVO); + when(extensionResourceMapVO.getExtensionId()).thenReturn(10L); + when(extensionDao.findById(10L)).thenReturn(null); + discoverer.find(1L, 2L, 1L, new URI("http://host"), "user", "pass", Collections.emptyList()); + } + + @Test + public void testBuildConfigParamsAddsExtensionData() { + when(hostVO.getClusterId()).thenReturn(1L); + HashMap params = new HashMap<>(); + params.put("cluster", "1"); + discoverer.extensionResourceMapDao = extensionResourceMapDao; + discoverer.extensionDao = extensionDao; + when(extensionResourceMapDao.findByResourceIdAndType(1L, ExtensionResourceMap.ResourceType.Cluster)).thenReturn(extensionResourceMapVO); + when(extensionResourceMapVO.getExtensionId()).thenReturn(10L); + when(extensionDao.findById(10L)).thenReturn(extensionVO); + when(extensionVO.getName()).thenReturn("ext"); + when(extensionVO.getRelativePath()).thenReturn("entry.sh"); + when(extensionVO.getState()).thenReturn(ExtensionVO.State.Enabled); + when(_clusterDao.findById(anyLong())).thenReturn(clusterVO); + when(clusterVO.getGuid()).thenReturn(UUID.randomUUID().toString()); + + HashMap result = discoverer.buildConfigParams(hostVO); + assertEquals("ext", result.get("extensionName")); + assertEquals("entry.sh", result.get("extensionRelativePath")); + assertEquals(ExtensionVO.State.Enabled, result.get("extensionState")); + } + + @Test + public void testMatchHypervisor() { + assertTrue(discoverer.matchHypervisor(null)); + assertTrue(discoverer.matchHypervisor("External")); + assertFalse(discoverer.matchHypervisor("KVM")); + } + + @Test + public void testGetHypervisorType() { + assertEquals(Hypervisor.HypervisorType.External, discoverer.getHypervisorType()); + } + + @Test + public void testIsRecurringAndTimeout() { + assertFalse(discoverer.isRecurring()); + assertEquals(0, discoverer.getTimeout()); + } +} diff --git a/plugins/hypervisors/external/src/test/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisionerTest.java b/plugins/hypervisors/external/src/test/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisionerTest.java new file mode 100644 index 00000000000..8c63a20fa31 --- /dev/null +++ b/plugins/hypervisors/external/src/test/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisionerTest.java @@ -0,0 +1,750 @@ +// +// 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.hypervisor.external.provisioner; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +import javax.naming.ConfigurationException; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.extension.Extension; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; +import org.apache.logging.log4j.Logger; +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.test.util.ReflectionTestUtils; + +import com.cloud.agent.api.HostVmStateReportEntry; +import com.cloud.agent.api.PrepareExternalProvisioningAnswer; +import com.cloud.agent.api.PrepareExternalProvisioningCommand; +import com.cloud.agent.api.RebootAnswer; +import com.cloud.agent.api.RebootCommand; +import com.cloud.agent.api.RunCustomActionAnswer; +import com.cloud.agent.api.RunCustomActionCommand; +import com.cloud.agent.api.StartAnswer; +import com.cloud.agent.api.StartCommand; +import com.cloud.agent.api.StopAnswer; +import com.cloud.agent.api.StopCommand; +import com.cloud.agent.api.to.VirtualMachineTO; +import com.cloud.host.HostVO; +import com.cloud.host.dao.HostDao; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.hypervisor.HypervisorGuru; +import com.cloud.hypervisor.HypervisorGuruManager; +import com.cloud.template.VirtualMachineTemplate; +import com.cloud.utils.FileUtil; +import com.cloud.utils.Pair; +import com.cloud.utils.PropertiesUtil; +import com.cloud.utils.script.Script; +import com.cloud.vm.UserVmVO; +import com.cloud.vm.VMInstanceVO; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.VirtualMachineProfile; +import com.cloud.vm.dao.UserVmDao; +import com.cloud.vm.dao.VMInstanceDao; + +@RunWith(MockitoJUnitRunner.class) +public class ExternalPathPayloadProvisionerTest { + + @Spy + @InjectMocks + private ExternalPathPayloadProvisioner provisioner; + + @Mock + private UserVmDao userVmDao; + + @Mock + private HostDao hostDao; + + @Mock + private VMInstanceDao vmInstanceDao; + + @Mock + private HypervisorGuruManager hypervisorGuruManager; + + @Mock + private HypervisorGuru hypervisorGuru; + + @Mock + private Logger logger; + + @Mock + private ExtensionsManager extensionsManager; + + private File tempDir; + private File tempDataDir; + private Properties testProperties; + private File testScript; + + @Before + public void setUp() throws IOException { + tempDir = Files.createTempDirectory("extensions-test").toFile(); + tempDataDir = Files.createTempDirectory("extensions-data-test").toFile(); + + testScript = new File(tempDir, "test-extension.sh"); + testScript.createNewFile(); + resetTestScript(); + + testProperties = new Properties(); + testProperties.setProperty("extensions.deployment.mode", "developer"); + + ReflectionTestUtils.setField(provisioner, "extensionsDirectory", tempDir.getAbsolutePath()); + ReflectionTestUtils.setField(provisioner, "extensionsDataDirectory", tempDataDir.getAbsolutePath()); + + try (MockedStatic propertiesUtilMock = Mockito.mockStatic(PropertiesUtil.class)) { + File mockPropsFile = mock(File.class); + propertiesUtilMock.when(() -> PropertiesUtil.findConfigFile(anyString())).thenReturn(mockPropsFile); + } + } + + @After + public void tearDown() { + if (tempDir != null && tempDir.exists()) { + deleteDirectory(tempDir); + } + if (tempDataDir != null && tempDataDir.exists()) { + deleteDirectory(tempDataDir); + } + } + + private void deleteDirectory(File dir) { + if (dir.isDirectory()) { + File[] files = dir.listFiles(); + if (files != null) { + for (File file : files) { + deleteDirectory(file); + } + } + } + dir.delete(); + } + + private void resetTestScript() { + testScript.setExecutable(true); + testScript.setReadable(true); + testScript.setWritable(true); + } + + @Test + public void testLoadAccessDetails() { + Map> externalDetails = new HashMap<>(); + externalDetails.put(ApiConstants.EXTENSION, Map.of("key1", "value1")); + + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + when(vmTO.getUuid()).thenReturn("test-uuid"); + when(vmTO.getName()).thenReturn("test-vm"); + + Map result = provisioner.loadAccessDetails(externalDetails, vmTO); + + assertNotNull(result); + assertEquals(externalDetails, result.get(ApiConstants.EXTERNAL_DETAILS)); + assertEquals("test-uuid", result.get(ApiConstants.VIRTUAL_MACHINE_ID)); + assertEquals("test-vm", result.get(ApiConstants.VIRTUAL_MACHINE_NAME)); + assertEquals(vmTO, result.get("cloudstack.vm.details")); + } + + @Test + public void testLoadAccessDetailsWithNullExternalDetails() { + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + when(vmTO.getUuid()).thenReturn("test-uuid"); + when(vmTO.getName()).thenReturn("test-vm"); + + Map result = provisioner.loadAccessDetails(null, vmTO); + + assertNotNull(result); + assertNull(result.get(ApiConstants.EXTERNAL_DETAILS)); + assertEquals("test-uuid", result.get(ApiConstants.VIRTUAL_MACHINE_ID)); + assertEquals("test-vm", result.get(ApiConstants.VIRTUAL_MACHINE_NAME)); + } + + @Test + public void testGetExtensionCheckedPathValidFile() { + String result = provisioner.getExtensionCheckedPath("test-extension", "test-extension.sh"); + + assertEquals(testScript.getAbsolutePath(), result); + } + + @Test + public void testGetExtensionCheckedPathFileNotExists() { + String result = provisioner.getExtensionCheckedPath("test-extension", "nonexistent.sh"); + + assertNull(result); + } + + @Test + public void testGetExtensionCheckedPathNoExecutePermissions() { + testScript.setExecutable(false); + String result = provisioner.getExtensionCheckedPath("test-extension", "test-extension.sh"); + assertNull(result); + Mockito.verify(logger).error("{} is not executable", "Entry point [" + testScript.getAbsolutePath() + "] for extension: test-extension"); + } + + @Test + public void testGetExtensionCheckedPathNoReadPermissions() { + testScript.setWritable(false); + testScript.setReadable(false); + Assume.assumeFalse("Skipping test as file can not be marked unreadable", testScript.canRead()); + String result = provisioner.getExtensionCheckedPath("test-extension", "test-extension.sh"); + assertNull(result); + Mockito.verify(logger).error("{} is not readable", "Entry point [" + testScript.getAbsolutePath() + "] for extension: test-extension"); + } + + @Test + public void testCheckExtensionsDirectoryValid() { + boolean result = provisioner.checkExtensionsDirectory(); + assertTrue(result); + } + + @Test + public void testCheckExtensionsDirectoryInvalid() { + ReflectionTestUtils.setField(provisioner, "extensionsDirectory", "/nonexistent/path"); + + boolean result = provisioner.checkExtensionsDirectory(); + assertFalse(result); + } + + @Test + public void testCreateOrCheckExtensionsDataDirectory() throws ConfigurationException { + provisioner.createOrCheckExtensionsDataDirectory(); + Mockito.verify(logger).info("Extensions data directory path: {}", tempDataDir.getAbsolutePath()); + } + + @Test(expected = ConfigurationException.class) + public void testCreateOrCheckExtensionsDataDirectoryCreateThrowsExceptionFail() throws ConfigurationException { + ReflectionTestUtils.setField(provisioner, "extensionsDataDirectory", "/nonexistent/path"); + try(MockedStatic filesMock = Mockito.mockStatic(Files.class)) { + filesMock.when(() -> Files.createDirectories(any())).thenThrow(new IOException("fail")); + provisioner.createOrCheckExtensionsDataDirectory(); + } + } + + @Test(expected = ConfigurationException.class) + public void testCreateOrCheckExtensionsDataDirectoryNoCreateFail() throws ConfigurationException { + ReflectionTestUtils.setField(provisioner, "extensionsDataDirectory", "/nonexistent/path"); + try(MockedStatic filesMock = Mockito.mockStatic(Files.class)) { + filesMock.when(() -> Files.createDirectories(any())).thenReturn(mock(Path.class)); + provisioner.createOrCheckExtensionsDataDirectory(); + } + } + + @Test + public void testGetExtensionPath() { + String result = provisioner.getExtensionPath("test-extension.sh"); + String expected = tempDir.getAbsolutePath() + File.separator + "test-extension.sh"; + assertEquals(expected, result); + } + + @Test + public void testGetChecksumForExtensionPath() { + String result = provisioner.getChecksumForExtensionPath("test-extension", "test-extension.sh"); + + assertNotNull(result); + } + + @Test + public void testGetChecksumForExtensionPath_InvalidFile() { + String result = provisioner.getChecksumForExtensionPath("test-extension", "nonexistent.sh"); + + assertNull(result); + } + + @Test + public void testPrepareExternalProvisioning() { + try (MockedStatic diff --git a/ui/src/components/view/SearchView.vue b/ui/src/components/view/SearchView.vue index 969957b1455..7621aaba8e8 100644 --- a/ui/src/components/view/SearchView.vue +++ b/ui/src/components/view/SearchView.vue @@ -72,7 +72,7 @@ v-for="(opt, idx) in field.opts" :key="idx" :value="['account'].includes(field.name) ? opt.name : opt.id" - :label="$t((['storageid'].includes(field.name) || !opt.path) ? opt.name : opt.path)"> + :label="$t((field.name.startsWith('domain') && opt.path) ? opt.path : opt.name)">
@@ -89,7 +89,7 @@ - {{ $t((['storageid'].includes(field.name) || !opt.path) ? opt.name : opt.path) }} + {{ $t((field.name.startsWith('domain') && opt.path) ? opt.path : opt.name) }}
@@ -309,7 +309,8 @@ export default { 'clusterid', 'podid', 'groupid', 'entitytype', 'accounttype', 'systemvmtype', 'scope', 'provider', 'type', 'scope', 'managementserverid', 'serviceofferingid', 'diskofferingid', 'networkid', 'usagetype', 'restartrequired', - 'displaynetwork', 'guestiptype', 'usersource', 'arch', 'oscategoryid', 'templatetype'].includes(item) + 'displaynetwork', 'guestiptype', 'usersource', 'arch', 'oscategoryid', 'templatetype', + 'extensionid'].includes(item) ) { type = 'list' } else if (item === 'tags') { @@ -485,6 +486,7 @@ export default { let usageTypeIndex = -1 let volumeIndex = -1 let osCategoryIndex = -1 + let extensionIndex = -1 if (arrayField.includes('type')) { if (this.$route.path === '/alert') { @@ -504,6 +506,12 @@ export default { promises.push(await this.fetchZones(searchKeyword)) } + if (arrayField.includes('extensionid')) { + extensionIndex = this.fields.findIndex(item => item.name === 'extensionid') + this.fields[extensionIndex].loading = true + promises.push(await this.fetchExtensions(searchKeyword)) + } + if (arrayField.includes('domainid')) { domainIndex = this.fields.findIndex(item => item.name === 'domainid') this.fields[domainIndex].loading = true @@ -607,6 +615,12 @@ export default { this.fields[zoneIndex].opts = this.sortArray(zones[0].data) } } + if (extensionIndex > -1) { + const extensions = response.filter(item => item.type === 'extensionid') + if (extensions && extensions.length > 0) { + this.fields[extensionIndex].opts = this.sortArray(extensions[0].data) + } + } if (domainIndex > -1) { const domain = response.filter(item => item.type === 'domainid') if (domain && domain.length > 0) { @@ -704,6 +718,9 @@ export default { if (zoneIndex > -1) { this.fields[zoneIndex].loading = false } + if (extensionIndex > -1) { + this.fields[extensionIndex].loading = false + } if (domainIndex > -1) { this.fields[domainIndex].loading = false } @@ -796,6 +813,19 @@ export default { }) }) }, + fetchExtensions (searchKeyword) { + return new Promise((resolve, reject) => { + getAPI('listExtensions', { details: 'min', showicon: true, keyword: searchKeyword }).then(json => { + const extensions = json.listextensionsresponse.extension + resolve({ + type: 'extensionid', + data: extensions + }) + }).catch(error => { + reject(error.response.headers['x-description']) + }) + }) + }, fetchDomains (searchKeyword) { return new Promise((resolve, reject) => { getAPI('listDomains', { listAll: true, details: 'min', showicon: true, keyword: searchKeyword }).then(json => { diff --git a/ui/src/components/widgets/DetailsInput.vue b/ui/src/components/widgets/DetailsInput.vue new file mode 100644 index 00000000000..eeab0e3d528 --- /dev/null +++ b/ui/src/components/widgets/DetailsInput.vue @@ -0,0 +1,186 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + + + + + diff --git a/ui/src/components/widgets/Status.vue b/ui/src/components/widgets/Status.vue index 22a8236d6cd..809f7a8a7a3 100644 --- a/ui/src/components/widgets/Status.vue +++ b/ui/src/components/widgets/Status.vue @@ -97,6 +97,12 @@ export default { case 'Up': state = this.$t('state.up') break + case 'Yes': + state = this.$t('label.yes') + break + case 'no': + state = this.$t('label.no') + break } return state.charAt(0).toUpperCase() + state.slice(1) } @@ -124,6 +130,7 @@ export default { case 'success': case 'poweron': case 'primary': + case 'yes': status = 'success' break case 'alert': @@ -138,6 +145,7 @@ export default { case 'poweroff': case 'stopped': case 'failed': + case 'no': status = 'error' break case 'migrating': diff --git a/ui/src/config/router.js b/ui/src/config/router.js index aa85f452b73..582fbaaf2f3 100644 --- a/ui/src/config/router.js +++ b/ui/src/config/router.js @@ -38,6 +38,8 @@ import infra from '@/config/section/infra' import zone from '@/config/section/zone' import offering from '@/config/section/offering' import config from '@/config/section/config' +import extension from '@/config/section/extension' +import customaction from '@/config/section/extension/customaction' import tools from '@/config/section/tools' import quota from '@/config/section/plugin/quota' import cloudian from '@/config/section/plugin/cloudian' @@ -221,6 +223,8 @@ export function asyncRouterMap () { generateRouterMap(zone), generateRouterMap(offering), generateRouterMap(config), + generateRouterMap(extension), + generateRouterMap(customaction), generateRouterMap(tools), generateRouterMap(quota), generateRouterMap(cloudian), diff --git a/ui/src/config/section/compute.js b/ui/src/config/section/compute.js index 719ed25671f..2310598d961 100644 --- a/ui/src/config/section/compute.js +++ b/ui/src/config/section/compute.js @@ -81,7 +81,7 @@ export default { fields.push('zonename') return fields }, - searchFilters: ['name', 'zoneid', 'domainid', 'account', 'groupid', 'arch', 'tags'], + searchFilters: ['name', 'zoneid', 'domainid', 'account', 'groupid', 'arch', 'extensionid', 'tags'], details: () => { var fields = ['name', 'displayname', 'id', 'state', 'ipaddress', 'ip6address', 'templatename', 'ostypename', 'serviceofferingname', 'isdynamicallyscalable', 'haenable', 'hypervisor', 'arch', 'boottype', 'bootmode', 'account', @@ -182,7 +182,7 @@ export default { message: 'message.reinstall.vm', dataView: true, popup: true, - show: (record) => { return ['Running', 'Stopped'].includes(record.state) && record.vmtype !== 'sharedfsvm' }, + show: (record) => { return record.hypervisor !== 'External' && ['Running', 'Stopped'].includes(record.state) && record.vmtype !== 'sharedfsvm' }, disabled: (record) => { return record.hostcontrolstate === 'Offline' }, component: shallowRef(defineAsyncComponent(() => import('@/views/compute/ReinstallVm.vue'))) }, @@ -200,7 +200,7 @@ export default { return args }, show: (record) => { - return (((['Running'].includes(record.state) && record.hypervisor !== 'LXC') || + return record.hypervisor !== 'External' && (((['Running'].includes(record.state) && record.hypervisor !== 'LXC') || (['Stopped'].includes(record.state) && (!['KVM', 'LXC'].includes(record.hypervisor) || (record.hypervisor === 'KVM' && ['PowerFlex', 'Filesystem', 'NetworkFilesystem', 'SharedMountPoint'].includes(record.pooltype))))) && record.vmtype !== 'sharedfsvm') }, @@ -219,9 +219,9 @@ export default { dataView: true, popup: true, show: (record, store) => { - return (record.hypervisor !== 'KVM') || + return record.hypervisor !== 'External' && ((record.hypervisor !== 'KVM') || ['Stopped', 'Destroyed'].includes(record.state) || - store.features.kvmsnapshotenabled + store.features.kvmsnapshotenabled) }, disabled: (record) => { return record.hostcontrolstate === 'Offline' && record.hypervisor === 'KVM' }, component: shallowRef(defineAsyncComponent(() => import('@/views/compute/CreateSnapshotWizard.vue'))) @@ -234,7 +234,7 @@ export default { docHelp: 'adminguide/virtual_machines.html#backup-offerings', dataView: true, args: ['virtualmachineid', 'backupofferingid'], - show: (record) => { return !record.backupofferingid }, + show: (record) => { return record.hypervisor !== 'External' && !record.backupofferingid }, mapping: { backupofferingid: { api: 'listBackupOfferings', @@ -300,7 +300,7 @@ export default { docHelp: 'adminguide/templates.html#attaching-an-iso-to-a-vm', dataView: true, popup: true, - show: (record) => { return ['Running', 'Stopped'].includes(record.state) && !record.isoid && record.vmtype !== 'sharedfsvm' }, + show: (record) => { return record.hypervisor !== 'External' && ['Running', 'Stopped'].includes(record.state) && !record.isoid && record.vmtype !== 'sharedfsvm' }, disabled: (record) => { return record.hostcontrolstate === 'Offline' || record.hostcontrolstate === 'Maintenance' }, component: shallowRef(defineAsyncComponent(() => import('@/views/compute/AttachIso.vue'))) }, @@ -317,7 +317,7 @@ export default { } return args }, - show: (record) => { return ['Running', 'Stopped'].includes(record.state) && 'isoid' in record && record.isoid && record.vmtype !== 'sharedfsvm' }, + show: (record) => { return record.hypervisor !== 'External' && ['Running', 'Stopped'].includes(record.state) && 'isoid' in record && record.isoid && record.vmtype !== 'sharedfsvm' }, disabled: (record) => { return record.hostcontrolstate === 'Offline' || record.hostcontrolstate === 'Maintenance' }, mapping: { virtualmachineid: { @@ -332,7 +332,7 @@ export default { docHelp: 'adminguide/virtual_machines.html#change-affinity-group-for-an-existing-vm', dataView: true, args: ['affinitygroupids'], - show: (record) => { return ['Stopped'].includes(record.state) && record.vmtype !== 'sharedfsvm' }, + show: (record) => { return record.hypervisor !== 'External' && ['Stopped'].includes(record.state) && record.vmtype !== 'sharedfsvm' }, component: shallowRef(defineAsyncComponent(() => import('@/views/compute/ChangeAffinity'))), popup: true }, @@ -342,7 +342,7 @@ export default { label: 'label.scale.vm', docHelp: 'adminguide/virtual_machines.html#how-to-dynamically-scale-cpu-and-ram', dataView: true, - show: (record) => { return (['Stopped'].includes(record.state) || (['Running'].includes(record.state) && record.hypervisor !== 'LXC')) && record.vmtype !== 'sharedfsvm' }, + show: (record) => { return record.hypervisor !== 'External' && (['Stopped'].includes(record.state) || (['Running'].includes(record.state) && record.hypervisor !== 'LXC')) && record.vmtype !== 'sharedfsvm' }, disabled: (record) => { return record.state === 'Running' && !record.isdynamicallyscalable }, popup: true, component: shallowRef(defineAsyncComponent(() => import('@/views/compute/ScaleVM.vue'))) @@ -353,7 +353,7 @@ export default { label: 'label.migrate.instance.to.host', docHelp: 'adminguide/virtual_machines.html#moving-vms-between-hosts-manual-live-migration', dataView: true, - show: (record, store) => { return ['Running'].includes(record.state) && ['Admin'].includes(store.userInfo.roletype) }, + show: (record, store) => { return record.hypervisor !== 'External' && ['Running'].includes(record.state) && ['Admin'].includes(store.userInfo.roletype) }, disabled: (record) => { return record.hostcontrolstate === 'Offline' }, popup: true, component: shallowRef(defineAsyncComponent(() => import('@/views/compute/MigrateWizard.vue'))) @@ -365,7 +365,7 @@ export default { message: 'message.migrate.instance.to.ps', docHelp: 'adminguide/virtual_machines.html#moving-vms-between-hosts-manual-live-migration', dataView: true, - show: (record, store) => { return ['Stopped'].includes(record.state) && ['Admin'].includes(store.userInfo.roletype) }, + show: (record, store) => { return record.hypervisor !== 'External' && ['Stopped'].includes(record.state) && ['Admin'].includes(store.userInfo.roletype) }, disabled: (record) => { return record.hostcontrolstate === 'Offline' }, component: shallowRef(defineAsyncComponent(() => import('@/views/compute/MigrateVMStorage'))), popup: true @@ -377,7 +377,7 @@ export default { message: 'message.action.instance.reset.password', dataView: true, args: ['password'], - show: (record) => { return ['Stopped'].includes(record.state) && record.passwordenabled }, + show: (record) => { return record.hypervisor !== 'External' && ['Stopped'].includes(record.state) && record.passwordenabled }, response: (result) => { return { message: result.virtualmachine && result.virtualmachine.password ? `The password of VM ${result.virtualmachine.displayname} is ${result.virtualmachine.password}` : null, @@ -393,7 +393,7 @@ export default { message: 'message.desc.reset.ssh.key.pair', docHelp: 'adminguide/virtual_machines.html#resetting-ssh-keys', dataView: true, - show: (record) => { return ['Stopped'].includes(record.state) && record.vmtype !== 'sharedfsvm' }, + show: (record) => { return record.hypervisor !== 'External' && ['Stopped'].includes(record.state) && record.vmtype !== 'sharedfsvm' }, popup: true, component: shallowRef(defineAsyncComponent(() => import('@/views/compute/ResetSshKeyPair'))) }, @@ -404,7 +404,7 @@ export default { message: 'message.desc.reset.userdata', docHelp: 'adminguide/virtual_machines.html#resetting-userdata', dataView: true, - show: (record) => { return ['Stopped'].includes(record.state) && record.vmtype !== 'sharedfsvm' }, + show: (record) => { return record.hypervisor !== 'External' && ['Stopped'].includes(record.state) && record.vmtype !== 'sharedfsvm' }, popup: true, component: shallowRef(defineAsyncComponent(() => import('@/views/compute/ResetUserData'))) }, @@ -415,7 +415,7 @@ export default { dataView: true, component: shallowRef(defineAsyncComponent(() => import('@/views/compute/AssignInstance'))), popup: true, - show: (record) => { return ['Stopped'].includes(record.state) && record.vmtype !== 'sharedfsvm' } + show: (record) => { return record.hypervisor !== 'External' && ['Stopped'].includes(record.state) && record.vmtype !== 'sharedfsvm' } }, { api: 'recoverVirtualMachine', @@ -423,7 +423,16 @@ export default { label: 'label.recover.vm', message: 'message.recover.vm', dataView: true, - show: (record, store) => { return ['Destroyed'].includes(record.state) && store.features.allowuserexpungerecovervm && record.vmtype !== 'sharedfsvm' } + show: (record, store) => { return record.hypervisor !== 'External' && ['Destroyed'].includes(record.state) && store.features.allowuserexpungerecovervm && record.vmtype !== 'sharedfsvm' } + }, + { + api: 'runCustomAction', + icon: 'play-square-outlined', + label: 'label.run.custom.action', + dataView: true, + component: shallowRef(defineAsyncComponent(() => import('@/views/extension/RunCustomAction'))), + popup: true, + show: (record) => { return ['External'].includes(record.hypervisor) } }, { api: 'unmanageVirtualMachine', @@ -431,7 +440,7 @@ export default { label: 'label.action.unmanage.virtualmachine', message: 'message.action.unmanage.virtualmachine', dataView: true, - show: (record) => { return ['Running', 'Stopped'].includes(record.state) && ['VMware', 'KVM'].includes(record.hypervisor) && record.vmtype !== 'sharedfsvm' } + show: (record) => { return record.hypervisor !== 'External' && ['Running', 'Stopped'].includes(record.state) && ['VMware', 'KVM'].includes(record.hypervisor) && record.vmtype !== 'sharedfsvm' } }, { api: 'expungeVirtualMachine', diff --git a/ui/src/config/section/extension.js b/ui/src/config/section/extension.js new file mode 100644 index 00000000000..4c6d9ebf076 --- /dev/null +++ b/ui/src/config/section/extension.js @@ -0,0 +1,147 @@ +// 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. + +import { shallowRef, defineAsyncComponent } from 'vue' +import store from '@/store' + +export default { + name: 'extension', + title: 'label.extensions', + icon: 'appstore-add-outlined', + docHelp: 'adminguide/extensions.html', + permission: ['listExtensions'], + params: (dataView) => { + const params = {} + if (!dataView) { + params.details = 'min' + } + return params + }, + resourceType: 'Extension', + columns: () => { + var fields = ['name', 'state', 'type', 'path', + { + availability: (record) => { + if (record.pathready) { + return 'Ready' + } + return 'Not Ready' + } + }, 'created'] + return fields + }, + details: ['name', 'description', 'id', 'type', 'details', 'path', 'pathready', 'isuserdefined', 'orchestratorrequirespreparevm', 'created'], + filters: ['orchestrator'], + tabs: [{ + name: 'details', + component: shallowRef(defineAsyncComponent(() => import('@/components/view/DetailsTab.vue'))) + }, + { + name: 'resources', + component: shallowRef(defineAsyncComponent(() => import('@/views/extension/ExtensionResourcesTab.vue'))) + }, + { + name: 'customactions', + component: shallowRef(defineAsyncComponent(() => import('@/views/extension/ExtensionCustomActionsTab.vue'))) + }, + { + name: 'events', + resourceType: 'Extension', + component: shallowRef(defineAsyncComponent(() => import('@/components/view/EventsTab.vue'))), + show: () => { return 'listEvents' in store.getters.apis } + }], + related: [ + { + name: 'vm', + title: 'label.instances', + param: 'extensionid' + }, + { + name: 'template', + title: 'label.templates', + param: 'extensionid' + } + ], + actions: [ + { + api: 'createExtension', + icon: 'plus-outlined', + label: 'label.create.extension', + docHelp: 'adminguide/extensions.html', + listView: true, + popup: true, + component: shallowRef(defineAsyncComponent(() => import('@/views/extension/CreateExtension.vue'))) + }, + { + api: 'updateExtension', + icon: 'edit-outlined', + label: 'label.update.extension', + dataView: true, + popup: true, + component: shallowRef(defineAsyncComponent(() => import('@/views/extension/UpdateExtension.vue'))) + }, + { + api: 'registerExtension', + icon: 'api-outlined', + label: 'label.register.extension', + message: 'message.action.register.extension', + dataView: true, + popup: true, + component: shallowRef(defineAsyncComponent(() => import('@/views/extension/RegisterExtension.vue'))) + }, + { + api: 'updateExtension', + icon: 'play-circle-outlined', + label: 'label.enable.extension', + message: 'message.confirm.enable.extension', + dataView: true, + groupAction: true, + popup: true, + defaultArgs: { state: 'Enabled' }, + groupMap: (selection) => { return selection.map(x => { return { id: x } }) }, + show: (record) => { return ['Disabled'].includes(record.state) } + }, + { + api: 'updateExtension', + icon: 'pause-circle-outlined', + label: 'label.disable.extension', + message: 'message.confirm.disable.extension', + dataView: true, + groupAction: true, + popup: true, + defaultArgs: { state: 'Disabled' }, + groupMap: (selection) => { return selection.map(x => { return { id: x } }) }, + show: (record) => { return ['Enabled'].includes(record.state) } + }, + { + api: 'deleteExtension', + icon: 'delete-outlined', + label: 'label.delete.extension', + message: 'message.action.delete.extension', + dataView: true, + popup: true, + args: ['id', 'cleanup'], + mapping: { + id: { + value: (record, params) => { return record.id } + }, + cleanup: false + }, + show: (record) => { return record.isuserdefined } + } + ] +} diff --git a/ui/src/config/section/extension/customaction.js b/ui/src/config/section/extension/customaction.js new file mode 100644 index 00000000000..e01f9bc944b --- /dev/null +++ b/ui/src/config/section/extension/customaction.js @@ -0,0 +1,94 @@ +// 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. + +import { shallowRef, defineAsyncComponent } from 'vue' +import store from '@/store' + +export default { + name: 'customaction', + title: 'label.custom.actions', + icon: 'play-square-outlined', + docHelp: 'adminguide/extensions.html#custom-actions', + permission: ['listCustomActions'], + resourceType: 'ExtensionCustomAction', + hidden: true, + columns: ['name', 'extensionname', 'enabled', 'created'], + details: ['name', 'id', 'description', 'extensionname', 'allowedroletypes', 'resourcetype', 'parameters', 'timeout', 'successmessage', 'errormessage', 'details', 'created'], + tabs: [{ + name: 'details', + component: shallowRef(defineAsyncComponent(() => import('@/components/view/DetailsTab.vue'))) + }, + { + name: 'events', + resourceType: 'ExtensionCustomAction', + component: shallowRef(defineAsyncComponent(() => import('@/components/view/EventsTab.vue'))), + show: () => { return 'listEvents' in store.getters.apis } + }], + actions: [ + { + api: 'addCustomAction', + icon: 'plus-outlined', + label: 'label.add.custom.action', + docHelp: 'adminguide/extensions.html#custom-actions', + listView: true, + popup: true, + show: (record) => { return false }, // Hidden for now + component: shallowRef(defineAsyncComponent(() => import('@/views/extension/AddCustomAction.vue'))) + }, + { + api: 'updateCustomAction', + icon: 'edit-outlined', + label: 'label.update.custom.action', + message: 'message.action.update.extension', + dataView: true, + popup: true, + component: shallowRef(defineAsyncComponent(() => import('@/views/extension/UpdateCustomAction.vue'))) + }, + { + api: 'updateCustomAction', + icon: 'play-circle-outlined', + label: 'label.enable.custom.action', + message: 'message.confirm.enable.custom.action', + dataView: true, + groupAction: true, + popup: true, + defaultArgs: { enabled: true }, + groupMap: (selection) => { return selection.map(x => { return { id: x } }) }, + show: (record) => { return !record.enabled } + }, + { + api: 'updateCustomAction', + icon: 'pause-circle-outlined', + label: 'label.disable.custom.action', + message: 'message.confirm.disable.custom.action', + dataView: true, + groupAction: true, + popup: true, + defaultArgs: { enabled: false }, + groupMap: (selection) => { return selection.map(x => { return { id: x } }) }, + show: (record) => { return record.enabled } + }, + { + api: 'deleteCustomAction', + icon: 'delete-outlined', + label: 'label.delete.custom.action', + message: 'message.action.delete.custom.action', + dataView: true, + popup: true + } + ] +} diff --git a/ui/src/config/section/image.js b/ui/src/config/section/image.js index a00224c6377..f0458088b24 100644 --- a/ui/src/config/section/image.js +++ b/ui/src/config/section/image.js @@ -58,7 +58,7 @@ export default { return fields }, details: () => { - var fields = ['name', 'id', 'displaytext', 'checksum', 'hypervisor', 'arch', 'format', 'ostypename', 'size', 'physicalsize', 'isready', 'passwordenabled', + var fields = ['name', 'id', 'displaytext', 'checksum', 'hypervisor', 'arch', 'format', 'externalprovisioner', 'ostypename', 'size', 'physicalsize', 'isready', 'passwordenabled', 'crossZones', 'templatetype', 'directdownload', 'deployasis', 'ispublic', 'isfeatured', 'isextractable', 'isdynamicallyscalable', 'crosszones', 'type', 'account', 'domain', 'created', 'userdatadetails', 'userdatapolicy', 'forcks'] if (['Admin'].includes(store.getters.userInfo.roletype)) { @@ -67,7 +67,7 @@ export default { return fields }, searchFilters: () => { - var filters = ['name', 'zoneid', 'tags', 'arch', 'oscategoryid', 'templatetype'] + var filters = ['name', 'zoneid', 'tags', 'arch', 'oscategoryid', 'templatetype', 'extensionid'] if (['Admin', 'DomainAdmin'].includes(store.getters.userInfo.roletype)) { filters.push('storageid') filters.push('imagestoreid') diff --git a/ui/src/config/section/infra/clusters.js b/ui/src/config/section/infra/clusters.js index c03a1716a8d..ad6c59dda42 100644 --- a/ui/src/config/section/infra/clusters.js +++ b/ui/src/config/section/infra/clusters.js @@ -35,7 +35,7 @@ export default { fields.push('zonename') return fields }, - details: ['name', 'id', 'allocationstate', 'clustertype', 'managedstate', 'arch', 'hypervisortype', 'podname', 'zonename', 'drsimbalance', 'storageaccessgroups', 'podstorageaccessgroups', 'zonestorageaccessgroups'], + details: ['name', 'id', 'allocationstate', 'clustertype', 'managedstate', 'arch', 'hypervisortype', 'externalprovisioner', 'podname', 'zonename', 'drsimbalance', 'storageaccessgroups', 'podstorageaccessgroups', 'zonestorageaccessgroups', 'externaldetails'], related: [{ name: 'host', title: 'label.hosts', @@ -57,7 +57,8 @@ export default { component: shallowRef(defineAsyncComponent(() => import('@/components/view/SettingsTab.vue'))) }, { name: 'drs', - component: shallowRef(defineAsyncComponent(() => import('@/views/infra/ClusterDRSTab.vue'))) + component: shallowRef(defineAsyncComponent(() => import('@/views/infra/ClusterDRSTab.vue'))), + show: (resource) => { return resource.hypervisortype !== 'External' } }, { name: 'comments', component: shallowRef(defineAsyncComponent(() => import('@/components/view/AnnotationsTab.vue'))) @@ -132,7 +133,7 @@ export default { dataView: true, defaultArgs: { iterations: null }, args: ['iterations'], - show: (record) => { return record.managedstate === 'Managed' } + show: (record) => { return record.hypervisortype !== 'External' && record.managedstate === 'Managed' } }, { api: 'enableOutOfBandManagementForCluster', @@ -141,7 +142,7 @@ export default { message: 'label.outofbandmanagement.enable', dataView: true, show: (record) => { - return record?.resourcedetails?.outOfBandManagementEnabled === 'false' + return record.hypervisortype !== 'External' && record?.resourcedetails?.outOfBandManagementEnabled === 'false' }, args: ['clusterid'], mapping: { @@ -157,7 +158,7 @@ export default { message: 'label.outofbandmanagement.disable', dataView: true, show: (record) => { - return !(record?.resourcedetails?.outOfBandManagementEnabled === 'false') + return record.hypervisortype !== 'External' && !(record?.resourcedetails?.outOfBandManagementEnabled === 'false') }, args: ['clusterid'], mapping: { @@ -173,7 +174,7 @@ export default { message: 'label.ha.enable', dataView: true, show: (record) => { - return record?.resourcedetails?.resourceHAEnabled === 'false' + return record.hypervisortype !== 'External' && record?.resourcedetails?.resourceHAEnabled === 'false' }, args: ['clusterid'], mapping: { @@ -189,7 +190,7 @@ export default { message: 'label.ha.disable', dataView: true, show: (record) => { - return !(record?.resourcedetails?.resourceHAEnabled === 'false') + return record.hypervisortype !== 'External' && !(record?.resourcedetails?.resourceHAEnabled === 'false') }, args: ['clusterid'], mapping: { @@ -209,6 +210,9 @@ export default { clusterids: { value: (record) => { return record.id } } + }, + show: (record) => { + return record.hypervisortype !== 'External' } }, { diff --git a/ui/src/config/section/infra/hosts.js b/ui/src/config/section/infra/hosts.js index 474177918e4..81e25a9c794 100644 --- a/ui/src/config/section/infra/hosts.js +++ b/ui/src/config/section/infra/hosts.js @@ -45,7 +45,7 @@ export default { fields.push('managementservername') return fields }, - details: ['name', 'id', 'resourcestate', 'ipaddress', 'hypervisor', 'arch', 'type', 'clustername', 'podname', 'zonename', 'storageaccessgroups', 'clusterstorageaccessgroups', 'podstorageaccessgroups', 'zonestorageaccessgroups', 'managementservername', 'disconnected', 'created'], + details: ['name', 'id', 'resourcestate', 'ipaddress', 'hypervisor', 'externalprovisioner', 'arch', 'type', 'clustername', 'podname', 'zonename', 'storageaccessgroups', 'clusterstorageaccessgroups', 'podstorageaccessgroups', 'zonestorageaccessgroups', 'managementservername', 'disconnected', 'created', 'externaldetails'], tabs: [{ name: 'details', component: shallowRef(defineAsyncComponent(() => import('@/components/view/DetailsTab.vue'))) @@ -87,6 +87,7 @@ export default { label: 'label.action.change.password', dataView: true, popup: true, + show: (record) => { return record.hypervisor !== 'External' }, component: shallowRef(defineAsyncComponent(() => import('@/views/infra/ChangeHostPassword.vue'))) }, { @@ -169,6 +170,7 @@ export default { docHelp: 'adminguide/hosts.html#out-of-band-management', dataView: true, popup: true, + show: (record) => { return record.hypervisor !== 'External' }, component: shallowRef(defineAsyncComponent(() => import('@/views/infra/ConfigureHostOOBM'))) }, { @@ -179,7 +181,7 @@ export default { docHelp: 'adminguide/hosts.html#out-of-band-management', dataView: true, show: (record) => { - return !(record?.outofbandmanagement?.enabled === true) + return record.hypervisor !== 'External' && !(record?.outofbandmanagement?.enabled === true) }, args: ['hostid'], mapping: { @@ -196,7 +198,7 @@ export default { docHelp: 'adminguide/hosts.html#out-of-band-management', dataView: true, show: (record) => { - return record?.outofbandmanagement?.enabled === true + return record.hypervisor !== 'External' && record?.outofbandmanagement?.enabled === true }, args: ['hostid'], mapping: { @@ -213,7 +215,7 @@ export default { docHelp: 'adminguide/hosts.html#out-of-band-management', dataView: true, show: (record) => { - return record?.outofbandmanagement?.enabled === true + return record.hypervisor !== 'External' && record?.outofbandmanagement?.enabled === true }, args: ['hostid', 'action'], mapping: { @@ -233,7 +235,7 @@ export default { docHelp: 'adminguide/hosts.html#out-of-band-management', dataView: true, show: (record) => { - return record?.outofbandmanagement?.enabled === true + return record.hypervisor !== 'External' && record?.outofbandmanagement?.enabled === true }, args: ['hostid', 'password'], mapping: { @@ -268,7 +270,7 @@ export default { docHelp: 'adminguide/reliability.html#ha-for-hosts', dataView: true, show: (record) => { - return !(record?.hostha?.haenable === true) + return record.hypervisor !== 'External' && !(record?.hostha?.haenable === true) }, args: ['hostid'], mapping: { diff --git a/ui/src/config/section/offering.js b/ui/src/config/section/offering.js index ed519100b19..de18b84100c 100644 --- a/ui/src/config/section/offering.js +++ b/ui/src/config/section/offering.js @@ -16,6 +16,7 @@ // under the License. import { shallowRef, defineAsyncComponent } from 'vue' import store from '@/store' +import { getFilteredExternalDetails } from '@/utils/extension' export default { name: 'offering', @@ -40,7 +41,7 @@ export default { filters: ['active', 'inactive'], columns: ['name', 'displaytext', 'state', 'cpunumber', 'cpuspeed', 'memory', 'domain', 'zone', 'order'], details: () => { - var fields = ['name', 'id', 'displaytext', 'offerha', 'provisioningtype', 'storagetype', 'iscustomized', 'iscustomizediops', 'limitcpuuse', 'cpunumber', 'cpuspeed', 'memory', 'hosttags', 'tags', 'storageaccessgroups', 'storagetags', 'domain', 'zone', 'created', 'dynamicscalingenabled', 'diskofferingstrictness', 'encryptroot', 'purgeresources', 'leaseduration', 'leaseexpiryaction'] + var fields = ['name', 'id', 'displaytext', 'offerha', 'provisioningtype', 'storagetype', 'iscustomized', 'iscustomizediops', 'limitcpuuse', 'cpunumber', 'cpuspeed', 'memory', 'hosttags', 'tags', 'storageaccessgroups', 'storagetags', 'domain', 'zone', 'created', 'dynamicscalingenabled', 'diskofferingstrictness', 'encryptroot', 'purgeresources', 'leaseduration', 'leaseexpiryaction', 'externaldetails'] if (store.getters.apis.createServiceOffering && store.getters.apis.createServiceOffering.params.filter(x => x.name === 'storagepolicy').length > 0) { fields.splice(6, 0, 'vspherestoragepolicy') @@ -95,7 +96,12 @@ export default { label: 'label.edit', docHelp: 'adminguide/service_offerings.html#modifying-or-deleting-a-service-offering', dataView: true, - args: ['name', 'displaytext', 'storagetags', 'hosttags'] + args: ['name', 'displaytext', 'storageatags', 'hosttags', 'externaldetails'], + mapping: { + externaldetails: { + transformedvalue: (record) => { return getFilteredExternalDetails(record.serviceofferingdetails) } + } + } }, { api: 'updateServiceOffering', icon: 'lock-outlined', diff --git a/ui/src/core/lazy_lib/icons_use.js b/ui/src/core/lazy_lib/icons_use.js index 01ffa2e6859..82cd1ae5a46 100644 --- a/ui/src/core/lazy_lib/icons_use.js +++ b/ui/src/core/lazy_lib/icons_use.js @@ -19,6 +19,7 @@ import { AimOutlined, ApartmentOutlined, ApiOutlined, + AppstoreAddOutlined, AppstoreOutlined, ArrowDownOutlined, ArrowRightOutlined, @@ -137,6 +138,7 @@ import { PictureOutlined, PieChartOutlined, PlayCircleOutlined, + PlaySquareOutlined, PlusCircleOutlined, PlusOutlined, PlusSquareOutlined, @@ -191,6 +193,7 @@ export default { app.component('AimOutlined', AimOutlined) app.component('ApartmentOutlined', ApartmentOutlined) app.component('ApiOutlined', ApiOutlined) + app.component('AppstoreAddOutlined', AppstoreAddOutlined) app.component('AppstoreOutlined', AppstoreOutlined) app.component('ArrowDownOutlined', ArrowDownOutlined) app.component('ArrowRightOutlined', ArrowRightOutlined) @@ -309,6 +312,7 @@ export default { app.component('PictureOutlined', PictureOutlined) app.component('PieChartOutlined', PieChartOutlined) app.component('PlayCircleOutlined', PlayCircleOutlined) + app.component('PlaySquareOutlined', PlaySquareOutlined) app.component('PlusCircleOutlined', PlusCircleOutlined) app.component('PlusOutlined', PlusOutlined) app.component('PlusSquareOutlined', PlusSquareOutlined) diff --git a/ui/src/main.js b/ui/src/main.js index 2bd9d945e9a..f117fb57810 100644 --- a/ui/src/main.js +++ b/ui/src/main.js @@ -39,7 +39,8 @@ import { localesPlugin, dialogUtilPlugin, cpuArchitectureUtilPlugin, - imagesUtilPlugin + imagesUtilPlugin, + extensionsUtilPlugin } from './utils/plugins' import { VueAxios } from './utils/request' import directives from './utils/directives' @@ -61,6 +62,7 @@ vueApp.use(genericUtilPlugin) vueApp.use(dialogUtilPlugin) vueApp.use(cpuArchitectureUtilPlugin) vueApp.use(imagesUtilPlugin) +vueApp.use(extensionsUtilPlugin) vueApp.use(extensions) vueApp.use(directives) diff --git a/ui/src/utils/extension.js b/ui/src/utils/extension.js new file mode 100644 index 00000000000..cd8d0c4daca --- /dev/null +++ b/ui/src/utils/extension.js @@ -0,0 +1,30 @@ +// 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. + +export function getFilteredExternalDetails (details) { + if (!details || typeof details !== 'object') { + return null + } + const prefix = 'External:' + const result = {} + for (const key in details) { + if (key.startsWith(prefix)) { + result[key.substring(prefix.length)] = details[key] + } + } + return Object.keys(result).length > 0 ? result : null +} diff --git a/ui/src/utils/plugins.js b/ui/src/utils/plugins.js index a8b9b54fc9e..61456d98b12 100644 --- a/ui/src/utils/plugins.js +++ b/ui/src/utils/plugins.js @@ -32,6 +32,7 @@ export const pollJobPlugin = { * @param {String} [name=''] * @param {String} [title=''] * @param {String} [description=''] + * @param {Boolean} [showSuccessMessage=true] * @param {String} [successMessage=Success] * @param {Function} [successMethod=() => {}] * @param {String} [errorMessage=Error] @@ -49,6 +50,7 @@ export const pollJobPlugin = { name = '', title = '', description = '', + showSuccessMessage = true, successMessage = i18n.global.t('label.success'), successMethod = () => {}, errorMessage = i18n.global.t('label.error'), @@ -92,18 +94,22 @@ export const pollJobPlugin = { const result = json.queryasyncjobresultresponse eventBus.emit('update-job-details', { jobId, resourceId }) if (result.jobstatus === 1) { - var content = successMessage - if (successMessage === 'Success' && action && action.label) { - content = i18n.global.t(action.label) + if (showSuccessMessage) { + var content = successMessage + if (successMessage === 'Success' && action && action.label) { + content = i18n.global.t(action.label) + } + if (name) { + content = content + ' - ' + name + } + message.success({ + content, + key: jobId, + duration: 2 + }) + } else { + message.destroy(jobId) } - if (name) { - content = content + ' - ' + name - } - message.success({ - content, - key: jobId, - duration: 2 - }) store.dispatch('AddHeaderNotice', { key: jobId, title, @@ -384,6 +390,8 @@ export const resourceTypePlugin = { return 'kubernetes' case 'KubernetesSupportedVersion': return 'kubernetesiso' + case 'ExtensionCustomAction': + return 'customaction' case 'SystemVm': case 'PhysicalNetwork': case 'Backup': @@ -413,6 +421,7 @@ export const resourceTypePlugin = { case 'AutoScaleVmGroup': case 'QuotaTariff': case 'GuestOsCategory': + case 'Extension': return resourceType.toLowerCase() } return '' @@ -557,8 +566,12 @@ export const cpuArchitectureUtilPlugin = { export const imagesUtilPlugin = { install (app) { - app.config.globalProperties.$fetchTemplateTypes = function () { - const baseTypes = ['USER', 'VNF'] + app.config.globalProperties.$fetchTemplateTypes = function (hypervisor) { + const baseTypes = ['USER'] + if (hypervisor === 'External') { + return baseTypes.map(type => ({ id: type, name: type, description: type })) + } + baseTypes.push('VNF') const adminTypes = ['SYSTEM', 'BUILTIN', 'ROUTING'] const types = [...baseTypes] if (store.getters.userInfo?.roletype === 'Admin') { @@ -568,3 +581,19 @@ export const imagesUtilPlugin = { } } } + +export const extensionsUtilPlugin = { + install (app) { + app.config.globalProperties.$fetchCustomActionRoleTypes = function () { + const roleTypes = [] + const roleTypesList = ['Admin', 'Resource Admin', 'Domain Admin', 'User'] + roleTypesList.forEach((item) => { + roleTypes.push({ + id: item.replace(' ', ''), + description: item + }) + }) + return roleTypes + } + } +} diff --git a/ui/src/views/AutogenView.vue b/ui/src/views/AutogenView.vue index ab086cf189f..589def212a2 100644 --- a/ui/src/views/AutogenView.vue +++ b/ui/src/views/AutogenView.vue @@ -51,7 +51,7 @@ @@ -363,6 +363,9 @@ {{ opt.name && opt.type ? opt.name + ' (' + opt.type + ')' : opt.name || opt.description }} + { + params[param.name + '[0].' + key] = value + }) } else { params[key] = input } @@ -1819,6 +1836,12 @@ export default { } } else if (['computeoffering', 'systemoffering', 'diskoffering'].includes(this.$route.name)) { query.state = filter + } else if (['extension'].includes(this.$route.name)) { + if (filter === 'all') { + delete query.type + } else { + query.type = filter + } } query.filter = filter query.page = '1' diff --git a/ui/src/views/compute/CreateKubernetesCluster.vue b/ui/src/views/compute/CreateKubernetesCluster.vue index ac9d3f5fd8a..7a2bcec49b9 100644 --- a/ui/src/views/compute/CreateKubernetesCluster.vue +++ b/ui/src/views/compute/CreateKubernetesCluster.vue @@ -732,9 +732,7 @@ export default { getAPI('listHypervisors', params).then(json => { const listResponse = json.listhypervisorsresponse.hypervisor || [] - if (listResponse) { - this.selectedZoneHypervisors = listResponse - } + this.selectedZoneHypervisors = listResponse.filter(hypervisor => hypervisor.name !== 'External') }).finally(() => { this.hypervisorLoading = false }) diff --git a/ui/src/views/compute/DeployVM.vue b/ui/src/views/compute/DeployVM.vue index ac98f35d806..3a7b015e533 100644 --- a/ui/src/views/compute/DeployVM.vue +++ b/ui/src/views/compute/DeployVM.vue @@ -165,7 +165,7 @@ :key="templateKey" @handle-search-filter="filters => fetchAllTemplates(filters)" @update-template-iso="updateFieldValue" /> -
+
{{ $t('label.override.rootdisk.size') }} - + {{ $t('label.override.root.diskoffering') }} - + @@ -811,6 +815,17 @@ :filterOption="filterOption" > + + + + +
{{ $t('message.add.orchestrator.resource.details') }}
+ +
+
@@ -907,6 +922,7 @@ import UserDataSelection from '@views/compute/wizard/UserDataSelection' import SecurityGroupSelection from '@views/compute/wizard/SecurityGroupSelection' import TooltipLabel from '@/components/widgets/TooltipLabel' import InstanceNicsNetworkSelectListView from '@/components/view/InstanceNicsNetworkSelectListView' +import DetailsInput from '@/components/widgets/DetailsInput' export default { name: 'Wizard', @@ -931,7 +947,8 @@ export default { ComputeSelection, SecurityGroupSelection, TooltipLabel, - InstanceNicsNetworkSelectListView + InstanceNicsNetworkSelectListView, + DetailsInput }, props: { visible: { @@ -1106,7 +1123,9 @@ export default { }, architectureTypes: { opts: [] - } + }, + externalDetailsEnabled: false, + selectedExtensionId: null } }, computed: { @@ -1474,6 +1493,9 @@ export default { }, guestOsCategoriesSelectionDisallowed () { return (!this.queryGuestOsCategoryId || this.options.guestOsCategories.length === 0) && (!!this.queryTemplateId || !!this.queryIsoId) + }, + isTemplateHypervisorExternal () { + return !!this.template && this.template.hypervisor === 'External' } }, watch: { @@ -1641,6 +1663,9 @@ export default { this.doUserdataAppend = false } }, + beforeCreate () { + this.apiParams = this.$getApiParams('deployVirtualMachine') + }, created () { this.initForm() this.dataPreFill = this.preFillContent && Object.keys(this.preFillContent).length > 0 ? this.preFillContent : {} @@ -1951,6 +1976,7 @@ export default { if (template.details['vmware-to-kvm-mac-addresses']) { this.dataPreFill.macAddressArray = JSON.parse(template.details['vmware-to-kvm-mac-addresses']) } + this.dataPreFill.hypervisorType = template.hypervisor } } else if (name === 'isoid') { this.imageType = 'isoid' @@ -2123,19 +2149,11 @@ export default { }, changeArchitecture (arch) { this.selectedArchitecture = arch - if (this.isModernImageSelection) { - this.fetchGuestOsCategories() - return - } - this.fetchImages() + this.updateImages() }, changeImageType (imageType) { this.imageType = imageType - if (this.isModernImageSelection) { - this.fetchGuestOsCategories() - } else { - this.fetchImages() - } + this.updateImages() }, handleSubmitAndStay (e) { this.form.stayonpage = true @@ -2357,6 +2375,12 @@ export default { deployVmData.projectid = this.owner.projectid } + if (this.imageType === 'templateid' && this.template && this.template.hypervisor === 'External' && values.externaldetails) { + Object.entries(values.externaldetails).forEach(([key, value]) => { + deployVmData['externaldetails[0].' + key] = value + }) + } + const title = this.$t('label.launch.vm') const description = values.name || '' const password = this.$t('label.password') @@ -2573,6 +2597,9 @@ export default { if (this.isZoneSelectedMultiArch) { args.arch = this.selectedArchitecture } + if (this.selectedExtensionId) { + args.extensionid = this.selectedExtensionId + } args.account = store.getters.project?.id ? null : this.owner.account args.domainid = store.getters.project?.id ? null : this.owner.domainid args.projectid = store.getters.project?.id || this.owner.projectid @@ -2772,7 +2799,7 @@ export default { this.fetchOptions(this.params.hosts, 'hosts') if (this.clusterId && Array.isArray(this.options.clusters)) { const cluster = this.options.clusters.find(c => c.id === this.clusterId) - this.handleArchResourceSelected(cluster.arch) + this.handleComputeResourceSelected(cluster) } }, onSelectHostId (value) { @@ -2782,15 +2809,40 @@ export default { } if (this.hostId && Array.isArray(this.options.hosts)) { const host = this.options.hosts.find(h => h.id === this.hostId) - this.handleArchResourceSelected(host.arch) + this.handleComputeResourceSelected(host) } }, - handleArchResourceSelected (resourceArch) { - if (!resourceArch || !this.isZoneSelectedMultiArch || this.selectedArchitecture === resourceArch) { + updateImages () { + if (this.isModernImageSelection) { + this.fetchGuestOsCategories() return } - this.selectedArchitecture = resourceArch - this.changeArchitecture(resourceArch, this.tabKey === 'templateid') + this.fetchImages() + }, + handleComputeResourceSelected (computeResource) { + if (!computeResource) { + this.selectedExtensionId = null + return + } + const resourceArch = computeResource.arch + const needArchChange = resourceArch && + this.isZoneSelectedMultiArch && + this.selectedArchitecture !== resourceArch + const resourceHypervisor = computeResource.hypervisor || computeResource.hypervisortype + const resourceExtensionId = resourceHypervisor === 'External' ? computeResource.extensionid : null + const needExtensionIdChange = this.selectedExtensionId !== resourceExtensionId + if (!needArchChange && !needExtensionIdChange) { + return + } + if (needArchChange && !needExtensionIdChange) { + this.changeArchitecture(resourceArch, this.imageType === 'templateid') + return + } + this.selectedExtensionId = resourceExtensionId + if (needArchChange) { + this.selectedArchitecture = resourceArch + } + this.updateImages() }, onSelectGuestOsCategory (value) { this.form.guestoscategoryid = value @@ -3113,6 +3165,12 @@ export default { return Promise.reject(this.$t('message.error.number')) } return Promise.resolve() + }, + onExternalDetailsEnabledChange (val) { + if (val || !this.form.externaldetails) { + return + } + this.form.externaldetails = undefined } } } diff --git a/ui/src/views/compute/InstanceTab.vue b/ui/src/views/compute/InstanceTab.vue index fa50e6e79b7..8dc90efae06 100644 --- a/ui/src/views/compute/InstanceTab.vue +++ b/ui/src/views/compute/InstanceTab.vue @@ -39,7 +39,7 @@ style="width: 100%; margin-bottom: 10px" @click="showAddVolModal" :loading="loading" - :disabled="!('createVolume' in $store.getters.apis) || this.vm.state === 'Error'"> + :disabled="!('createVolume' in $store.getters.apis) || this.vm.state === 'Error' || resource.hypervisor === 'External'"> {{ $t('label.action.create.volume.add') }} diff --git a/ui/src/views/extension/AddCustomAction.vue b/ui/src/views/extension/AddCustomAction.vue new file mode 100644 index 00000000000..f74255fa8de --- /dev/null +++ b/ui/src/views/extension/AddCustomAction.vue @@ -0,0 +1,247 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + + + + + + diff --git a/ui/src/views/extension/CreateExtension.vue b/ui/src/views/extension/CreateExtension.vue new file mode 100644 index 00000000000..11d0c776e4d --- /dev/null +++ b/ui/src/views/extension/CreateExtension.vue @@ -0,0 +1,256 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + + + + + + diff --git a/ui/src/views/extension/ExtensionCustomActionsTab.vue b/ui/src/views/extension/ExtensionCustomActionsTab.vue new file mode 100644 index 00000000000..a7b6d59c1cf --- /dev/null +++ b/ui/src/views/extension/ExtensionCustomActionsTab.vue @@ -0,0 +1,274 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + + + + diff --git a/ui/src/views/extension/ExtensionResourcesTab.vue b/ui/src/views/extension/ExtensionResourcesTab.vue new file mode 100644 index 00000000000..c69c79306c8 --- /dev/null +++ b/ui/src/views/extension/ExtensionResourcesTab.vue @@ -0,0 +1,135 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + + + + diff --git a/ui/src/views/extension/ExternalConfigurationDetails.vue b/ui/src/views/extension/ExternalConfigurationDetails.vue new file mode 100644 index 00000000000..4322651ea3e --- /dev/null +++ b/ui/src/views/extension/ExternalConfigurationDetails.vue @@ -0,0 +1,115 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + + + + diff --git a/ui/src/views/extension/ParametersInput.vue b/ui/src/views/extension/ParametersInput.vue new file mode 100644 index 00000000000..f77b0471eaa --- /dev/null +++ b/ui/src/views/extension/ParametersInput.vue @@ -0,0 +1,328 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + + + + + + diff --git a/ui/src/views/extension/RegisterExtension.vue b/ui/src/views/extension/RegisterExtension.vue new file mode 100644 index 00000000000..f856c181f8c --- /dev/null +++ b/ui/src/views/extension/RegisterExtension.vue @@ -0,0 +1,200 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + + + + + + diff --git a/ui/src/views/extension/RunCustomAction.vue b/ui/src/views/extension/RunCustomAction.vue new file mode 100644 index 00000000000..bd26cf7f375 --- /dev/null +++ b/ui/src/views/extension/RunCustomAction.vue @@ -0,0 +1,386 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + + + + + + diff --git a/ui/src/views/extension/UpdateCustomAction.vue b/ui/src/views/extension/UpdateCustomAction.vue new file mode 100644 index 00000000000..008882a5238 --- /dev/null +++ b/ui/src/views/extension/UpdateCustomAction.vue @@ -0,0 +1,232 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + + + + + + diff --git a/ui/src/views/extension/UpdateExtension.vue b/ui/src/views/extension/UpdateExtension.vue new file mode 100644 index 00000000000..192a339b43a --- /dev/null +++ b/ui/src/views/extension/UpdateExtension.vue @@ -0,0 +1,160 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + + + + + + diff --git a/ui/src/views/iam/CreateRole.vue b/ui/src/views/iam/CreateRole.vue index 3f6013acd80..11cecf69efe 100644 --- a/ui/src/views/iam/CreateRole.vue +++ b/ui/src/views/iam/CreateRole.vue @@ -143,7 +143,7 @@ export default { }, watch: { '$route' (to, from) { - if (to.fullPath !== from.fullPath && !to.fullPath.includes('action/')) { + if (to.fullPath !== from.fullPath && !to.fullPath.includes('/action/')) { this.fetchRoles() } }, diff --git a/ui/src/views/image/RegisterOrUploadTemplate.vue b/ui/src/views/image/RegisterOrUploadTemplate.vue index 7467d965b87..47c99db8b9c 100644 --- a/ui/src/views/image/RegisterOrUploadTemplate.vue +++ b/ui/src/views/image/RegisterOrUploadTemplate.vue @@ -194,7 +194,7 @@
- + @@ -212,10 +212,38 @@ + + + + + {{ extension.name || extension.description }} + + + - - + + + + +
{{ $t('message.add.orchestrator.resource.details') }}
+ +
+
+ +