diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d101777c70e..d1a295e61493 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 4766c30ead28..c4e99cb41707 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 @@ 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 enum State { 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 void setRequestSequence(long requestSequence) { 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 d67ce6796841..cffb98740805 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 @@ 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 void setMetadataProductName(String metadataProductName) { 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 88050349bc99..e4fcb089960d 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.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; @@ -801,11 +803,25 @@ public class EventTypes { // Resource Limit public static final String EVENT_RESOURCE_LIMIT_UPDATE = "RESOURCE.LIMIT.UPDATE"; + // 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"; public static final String VM_LEASE_EXPIRING = "VM.LEASE.EXPIRING"; + // 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 @@ -1306,6 +1322,16 @@ public class EventTypes { entityEventDetails.put(VM_LEASE_EXPIRING, VirtualMachine.class); entityEventDetails.put(VM_LEASE_DISABLED, VirtualMachine.class); entityEventDetails.put(VM_LEASE_CANCELLED, VirtualMachine.class); + + // 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 27ffef1c3708..5baac4847722 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 enum Functionality { 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 a4cd87af0080..eb496ac4e0b9 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 36d58c737ccc..454fbdcf4056 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 @@ 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,15 @@ 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.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 +58,6 @@ 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 +270,6 @@ Network createPrivateNetwork(String networkName, String displayText, long physic List getInternalLoadBalancerElements(); boolean handleCksIsoOnNetworkVirtualRouter(Long virtualRouterId, boolean mount) throws ResourceUnavailableException; + + String getNsxSegmentId(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 05b8b3ab7a86..1ad3731b9eaa 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 static enum ImageFormat { 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 89953d225a06..b8c646048b97 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 enum TemplateFilter { 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 14f15c9fd0fd..ea5d209a5d41 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 005d47c85bc2..46e4f1bc510d 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 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 static Long getRoleByAccountType(final Long roleId, final Account.Type ac 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 1250284b5c27..5146e5c38e8e 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 @@ private AlertType(short type, String name, boolean isDefault) { 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 4cd89c877b24..4d33ba859a5b 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 22e7e8075020..ebe61f51fae9 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"; @@ -137,6 +138,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"; @@ -161,6 +163,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"; @@ -202,6 +205,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"; @@ -212,6 +216,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"; @@ -344,6 +354,7 @@ public class ApiConstants { public static final String MAX_BACKUPS = "maxbackups"; public static final String MAX_CPU_NUMBER = "maxcpunumber"; public static final String MAX_MEMORY = "maxmemory"; + 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"; @@ -403,6 +414,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"; @@ -425,6 +437,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"; @@ -447,6 +460,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"; @@ -518,6 +532,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"; @@ -558,7 +573,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"; @@ -657,7 +675,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"; @@ -1049,6 +1067,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"; @@ -1084,6 +1103,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"; @@ -1308,6 +1328,10 @@ public enum DomainDetails { 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/command/admin/cluster/AddClusterCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/cluster/AddClusterCmd.java index 15265f561e73..c6f918ea5a3e 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 @@ 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 @@ 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 using format externaldetails[i].keyname=keyvalue. Example: externaldetails[0].endpoint.url=urlvalue", + since = "4.21.0") + protected Map externalDetails; + public String getOvm3Pool() { return ovm3pool; } @@ -190,6 +204,10 @@ public String getHypervisor() { return hypervisor; } + public Long getExtensionId() { + return extensionId; + } + public String getClusterType() { return clusterType; } @@ -224,6 +242,10 @@ public CPU.CPUArch getArch() { 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 9cc39503fbf7..65dd1b232fad 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.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 @@ requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) public class ListClustersCmd extends BaseListCmd { + @Inject + ExtensionHelper extensionHelper; ///////////////////////////////////////////////////// //////////////// API parameters ///////////////////// @@ -143,15 +153,38 @@ public ListClustersCmd(String storageAccessGroup) { /////////////// 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 6531444b52e5..97c609845a2f 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 @@ -17,8 +17,9 @@ package org.apache.cloudstack.api.command.admin.host; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; - +import java.util.Map; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; @@ -35,6 +36,7 @@ import com.cloud.exception.DiscoveryException; import com.cloud.host.Host; import com.cloud.user.Account; +import com.cloud.vm.VmDetailConstants; @APICommand(name = "addHost", description = "Adds a new host.", responseObject = HostResponse.class, requestHasSensitiveInfo = true, responseHasSensitiveInfo = false) @@ -81,6 +83,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 +137,16 @@ public String getAllocationState() { return allocationState; } + public Map getExternalDetails() { + 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; + } + ///////////////////////////////////////////////////// /////////////// 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 397f9c80735e..04da3b6725c4 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 @@ -18,6 +18,7 @@ import com.cloud.host.Host; import com.cloud.user.Account; +import com.cloud.vm.VmDetailConstants; import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; @@ -28,7 +29,9 @@ import org.apache.cloudstack.api.response.GuestOSCategoryResponse; import org.apache.cloudstack.api.response.HostResponse; +import java.util.HashMap; import java.util.List; +import java.util.Map; @APICommand(name = "updateHost", description = "Updates a host.", responseObject = HostResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) @@ -67,6 +70,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 +109,16 @@ public String getAnnotation() { return annotation; } + public Map getExternalDetails() { + 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; + } + ///////////////////////////////////////////////////// /////////////// 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 4cadaad0e47e..d862e76b80c2 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,21 +29,22 @@ 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; import com.cloud.storage.Storage; import com.cloud.user.Account; +import com.cloud.vm.VmDetailConstants; @APICommand(name = "createServiceOffering", description = "Creates a service offering.", responseObject = ServiceOfferingResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) @@ -263,6 +264,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") + protected Map externalDetails; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -369,9 +376,21 @@ public Map getDetails() { } } } + + detailsMap.putAll(getExternalDetails()); return detailsMap; } + public Map getExternalDetails() { + 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; + } + public Long getRootDiskSize() { return rootDiskSize; } 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 8881a2bc354e..0bc993ef1f71 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 void execute() { 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 77a7a7fd8eac..91632b910ffd 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 void execute() { 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 585114e07ad6..29a61248a7f5 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.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; @@ -120,6 +121,11 @@ public class ListTemplatesCmd extends BaseListTaggedResourcesCmd implements User since = "4.21.0") private Long osCategoryId; + @Parameter(name = ApiConstants.EXTENSION_ID, type = CommandType.UUID, entityType= ExtensionResponse.class, + description = "the ID of the extension for the template", + since = "4.21.0") + private Long extensionId; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -220,6 +226,10 @@ public Long getOsCategoryId() { 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 6ea149fd90de..7bd5f842ddaa 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 @@ -21,10 +21,12 @@ import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; import java.util.List; import java.util.Map; import com.cloud.hypervisor.HypervisorGuru; +import com.cloud.vm.VmDetailConstants; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiCommandResourceType; import org.apache.cloudstack.api.ApiConstants; @@ -35,6 +37,7 @@ 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; @@ -183,6 +186,12 @@ 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 = "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 using format externaldetails[i].keyname=keyvalue. Example: externaldetails[0].endpoint.url=urlvalue", since = "4.21.0") + protected Map externalDetails; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -312,6 +321,20 @@ public CPU.CPUArch getArch() { return CPU.CPUArch.fromType(arch); } + public Long getExtensionId() { + return extensionId; + } + + public Map getExternalDetails() { + 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; + } + ///////////////////////////////////////////////////// /////////////// 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 bf6e41e6d59d..536a1d1c5e58 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,24 +16,17 @@ // 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.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 javax.annotation.Nonnull; + import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.affinity.AffinityGroupResponse; import org.apache.cloudstack.api.ACL; @@ -67,15 +60,24 @@ 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.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) @@ -286,6 +288,12 @@ public class DeployVMCmd extends BaseAsyncCreateCustomIdCmd implements SecurityG 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].server.type=typevalue", + since = "4.21.0") + protected Map externalDetails; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -359,9 +367,22 @@ public Map getDetails() { customparameterMap.put(VmDetailConstants.NIC_PACKED_VIRTQUEUES_ENABLED, BooleanUtils.toStringTrueFalse(nicPackedVirtQueues)); } + if (MapUtils.isNotEmpty(externalDetails)) { + customparameterMap.putAll(getExternalDetails()); + } + return customparameterMap; } + public Map getExternalDetails() { + 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; + } 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 9fc4258c6d9c..8489a0a68a05 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.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 boolean getOnlyLeasedInstances() { 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 74dbfa15a431..b0a82c86fb52 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 void setSharedFsVmMinRamSize(Integer sharedFsVmMinRamSize) { 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 17c86072b98c..202ff4bd8705 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 @@ @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 String getZoneStorageAccessGroups() { 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 c60917bbe7a2..c1e04beee732 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 000000000000..d627f8077dc1 --- /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 000000000000..96edf6d2fd80 --- /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 000000000000..aa370887b748 --- /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 000000000000..fdf1e87df506 --- /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 342a1eb7df30..692779b0e30a 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 void setManagementServerName(String managementServerName) { 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 Boolean getEncryptionSupported() { 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 f98cf0acd5dd..00f1e4e3bb0b 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 57970368d7ee..b02c60f8b1b8 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 @@ -132,7 +132,6 @@ public class TemplateResponse extends BaseResponseWithTagInformation implements @SerializedName(ApiConstants.HYPERVISOR) @Param(description = "the hypervisor on which the template runs") private String hypervisor; - @SerializedName(ApiConstants.DOMAIN) @Param(description = "the name of the domain to which the template belongs") private String domainName; @@ -254,6 +253,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 +552,20 @@ public void setUserDataParams(String userDataParams) { 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 cec70f20cff7..e9d45cb506ae 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 @@ 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 000000000000..33ff70fcace5 --- /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 000000000000..3068612ed6fe --- /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 000000000000..ea0a8fafa1a6 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/extension/ExtensionCustomAction.java @@ -0,0 +1,383 @@ +//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: + return Arrays.asList("true", "false").contains(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 000000000000..f50f841ed74a --- /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 000000000000..40ebc19eb5e3 --- /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 000000000000..af53a539e670 --- /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 000000000000..b0d6c6becf71 --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/extension/ExtensionCustomActionTest.java @@ -0,0 +1,485 @@ +//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 + 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"); + assertEquals(false, result); + } + + @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 ce38727b80a7..8c3812eb7d29 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 0a6078048d36..fd75c9d3ea0f 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 2b673d7750e9..d2ff28e8872b 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -347,6 +347,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 000000000000..b94d18c537ea --- /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 000000000000..44f57607eba9 --- /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 000000000000..1deb789c995f --- /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 000000000000..36489ad4fa5f --- /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 3923a35bd0a7..d07ffa2e31f7 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 Map getVlanToPersistenceMap() { 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 08f56d4f117f..51fc5bf2a661 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 3d0d7e238149..b2a32bd93c14 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 d5d50a4718c3..fde3ba96de0a 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 d178afa67307..24cb2b033121 100755 --- a/debian/rules +++ b/debian/rules @@ -76,6 +76,8 @@ override_dh_auto_install: mkdir $(DESTDIR)/var/log/$(PACKAGE)/ipallocator mkdir $(DESTDIR)/var/lib/$(PACKAGE)/management mkdir $(DESTDIR)/var/lib/$(PACKAGE)/mnt + mkdir -p $(DESTDIR)/$(SYSCONFDIR)/$(PACKAGE)/extensions/HyperV + mkdir -p $(DESTDIR)/$(SYSCONFDIR)/$(PACKAGE)/extensions/Proxmox cp -r client/target/utilities/scripts/db/* $(DESTDIR)/usr/share/$(PACKAGE)-management/setup/ cp -r client/target/classes/META-INF/webapp $(DESTDIR)/usr/share/$(PACKAGE)-management/webapp @@ -86,6 +88,8 @@ override_dh_auto_install: cp -r engine/schema/dist/systemvm-templates/* $(DESTDIR)/usr/share/$(PACKAGE)-management/templates/systemvm/ cp -r plugins/integrations/kubernetes-service/src/main/resources/conf/* $(DESTDIR)/usr/share/$(PACKAGE)-management/cks/conf/ rm -rf $(DESTDIR)/usr/share/$(PACKAGE)-management/templates/systemvm/md5sum.txt + cp extensions/HyperV/hyperv.py $(DESTDIR)/$(SYSCONFDIR)/$(PACKAGE)/extensions/HyperV/ + cp extensions/Proxmox/proxmox.sh $(DESTDIR)/$(SYSCONFDIR)/$(PACKAGE)/extensions/Proxmox/ # Bundle cmk in cloudstack-management wget https://github.com/apache/cloudstack-cloudmonkey/releases/download/$(CMK_REL)/cmk.linux.x86-64 -O $(DESTDIR)/usr/bin/cmk @@ -106,6 +110,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 000000000000..a22ea4211132 --- /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/orchestration/pom.xml b/engine/orchestration/pom.xml index 437c98dac877..151f95ff9441 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 dc7852ed82b7..75e9fb20e5ab 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 Answer[] send(final Long hostId, final Commands commands, int timeout) th 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; + } + 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 Status.Alert; + 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 a7dca34f0321..c64489828033 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.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.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.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 String dispatch(final ClusterServicePdu pdu) { } 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 13475579f771..1257acbb3d78 100755 --- a/engine/orchestration/src/main/java/com/cloud/vm/VirtualMachineManagerImpl.java +++ b/engine/orchestration/src/main/java/com/cloud/vm/VirtualMachineManagerImpl.java @@ -71,6 +71,9 @@ 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; @@ -98,6 +101,7 @@ 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; @@ -122,6 +126,8 @@ 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; @@ -202,12 +208,14 @@ 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; @@ -332,6 +340,8 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac @Inject private HostDao _hostDao; @Inject + private HostDetailsDao hostDetailsDao; + @Inject private AlertManager _alertMgr; @Inject private GuestOSCategoryDao _guestOsCategoryDao; @@ -414,6 +424,8 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac @Inject private DomainDao domainDao; @Inject + public NetworkService networkService; + @Inject ResourceCleanupService resourceCleanupService; @Inject VmWorkJobDao vmWorkJobDao; @@ -430,6 +442,10 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac @Inject private VolumeDataFactory volumeDataFactory; + @Inject + ExtensionsManager extensionsManager; + @Inject + ExtensionDetailsDao extensionDetailsDao; VmWorkJobHandlerProxy _jobHandlerProxy = new VmWorkJobHandlerProxy(this); @@ -591,8 +607,8 @@ private void allocateRootVolume(VMInstanceVO vm, VirtualMachineTemplate template 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); @@ -652,6 +668,13 @@ protected void advanceExpunge(VMInstanceVO vm) throws ResourceUnavailableExcepti 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()); @@ -1144,6 +1167,141 @@ protected void updateVmMetadataManufacturerAndProduct(VirtualMachineTO vmTO, VMI 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 { @@ -1155,6 +1313,8 @@ public void orchestrateStart(final String vmUuid, 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); @@ -1496,6 +1659,60 @@ public void orchestrateStart(final String vmUuid, final Map vmExternalDetails = vmTO.getExternalDetails(); + for (NicTO nic : vmTO.getNics()) { + if (!nic.isDefaultNic()) { + continue; + } + Networks.BroadcastDomainType broadcastDomainType = Networks.BroadcastDomainType.getSchemeValue(nic.getBroadcastUri()); + NetworkVO networkVO = _networkDao.findById(nic.getNetworkId()); + if (Networks.BroadcastDomainType.NSX.equals(broadcastDomainType)) { + String segmentName = networkService.getNsxSegmentId(networkVO.getDomainId(), networkVO.getAccountId(), networkVO.getDataCenterId(), networkVO.getVpcId(), networkVO.getId()); + vmExternalDetails.put(VmDetailConstants.CLOUDSTACK_VLAN, segmentName); + } else { + vmExternalDetails.put(VmDetailConstants.CLOUDSTACK_VLAN, Networks.BroadcastDomainType.getValue(nic.getBroadcastUri())); + } + } + 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())) { @@ -1883,7 +2100,9 @@ private List> getVolumesToDisconnect(VirtualMachine vm) { 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); } @@ -2014,7 +2233,7 @@ protected void releaseVmResources(final VirtualMachineProfile profile, final boo } 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); } @@ -2211,6 +2430,7 @@ private void advanceStop(final VMInstanceVO vm, final boolean cleanUpEvenIfUnabl 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); } @@ -2260,6 +2480,14 @@ private void advanceStop(final VMInstanceVO vm, final boolean cleanUpEvenIfUnabl } 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())) { @@ -3727,6 +3955,7 @@ private void orchestrateReboot(final String vmUuid, final Map _stateMachine; + @Mock + ExtensionsManager extensionsManager; + @Mock + ExtensionDetailsDao extensionDetailsDao; + @Mock + NicDao _nicsDao; + @Mock + NetworkService networkService; private ConfigDepotImpl configDepotImpl; private boolean updatedConfigKeyDepot = false; @@ -458,8 +481,8 @@ public void executeManagedStorageChecksWhenTargetStoragePoolProvidedTestCurrentS 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 +492,8 @@ public void allowVolumeMigrationsForPowerFlexStorage() { 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 +508,8 @@ public void executeManagedStorageChecksWhenTargetStoragePoolProvidedTestCurrentS 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 +533,7 @@ public void buildMapUsingUserInformationTestUserDefinedMigrationMapEmpty() { Assert.assertTrue(volumeToPoolObjectMap.isEmpty()); - Mockito.verify(userDefinedVolumeToStoragePoolMap, times(0)).keySet(); + verify(userDefinedVolumeToStoragePoolMap, times(0)).keySet(); } @Test(expected = CloudRuntimeException.class) @@ -539,7 +562,7 @@ public void buildMapUsingUserInformationTestTargetHostHasAccessToPool() { assertFalse(volumeToPoolObjectMap.isEmpty()); assertEquals(storagePoolVoMock, volumeToPoolObjectMap.get(volumeVoMock)); - Mockito.verify(userDefinedVolumeToStoragePoolMap, times(1)).keySet(); + verify(userDefinedVolumeToStoragePoolMap, times(1)).keySet(); } @Test @@ -566,8 +589,8 @@ public void executeManagedStorageChecksWhenTargetStoragePoolNotProvidedTestCurre 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 +600,8 @@ public void executeManagedStorageChecksWhenTargetStoragePoolNotProvidedTestCurre 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 +700,11 @@ public void getCandidateStoragePoolsToMigrateLocalVolumeTestMoreThanOneAllocator 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 +762,8 @@ public void createStoragePoolMappingsForVolumesTestLocalStoragevolume() { 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 +781,9 @@ public void createStoragePoolMappingsForVolumesTestCrossCluterMigration() { 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 +802,9 @@ public void createStoragePoolMappingsForVolumesTestNotCrossCluterMigrationWithCl 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); } @@ -1098,7 +1121,7 @@ public void testOrchestrateStartNonNullPodId() throws Exception { when(cluster.getId()).thenReturn(1L); when(_clusterDetailsDao.findDetail(1L, VmDetailConstants.CPU_OVER_COMMIT_RATIO)).thenReturn(cluster_detail_cpu); when(_clusterDetailsDao.findDetail(1L, VmDetailConstants.MEMORY_OVER_COMMIT_RATIO)).thenReturn(cluster_detail_ram); - when(userVmDetailsDao.findDetail(anyLong(), Mockito.anyString())).thenReturn(null); + when(userVmDetailsDao.findDetail(anyLong(), anyString())).thenReturn(null); when(cluster_detail_cpu.getValue()).thenReturn("1.0"); when(cluster_detail_ram.getValue()).thenReturn("1.0"); doReturn(false).when(virtualMachineManagerImpl).areAllVolumesAllocated(Mockito.anyLong()); @@ -1194,7 +1217,7 @@ public void testOrchestrateStartNullPodId() throws Exception { when(cluster.getId()).thenReturn(1L); when(_clusterDetailsDao.findDetail(1L, VmDetailConstants.CPU_OVER_COMMIT_RATIO)).thenReturn(cluster_detail_cpu); when(_clusterDetailsDao.findDetail(1L, VmDetailConstants.MEMORY_OVER_COMMIT_RATIO)).thenReturn(cluster_detail_ram); - when(userVmDetailsDao.findDetail(anyLong(), Mockito.anyString())).thenReturn(null); + when(userVmDetailsDao.findDetail(anyLong(), anyString())).thenReturn(null); when(cluster_detail_cpu.getValue()).thenReturn("1.0"); when(cluster_detail_ram.getValue()).thenReturn("1.0"); doReturn(true).when(virtualMachineManagerImpl).areAllVolumesAllocated(Mockito.anyLong()); @@ -1318,7 +1341,7 @@ public void recreateCheckpointsKvmOnVmAfterMigrationTestReturnIfNotKvm() { virtualMachineManagerImpl.recreateCheckpointsKvmOnVmAfterMigration(vmInstanceMock, 0); - Mockito.verify(volumeDaoMock, Mockito.never()).findByInstance(Mockito.anyLong()); + verify(volumeDaoMock, never()).findByInstance(Mockito.anyLong()); } @Test @@ -1328,7 +1351,7 @@ public void recreateCheckpointsKvmOnVmAfterMigrationTestReturnIfVolumesDoNotHave 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 +1364,7 @@ public void recreateCheckpointsKvmOnVmAfterMigrationTestAgentUnavailableThrowsCl 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 +1377,7 @@ public void recreateCheckpointsKvmOnVmAfterMigrationTestOperationTimedoutExcepti 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 +1390,7 @@ public void recreateCheckpointsKvmOnVmAfterMigrationTestRecreationFails() throws 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 +1402,245 @@ public void recreateCheckpointsKvmOnVmAfterMigrationTestRecreationSucceeds() thr 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); + NetworkVO networkVO = mock(NetworkVO.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(nic.getBroadcastUri()).thenReturn(URI.create("nsx://segment")); + when(nic.getNetworkId()).thenReturn(1L); + when(networkDao.findById(1L)).thenReturn(networkVO); + when(networkService.getNsxSegmentId(anyLong(), anyLong(), anyLong(), anyLong(), anyLong())).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 a6fe3123c4ed..1745f5380e21 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 7c0d0c538144..ea82a10f9c95 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 List listDistinctStorageAccessGroups(String name, String keyword) 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 8dc4efa91f3c..95eff5e075c8 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,6 @@ public interface HostDetailsDao extends GenericDao { void deleteDetails(long hostId); List findByName(String name); + + List findByNameAndValue(String name, String value); } 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 9c1340592f93..5dc56b91d4a5 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 @@ -50,6 +50,7 @@ public HostDetailsDaoImpl() { DetailNameSearch = createSearchBuilder(); DetailNameSearch.and("name", DetailNameSearch.entity().getName(), SearchCriteria.Op.EQ); + DetailNameSearch.and("value", DetailNameSearch.entity().getValue(), SearchCriteria.Op.EQ); DetailNameSearch.done(); } @@ -130,4 +131,12 @@ public List findByName(String name) { sc.setParameters("name", name); return listBy(sc); } + + @Override + public List findByNameAndValue(String name, String value) { + SearchCriteria sc = DetailNameSearch.create(); + sc.setParameters("name", name); + sc.setParameters("value", value); + return listBy(sc); + } } 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 4811b59d31e5..09d9f1d7fbf9 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 String getNetworkTag(long physicalNetworkId, TrafficType trafficType, Hyp sc = simulatorAllFieldsSearch.create(); } else if (hType == HypervisorType.Ovm) { sc = ovmAllFieldsSearch.create(); - } else if (hType == HypervisorType.BareMetal) { + } else if (hType == HypervisorType.BareMetal || hType == HypervisorType.External) { return null; } else if (hType == HypervisorType.Hyperv) { sc = hypervAllFieldsSearch.create(); diff --git a/engine/schema/src/main/java/com/cloud/storage/VMTemplateVO.java b/engine/schema/src/main/java/com/cloud/storage/VMTemplateVO.java index 93f6a4640195..3486bac010e1 100644 --- a/engine/schema/src/main/java/com/cloud/storage/VMTemplateVO.java +++ b/engine/schema/src/main/java/com/cloud/storage/VMTemplateVO.java @@ -176,6 +176,9 @@ public class VMTemplateVO implements VirtualMachineTemplate { @Convert(converter = CPUArchConverter.class) private CPU.CPUArch arch; + @Column(name = "extension_id") + private Long extensionId; + @Override public String getUniqueName() { return uniqueName; @@ -218,7 +221,7 @@ private VMTemplateVO(long id, String name, ImageFormat format, boolean isPublic, public VMTemplateVO(long id, String name, ImageFormat format, boolean isPublic, boolean featured, boolean isExtractable, TemplateType type, String url, boolean requiresHvm, int bits, long accountId, String cksum, String displayText, boolean enablePassword, long guestOSId, boolean bootable, HypervisorType hyperType, String templateTag, Map 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 VMTemplateVO(long id, String name, ImageFormat format, boolean isPublic, 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 void setArch(CPU.CPUArch arch) { 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 2835cf3cb3c1..d70eeb87653a 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 138677927e6e..267cef2169ae 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 List listIdsByTemplateTag(String tag) { 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 6b6fe200c10d..7c113a10af45 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 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 29d3f88fd902..58b60531e5a7 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 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 Map listDetailsVisibility(long resourceId) { 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 5a50b96d8f2a..28ba8106f5f0 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 @@ -203,3 +203,313 @@ SET `sort_key` = CASE ELSE `sort_key` END; -- End: Changes for Guest OS category cleanup + +-- 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 '3' 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, 'Disabled', 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', 'url', '', 1); +CALL `cloud`.`INSERT_EXTENSION_DETAIL_IF_NOT_EXISTS`('Proxmox', 'user', '', 1); +CALL `cloud`.`INSERT_EXTENSION_DETAIL_IF_NOT_EXISTS`('Proxmox', 'token', '', 1); +CALL `cloud`.`INSERT_EXTENSION_DETAIL_IF_NOT_EXISTS`('Proxmox', 'secret', '', 1); +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'); +CALL `cloud`.`INSERT_EXTENSION_DETAIL_IF_NOT_EXISTS`('HyperV', 'url', '', 1); +CALL `cloud`.`INSERT_EXTENSION_DETAIL_IF_NOT_EXISTS`('HyperV', 'username', '', 1); +CALL `cloud`.`INSERT_EXTENSION_DETAIL_IF_NOT_EXISTS`('HyperV', 'password', '', 1); + +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', '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 specifeid 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', + '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', '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', + '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 6bfcdaddbcc3..76a8be16bda6 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 96f60547f508..7f151730c9c7 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.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 void testListTemplateIsoByArchAndZone_WithoutIsIso() { 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 38e0d0d081cb..3092b00bac2e 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 void downloadBootstrapSysTemplate(DataStore store) { } /* 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 void handleTemplateSync(DataStore store) { } /* 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 @@ private boolean createChildDataDiskTemplate(DatadiskTO dataDiskTemplate, VMTempl 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 0dbe4fd72462..5cb500f5e6cf 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 CPU.CPUArch getArch() { 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 000000000000..14ab746272fe --- /dev/null +++ b/extensions/HyperV/hyperv.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python +# 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 paramiko +import json +import sys +import random + + +def parse_json(json_data): + try: + data = {} + + data["vmname"] = json_data["cloudstack.vm.details"]["name"] + "-" + json_data["virtualmachineid"] + + external_host_details = json_data["externaldetails"].get("host", []) + data["url"] = external_host_details["url"] + data["username"] = external_host_details["username"] + data["password"] = external_host_details["password"] + data["network_switch"] = external_host_details["network_switch"] + data["vhd_path"] = external_host_details["vhd_path"] + data["vm_path"] = external_host_details["vm_path"] + + 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"] + + 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 fail(message): + print(json.dumps({"error": message})) + sys.exit(1) + + +def succeed(data): + print(json.dumps(data)) + sys.exit(0) + + +def run_powershell_ssh_int(command, url, username, password): + print(f"[INFO] Connecting to {url} as {username}...") + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(url, username=username, password=password) + + ps_command = f'powershell -NoProfile -Command "{command.strip()}"' + print(f"[INFO] Executing: {ps_command}") + stdin, stdout, stderr = ssh.exec_command(ps_command) + + output = stdout.read().decode().strip() + error = stderr.read().decode().strip() + ssh.close() + + if error: + raise Exception(error) + return output + +def run_powershell_ssh(command, url, username, password): + try: + output = run_powershell_ssh_int(command, url, username, password) + return output + except Exception as e: + fail(str(e)) + + +def create(data): + vm_name = data["vmname"] + cpus = data["cpus"] + memory = data["memory"] + memory_mb = int(memory) / 1024 / 1024 + template_path = data["template_path"] + vhd_path = data["vhd_path"] + "\\" + vm_name + ".vhdx" + vhd_size_gb = data["vhd_size_gb"] + generation = data["generation"] + iso_path = data["iso_path"] + network_switch = data["network_switch"] + vm_path = data["vm_path"] + template_type = 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") + run_powershell_ssh_int(f'Copy-Item \\"{template_path}\\" \\"{vhd_path}\\"', data["url"], data["username"], data["password"]) + vhd_created = True + command += f'-VHDPath \\"{vhd_path}\\"; ' + + run_powershell_ssh_int(command, data["url"], data["username"], data["password"]) + vm_created = True + + command = f'Remove-VMNetworkAdapter -VMName \\"{vm_name}\\" -Name \\"Network Adapter\\" -ErrorAction SilentlyContinue; ' + run_powershell_ssh_int(command, data["url"], data["username"], data["password"]) + + command = f'Set-VMProcessor -VMName \\"{vm_name}\\" -Count \\"{cpus}\\"; ' + if (generation == 2): + command += f'Set-VMFirmware -VMName "{vm_name}" -EnableSecureBoot Off; ' + + run_powershell_ssh_int(command, data["url"], data["username"], data["password"]) + + for idx, nic in enumerate(data["nics"]): + adapter_name = f"NIC{idx+1}" + run_powershell_ssh_int(f'Add-VMNetworkAdapter -VMName "{vm_name}" -SwitchName \\"{network_switch}\\" -Name "{adapter_name}"', data["url"], data["username"], data["password"]) + run_powershell_ssh_int(f'Set-VMNetworkAdapter -VMName "{vm_name}" -Name "{adapter_name}" -StaticMacAddress "{nic["mac"]}"', data["url"], data["username"], data["password"]) + run_powershell_ssh_int(f'Set-VMNetworkAdapterVlan -VMName "{vm_name}" -VMNetworkAdapterName "{adapter_name}" -Access -VlanId "{nic["vlan"]}"', data["url"], data["username"], data["password"]) + + run_powershell_ssh_int(f'Start-VM -Name "{vm_name}"', data["url"], data["username"], data["password"]) + vm_started = True + + succeed({"status": "success", "message": "Instance created"}) + + except Exception as e: + if vm_started: + run_powershell_ssh_int(f'Stop-VM -Name "{vm_name}" -Force -TurnOff', data["url"], data["username"], data["password"]) + if vm_created: + run_powershell_ssh_int(f'Remove-VM -Name "{vm_name}" -Force', data["url"], data["username"], data["password"]) + if vhd_created: + run_powershell_ssh_int(f'Remove-Item -Path \\"{vhd_path}\\" -Force', data["url"], data["username"], data["password"]) + fail(str(e)) + +def start(data): + run_powershell_ssh(f'Start-VM -Name "{data["vmname"]}"', data["url"], data["username"], data["password"]) + succeed({"status": "success", "message": "Instance started"}) + + +def stop(data): + run_powershell_ssh(f'Stop-VM -Name "{data["vmname"]}"', data["url"], data["username"], data["password"]) + succeed({"status": "success", "message": "Instance stopped"}) + + +def reboot(data): + run_powershell_ssh(f'Restart-VM -Name "{data["vmname"]}" -Force', data["url"], data["username"], data["password"]) + succeed({"status": "success", "message": "Instance rebooted"}) + + +def status(data): + command = f'(Get-VM -Name "{data["vmname"]}").State' + state = run_powershell_ssh(command, data["url"], data["username"], data["password"]) + 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(data): + run_powershell_ssh(f'Remove-VM -Name "{data["vmname"]}" -Force', data["url"], data["username"], data["password"]) + succeed({"status": "success", "message": "Instance deleted"}) + + +def suspend(data): + run_powershell_ssh(f'Suspend-VM -Name "{data["vmname"]}"', data["url"], data["username"], data["password"]) + succeed({"status": "success", "message": "Instance suspended"}) + + +def resume(data): + run_powershell_ssh(f'Resume-VM -Name "{data["vmname"]}"', data["url"], data["username"], data["password"]) + succeed({"status": "success", "message": "Instance resumed"}) + + +def create_snapshot(data): + snapshot_name = data["snapshot_name"] + if snapshot_name == "": + fail("Missing required field in JSON: snapshot_name") + command = f'Checkpoint-VM -VMName \\"{data["vmname"]}\\" -SnapshotName \\"{snapshot_name}\\"' + run_powershell_ssh(command, data["url"], data["username"], data["password"]) + succeed({"status": "success", "message": f"Snapshot '{snapshot_name}' created"}) + + +def restore_snapshot(data): + snapshot_name = data["snapshot_name"] + if snapshot_name == "": + fail("Missing required field in JSON: snapshot_name") + command = f'Restore-VMSnapshot -VMName \\"{data["vmname"]}\\" -Name \\"{snapshot_name}\\" -Confirm:$false' + run_powershell_ssh(command, data["url"], data["username"], data["password"]) + succeed({"status": "success", "message": f"Snapshot '{snapshot_name}' restored"}) + + +def delete_snapshot(data): + snapshot_name = data["snapshot_name"] + if snapshot_name == "": + fail("Missing required field in JSON: snapshot_name") + command = f'Remove-VMSnapshot -VMName \\"{data["vmname"]}\\" -Name \\"{snapshot_name}\\" -Confirm:$false' + run_powershell_ssh(command, data["url"], data["username"], data["password"]) + 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: + with open(json_file_path, 'r') as f: + json_data = json.load(f) + data = parse_json(json_data) + except FileNotFoundError: + fail(f"JSON file not found: {json_file_path}") + except json.JSONDecodeError: + fail("Invalid JSON in file") + + operations = { + "create": create, + "start": start, + "stop": stop, + "reboot": reboot, + "delete": delete, + "status": status, + "suspend": suspend, + "resume": resume, + "createsnapshot": create_snapshot, + "restoresnapshot": restore_snapshot, + "deletesnapshot": delete_snapshot + } + + if operation not in operations: + fail("Invalid action") + + operations[operation](data) + + +if __name__ == "__main__": + main() diff --git a/extensions/Proxmox/proxmox.sh b/extensions/Proxmox/proxmox.sh new file mode 100755 index 000000000000..cdccbce0b2af --- /dev/null +++ b/extensions/Proxmox/proxmox.sh @@ -0,0 +1,352 @@ +#!/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 // ""), + "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 + + #echo "curl -sk --fail -X $method -H \"Authorization: PVEAPIToken=${user}!${token}=${secret}\" ${data:+-d \"$data\"} https://${url}:8006/api2/json${path}" >&2 + response=$(curl -sk --fail -X "$method" \ + -H "Authorization: PVEAPIToken=${user}!${token}=${secret}" \ + ${data:+-d "$data"} \ + "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" +} + +prepare() { + parse_json "$1" || exit 1 + + response=$(call_proxmox_api GET "/cluster/nextid") + vmid=$(echo "$response" | jq -r '.data // ""') + + echo "{\"details\":{\"proxmox_vmid\": \"$vmid\"}}" +} + +create() { + parse_json "$1" || exit 1 + + if [[ -z "$vm_name" ]]; then + vm_name="$vm_internal_name" + fi + validate_name "VM" "$vm_name" + check_required_fields vmid network_bridge + + if [[ "${template_type^^}" == "ISO" ]]; then + check_required_fields iso_path vmcpus vmmemory + 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" + 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" + 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" + + echo '{"status": "success", "message": "Instance created"}' +} + +start() { + parse_json "$1" || exit 1 + execute_and_wait POST "/nodes/${node}/qemu/${vmid}/status/start" + echo '{"status": "success", "message": "Instance started"}' +} + +delete() { + parse_json "$1" || exit 1 + execute_and_wait DELETE "/nodes/${node}/qemu/${vmid}" + echo '{"status": "success", "message": "Instance deleted"}' +} + +stop() { + parse_json "$1" || exit 1 + execute_and_wait POST "/nodes/${node}/qemu/${vmid}/status/stop" + echo '{"status": "success", "message": "Instance stopped"}' +} + +reboot() { + parse_json "$1" || exit 1 + execute_and_wait POST "/nodes/${node}/qemu/${vmid}/status/reboot" + echo '{"status": "success", "message": "Instance rebooted"}' +} + +status() { + parse_json "$1" || exit 1 + + 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\"}" +} + +create_snapshot() { + parse_json "$1" || exit 1 + + 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() { + parse_json "$1" || exit 1 + + 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() { + parse_json "$1" || exit 1 + + 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") + +case $action in + prepare) + prepare "$parameters" + ;; + create) + create "$parameters" + ;; + delete) + delete "$parameters" + ;; + start) + start "$parameters" + ;; + stop) + stop "$parameters" + ;; + reboot) + reboot "$parameters" + ;; + status) + status "$parameters" + ;; + CreateSnapshot) + create_snapshot "$parameters" + ;; + RestoreSnapshot) + restore_snapshot "$parameters" + ;; + DeleteSnapshot) + delete_snapshot "$parameters" + ;; + *) + 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 000000000000..d3b8f81bc714 --- /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 000000000000..7e8d49a0f2b4 --- /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 3 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 000000000000..5ab54149645e --- /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 000000000000..6f2153ad6bce --- /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 000000000000..cdae48fdb3a8 --- /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 000000000000..4f492bd20a6b --- /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 000000000000..4426f259380b --- /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 000000000000..e8f71d7ac8c4 --- /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 000000000000..dea09cf16833 --- /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 000000000000..0edc7a247fda --- /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 000000000000..bb03be00c5d5 --- /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 000000000000..713e7550a1eb --- /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 000000000000..ba542d52e85d --- /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 000000000000..ead3c2e4012e --- /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 000000000000..3e0411124356 --- /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 000000000000..870dc8e2b7e2 --- /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 000000000000..13f503d67ba9 --- /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 000000000000..4c8b920b2f30 --- /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 000000000000..6db0a02d976b --- /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 000000000000..cd7731d20513 --- /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 000000000000..a34eb0082d1c --- /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 000000000000..80add008341e --- /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 000000000000..3355457ed25b --- /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 000000000000..8e17199de6ca --- /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 000000000000..a23a4eb7442e --- /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 000000000000..1989db49c240 --- /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 000000000000..930ef8675531 --- /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 000000000000..6f19ef8b8b66 --- /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 000000000000..11d445b22427 --- /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 000000000000..cff01495054b --- /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 000000000000..8b9ad96b3c41 --- /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 000000000000..2c1d9f12044d --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java @@ -0,0 +1,1565 @@ +// +// 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.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.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; + + 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); + } + + 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()); + + 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(); + } + + 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(); + } + 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); + } + 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(); + + 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); + } + 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); + try { + logger.info("Running custom action: {}", GsonHelper.getGson().toJson(runCustomActionCommand)); + 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 runCleanupForLongestRunningManagementServer() { + 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 { + runCleanupForLongestRunningManagementServer(); + } 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 000000000000..15a5af4f60c3 --- /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 000000000000..9426484e3851 --- /dev/null +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/vo/ExtensionCustomActionVO.java @@ -0,0 +1,208 @@ +// 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 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; + } + +} 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 000000000000..535a0f703958 --- /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 000000000000..5cb6f7b85114 --- /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 000000000000..48d70b937e35 --- /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 000000000000..20423764c1c3 --- /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 000000000000..9d44d8ff7f3d --- /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 000000000000..b25de85a69d5 --- /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 000000000000..2edb6ea48e3f --- /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 000000000000..3a217d009fa7 --- /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 000000000000..9078a05dfda7 --- /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 000000000000..6648b840c1cf --- /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 000000000000..1ca601293a37 --- /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 000000000000..b5281342d4c7 --- /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 000000000000..0fb4f628d275 --- /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 000000000000..f3c26f71f706 --- /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 000000000000..5ba17111c1ce --- /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 000000000000..f0a3a6fcf219 --- /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 000000000000..ba95cc2da972 --- /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 000000000000..dccd1ebf1edb --- /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 000000000000..545feba0b3d3 --- /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 000000000000..76a0175e7576 --- /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 000000000000..223f4ea5bea1 --- /dev/null +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java @@ -0,0 +1,1751 @@ +// 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.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.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.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.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; + + @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 = Mockito.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 = Mockito.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 = Mockito.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); + } + + @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)); + + 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); + + 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); + + 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); + + 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); + + 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); + + 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); + + 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 77a2710c3350..3b534a4bb5af 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 db88310d81cd..5ea4a03b275d 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 2c6898cac7c6..995f758033ae 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 efeab01166ea..a6094b59c73b 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 000000000000..05a22cd2f9de --- /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 000000000000..24f0816868cc --- /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 000000000000..c33ce479885d --- /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 000000000000..4f895ec8465a --- /dev/null +++ b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/agent/manager/ExternalServerPlanner.java @@ -0,0 +1,178 @@ +// 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; + } + + 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 000000000000..ca4481065974 --- /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())); + + 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 000000000000..cd6a2cf996a5 --- /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 000000000000..baa384306eef --- /dev/null +++ b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/discoverer/ExternalServerDiscoverer.java @@ -0,0 +1,286 @@ +// 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()); + } + + @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())); + } + 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; + } + final ClusterVO cluster = _clusterDao.findById(host.getClusterId()); + ExtensionResourceMapVO extensionResourceMapVO = extensionResourceMapDao.findByResourceIdAndType(cluster.getId(), + ExtensionResourceMap.ResourceType.Cluster); + ExtensionVO extension = extensionDao.findById(extensionResourceMapVO.getExtensionId()); + logger.debug("Creating host for {}", extension); + extensionsManager.prepareExtensionPathAcrossServers(extension); + 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 000000000000..7793a32ebafe --- /dev/null +++ b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisioner.java @@ -0,0 +1,805 @@ +// +// 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.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; + +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); + } + + 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 the VM {}", vmUUID); + Pair result = getInstanceStatusOnExternalSystem(extensionName, extensionPath, vmUUID, + modifiedDetails, AgentManager.Wait.value()); + if (result.first()) { + if (result.second().equalsIgnoreCase(VirtualMachine.PowerState.PowerOn.toString())) { + return VirtualMachine.PowerState.PowerOn; + } else if (result.second().equalsIgnoreCase(VirtualMachine.PowerState.PowerOff.toString())) { + return VirtualMachine.PowerState.PowerOff; + } else { + return VirtualMachine.PowerState.PowerUnknown; + } + } else { + logger.debug("Exception occurred while trying to fetch the power status of the {} : {}", userVmVO, result.second()); + return VirtualMachine.PowerState.PowerUnknown; + } + } + + 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); + long epochMillis = System.currentTimeMillis(); + String fileName = epochMillis + ".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 000000000000..483ef1b1efb3 --- /dev/null +++ b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/resource/ExternalResource.java @@ -0,0 +1,374 @@ +// 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.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 = 4; + protected static final long CPU_SPEED = 4000L; + protected static final long RAM = 16000 * 1024 * 1024L; + protected static final long DOM0_RAM = 768 * 1024 * 1024L; + 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; + + protected boolean isExtensionDisconnected() { + return StringUtils.isAnyBlank(extensionName, extensionRelativePath); + } + + protected boolean isExtensionNotEnabled() { + return !Extension.State.Enabled.equals(extensionState); + } + + 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 null; + } + 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"; + } + logger.error("Extension: {} connected to host: {} is not in Enabled state", extensionName, name); + return "Extension is disabled"; + } + + 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) { + if (isExtensionDisconnected()) { + return new ReadyAnswer(cmd, logAndGetExtensionNotConnectedOrDisabledError()); + } + return new ReadyAnswer(cmd); + } + + private Answer execute(CheckHealthCommand cmd) { + if (isExtensionDisconnected()) { + logAndGetExtensionNotConnectedOrDisabledError(); + } + return new CheckHealthAnswer(cmd, !isExtensionDisconnected()); + } + + private Answer execute(CheckNetworkCommand cmd) { + if (isExtensionDisconnected()) { + return new CheckNetworkAnswer(cmd, false, logAndGetExtensionNotConnectedOrDisabledError()); + } + 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) { + if (isExtensionDisconnected()) { + return new MaintainAnswer(cmd, false, logAndGetExtensionNotConnectedOrDisabledError()); + } + 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()) { + return new StartAnswer(cmd, logAndGetExtensionNotConnectedOrDisabledError()); + } + return externalProvisioner.startInstance(guid, extensionName, extensionRelativePath, cmd); + } + + public StopAnswer execute(StopCommand cmd) { + if (isExtensionDisconnected() || isExtensionNotEnabled()) { + 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()) { + return new RebootAnswer(cmd, logAndGetExtensionNotConnectedOrDisabledError(), false); + } + return externalProvisioner.rebootInstance(guid, extensionName, extensionRelativePath, cmd); + } + + public PrepareExternalProvisioningAnswer execute(PrepareExternalProvisioningCommand cmd) { + if (isExtensionDisconnected() || isExtensionNotEnabled()) { + return new PrepareExternalProvisioningAnswer(cmd, false, logAndGetExtensionNotConnectedOrDisabledError()); + } + return externalProvisioner.prepareExternalProvisioning(guid, extensionName, extensionRelativePath, cmd); + } + + public RunCustomActionAnswer execute(RunCustomActionCommand cmd) { + if (isExtensionDisconnected() || isExtensionNotEnabled()) { + return new RunCustomActionAnswer(cmd, false, logAndGetExtensionNotConnectedOrDisabledError()); + } + return externalProvisioner.runCustomAction(guid, extensionName, extensionRelativePath, cmd); + } + + public Answer execute(Command cmd) { + if (isExtensionDisconnected() || isExtensionNotEnabled()) { + 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"); + 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 000000000000..abc704c99474 --- /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 000000000000..726cb6d197d8 --- /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 000000000000..7dd21d6f7a95 --- /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 000000000000..2220b459b441 --- /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 000000000000..6a7181bdcd2f --- /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 000000000000..3d9d10b85377 --- /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 000000000000..da915b1557d0 --- /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 000000000000..c040872c19c6 --- /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 000000000000..b3f5b6c306ba --- /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 000000000000..88fad59d9fae --- /dev/null +++ b/plugins/hypervisors/external/src/test/java/org/apache/cloudstack/agent/manager/ExternalTemplateAdapterTest.java @@ -0,0 +1,171 @@ +// 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.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.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())).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 000000000000..367ec58fcecd --- /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 000000000000..d090a9afc744 --- /dev/null +++ b/plugins/hypervisors/external/src/test/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisionerTest.java @@ -0,0 +1,625 @@ +// +// 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.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.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 c2647c9db773..d5413b8c7711 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) => { + api('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) => { api('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 000000000000..d6c91d45fdfe --- /dev/null +++ b/ui/src/components/widgets/DetailsInput.vue @@ -0,0 +1,178 @@ +// 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 22a8236d6cdf..809f7a8a7a30 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 aa85f452b734..582fbaaf2f35 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 c5647fd28049..cc590a1c42aa 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) && ((record.hypervisor !== 'KVM' && record.hypervisor !== 'LXC') || (record.hypervisor === 'KVM' && record.pooltype === 'PowerFlex')))) && 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', @@ -440,7 +449,7 @@ export default { message: (record) => { return record.backupofferingid ? 'message.action.expunge.instance.with.backups' : 'message.action.expunge.instance' }, docHelp: 'adminguide/virtual_machines.html#deleting-vms', dataView: true, - show: (record, store) => { return ['Destroyed', 'Expunging'].includes(record.state) && store.features.allowuserexpungerecovervm && record.vmtype !== 'sharedfsvm' } + show: (record, store) => { return record.hypervisor !== 'External' && ['Destroyed', 'Expunging'].includes(record.state) && store.features.allowuserexpungerecovervm && record.vmtype !== 'sharedfsvm' } }, { api: 'destroyVirtualMachine', diff --git a/ui/src/config/section/extension.js b/ui/src/config/section/extension.js new file mode 100644 index 000000000000..0f7732dfdc9d --- /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: 'node-expand-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 000000000000..862580653101 --- /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', '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 a00224c6377f..93df757c9184 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)) { diff --git a/ui/src/config/section/infra/clusters.js b/ui/src/config/section/infra/clusters.js index c03a1716a8d4..3e659232916b 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'], related: [{ name: 'host', title: 'label.hosts', diff --git a/ui/src/config/section/infra/hosts.js b/ui/src/config/section/infra/hosts.js index 474177918e4d..fcbfab432db8 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', 'details'], 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 024573b84caf..6783d8dd2871 100644 --- a/ui/src/config/section/offering.js +++ b/ui/src/config/section/offering.js @@ -40,7 +40,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', 'serviceofferingdetails'] if (store.getters.apis.createServiceOffering && store.getters.apis.createServiceOffering.params.filter(x => x.name === 'storagepolicy').length > 0) { fields.splice(6, 0, 'vspherestoragepolicy') diff --git a/ui/src/core/lazy_lib/icons_use.js b/ui/src/core/lazy_lib/icons_use.js index 01ffa2e6859f..b68461177aa2 100644 --- a/ui/src/core/lazy_lib/icons_use.js +++ b/ui/src/core/lazy_lib/icons_use.js @@ -126,6 +126,7 @@ import { MobileOutlined, MoreOutlined, NodeIndexOutlined, + NodeExpandOutlined, NotificationOutlined, NumberOutlined, LaptopOutlined, @@ -137,6 +138,7 @@ import { PictureOutlined, PieChartOutlined, PlayCircleOutlined, + PlaySquareOutlined, PlusCircleOutlined, PlusOutlined, PlusSquareOutlined, @@ -298,6 +300,7 @@ export default { app.component('MobileOutlined', MobileOutlined) app.component('MoreOutlined', MoreOutlined) app.component('NodeIndexOutlined', NodeIndexOutlined) + app.component('NodeExpandOutlined', NodeExpandOutlined) app.component('NotificationOutlined', NotificationOutlined) app.component('NumberOutlined', NumberOutlined) app.component('LaptopOutlined', LaptopOutlined) @@ -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 3fee7428210b..c50da19c821d 100644 --- a/ui/src/main.js +++ b/ui/src/main.js @@ -38,7 +38,8 @@ import { localesPlugin, dialogUtilPlugin, cpuArchitectureUtilPlugin, - imagesUtilPlugin + imagesUtilPlugin, + extensionsUtilPlugin } from './utils/plugins' import { VueAxios } from './utils/request' import directives from './utils/directives' @@ -57,6 +58,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/plugins.js b/ui/src/utils/plugins.js index de8e3975787a..8c7b9b34734f 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,20 @@ 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 (name) { - content = content + ' - ' + name + 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 + }) } - message.success({ - content, - key: jobId, - duration: 2 - }) store.dispatch('AddHeaderNotice', { key: jobId, title, @@ -384,6 +388,8 @@ export const resourceTypePlugin = { return 'kubernetes' case 'KubernetesSupportedVersion': return 'kubernetesiso' + case 'ExtensionCustomAction': + return 'customaction' case 'SystemVm': case 'PhysicalNetwork': case 'Backup': @@ -568,3 +574,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 e29fa0f20974..b3524d94e222 100644 --- a/ui/src/views/AutogenView.vue +++ b/ui/src/views/AutogenView.vue @@ -51,7 +51,7 @@ @@ -648,7 +648,8 @@ export default { }, watch: { '$route' (to, from) { - if (to.fullPath !== from.fullPath && !to.fullPath.includes('action/') && to?.query?.tab !== 'browser') { + console.log('DEBUG - Route changed from', from.fullPath, 'to', to.fullPath) + if (to.fullPath !== from.fullPath && !to.fullPath.includes('/action/') && to?.query?.tab !== 'browser') { if ('page' in to.query) { this.page = Number(to.query.page) this.pageSize = Number(to.query.pagesize) @@ -692,7 +693,7 @@ export default { return this.$route.query.filter } const routeName = this.$route.name - if ((this.projectView && routeName === 'vm') || (['Admin', 'DomainAdmin'].includes(this.$store.getters.userInfo.roletype) && ['vm', 'iso', 'template', 'pod', 'cluster', 'host', 'systemvm', 'router', 'storagepool'].includes(routeName)) || ['account', 'guestnetwork', 'guestvlans', 'oauthsetting', 'guestos', 'guestoshypervisormapping', 'kubernetes', 'asnumbers', 'networkoffering'].includes(routeName)) { + if ((this.projectView && routeName === 'vm') || (['Admin', 'DomainAdmin'].includes(this.$store.getters.userInfo.roletype) && ['vm', 'iso', 'template', 'pod', 'cluster', 'host', 'systemvm', 'router', 'storagepool'].includes(routeName)) || ['account', 'guestnetwork', 'guestvlans', 'oauthsetting', 'guestos', 'guestoshypervisormapping', 'kubernetes', 'asnumbers', 'networkoffering', 'extension'].includes(routeName)) { return 'all' } if (['publicip'].includes(routeName)) { @@ -763,10 +764,13 @@ export default { const refreshed = ('irefresh' in params) params.listall = true + + this.dataView = !!(this.$route?.params?.id || !!this.$route?.query?.dataView) + if (this.$route.meta.params) { const metaParams = this.$route.meta.params if (typeof metaParams === 'function') { - Object.assign(params, metaParams()) + Object.assign(params, metaParams(this.dataView)) } else { Object.assign(params, metaParams) } @@ -810,14 +814,9 @@ export default { 'vpc', 'securitygroups', 'publicip', 'vpncustomergateway', 'template', 'iso', 'event', 'kubernetes', 'sharedfs', 'autoscalevmgroup', 'vnfapp', 'webhook'].includes(this.$route.name) - if ((this.$route && this.$route.params && this.$route.params.id) || this.$route.query.dataView) { - this.dataView = true - if (!refreshed) { - this.resource = {} - this.$emit('change-resource', this.resource) - } - } else { - this.dataView = false + if (this.dataView && !refreshed) { + this.resource = {} + this.$emit('change-resource', this.resource) } if (this.dataView && ['Admin'].includes(this.$store.getters.userInfo.roletype) && this.routeName === 'volume') { @@ -1824,6 +1823,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/DeployVM.vue b/ui/src/views/compute/DeployVM.vue index 710a4445bbde..72566ec83223 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') @@ -2577,6 +2601,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 @@ -2776,7 +2803,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) { @@ -2786,15 +2813,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 @@ -3117,6 +3169,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 d2007e0f2430..7f2d02b86376 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 000000000000..8f4935dd7e16 --- /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 000000000000..d1e38c8ecd07 --- /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 000000000000..e8a7c7fba37d --- /dev/null +++ b/ui/src/views/extension/ExtensionCustomActionsTab.vue @@ -0,0 +1,271 @@ +// 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 000000000000..15b2aa2fd765 --- /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/ParametersInput.vue b/ui/src/views/extension/ParametersInput.vue new file mode 100644 index 000000000000..f77b0471eaa2 --- /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 000000000000..580fd6706224 --- /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 000000000000..da36e6adb375 --- /dev/null +++ b/ui/src/views/extension/RunCustomAction.vue @@ -0,0 +1,315 @@ +// 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 000000000000..149f282d0c23 --- /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 000000000000..0802afc67dce --- /dev/null +++ b/ui/src/views/extension/UpdateExtension.vue @@ -0,0 +1,158 @@ +// 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 e3513b62a3f5..429b31156e46 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 446f3a9576c8..4eec4ec2c87c 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') }}
+ +
+
+ +