diff --git a/.asf.yaml b/.asf.yaml index f27b5afa425..cb957e57fd9 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -58,8 +58,6 @@ github: - GaOrtiga - bhouse-nexthop - protected_branches: - rulesets: - name: "Default Branch Protection" type: branch diff --git a/api/pom.xml b/api/pom.xml index c80c3559345..4cdb57b6414 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -71,6 +71,11 @@ cloud-framework-direct-download ${project.version} + + org.apache.cloudstack + cloud-framework-kms + ${project.version} + diff --git a/api/src/main/java/com/cloud/event/EventTypes.java b/api/src/main/java/com/cloud/event/EventTypes.java index a32e3879810..c97e916fe43 100644 --- a/api/src/main/java/com/cloud/event/EventTypes.java +++ b/api/src/main/java/com/cloud/event/EventTypes.java @@ -39,6 +39,8 @@ import org.apache.cloudstack.gpu.GpuCard; import org.apache.cloudstack.gpu.GpuDevice; import org.apache.cloudstack.gpu.VgpuProfile; import org.apache.cloudstack.ha.HAConfig; +import org.apache.cloudstack.kms.HSMProfile; +import org.apache.cloudstack.kms.KMSKey; import org.apache.cloudstack.network.BgpPeer; import org.apache.cloudstack.network.Ipv4GuestSubnetNetworkMap; import org.apache.cloudstack.quota.QuotaTariff; @@ -46,7 +48,7 @@ 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; +import org.apache.cloudstack.schedule.ResourceSchedule; import com.cloud.dc.DataCenter; import com.cloud.dc.DataCenterGuestIpv6Prefix; @@ -128,17 +130,18 @@ public class EventTypes { public static final String EVENT_VM_UNMANAGE = "VM.UNMANAGE"; public static final String EVENT_VM_RECOVER = "VM.RECOVER"; - // VM Schedule - public static final String EVENT_VM_SCHEDULE_CREATE = "VM.SCHEDULE.CREATE"; - public static final String EVENT_VM_SCHEDULE_UPDATE = "VM.SCHEDULE.UPDATE"; - public static final String EVENT_VM_SCHEDULE_DELETE = "VM.SCHEDULE.DELETE"; - + // VM Schedule action-execution events (fired when a scheduled action runs). public static final String EVENT_VM_SCHEDULE_START = "VM.SCHEDULE.START"; public static final String EVENT_VM_SCHEDULE_STOP = "VM.SCHEDULE.STOP"; public static final String EVENT_VM_SCHEDULE_REBOOT = "VM.SCHEDULE.REBOOT"; public static final String EVENT_VM_SCHEDULE_FORCE_STOP = "VM.SCHEDULE.FORCE.STOP"; public static final String EVENT_VM_SCHEDULE_FORCE_REBOOT = "VM.SCHEDULE.FORCE.REBOOT"; + // Generic Resource Schedule CRUD events (apply to all resource types). + public static final String EVENT_SCHEDULE_CREATE = "SCHEDULE.CREATE"; + public static final String EVENT_SCHEDULE_UPDATE = "SCHEDULE.UPDATE"; + public static final String EVENT_SCHEDULE_DELETE = "SCHEDULE.DELETE"; + // Domain Router public static final String EVENT_ROUTER_CREATE = "ROUTER.CREATE"; public static final String EVENT_ROUTER_DESTROY = "ROUTER.DESTROY"; @@ -274,6 +277,20 @@ public class EventTypes { public static final String EVENT_CA_CERTIFICATE_REVOKE = "CA.CERTIFICATE.REVOKE"; public static final String EVENT_CA_CERTIFICATE_PROVISION = "CA.CERTIFICATE.PROVISION"; + // KMS (Key Management Service) events + public static final String EVENT_KMS_KEY_WRAP = "KMS.KEY.WRAP"; + public static final String EVENT_KMS_KEY_UNWRAP = "KMS.KEY.UNWRAP"; + public static final String EVENT_KMS_KEY_CREATE = "KMS.KEY.CREATE"; + public static final String EVENT_KMS_KEY_UPDATE = "KMS.KEY.UPDATE"; + public static final String EVENT_KMS_KEY_ROTATE = "KMS.KEY.ROTATE"; + public static final String EVENT_KMS_KEY_DELETE = "KMS.KEY.DELETE"; + public static final String EVENT_VOLUME_MIGRATE_TO_KMS = "VOLUME.MIGRATE.TO.KMS"; + + // HSM Profile events + public static final String EVENT_HSM_PROFILE_CREATE = "HSM.PROFILE.CREATE"; + public static final String EVENT_HSM_PROFILE_UPDATE = "HSM.PROFILE.UPDATE"; + public static final String EVENT_HSM_PROFILE_DELETE = "HSM.PROFILE.DELETE"; + // Account events public static final String EVENT_ACCOUNT_ENABLE = "ACCOUNT.ENABLE"; public static final String EVENT_ACCOUNT_DISABLE = "ACCOUNT.DISABLE"; @@ -679,6 +696,7 @@ public class EventTypes { public static final String EVENT_AUTOSCALEVMGROUP_DISABLE = "AUTOSCALEVMGROUP.DISABLE"; public static final String EVENT_AUTOSCALEVMGROUP_SCALEDOWN = "AUTOSCALEVMGROUP.SCALEDOWN"; public static final String EVENT_AUTOSCALEVMGROUP_SCALEUP = "AUTOSCALEVMGROUP.SCALEUP"; + public static final String EVENT_AUTOSCALEVMGROUP_SCHEDULE_UPDATE = "AUTOSCALEVMGROUP.SCHEDULE.UPDATE"; public static final String EVENT_BAREMETAL_DHCP_SERVER_ADD = "PHYSICAL.DHCP.ADD"; public static final String EVENT_BAREMETAL_DHCP_SERVER_DELETE = "PHYSICAL.DHCP.DELETE"; @@ -902,15 +920,18 @@ public class EventTypes { entityEventDetails.put(EVENT_VM_IMPORT, VirtualMachine.class); entityEventDetails.put(EVENT_VM_UNMANAGE, VirtualMachine.class); - // VMSchedule - entityEventDetails.put(EVENT_VM_SCHEDULE_CREATE, VMSchedule.class); - entityEventDetails.put(EVENT_VM_SCHEDULE_DELETE, VMSchedule.class); - entityEventDetails.put(EVENT_VM_SCHEDULE_UPDATE, VMSchedule.class); - entityEventDetails.put(EVENT_VM_SCHEDULE_START, VMSchedule.class); - entityEventDetails.put(EVENT_VM_SCHEDULE_STOP, VMSchedule.class); - entityEventDetails.put(EVENT_VM_SCHEDULE_REBOOT, VMSchedule.class); - entityEventDetails.put(EVENT_VM_SCHEDULE_FORCE_STOP, VMSchedule.class); - entityEventDetails.put(EVENT_VM_SCHEDULE_FORCE_REBOOT, VMSchedule.class); + // VMSchedule action-execution events + entityEventDetails.put(EVENT_VM_SCHEDULE_START, ResourceSchedule.class); + entityEventDetails.put(EVENT_VM_SCHEDULE_STOP, ResourceSchedule.class); + entityEventDetails.put(EVENT_VM_SCHEDULE_REBOOT, ResourceSchedule.class); + entityEventDetails.put(EVENT_VM_SCHEDULE_FORCE_STOP, ResourceSchedule.class); + entityEventDetails.put(EVENT_VM_SCHEDULE_FORCE_REBOOT, ResourceSchedule.class); + entityEventDetails.put(EVENT_AUTOSCALEVMGROUP_SCHEDULE_UPDATE, ResourceSchedule.class); + + // Generic Resource Schedule + entityEventDetails.put(EVENT_SCHEDULE_CREATE, ResourceSchedule.class); + entityEventDetails.put(EVENT_SCHEDULE_UPDATE, ResourceSchedule.class); + entityEventDetails.put(EVENT_SCHEDULE_DELETE, ResourceSchedule.class); entityEventDetails.put(EVENT_ROUTER_CREATE, VirtualRouter.class); entityEventDetails.put(EVENT_ROUTER_DESTROY, VirtualRouter.class); @@ -1029,6 +1050,20 @@ public class EventTypes { entityEventDetails.put(EVENT_VOLUME_RECOVER, Volume.class); entityEventDetails.put(EVENT_VOLUME_CHANGE_DISK_OFFERING, Volume.class); + // KMS Key Events + entityEventDetails.put(EVENT_KMS_KEY_CREATE, KMSKey.class); + entityEventDetails.put(EVENT_KMS_KEY_UPDATE, KMSKey.class); + entityEventDetails.put(EVENT_KMS_KEY_UNWRAP, KMSKey.class); + entityEventDetails.put(EVENT_KMS_KEY_WRAP, KMSKey.class); + entityEventDetails.put(EVENT_KMS_KEY_DELETE, KMSKey.class); + entityEventDetails.put(EVENT_KMS_KEY_ROTATE, KMSKey.class); + entityEventDetails.put(EVENT_VOLUME_MIGRATE_TO_KMS, KMSKey.class); + + // HSM Profile Events + entityEventDetails.put(EVENT_HSM_PROFILE_CREATE, HSMProfile.class); + entityEventDetails.put(EVENT_HSM_PROFILE_UPDATE, HSMProfile.class); + entityEventDetails.put(EVENT_HSM_PROFILE_DELETE, HSMProfile.class); + // Domains entityEventDetails.put(EVENT_DOMAIN_CREATE, Domain.class); entityEventDetails.put(EVENT_DOMAIN_DELETE, Domain.class); diff --git a/api/src/main/java/com/cloud/host/Host.java b/api/src/main/java/com/cloud/host/Host.java index 8b14cfd3a39..c110e4ca94e 100644 --- a/api/src/main/java/com/cloud/host/Host.java +++ b/api/src/main/java/com/cloud/host/Host.java @@ -63,6 +63,7 @@ public interface Host extends StateObject, Identity, Partition, HAResour String HOST_OVFTOOL_VERSION = "host.ovftool.version"; String HOST_VIRTV2V_VERSION = "host.virtv2v.version"; String HOST_SSH_PORT = "host.ssh.port"; + String HOST_CDROM_MAX_COUNT = "host.cdrom.max.count"; String GUEST_OS_CATEGORY_ID = "guest.os.category.id"; String GUEST_OS_RULE = "guest.os.rule"; diff --git a/api/src/main/java/com/cloud/offering/DiskOfferingInfo.java b/api/src/main/java/com/cloud/offering/DiskOfferingInfo.java index 12dcf423e34..197565a1fcc 100644 --- a/api/src/main/java/com/cloud/offering/DiskOfferingInfo.java +++ b/api/src/main/java/com/cloud/offering/DiskOfferingInfo.java @@ -23,6 +23,7 @@ public class DiskOfferingInfo { private Long _size; private Long _minIops; private Long _maxIops; + private Long _kmsKeyId; public DiskOfferingInfo() { } @@ -38,6 +39,14 @@ public class DiskOfferingInfo { _maxIops = maxIops; } + public DiskOfferingInfo(DiskOffering diskOffering, Long size, Long minIops, Long maxIops, Long kmsKeyId) { + _diskOffering = diskOffering; + _size = size; + _minIops = minIops; + _maxIops = maxIops; + _kmsKeyId = kmsKeyId; + } + public void setDiskOffering(DiskOffering diskOffering) { _diskOffering = diskOffering; } @@ -69,4 +78,12 @@ public class DiskOfferingInfo { public Long getMaxIops() { return _maxIops; } + + public void setKmsKeyId(Long kmsKeyId) { + _kmsKeyId = kmsKeyId; + } + + public Long getKmsKeyId() { + return _kmsKeyId; + } } diff --git a/api/src/main/java/com/cloud/storage/Volume.java b/api/src/main/java/com/cloud/storage/Volume.java index c7fbdb0a544..c7a13d5780d 100644 --- a/api/src/main/java/com/cloud/storage/Volume.java +++ b/api/src/main/java/com/cloud/storage/Volume.java @@ -275,6 +275,14 @@ public interface Volume extends ControlledEntity, Identity, InternalIdentity, Ba void setPassphraseId(Long id); + Long getKmsKeyId(); + + void setKmsKeyId(Long id); + + Long getKmsWrappedKeyId(); + + void setKmsWrappedKeyId(Long id); + String getEncryptFormat(); void setEncryptFormat(String encryptFormat); diff --git a/api/src/main/java/com/cloud/storage/VolumeApiService.java b/api/src/main/java/com/cloud/storage/VolumeApiService.java index 43ead8d287b..d287cc335ee 100644 --- a/api/src/main/java/com/cloud/storage/VolumeApiService.java +++ b/api/src/main/java/com/cloud/storage/VolumeApiService.java @@ -72,7 +72,7 @@ public interface VolumeApiService { Volume allocVolume(CreateVolumeCmd cmd) throws ResourceAllocationException; Volume allocVolume(long ownerId, Long zoneId, Long diskOfferingId, Long vmId, Long snapshotId, String name, - Long cmdSize, Boolean displayVolume, Long cmdMinIops, Long cmdMaxIops, String customId) + Long cmdSize, Boolean displayVolume, Long cmdMinIops, Long cmdMaxIops, String customId, Long kmsKeyId) throws ResourceAllocationException; /** diff --git a/api/src/main/java/com/cloud/vm/DiskProfile.java b/api/src/main/java/com/cloud/vm/DiskProfile.java index 971ebde496e..766573d4d40 100644 --- a/api/src/main/java/com/cloud/vm/DiskProfile.java +++ b/api/src/main/java/com/cloud/vm/DiskProfile.java @@ -82,7 +82,7 @@ public class DiskProfile { null); this.hyperType = hyperType; this.provisioningType = offering.getProvisioningType(); - this.requiresEncryption = offering.getEncrypt() || vol.getPassphraseId() != null; + this.requiresEncryption = offering.getEncrypt() || vol.getPassphraseId() != null || vol.getKmsKeyId() != null; } public DiskProfile(DiskProfile dp) { diff --git a/api/src/main/java/com/cloud/vm/UserVmService.java b/api/src/main/java/com/cloud/vm/UserVmService.java index 67aa0534a5f..ffa00734d57 100644 --- a/api/src/main/java/com/cloud/vm/UserVmService.java +++ b/api/src/main/java/com/cloud/vm/UserVmService.java @@ -229,7 +229,7 @@ public interface UserVmService { String userData, Long userDataId, String userDataDetails, List sshKeyPairs, Map requestedIps, IpAddresses defaultIp, Boolean displayVm, String keyboard, List affinityGroupIdList, Map customParameter, String customId, Map> dhcpOptionMap, Map dataDiskTemplateToDiskOfferingMap, - Map userVmOVFProperties, boolean dynamicScalingEnabled, Long overrideDiskOfferingId, Volume volume, Snapshot snapshot) throws InsufficientCapacityException, + Map userVmOVFProperties, boolean dynamicScalingEnabled, Long overrideDiskOfferingId, Long rootDiskKmsKeyId, Volume volume, Snapshot snapshot) throws InsufficientCapacityException, ConcurrentOperationException, ResourceUnavailableException, StorageUnavailableException, ResourceAllocationException; /** @@ -305,7 +305,7 @@ public interface UserVmService { List securityGroupIdList, Account owner, String hostName, String displayName, Long diskOfferingId, Long diskSize, List dataDiskInfoList, String group, HypervisorType hypervisor, HTTPMethod httpmethod, String userData, Long userDataId, String userDataDetails, List sshKeyPairs, Map requestedIps, IpAddresses defaultIps, Boolean displayVm, String keyboard, List affinityGroupIdList, Map customParameters, String customId, Map> dhcpOptionMap, - Map dataDiskTemplateToDiskOfferingMap, Map userVmOVFProperties, boolean dynamicScalingEnabled, Long overrideDiskOfferingId, String vmType, Volume volume, Snapshot snapshot) throws InsufficientCapacityException, ConcurrentOperationException, ResourceUnavailableException, StorageUnavailableException, ResourceAllocationException; + Map dataDiskTemplateToDiskOfferingMap, Map userVmOVFProperties, boolean dynamicScalingEnabled, Long overrideDiskOfferingId, Long rootDiskKmsKeyId, String vmType, Volume volume, Snapshot snapshot) throws InsufficientCapacityException, ConcurrentOperationException, ResourceUnavailableException, StorageUnavailableException, ResourceAllocationException; /** * Creates a User VM in Advanced Zone (Security Group feature is disabled) @@ -377,7 +377,7 @@ public interface UserVmService { String hostName, String displayName, Long diskOfferingId, Long diskSize, List dataDiskInfoList, String group, HypervisorType hypervisor, HTTPMethod httpmethod, String userData, Long userDataId, String userDataDetails, List sshKeyPairs, Map requestedIps, IpAddresses defaultIps, Boolean displayVm, String keyboard, List affinityGroupIdList, Map customParameters, String customId, Map> dhcpOptionMap, Map dataDiskTemplateToDiskOfferingMap, - Map templateOvfPropertiesMap, boolean dynamicScalingEnabled, String vmType, Long overrideDiskOfferingId, Volume volume, Snapshot snapshot) + Map templateOvfPropertiesMap, boolean dynamicScalingEnabled, String vmType, Long overrideDiskOfferingId, Long rootDiskKmsKeyId, Volume volume, Snapshot snapshot) throws InsufficientCapacityException, ConcurrentOperationException, ResourceUnavailableException, StorageUnavailableException, ResourceAllocationException; diff --git a/api/src/main/java/com/cloud/vm/VmDiskInfo.java b/api/src/main/java/com/cloud/vm/VmDiskInfo.java index b8779a8d77c..97683e8397f 100644 --- a/api/src/main/java/com/cloud/vm/VmDiskInfo.java +++ b/api/src/main/java/com/cloud/vm/VmDiskInfo.java @@ -33,6 +33,11 @@ public class VmDiskInfo extends DiskOfferingInfo { _deviceId = deviceId; } + public VmDiskInfo(DiskOffering diskOffering, Long size, Long minIops, Long maxIops, Long deviceId, Long kmsKeyId) { + super(diskOffering, size, minIops, maxIops, kmsKeyId); + _deviceId = deviceId; + } + public Long getDeviceId() { return _deviceId; } 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 e2ebb242cbf..2aa97b65a3d 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiCommandResourceType.java @@ -89,7 +89,9 @@ public enum ApiCommandResourceType { KubernetesSupportedVersion(null), SharedFS(org.apache.cloudstack.storage.sharedfs.SharedFS.class), Extension(org.apache.cloudstack.extension.Extension.class), - ExtensionCustomAction(org.apache.cloudstack.extension.ExtensionCustomAction.class); + ExtensionCustomAction(org.apache.cloudstack.extension.ExtensionCustomAction.class), + KmsKey(org.apache.cloudstack.kms.KMSKey.class), + HsmProfile(org.apache.cloudstack.kms.HSMProfile.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 f10b3f0a390..2413760d69d 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -202,6 +202,7 @@ public class ApiConstants { public static final String UTILIZATION = "utilization"; public static final String DRIVER = "driver"; public static final String ROOT_DISK_SIZE = "rootdisksize"; + public static final String ROOT_DISK_KMS_KEY_ID = "rootdiskkmskeyid"; public static final String DHCP_OPTIONS_NETWORK_LIST = "dhcpoptionsnetworklist"; public static final String DHCP_OPTIONS = "dhcpoptions"; public static final String DHCP_PREFIX = "dhcp:"; @@ -886,7 +887,14 @@ public class ApiConstants { public static final String ITEMS = "items"; public static final String SORT_BY = "sortby"; public static final String CHANGE_CIDR = "changecidr"; + public static final String HSM_PROFILE = "hsmprofile"; + public static final String HSM_PROFILE_ID = "hsmprofileid"; public static final String PURPOSE = "purpose"; + public static final String KMS_KEY = "kmskey"; + public static final String KMS_KEY_ID = "kmskeyid"; + public static final String KMS_KEY_VERSION = "kmskeyversion"; + public static final String KEK_LABEL = "keklabel"; + public static final String KEY_BITS = "keybits"; public static final String IS_TAGGED = "istagged"; public static final String INSTANCE_NAME = "instancename"; public static final String CONSIDER_LAST_HOST = "considerlasthost"; @@ -1343,8 +1351,10 @@ public class ApiConstants { public static final String VNF_CONFIGURE_MANAGEMENT = "vnfconfiguremanagement"; public static final String VNF_CIDR_LIST = "vnfcidrlist"; + public static final String AUTHORIZE_URL = "authorizeurl"; public static final String CLIENT_ID = "clientid"; public static final String REDIRECT_URI = "redirecturi"; + public static final String TOKEN_URL = "tokenurl"; public static final String IS_TAG_A_RULE = "istagarule"; diff --git a/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java b/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java index b0738cf78e1..6e880c89432 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java +++ b/api/src/main/java/org/apache/cloudstack/api/ResponseGenerator.java @@ -281,7 +281,8 @@ public interface ResponseGenerator { List createUserVmResponse(ResponseView view, String objectName, UserVm... userVms); - List createUserVmResponse(ResponseView view, String objectName, EnumSet details, UserVm... userVms); + List createUserVmResponse(ResponseView view, String objectName, EnumSet details, + UserVm... userVms); SystemVmResponse createSystemVmResponse(VirtualMachine systemVM); @@ -307,11 +308,13 @@ public interface ResponseGenerator { LoadBalancerResponse createLoadBalancerResponse(LoadBalancer loadBalancer); - LBStickinessResponse createLBStickinessPolicyResponse(List stickinessPolicies, LoadBalancer lb); + LBStickinessResponse createLBStickinessPolicyResponse(List stickinessPolicies, + LoadBalancer lb); LBStickinessResponse createLBStickinessPolicyResponse(StickinessPolicy stickinessPolicy, LoadBalancer lb); - LBHealthCheckResponse createLBHealthCheckPolicyResponse(List healthcheckPolicies, LoadBalancer lb); + LBHealthCheckResponse createLBHealthCheckPolicyResponse(List healthcheckPolicies, + LoadBalancer lb); LBHealthCheckResponse createLBHealthCheckPolicyResponse(HealthCheckPolicy healthcheckPolicy, LoadBalancer lb); @@ -319,7 +322,8 @@ public interface ResponseGenerator { PodResponse createMinimalPodResponse(Pod pod); - ZoneResponse createZoneResponse(ResponseView view, DataCenter dataCenter, Boolean showCapacities, Boolean showResourceIcon); + ZoneResponse createZoneResponse(ResponseView view, DataCenter dataCenter, Boolean showCapacities, + Boolean showResourceIcon); DataCenterGuestIpv6PrefixResponse createDataCenterGuestIpv6PrefixResponse(DataCenterGuestIpv6Prefix prefix); @@ -361,7 +365,8 @@ public interface ResponseGenerator { List createTemplateResponses(ResponseView view, long templateId, Long zoneId, boolean readyOnly); - List createTemplateResponses(ResponseView view, long templateId, Long snapshotId, Long volumeId, boolean readyOnly); + List createTemplateResponses(ResponseView view, long templateId, Long snapshotId, Long volumeId, + boolean readyOnly); SecurityGroupResponse createSecurityGroupResponseFromSecurityGroupRule(List securityRules); @@ -380,14 +385,15 @@ public interface ResponseGenerator { TemplateResponse createTemplateUpdateResponse(ResponseView view, VirtualMachineTemplate result); List createTemplateResponses(ResponseView view, VirtualMachineTemplate result, - Long zoneId, boolean readyOnly); + Long zoneId, boolean readyOnly); List createTemplateResponses(ResponseView view, VirtualMachineTemplate result, - List zoneIds, boolean readyOnly); + List zoneIds, boolean readyOnly); List createCapacityResponse(List result, DecimalFormat format); - TemplatePermissionsResponse createTemplatePermissionsResponse(ResponseView view, List accountNames, Long id); + TemplatePermissionsResponse createTemplatePermissionsResponse(ResponseView view, List accountNames, + Long id); AsyncJobResponse queryJobResult(QueryAsyncJobResultCmd cmd); @@ -401,7 +407,8 @@ public interface ResponseGenerator { Long getSecurityGroupId(String groupName, long accountId); - List createIsoResponses(ResponseView view, VirtualMachineTemplate iso, Long zoneId, boolean readyOnly); + List createIsoResponses(ResponseView view, VirtualMachineTemplate iso, Long zoneId, + boolean readyOnly); ProjectResponse createProjectResponse(Project project); @@ -502,13 +509,15 @@ public interface ResponseGenerator { GuestOsMappingResponse createGuestOSMappingResponse(GuestOSHypervisor osHypervisor); - HypervisorGuestOsNamesResponse createHypervisorGuestOSNamesResponse(List> hypervisorGuestOsNames); + HypervisorGuestOsNamesResponse createHypervisorGuestOSNamesResponse( + List> hypervisorGuestOsNames); SnapshotScheduleResponse createSnapshotScheduleResponse(SnapshotSchedule sched); UsageRecordResponse createUsageResponse(Usage usageRecord); - UsageRecordResponse createUsageResponse(Usage usageRecord, Map> resourceTagResponseMap, boolean oldFormat); + UsageRecordResponse createUsageResponse(Usage usageRecord, + Map> resourceTagResponseMap, boolean oldFormat); public Map> getUsageResourceTags(); @@ -520,7 +529,8 @@ public interface ResponseGenerator { public NicResponse createNicResponse(Nic result); - ApplicationLoadBalancerResponse createLoadBalancerContainerReponse(ApplicationLoadBalancerRule lb, Map lbInstances); + ApplicationLoadBalancerResponse createLoadBalancerContainerReponse(ApplicationLoadBalancerRule lb, + Map lbInstances); AffinityGroupResponse createAffinityGroupResponse(AffinityGroup group); @@ -546,9 +556,12 @@ public interface ResponseGenerator { ManagementServerResponse createManagementResponse(ManagementServerHost mgmt); - List createHealthCheckResponse(VirtualMachine router, List healthCheckResults); + List createHealthCheckResponse(VirtualMachine router, + List healthCheckResults); - RollingMaintenanceResponse createRollingMaintenanceResponse(Boolean success, String details, List hostsUpdated, List hostsSkipped); + RollingMaintenanceResponse createRollingMaintenanceResponse(Boolean success, String details, + List hostsUpdated, + List hostsSkipped); ResourceIconResponse createResourceIconResponse(ResourceIcon resourceIcon); @@ -558,11 +571,14 @@ public interface ResponseGenerator { DirectDownloadCertificateResponse createDirectDownloadCertificateResponse(DirectDownloadCertificate certificate); - List createDirectDownloadCertificateHostMapResponse(List hostMappings); + List createDirectDownloadCertificateHostMapResponse( + List hostMappings); - DirectDownloadCertificateHostStatusResponse createDirectDownloadCertificateHostStatusResponse(DirectDownloadManager.HostCertificateStatus status); + DirectDownloadCertificateHostStatusResponse createDirectDownloadCertificateHostStatusResponse( + DirectDownloadManager.HostCertificateStatus status); - DirectDownloadCertificateHostStatusResponse createDirectDownloadCertificateProvisionResponse(Long certificateId, Long hostId, Pair result); + DirectDownloadCertificateHostStatusResponse createDirectDownloadCertificateProvisionResponse(Long certificateId, + Long hostId, Pair result); FirewallResponse createIpv6FirewallRuleResponse(FirewallRule acl); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/MigrateVolumesToKMSCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/MigrateVolumesToKMSCmd.java new file mode 100644 index 00000000000..358f7f749f6 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/kms/MigrateVolumesToKMSCmd.java @@ -0,0 +1,131 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms; + +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.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.AsyncJobResponse; +import org.apache.cloudstack.api.response.DomainResponse; +import org.apache.cloudstack.api.response.KMSKeyResponse; +import org.apache.cloudstack.api.response.VolumeResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.kms.KMSKey; +import org.apache.cloudstack.kms.KMSManager; + +import javax.inject.Inject; +import java.util.List; + +@APICommand(name = "migrateVolumesToKMS", + description = "Migrates encrypted volumes to KMS", + responseObject = AsyncJobResponse.class, + since = "4.23.0", + authorized = {RoleType.Admin}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false) +public class MigrateVolumesToKMSCmd extends BaseAsyncCmd { + + @Inject + private KMSManager kmsManager; + + @Parameter(name = ApiConstants.ACCOUNT, + type = CommandType.STRING, + description = "Migrate volumes for specific account") + private String accountName; + + @Parameter(name = ApiConstants.DOMAIN_ID, + type = CommandType.UUID, + entityType = DomainResponse.class, + description = "Domain ID") + private Long domainId; + + @Parameter(name = ApiConstants.VOLUME_IDS, + type = CommandType.LIST, + collectionType = CommandType.UUID, + entityType = VolumeResponse.class, + required = true, + description = "List of volume IDs to migrate") + private List volumeIds; + + @Parameter(name = ApiConstants.KMS_KEY_ID, + required = true, + type = CommandType.UUID, + entityType = KMSKeyResponse.class, + description = "KMS Key ID to use for migrating volumes") + private Long kmsKeyId; + + public String getAccountName() { + return accountName; + } + + public Long getDomainId() { + return domainId; + } + + public List getVolumeIds() { + return volumeIds; + } + + public Long getKmsKeyId() { + return kmsKeyId; + } + + @Override + public void execute() { + try { + kmsManager.migrateVolumesToKMS(this); + } catch (KMSException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + "Failed to migrate volumes to KMS: " + e.getMessage()); + } + } + + @Override + public long getEntityOwnerId() { + KMSKey key = _entityMgr.findById(KMSKey.class, kmsKeyId); + if (key != null) { + return key.getAccountId(); + } + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public String getEventType() { + return com.cloud.event.EventTypes.EVENT_VOLUME_MIGRATE_TO_KMS; + } + + @Override + public String getEventDescription() { + return "Migrating volumes to KMS key: " + _uuidMgr.getUuid(KMSKey.class, kmsKeyId); + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.KmsKey; + } + + @Override + public Long getApiResourceId() { + return kmsKeyId; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/iso/DetachIsoCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/iso/DetachIsoCmd.java index cf4aa41f795..2560d837de1 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/iso/DetachIsoCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/iso/DetachIsoCmd.java @@ -27,6 +27,7 @@ import org.apache.cloudstack.api.ResponseObject.ResponseView; import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.command.user.UserCmd; import org.apache.cloudstack.api.command.user.vm.DeployVMCmd; +import org.apache.cloudstack.api.response.TemplateResponse; import org.apache.cloudstack.api.response.UserVmResponse; import com.cloud.event.EventTypes; @@ -51,6 +52,10 @@ public class DetachIsoCmd extends BaseAsyncCmd implements UserCmd { description = "If true, ejects the ISO before detaching on VMware. Default: false", since = "4.15.1") protected Boolean forced; + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = TemplateResponse.class, + description = "The ID of the ISO to detach. Required when the Instance has more than one ISO attached.", since = "4.23.0") + protected Long id; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -104,7 +109,7 @@ public class DetachIsoCmd extends BaseAsyncCmd implements UserCmd { @Override public void execute() { - boolean result = _templateService.detachIso(virtualMachineId, null, isForced()); + boolean result = _templateService.detachIso(virtualMachineId, id, isForced()); if (result) { UserVm userVm = _entityMgr.findById(UserVm.class, virtualMachineId); UserVmResponse response = _responseGenerator.createUserVmResponse(getResponseView(), "virtualmachine", userVm).get(0); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/CreateKMSKeyCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/CreateKMSKeyCmd.java new file mode 100644 index 00000000000..c2b8fb6fe09 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/CreateKMSKeyCmd.java @@ -0,0 +1,159 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.cloudstack.api.command.user.kms; + +import com.cloud.exception.ResourceAllocationException; +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.command.user.UserCmd; +import org.apache.cloudstack.api.response.DomainResponse; +import org.apache.cloudstack.api.response.HSMProfileResponse; +import org.apache.cloudstack.api.response.KMSKeyResponse; +import org.apache.cloudstack.api.response.ProjectResponse; +import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.kms.KMSManager; + +import javax.inject.Inject; + +@APICommand(name = "createKMSKey", + description = "Creates a new KMS key (Key Encryption Key) for encryption", + responseObject = KMSKeyResponse.class, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false) +public class CreateKMSKeyCmd extends BaseCmd implements UserCmd { + + @Inject + private KMSManager kmsManager; + + @Parameter(name = ApiConstants.NAME, + required = true, + type = CommandType.STRING, + description = "Name of the KMS key") + private String name; + + @Parameter(name = ApiConstants.DESCRIPTION, + type = CommandType.STRING, + description = "Description of the KMS key") + private String description; + + @Parameter(name = ApiConstants.ZONE_ID, + required = true, + type = CommandType.UUID, + entityType = ZoneResponse.class, + description = "Zone ID where the key will be valid") + private Long zoneId; + + @Parameter(name = ApiConstants.ACCOUNT, + type = CommandType.STRING, + description = "Account name (for creating keys for child accounts - requires domain admin or admin)") + private String accountName; + + @Parameter(name = ApiConstants.DOMAIN_ID, + type = CommandType.UUID, + entityType = DomainResponse.class, + description = "Domain ID (for creating keys for child accounts - requires domain admin or admin)") + private Long domainId; + + @Parameter(name = ApiConstants.PROJECT_ID, + type = CommandType.UUID, + entityType = ProjectResponse.class, + description = "ID of the project to create the KMS key for") + private Long projectId; + + @Parameter(name = ApiConstants.KEY_BITS, + type = CommandType.INTEGER, + description = "Key size in bits: 128, 192, or 256 (default: 256)") + private Integer keyBits; + + @Parameter(name = ApiConstants.HSM_PROFILE_ID, + type = CommandType.UUID, + entityType = HSMProfileResponse.class, + required = true, + description = "ID of HSM profile to create key in") + private Long hsmProfileId; + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public Long getZoneId() { + return zoneId; + } + + public String getAccountName() { + return accountName; + } + + public Long getDomainId() { + return domainId; + } + + public Long getProjectId() { + return projectId; + } + + public Integer getKeyBits() { + return keyBits != null ? keyBits : 256; + } + + public Long getHsmProfileId() { + return hsmProfileId; + } + + @Override + public void execute() throws ResourceAllocationException { + try { + KMSKeyResponse response = kmsManager.createKMSKey(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } catch (KMSException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + "Failed to create KMS key: " + e.getMessage()); + } + } + + @Override + public long getEntityOwnerId() { + Long accountId = _accountService.finalizeAccountId(accountName, domainId, projectId, true); + if (accountId != null) { + return accountId; + } + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.KmsKey; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/DeleteKMSKeyCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/DeleteKMSKeyCmd.java new file mode 100644 index 00000000000..58f67ec60c4 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/DeleteKMSKeyCmd.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.api.command.user.kms; + +import com.cloud.event.EventTypes; +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.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.command.user.UserCmd; +import org.apache.cloudstack.api.response.KMSKeyResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.kms.KMSKey; +import org.apache.cloudstack.kms.KMSManager; + +import javax.inject.Inject; + +@APICommand(name = "deleteKMSKey", + description = "Deletes a KMS key (only if not in use)", + responseObject = SuccessResponse.class, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false) +public class DeleteKMSKeyCmd extends BaseAsyncCmd implements UserCmd { + + @Inject + private KMSManager kmsManager; + + @Parameter(name = ApiConstants.ID, + required = true, + type = CommandType.UUID, + entityType = KMSKeyResponse.class, + description = "The UUID of the KMS key to delete") + private Long id; + + @Override + public void execute() { + try { + SuccessResponse response = kmsManager.deleteKMSKey(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } catch (KMSException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + "Failed to delete KMS key: " + e.getMessage()); + } + } + + @Override + public long getEntityOwnerId() { + KMSKey key = _entityMgr.findById(KMSKey.class, id); + if (key != null) { + return key.getAccountId(); + } + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.KmsKey; + } + + @Override + public Long getApiResourceId() { + return getId(); + } + + @Override + public String getEventType() { + return EventTypes.EVENT_KMS_KEY_DELETE; + } + + @Override + public String getEventDescription() { + return "deleting KMS key: " + getId(); + } + + public Long getId() { + return id; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/ListKMSKeysCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/ListKMSKeysCmd.java new file mode 100644 index 00000000000..c6e4292099f --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/ListKMSKeysCmd.java @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.cloudstack.api.command.user.kms; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseListProjectAndAccountResourcesCmd; +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.HSMProfileResponse; +import org.apache.cloudstack.api.response.KMSKeyResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.kms.KMSManager; + +import javax.inject.Inject; + +@APICommand(name = "listKMSKeys", + description = "Lists KMS keys available to the caller", + responseObject = KMSKeyResponse.class, + responseView = ResponseView.Restricted, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false) +public class ListKMSKeysCmd extends BaseListProjectAndAccountResourcesCmd implements UserCmd { + private static final String s_name = "listkmskeysresponse"; + + @Inject + private KMSManager kmsManager; + + @Parameter(name = ApiConstants.ID, + type = CommandType.UUID, + entityType = KMSKeyResponse.class, + description = "List KMS key by UUID") + private Long id; + + @Parameter(name = ApiConstants.ZONE_ID, + type = CommandType.UUID, + entityType = ZoneResponse.class, + description = "Filter by zone ID") + private Long zoneId; + + @Parameter(name = ApiConstants.ENABLED, + type = CommandType.BOOLEAN, + description = "Filter by enabled status") + private Boolean enabled; + + @Parameter(name = ApiConstants.HSM_PROFILE_ID, + type = CommandType.UUID, + entityType = HSMProfileResponse.class, + description = "Filter by HSM profile ID") + private Long hsmProfileId; + + public Long getId() { + return id; + } + + public Long getZoneId() { + return zoneId; + } + + public Boolean getEnabled() { + return enabled; + } + + public Long getHsmProfileId() { + return hsmProfileId; + } + + @Override + public void execute() { + ListResponse listResponse = kmsManager.listKMSKeys(this); + listResponse.setResponseName(getCommandName()); + setResponseObject(listResponse); + } + + @Override + public String getCommandName() { + return s_name; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/RotateKMSKeyCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/RotateKMSKeyCmd.java new file mode 100644 index 00000000000..e3c6c5822b2 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/RotateKMSKeyCmd.java @@ -0,0 +1,128 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.user.kms; + +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.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.AsyncJobResponse; +import org.apache.cloudstack.api.response.HSMProfileResponse; +import org.apache.cloudstack.api.response.KMSKeyResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.kms.KMSKey; +import org.apache.cloudstack.kms.KMSManager; + +import javax.inject.Inject; + +@APICommand(name = "rotateKMSKey", + description = "Rotates KMS key (KEK) by creating new version and scheduling gradual re-encryption", + responseObject = AsyncJobResponse.class, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false) +public class RotateKMSKeyCmd extends BaseAsyncCmd { + private static final String s_name = "rotatekmskeyresponse"; + + @Inject + private KMSManager kmsManager; + + @Parameter(name = ApiConstants.ID, + required = true, + type = CommandType.UUID, + entityType = KMSKeyResponse.class, + description = "KMS Key UUID to rotate") + private Long id; + + @Parameter(name = ApiConstants.KEY_BITS, + type = CommandType.INTEGER, + description = "Key size for new KEK (default: same as current)") + private Integer keyBits; + + @Parameter(name = ApiConstants.HSM_PROFILE_ID, + type = CommandType.UUID, + entityType = HSMProfileResponse.class, + description = "The target HSM profile ID for the new KEK version. If provided, migrates the key to " + + "this HSM.") + private Long hsmProfileId; + + public Long getId() { + return id; + } + + public Integer getKeyBits() { + return keyBits; + } + + public Long getHsmProfileId() { + return hsmProfileId; + } + + @Override + public void execute() { + try { + kmsManager.rotateKMSKey(this); + SuccessResponse response = new SuccessResponse(); + response.setResponseName(getCommandName()); + setResponseObject(response); + } catch (KMSException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + "Failed to rotate KMS key: " + e.getMessage()); + } + } + + @Override + public String getCommandName() { + return s_name; + } + + @Override + public long getEntityOwnerId() { + KMSKey key = _entityMgr.findById(KMSKey.class, id); + if (key != null) { + return key.getAccountId(); + } + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public String getEventType() { + return com.cloud.event.EventTypes.EVENT_KMS_KEY_ROTATE; + } + + @Override + public String getEventDescription() { + return "Rotating KMS key: " + _uuidMgr.getUuid(KMSKey.class, id); + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.KmsKey; + } + + @Override + public Long getApiResourceId() { + return getId(); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/UpdateKMSKeyCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/UpdateKMSKeyCmd.java new file mode 100644 index 00000000000..77654aa13ac --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/UpdateKMSKeyCmd.java @@ -0,0 +1,113 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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.user.kms; + +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.KMSKeyResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.kms.KMSManager; + +import javax.inject.Inject; + +@APICommand(name = "updateKMSKey", + description = "Updates KMS key name, description, or enabled status", + responseObject = KMSKeyResponse.class, + since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}, + requestHasSensitiveInfo = false, + responseHasSensitiveInfo = false) +public class UpdateKMSKeyCmd extends BaseCmd { + + @Inject + private KMSManager kmsManager; + + @Parameter(name = ApiConstants.ID, + required = true, + type = CommandType.UUID, + entityType = KMSKeyResponse.class, + description = "The UUID of the KMS key to update") + private Long id; + + @Parameter(name = ApiConstants.NAME, + type = CommandType.STRING, + description = "New name for the key") + private String name; + + @Parameter(name = ApiConstants.DESCRIPTION, + type = CommandType.STRING, + description = "New description for the key") + private String description; + + @Parameter(name = ApiConstants.ENABLED, + type = CommandType.BOOLEAN, + description = "whether the key should be enabled") + private Boolean enabled; + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public Boolean getEnabled() { + return enabled; + } + + @Override + public void execute() { + try { + KMSKeyResponse response = kmsManager.updateKMSKey(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } catch (KMSException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + "Failed to update KMS key: " + e.getMessage()); + } + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.KmsKey; + } + + @Override + public Long getApiResourceId() { + return getId(); + } + + public Long getId() { + return id; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/CreateHSMProfileCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/CreateHSMProfileCmd.java new file mode 100644 index 00000000000..496ddee89b2 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/CreateHSMProfileCmd.java @@ -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. + +package org.apache.cloudstack.api.command.user.kms.hsm; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.utils.StringUtils; +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.DomainResponse; +import org.apache.cloudstack.api.response.HSMProfileResponse; +import org.apache.cloudstack.api.response.ProjectResponse; +import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.kms.HSMProfile; +import org.apache.cloudstack.kms.KMSManager; + +import javax.inject.Inject; +import java.util.Map; + +@APICommand(name = "createHSMProfile", description = "Creates a new HSM profile", responseObject = HSMProfileResponse.class, + requestHasSensitiveInfo = true, responseHasSensitiveInfo = true, since = "4.23.0", + authorized = { RoleType.Admin }) +public class CreateHSMProfileCmd extends BaseCmd { + + @Inject + private KMSManager kmsManager; + + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, required = true, + description = "the name of the HSM profile") + private String name; + + @Parameter(name = ApiConstants.PROTOCOL, type = CommandType.STRING, + description = "the protocol of the HSM profile (PKCS11, KMIP, etc.). Default is 'pkcs11'") + private String protocol; + + @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = ZoneResponse.class, + description = "the zone ID where the HSM profile is available. If null, global scope (for admin only)") + private Long zoneId; + + @Parameter(name = ApiConstants.DOMAIN_ID, type = CommandType.UUID, entityType = DomainResponse.class, + description = "the domain ID where the HSM profile is available. If specified without an account, " + + "the profile is domain-scoped and usable by all accounts directly in that domain.") + private Long domainId; + + @Parameter(name = ApiConstants.ACCOUNT, type = CommandType.STRING, + description = "the account name of the HSM profile owner. Must be used with domainId. " + + "Leave empty (with a domainId) to create a domain-scoped profile shared by all " + + "accounts in that domain.") + private String accountName; + + @Parameter(name = ApiConstants.PROJECT_ID, type = CommandType.UUID, entityType = ProjectResponse.class, + description = "the ID of the project to add the HSM profile for") + private Long projectId; + + @Parameter(name = ApiConstants.IS_PUBLIC, type = CommandType.BOOLEAN, + description = "whether this is a public HSM profile available to all users globally (root admin only). " + + "Default is false") + private Boolean isPublic; + + @Parameter(name = ApiConstants.VENDOR_NAME, type = CommandType.STRING, description = "the vendor name of the HSM") + private String vendorName; + + @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, + description = "HSM configuration details (protocol specific)") + private Map details; + + public String getName() { + return name; + } + + public String getProtocol() { + if (StringUtils.isBlank(protocol)) { + return "pkcs11"; + } + return protocol; + } + + public Long getZoneId() { + return zoneId; + } + + public Long getDomainId() { + return domainId; + } + + public String getAccountName() { + return accountName; + } + + public Long getProjectId() { + return projectId; + } + + public Boolean getIsPublic() { + return isPublic != null && isPublic; + } + + public String getVendorName() { + return vendorName; + } + + public Map getDetails() { + return convertDetailsToMap(details); + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + try { + HSMProfile profile = kmsManager.addHSMProfile(this); + HSMProfileResponse response = kmsManager.createHSMProfileResponse(profile); + response.setResponseName(getCommandName()); + setResponseObject(response); + } catch (KMSException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, e.getMessage()); + } + } + + @Override + public long getEntityOwnerId() { + Long accountId = _accountService.finalizeAccountId(accountName, domainId, projectId, true); + if (accountId != null) { + return accountId; + } + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.HsmProfile; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/DeleteHSMProfileCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/DeleteHSMProfileCmd.java new file mode 100644 index 00000000000..575a4447a69 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/DeleteHSMProfileCmd.java @@ -0,0 +1,93 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.api.command.user.kms.hsm; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +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.HSMProfileResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.kms.HSMProfile; +import org.apache.cloudstack.kms.KMSManager; + +import javax.inject.Inject; + +@APICommand(name = "deleteHSMProfile", description = "Deletes an HSM profile", responseObject = SuccessResponse.class, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "4.23.0", + authorized = { RoleType.Admin }) +public class DeleteHSMProfileCmd extends BaseCmd { + + @Inject + private KMSManager kmsManager; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = HSMProfileResponse.class, required = true, + description = "the ID of the HSM profile") + private Long id; + + public Long getId() { + return id; + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + try { + boolean result = kmsManager.deleteHSMProfile(this); + if (result) { + SuccessResponse response = new SuccessResponse(getCommandName()); + setResponseObject(response); + } else { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to delete HSM profile"); + } + } catch (KMSException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, e.getMessage()); + } + } + + @Override + public long getEntityOwnerId() { + HSMProfile profile = _entityMgr.findById(HSMProfile.class, id); + if (profile != null && profile.getAccountId() > 0) { + return profile.getAccountId(); + } + return CallContext.current().getCallingAccount().getId(); + } + + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.HsmProfile; + } + + @Override + public Long getApiResourceId() { + return getId(); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/ListHSMProfilesCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/ListHSMProfilesCmd.java new file mode 100644 index 00000000000..dc5bb589c42 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/ListHSMProfilesCmd.java @@ -0,0 +1,85 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.user.kms.hsm; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseListProjectAndAccountResourcesCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.response.HSMProfileResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.ZoneResponse; +import org.apache.cloudstack.kms.KMSManager; + +import javax.inject.Inject; + +@APICommand(name = "listHSMProfiles", description = "Lists HSM profiles", responseObject = HSMProfileResponse.class, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = true, since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class ListHSMProfilesCmd extends BaseListProjectAndAccountResourcesCmd { + + @Inject + private KMSManager kmsManager; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = HSMProfileResponse.class, + description = "the HSM profile ID") + private Long id; + + @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = ZoneResponse.class, + description = "the zone ID") + private Long zoneId; + + @Parameter(name = ApiConstants.PROTOCOL, type = CommandType.STRING, description = "the protocol of the HSM profile") + private String protocol; + + @Parameter(name = ApiConstants.ENABLED, type = CommandType.BOOLEAN, description = "list only enabled profiles") + private Boolean enabled; + + @Parameter(name = ApiConstants.IS_PUBLIC, + type = CommandType.BOOLEAN, + description = "when true, non-admin users see only system (global) profiles") + private Boolean isPublic; + + public Long getId() { + return id; + } + + public Long getZoneId() { + return zoneId; + } + + public String getProtocol() { + return protocol; + } + + public Boolean getEnabled() { + return enabled; + } + + public Boolean getIsPublic() { + return isPublic; + } + + @Override + public void execute() { + ListResponse response = kmsManager.listHSMProfiles(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/UpdateHSMProfileCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/UpdateHSMProfileCmd.java new file mode 100644 index 00000000000..fea7807d616 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/kms/hsm/UpdateHSMProfileCmd.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.api.command.user.kms.hsm; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +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.HSMProfileResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.kms.HSMProfile; +import org.apache.cloudstack.kms.KMSManager; + +import javax.inject.Inject; + +@APICommand(name = "updateHSMProfile", description = "Updates an HSM profile", + responseObject = HSMProfileResponse.class, + requestHasSensitiveInfo = true, responseHasSensitiveInfo = true, since = "4.23.0", + authorized = { RoleType.Admin }) +public class UpdateHSMProfileCmd extends BaseCmd { + + @Inject + private KMSManager kmsManager; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = HSMProfileResponse.class, required = true, + description = "the ID of the HSM profile") + private Long id; + + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, description = "the name of the HSM profile") + private String name; + + @Parameter(name = ApiConstants.ENABLED, type = CommandType.BOOLEAN, + description = "whether the HSM profile is enabled") + private Boolean enabled; + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Boolean getEnabled() { + return enabled; + } + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, + ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + try { + HSMProfile profile = kmsManager.updateHSMProfile(this); + HSMProfileResponse response = kmsManager.createHSMProfileResponse(profile); + response.setResponseName(getCommandName()); + setResponseObject(response); + } catch (KMSException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, e.getMessage()); + } + } + + @Override + public long getEntityOwnerId() { + HSMProfile profile = _entityMgr.findById(HSMProfile.class, id); + if (profile != null && profile.getAccountId() > 0) { + return profile.getAccountId(); + } + return CallContext.current().getCallingAccount().getId(); + } + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.HsmProfile; + } + + @Override + public Long getApiResourceId() { + return getId(); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/schedule/CreateResourceScheduleCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/schedule/CreateResourceScheduleCmd.java new file mode 100644 index 00000000000..f6268106c31 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/schedule/CreateResourceScheduleCmd.java @@ -0,0 +1,132 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.user.schedule; + +import com.cloud.exception.InvalidParameterValueException; +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.ResourceScheduleResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.schedule.ResourceScheduleManager; +import org.apache.commons.lang3.EnumUtils; + +import javax.inject.Inject; +import java.util.Date; +import java.util.Map; + +@APICommand(name = "createResourceSchedule", description = "Create Resource Schedule", responseObject = ResourceScheduleResponse.class, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class CreateResourceScheduleCmd extends BaseCmd { + + @Inject + ResourceScheduleManager resourceScheduleManager; + + @Parameter(name = ApiConstants.RESOURCE_TYPE, type = CommandType.STRING, required = true, description = "Type of the resource") + private String resourceType; + + @Parameter(name = ApiConstants.RESOURCE_ID, type = CommandType.STRING, required = true, description = "ID of the resource for which schedule is to be defined") + private String resourceId; + + @Parameter(name = ApiConstants.DESCRIPTION, type = CommandType.STRING, required = false, description = "Description of the schedule") + private String description; + + @Parameter(name = ApiConstants.SCHEDULE, type = CommandType.STRING, required = true, description = "Schedule for action on resource in cron format. e.g. '0 15 10 * *' for 'at 15:00 on 10th day of every month'") + private String schedule; + + @Parameter(name = ApiConstants.TIMEZONE, type = CommandType.STRING, required = true, description = "Specifies a timezone for this command. For more information on the timezone parameter, see TimeZone Format.") + private String timeZone; + + @Parameter(name = ApiConstants.ACTION, type = CommandType.STRING, required = true, description = "Action to take on the resource.") + private String action; + + @Parameter(name = ApiConstants.START_DATE, type = CommandType.DATE, required = false, description = "Start date from which the schedule becomes active. Defaults to current date plus 1 minute. (Format \"yyyy-MM-dd hh:mm:ss\")") + private Date startDate; + + @Parameter(name = ApiConstants.END_DATE, type = CommandType.DATE, required = false, description = "End date after which the schedule becomes inactive. (Format \"yyyy-MM-dd hh:mm:ss\")") + private Date endDate; + + @Parameter(name = ApiConstants.ENABLED, type = CommandType.BOOLEAN, required = false, description = "Enable schedule. Defaults to true") + private Boolean enabled; + + @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, required = false, description = "Map of (key/value pairs) details for the schedule.") + private Map details; + + public ApiCommandResourceType getResourceType() { + ApiCommandResourceType type = EnumUtils.getEnumIgnoreCase(ApiCommandResourceType.class, resourceType); + if (type == null) { + throw new InvalidParameterValueException("Unknown resource type: " + resourceType); + } + return type; + } + + public String getResourceId() { + return resourceId; + } + + public String getDescription() { + return description; + } + + public String getSchedule() { + return schedule; + } + + public String getTimeZone() { + return timeZone; + } + + public String getAction() { + return action; + } + + public Date getStartDate() { + return startDate; + } + + public Date getEndDate() { + return endDate; + } + + public Boolean getEnabled() { + if (enabled == null) { + enabled = true; + } + return enabled; + } + + public Map getDetails() { + return convertDetailsToMap(details); + } + + @Override + public void execute() { + ResourceScheduleResponse response = resourceScheduleManager.createSchedule(getResourceType(), getResourceId(), + getDescription(), getSchedule(), getTimeZone(), getAction(), getStartDate(), getEndDate(), getEnabled(), getDetails()); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getAccountId(); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/schedule/DeleteResourceScheduleCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/schedule/DeleteResourceScheduleCmd.java new file mode 100644 index 00000000000..fe9a695df63 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/schedule/DeleteResourceScheduleCmd.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.api.command.user.schedule; + +import com.cloud.exception.InvalidParameterValueException; +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.ResourceScheduleResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.schedule.ResourceScheduleManager; +import org.apache.commons.lang3.EnumUtils; + +import javax.inject.Inject; +import java.util.List; + +@APICommand(name = "deleteResourceSchedule", description = "Delete Resource Schedule", responseObject = SuccessResponse.class, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class DeleteResourceScheduleCmd extends BaseCmd { + + @Inject + ResourceScheduleManager resourceScheduleManager; + + @Parameter(name = ApiConstants.RESOURCE_TYPE, type = CommandType.STRING, required = true, description = "Type of the resource") + private String resourceType; + + @Parameter(name = ApiConstants.RESOURCE_ID, type = CommandType.STRING, required = true, description = "ID of the resource for which schedules are to be deleted") + private String resourceId; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = ResourceScheduleResponse.class, required = false, description = "ID of the schedule to be deleted") + private Long id; + + @Parameter(name = ApiConstants.IDS, type = CommandType.LIST, collectionType = CommandType.UUID, entityType = ResourceScheduleResponse.class, required = false, description = "comma separated list of schedule ids to be deleted") + private List ids; + + public ApiCommandResourceType getResourceType() { + ApiCommandResourceType type = EnumUtils.getEnumIgnoreCase(ApiCommandResourceType.class, resourceType); + if (type == null) { + throw new InvalidParameterValueException("Unknown resource type: " + resourceType); + } + return type; + } + + public String getResourceId() { + return resourceId; + } + + public Long getId() { + return id; + } + + public List getIds() { + return ids; + } + + @Override + public void execute() { + resourceScheduleManager.removeSchedule(getResourceType(), getResourceId(), getId(), getIds()); + SuccessResponse response = new SuccessResponse(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getAccountId(); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/schedule/ListResourceScheduleCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/schedule/ListResourceScheduleCmd.java new file mode 100644 index 00000000000..1c8869eff9c --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/schedule/ListResourceScheduleCmd.java @@ -0,0 +1,97 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.user.schedule; + +import com.cloud.exception.InvalidParameterValueException; +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.BaseListCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.ResourceScheduleResponse; +import org.apache.cloudstack.schedule.ResourceScheduleManager; +import org.apache.commons.lang3.EnumUtils; + +import javax.inject.Inject; +import java.util.List; + +@APICommand(name = "listResourceSchedule", description = "List Resource Schedules", responseObject = ResourceScheduleResponse.class, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class ListResourceScheduleCmd extends BaseListCmd { + + @Inject + ResourceScheduleManager resourceScheduleManager; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = ResourceScheduleResponse.class, required = false, description = "ID of the schedule") + private Long id; + + @Parameter(name = ApiConstants.IDS, type = CommandType.LIST, collectionType = CommandType.UUID, entityType = ResourceScheduleResponse.class, required = false, description = "comma separated list of schedule ids") + private List ids; + + @Parameter(name = ApiConstants.RESOURCE_TYPE, type = CommandType.STRING, required = true, description = "Type of the resource.") + private String resourceType; + + @Parameter(name = ApiConstants.RESOURCE_ID, type = CommandType.STRING, required = true, description = "ID of the resource for which schedules are to be listed.") + private String resourceId; + + @Parameter(name = ApiConstants.ACTION, type = CommandType.STRING, required = false, description = "Action to take on the resource.") + private String action; + + @Parameter(name = ApiConstants.ENABLED, type = CommandType.BOOLEAN, required = false, description = "Filter by enabled status.") + private Boolean enabled; + + public Long getId() { + return id; + } + + public List getIds() { + return ids; + } + + public ApiCommandResourceType getResourceType() { + ApiCommandResourceType type = EnumUtils.getEnumIgnoreCase(ApiCommandResourceType.class, resourceType); + if (type == null) { + throw new InvalidParameterValueException("Unknown resource type: " + resourceType); + } + return type; + } + + public String getResourceId() { + return resourceId; + } + + public String getAction() { + return action; + } + + public Boolean getEnabled() { + return enabled; + } + + @Override + public void execute() { + ListResponse response = resourceScheduleManager.listSchedule( + getId(), getIds(), getResourceType(), getResourceId(), getAction(), getEnabled(), + getStartIndex(), getPageSizeVal() + ); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/schedule/UpdateResourceScheduleCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/schedule/UpdateResourceScheduleCmd.java new file mode 100644 index 00000000000..5422588853e --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/schedule/UpdateResourceScheduleCmd.java @@ -0,0 +1,108 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.user.schedule; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.response.ResourceScheduleResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.schedule.ResourceScheduleManager; + +import javax.inject.Inject; +import java.util.Date; +import java.util.Map; + +@APICommand(name = "updateResourceSchedule", description = "Update Resource Schedule", responseObject = ResourceScheduleResponse.class, + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "4.23.0", + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class UpdateResourceScheduleCmd extends BaseCmd { + + @Inject + ResourceScheduleManager resourceScheduleManager; + + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = ResourceScheduleResponse.class, required = true, description = "ID of the schedule to be updated") + private Long id; + + @Parameter(name = ApiConstants.DESCRIPTION, type = CommandType.STRING, required = false, description = "Description of the schedule") + private String description; + + @Parameter(name = ApiConstants.SCHEDULE, type = CommandType.STRING, required = false, description = "Schedule for action on resource in cron format.") + private String schedule; + + @Parameter(name = ApiConstants.TIMEZONE, type = CommandType.STRING, required = false, description = "Specifies a timezone for this command.") + private String timeZone; + + @Parameter(name = ApiConstants.START_DATE, type = CommandType.DATE, required = false, description = "Start date from which the schedule becomes active.") + private Date startDate; + + @Parameter(name = ApiConstants.END_DATE, type = CommandType.DATE, required = false, description = "End date after which the schedule becomes inactive.") + private Date endDate; + + @Parameter(name = ApiConstants.ENABLED, type = CommandType.BOOLEAN, required = false, description = "Enable or disable the schedule.") + private Boolean enabled; + + @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, required = false, description = "Map of (key/value pairs) details for the schedule.") + private Map details; + + public Long getId() { + return id; + } + + public String getDescription() { + return description; + } + + public String getSchedule() { + return schedule; + } + + public String getTimeZone() { + return timeZone; + } + + public Date getStartDate() { + return startDate; + } + + public Date getEndDate() { + return endDate; + } + + public Boolean getEnabled() { + return enabled; + } + + public Map getDetails() { + return convertDetailsToMap(details); + } + + @Override + public void execute() { + ResourceScheduleResponse response = resourceScheduleManager.updateSchedule(getId(), getDescription(), getSchedule(), + getTimeZone(), getStartDate(), getEndDate(), getEnabled(), getDetails()); + response.setResponseName(getCommandName()); + setResponseObject(response); + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getAccountId(); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/BaseDeployVMCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/BaseDeployVMCmd.java index 68b0821ba44..38e9921853a 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/BaseDeployVMCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/BaseDeployVMCmd.java @@ -40,12 +40,14 @@ import org.apache.cloudstack.api.command.user.UserCmd; import org.apache.cloudstack.api.response.DiskOfferingResponse; import org.apache.cloudstack.api.response.DomainResponse; import org.apache.cloudstack.api.response.HostResponse; +import org.apache.cloudstack.api.response.KMSKeyResponse; import org.apache.cloudstack.api.response.NetworkResponse; import org.apache.cloudstack.api.response.ProjectResponse; import org.apache.cloudstack.api.response.SecurityGroupResponse; import org.apache.cloudstack.api.response.UserDataResponse; import org.apache.cloudstack.api.response.ZoneResponse; import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.kms.KMSKey; import org.apache.cloudstack.vm.lease.VMLeaseManager; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.collections.MapUtils; @@ -126,11 +128,19 @@ public abstract class BaseDeployVMCmd extends BaseAsyncCreateCustomIdCmd impleme since = "4.4") private Long rootdisksize; + @ACL + @Parameter(name = ApiConstants.ROOT_DISK_KMS_KEY_ID, + type = CommandType.UUID, + entityType = KMSKeyResponse.class, + description = "ID of the KMS Key to use for root disk encryption", + since = "4.23.0") + private Long rootDiskKmsKeyId; + @Parameter(name = ApiConstants.DATADISKS_DETAILS, type = CommandType.MAP, since = "4.21.0", description = "Disk offering details for creating multiple data volumes. Mutually exclusive with diskOfferingId." + - " Example: datadisksdetails[0].diskofferingid=a2a73a84-19db-4852-8930-dfddef053341&datadisksdetails[0].size=10&datadisksdetails[0].miniops=100&datadisksdetails[0].maxiops=200") + " Example: datadisksdetails[0].diskofferingid=a2a73a84-19db-4852-8930-dfddef053341&datadisksdetails[0].size=10&datadisksdetails[0].miniops=100&datadisksdetails[0].maxiops=200&datadisksdetails[0].kmskeyid=") private Map dataDisksDetails; @Parameter(name = ApiConstants.GROUP, type = CommandType.STRING, description = "an optional group for the virtual machine") @@ -300,6 +310,10 @@ public abstract class BaseDeployVMCmd extends BaseAsyncCreateCustomIdCmd impleme return diskOfferingId; } + public Long getRootDiskKmsKeyId() { + return rootDiskKmsKeyId; + } + public String getDeploymentPlanner() { return deploymentPlanner; } @@ -581,7 +595,19 @@ public abstract class BaseDeployVMCmd extends BaseAsyncCreateCustomIdCmd impleme minIops = Long.parseLong(dataDisk.get(ApiConstants.MIN_IOPS)); maxIops = Long.parseLong(dataDisk.get(ApiConstants.MAX_IOPS)); } - VmDiskInfo vmDiskInfo = new VmDiskInfo(diskOffering, size, minIops, maxIops, deviceId); + + // Extract KMS key ID if provided + Long kmsKeyId = null; + String kmsKeyUuid = dataDisk.get(ApiConstants.KMS_KEY_ID); + if (kmsKeyUuid != null) { + KMSKey kmsKey = _entityMgr.findByUuid(org.apache.cloudstack.kms.KMSKey.class, kmsKeyUuid); + if (kmsKey == null) { + throw new InvalidParameterValueException("Unable to find KMS key " + kmsKeyUuid); + } + kmsKeyId = kmsKey.getId(); + } + + VmDiskInfo vmDiskInfo = new VmDiskInfo(diskOffering, size, minIops, maxIops, deviceId, kmsKeyId); vmDiskInfoList.add(vmDiskInfo); } this.dataDiskInfoList = vmDiskInfoList; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/CreateVMScheduleCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/CreateVMScheduleCmd.java index 7e9bdd942ed..1fce8caf8e1 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/CreateVMScheduleCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/CreateVMScheduleCmd.java @@ -22,23 +22,26 @@ import com.cloud.exception.InvalidParameterValueException; import com.cloud.vm.VirtualMachine; 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.ResourceScheduleResponse; import org.apache.cloudstack.api.response.UserVmResponse; import org.apache.cloudstack.api.response.VMScheduleResponse; -import org.apache.cloudstack.vm.schedule.VMScheduleManager; +import org.apache.cloudstack.schedule.ResourceScheduleManager; import javax.inject.Inject; import java.util.Date; +@Deprecated @APICommand(name = "createVMSchedule", description = "Create Instance Schedule", responseObject = VMScheduleResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "4.19.0", authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) public class CreateVMScheduleCmd extends BaseCmd { @Inject - VMScheduleManager vmScheduleManager; + ResourceScheduleManager resourceScheduleManager; @Parameter(name = ApiConstants.VIRTUAL_MACHINE_ID, type = CommandType.UUID, @@ -75,14 +78,14 @@ public class CreateVMScheduleCmd extends BaseCmd { type = CommandType.DATE, required = false, description = "Start date from which the schedule becomes active. Defaults to current date plus 1 minute." - + "Use format \"yyyy-MM-dd hh:mm:ss\")") + + "(Format \"yyyy-MM-dd hh:mm:ss\")") private Date startDate; @Parameter(name = ApiConstants.END_DATE, type = CommandType.DATE, required = false, description = "End date after which the schedule becomes inactive" - + "Use format \"yyyy-MM-dd hh:mm:ss\")") + + "(Format \"yyyy-MM-dd hh:mm:ss\")") private Date endDate; @Parameter(name = ApiConstants.ENABLED, @@ -91,9 +94,9 @@ public class CreateVMScheduleCmd extends BaseCmd { description = "Enable Instance schedule. Defaults to true") private Boolean enabled; - ///////////////////////////////////////////////////// - /////////////////// Accessors /////////////////////// - ///////////////////////////////////////////////////// + /// ////////////////////////////////////////////////// + /// //////////////// Accessors /////////////////////// + /// ////////////////////////////////////////////////// public Long getVmId() { return vmId; @@ -130,13 +133,19 @@ public class CreateVMScheduleCmd extends BaseCmd { return enabled; } - ///////////////////////////////////////////////////// - /////////////// API Implementation/////////////////// - ///////////////////////////////////////////////////// + /// ////////////////////////////////////////////////// + /// //////////// API Implementation/////////////////// + /// ////////////////////////////////////////////////// @Override public void execute() { - VMScheduleResponse response = vmScheduleManager.createSchedule(this); + String resourceIdStr = getVmId() != null ? String.valueOf(getVmId()) : null; + + ResourceScheduleResponse scheduleResponse = resourceScheduleManager.createSchedule( + ApiCommandResourceType.VirtualMachine, + resourceIdStr, getDescription(), getSchedule(), getTimeZone(), getAction(), + getStartDate(), getEndDate(), getEnabled(), null); + VMScheduleResponse response = new VMScheduleResponse(scheduleResponse); response.setResponseName(getCommandName()); setResponseObject(response); } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/DeleteVMScheduleCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/DeleteVMScheduleCmd.java index f34d07b045d..f40db4ee048 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/DeleteVMScheduleCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/DeleteVMScheduleCmd.java @@ -22,6 +22,7 @@ import com.cloud.exception.InvalidParameterValueException; import com.cloud.vm.VirtualMachine; 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; @@ -30,19 +31,19 @@ import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.response.SuccessResponse; import org.apache.cloudstack.api.response.UserVmResponse; import org.apache.cloudstack.api.response.VMScheduleResponse; -import org.apache.cloudstack.vm.schedule.VMSchedule; -import org.apache.cloudstack.vm.schedule.VMScheduleManager; +import org.apache.cloudstack.schedule.ResourceScheduleManager; import javax.inject.Inject; import java.util.Collections; import java.util.List; +@Deprecated @APICommand(name = "deleteVMSchedule", description = "Delete Instance Schedule.", responseObject = SuccessResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "4.19.0", authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) public class DeleteVMScheduleCmd extends BaseCmd { @Inject - VMScheduleManager vmScheduleManager; + ResourceScheduleManager resourceScheduleManager; @Parameter(name = ApiConstants.VIRTUAL_MACHINE_ID, type = CommandType.UUID, @@ -50,12 +51,14 @@ public class DeleteVMScheduleCmd extends BaseCmd { required = true, description = "ID of Instance") private Long vmId; + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, entityType = VMScheduleResponse.class, required = false, description = "ID of Instance schedule") private Long id; + @Parameter(name = ApiConstants.IDS, type = CommandType.LIST, collectionType = CommandType.UUID, @@ -64,9 +67,9 @@ public class DeleteVMScheduleCmd extends BaseCmd { description = "IDs of Instance schedule") private List ids; - ///////////////////////////////////////////////////// - /////////////////// Accessors /////////////////////// - ///////////////////////////////////////////////////// + /// ////////////////////////////////////////////////// + /// //////////////// Accessors /////////////////////// + /// ////////////////////////////////////////////////// public Long getId() { return id; @@ -83,18 +86,21 @@ public class DeleteVMScheduleCmd extends BaseCmd { return vmId; } - ///////////////////////////////////////////////////// - /////////////// API Implementation/////////////////// - ///////////////////////////////////////////////////// + /// ////////////////////////////////////////////////// + /// //////////// API Implementation/////////////////// + /// ////////////////////////////////////////////////// @Override public void execute() { - long rowsRemoved = vmScheduleManager.removeSchedule(this); + String resourceIdStr = getVmId() != null ? String.valueOf(getVmId()) : null; + long rowsRemoved = resourceScheduleManager.removeSchedule( + ApiCommandResourceType.VirtualMachine, + resourceIdStr, getId(), getIds()); if (rowsRemoved > 0) { final SuccessResponse response = new SuccessResponse(); response.setResponseName(getCommandName()); - response.setObjectName(VMSchedule.class.getSimpleName().toLowerCase()); + response.setObjectName("vmschedule"); setResponseObject(response); } else { throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to delete Instance Schedules"); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/ListVMScheduleCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/ListVMScheduleCmd.java index be94315abe7..c7a9a75f2ea 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/ListVMScheduleCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/ListVMScheduleCmd.java @@ -20,23 +20,27 @@ package org.apache.cloudstack.api.command.user.vm; 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.BaseListCmd; import org.apache.cloudstack.api.Parameter; import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.ResourceScheduleResponse; import org.apache.cloudstack.api.response.UserVmResponse; import org.apache.cloudstack.api.response.VMScheduleResponse; -import org.apache.cloudstack.vm.schedule.VMSchedule; -import org.apache.cloudstack.vm.schedule.VMScheduleManager; +import org.apache.cloudstack.schedule.ResourceScheduleManager; import javax.inject.Inject; +import java.util.ArrayList; +import java.util.List; +@Deprecated @APICommand(name = "listVMSchedule", description = "List Instance Schedules.", responseObject = VMScheduleResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "4.19.0", authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) public class ListVMScheduleCmd extends BaseListCmd { @Inject - VMScheduleManager vmScheduleManager; + ResourceScheduleManager resourceScheduleManager; @Parameter(name = ApiConstants.VIRTUAL_MACHINE_ID, type = CommandType.UUID, @@ -61,12 +65,12 @@ public class ListVMScheduleCmd extends BaseListCmd { @Parameter(name = ApiConstants.ENABLED, type = CommandType.BOOLEAN, required = false, - description = "ID of Instance schedule") + description = "Filter by enabled status") private Boolean enabled; - ///////////////////////////////////////////////////// - /////////////////// Accessors /////////////////////// - ///////////////////////////////////////////////////// + /// ////////////////////////////////////////////////// + /// //////////////// Accessors /////////////////////// + /// ////////////////////////////////////////////////// public Long getVmId() { return vmId; @@ -84,14 +88,26 @@ public class ListVMScheduleCmd extends BaseListCmd { return enabled; } - ///////////////////////////////////////////////////// - /////////////// API Implementation/////////////////// - ///////////////////////////////////////////////////// + /// ////////////////////////////////////////////////// + /// //////////// API Implementation/////////////////// + /// ////////////////////////////////////////////////// @Override public void execute() { - ListResponse response = vmScheduleManager.listSchedule(this); + String resourceIdStr = getVmId() != null ? String.valueOf(getVmId()) : null; + + ListResponse scheduleResponse = resourceScheduleManager.listSchedule( + getId(), null, ApiCommandResourceType.VirtualMachine, resourceIdStr, getAction(), getEnabled(), + getStartIndex(), getPageSizeVal() + ); + + List vmScheduleResponses = new ArrayList<>(); + for (ResourceScheduleResponse resourceScheduleResponse : scheduleResponse.getResponses()) { + vmScheduleResponses.add(new VMScheduleResponse(resourceScheduleResponse)); + } + ListResponse response = new ListResponse<>(); + response.setResponses(vmScheduleResponses, scheduleResponse.getCount()); response.setResponseName(getCommandName()); - response.setObjectName(VMSchedule.class.getSimpleName().toLowerCase()); + response.setObjectName("vmschedule"); setResponseObject(response); } } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/UpdateVMScheduleCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/UpdateVMScheduleCmd.java index b7222944fe0..9b5b0537310 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/UpdateVMScheduleCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/UpdateVMScheduleCmd.java @@ -22,22 +22,25 @@ import com.cloud.exception.InvalidParameterValueException; import com.cloud.vm.VirtualMachine; 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.ResourceScheduleResponse; import org.apache.cloudstack.api.response.VMScheduleResponse; -import org.apache.cloudstack.vm.schedule.VMSchedule; -import org.apache.cloudstack.vm.schedule.VMScheduleManager; +import org.apache.cloudstack.schedule.ResourceSchedule; +import org.apache.cloudstack.schedule.ResourceScheduleManager; import javax.inject.Inject; import java.util.Date; +@Deprecated @APICommand(name = "updateVMSchedule", description = "Update Instance Schedule.", responseObject = VMScheduleResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "4.19.0", authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) public class UpdateVMScheduleCmd extends BaseCmd { @Inject - VMScheduleManager vmScheduleManager; + ResourceScheduleManager resourceScheduleManager; @Parameter(name = ApiConstants.ID, type = CommandType.UUID, @@ -68,14 +71,14 @@ public class UpdateVMScheduleCmd extends BaseCmd { type = CommandType.DATE, required = false, description = "Start date from which the schedule becomes active" - + "Use format \"yyyy-MM-dd hh:mm:ss\")") + + "(Format \"yyyy-MM-dd hh:mm:ss\")") private Date startDate; @Parameter(name = ApiConstants.END_DATE, type = CommandType.DATE, required = false, description = "End date after which the schedule becomes inactive" - + "Use format \"yyyy-MM-dd hh:mm:ss\")") + + "(Format \"yyyy-MM-dd hh:mm:ss\")") private Date endDate; @Parameter(name = ApiConstants.ENABLED, @@ -84,9 +87,9 @@ public class UpdateVMScheduleCmd extends BaseCmd { description = "Enable Instance schedule") private Boolean enabled; - ///////////////////////////////////////////////////// - /////////////////// Accessors /////////////////////// - ///////////////////////////////////////////////////// + /// ////////////////////////////////////////////////// + /// //////////////// Accessors /////////////////////// + /// ////////////////////////////////////////////////// public Long getId() { return id; @@ -116,24 +119,29 @@ public class UpdateVMScheduleCmd extends BaseCmd { return enabled; } - ///////////////////////////////////////////////////// - /////////////// API Implementation/////////////////// - ///////////////////////////////////////////////////// + /// ////////////////////////////////////////////////// + /// //////////// API Implementation/////////////////// + /// ////////////////////////////////////////////////// @Override public void execute() { - VMScheduleResponse response = vmScheduleManager.updateSchedule(this); + ResourceScheduleResponse scheduleResponse = resourceScheduleManager.updateSchedule( + getId(), getDescription(), getSchedule(), getTimeZone(), getStartDate(), getEndDate(), getEnabled(), null); + VMScheduleResponse response = new VMScheduleResponse(scheduleResponse); response.setResponseName(getCommandName()); setResponseObject(response); } @Override public long getEntityOwnerId() { - VMSchedule vmSchedule = _entityMgr.findById(VMSchedule.class, getId()); - if (vmSchedule == null) { - throw new InvalidParameterValueException(String.format("Unable to find vmSchedule by id=%d", getId())); + ResourceSchedule schedule = _entityMgr.findById(ResourceSchedule.class, getId()); + if (schedule == null || !ApiCommandResourceType.VirtualMachine.equals(schedule.getResourceType())) { + throw new InvalidParameterValueException(String.format("Unable to find VM schedule by id=%d", getId())); + } + VirtualMachine vm = _entityMgr.findById(VirtualMachine.class, schedule.getResourceId()); + if (vm == null) { + throw new InvalidParameterValueException(String.format("Unable to find VM schedule by id=%d", getId())); } - VirtualMachine vm = _entityMgr.findById(VirtualMachine.class, vmSchedule.getVmId()); return vm.getAccountId(); } } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/CreateVolumeCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/CreateVolumeCmd.java index ec7a626fa15..538e263ae9d 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/CreateVolumeCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/CreateVolumeCmd.java @@ -30,6 +30,7 @@ import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.command.user.UserCmd; import org.apache.cloudstack.api.response.DiskOfferingResponse; import org.apache.cloudstack.api.response.DomainResponse; +import org.apache.cloudstack.api.response.KMSKeyResponse; import org.apache.cloudstack.api.response.ProjectResponse; import org.apache.cloudstack.api.response.SnapshotResponse; import org.apache.cloudstack.api.response.StoragePoolResponse; @@ -110,6 +111,13 @@ public class CreateVolumeCmd extends BaseAsyncCreateCustomIdCmd implements UserC description = "The ID of the Instance; to be used with snapshot Id, Instance to which the volume gets attached after creation") private Long virtualMachineId; + @Parameter(name = ApiConstants.KMS_KEY_ID, + type = CommandType.UUID, + entityType = KMSKeyResponse.class, + description = "ID of the KMS Key for volume encryption (required if encryption enabled for zone)", + since = "4.23.0") + private Long kmsKeyId; + @Parameter(name = ApiConstants.STORAGE_ID, type = CommandType.UUID, entityType = StoragePoolResponse.class, @@ -190,6 +198,10 @@ public class CreateVolumeCmd extends BaseAsyncCreateCustomIdCmd implements UserC return virtualMachineId; } + public Long getKmsKeyId() { + return kmsKeyId; + } + ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/ListVolumesCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/ListVolumesCmd.java index a4cd299dae9..88f87e941e7 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/volume/ListVolumesCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/volume/ListVolumesCmd.java @@ -29,6 +29,7 @@ import org.apache.cloudstack.api.command.user.UserCmd; import org.apache.cloudstack.api.response.ClusterResponse; import org.apache.cloudstack.api.response.DiskOfferingResponse; import org.apache.cloudstack.api.response.HostResponse; +import org.apache.cloudstack.api.response.KMSKeyResponse; import org.apache.cloudstack.api.response.ListResponse; import org.apache.cloudstack.api.response.PodResponse; import org.apache.cloudstack.api.response.ServiceOfferingResponse; @@ -90,6 +91,9 @@ public class ListVolumesCmd extends BaseListRetrieveOnlyResourceCountCmd impleme @Parameter(name = ApiConstants.DISK_OFFERING_ID, type = CommandType.UUID, entityType = DiskOfferingResponse.class, description = "List volumes by disk offering", since = "4.4") private Long diskOfferingId; + @Parameter(name = ApiConstants.KMS_KEY_ID, type = CommandType.UUID, entityType = KMSKeyResponse.class, description = "List volumes by KMS Key", since = "4.23") + private Long kmsKeyId; + @Parameter(name = ApiConstants.DISPLAY_VOLUME, type = CommandType.BOOLEAN, description = "List resources by display flag; only ROOT admin is eligible to pass this parameter", since = "4.4", authorized = { RoleType.Admin}) private Boolean display; @@ -136,6 +140,10 @@ public class ListVolumesCmd extends BaseListRetrieveOnlyResourceCountCmd impleme return diskOfferingId; } + public Long getKmsKeyId() { + return kmsKeyId; + } + public String getType() { return type; } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/AttachedIsoResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/AttachedIsoResponse.java new file mode 100644 index 00000000000..b259de56218 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/AttachedIsoResponse.java @@ -0,0 +1,76 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.api.BaseResponse; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +public class AttachedIsoResponse extends BaseResponse { + + @SerializedName("id") + @Param(description = "The ID of the attached ISO") + private String id; + + @SerializedName("name") + @Param(description = "The name of the attached ISO") + private String name; + + @SerializedName("displaytext") + @Param(description = "The display text of the attached ISO") + private String displayText; + + @SerializedName("deviceseq") + @Param(description = "The cdrom slot that holds this ISO (3=hdc, 4=hdd, ...)") + private Integer deviceSeq; + + @SerializedName("bootable") + @Param(description = "Whether this is the bootable ISO for the VM") + private Boolean bootable; + + public AttachedIsoResponse() { + } + + public AttachedIsoResponse(String id, String name, String displayText, Integer deviceSeq, boolean bootable) { + this.id = id; + this.name = name; + this.displayText = displayText; + this.deviceSeq = deviceSeq; + this.bootable = bootable; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDisplayText() { + return displayText; + } + + public Integer getDeviceSeq() { + return deviceSeq; + } + + public Boolean getBootable() { + return bootable; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/HSMProfileResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/HSMProfileResponse.java new file mode 100644 index 00000000000..7576c76bef1 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/HSMProfileResponse.java @@ -0,0 +1,254 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.Map; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; +import org.apache.cloudstack.kms.HSMProfile; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +@EntityReference(value = HSMProfile.class) +public class HSMProfileResponse extends BaseResponse implements ControlledViewEntityResponse { + @SerializedName(ApiConstants.ID) + @Param(description = "the ID of the HSM profile") + private String id; + + @SerializedName(ApiConstants.NAME) + @Param(description = "the name of the HSM profile") + private String name; + + @SerializedName(ApiConstants.PROTOCOL) + @Param(description = "the protocol of the HSM profile") + private String protocol; + + @SerializedName(ApiConstants.ACCOUNT_ID) + @Param(description = "the account ID of the HSM profile owner") + private String accountId; + + @SerializedName(ApiConstants.ACCOUNT) + @Param(description = "the account name of the HSM profile owner") + private String accountName; + + @SerializedName(ApiConstants.DOMAIN_ID) + @Param(description = "the domain ID of the HSM profile owner") + private String domainId; + + @SerializedName(ApiConstants.DOMAIN) + @Param(description = "the domain name of the HSM profile owner") + private String domainName; + + @SerializedName(ApiConstants.DOMAIN_PATH) + @Param(description = "the domain path of the HSM profile owner") + private String domainPath; + + @SerializedName(ApiConstants.PROJECT_ID) + @Param(description = "the project ID of the HSM profile owner") + private String projectId; + + @SerializedName(ApiConstants.PROJECT) + @Param(description = "the project name of the HSM profile owner") + private String projectName; + + @SerializedName(ApiConstants.ZONE_ID) + @Param(description = "the zone ID where the HSM profile is available") + private String zoneId; + + @SerializedName(ApiConstants.ZONE_NAME) + @Param(description = "the zone name where the HSM profile is available") + private String zoneName; + + @SerializedName("vendor") + @Param(description = "the vendor name of the HSM profile") + private String vendorName; + + @SerializedName(ApiConstants.STATE) + @Param(description = "the state of the HSM profile") + private String state; + + @SerializedName(ApiConstants.ENABLED) + @Param(description = "whether the HSM profile is enabled") + private Boolean enabled; + + @SerializedName(ApiConstants.IS_PUBLIC) + @Param(description = "whether this is a system HSM profile available to all users globally") + private Boolean isPublic; + + @SerializedName(ApiConstants.CREATED) + @Param(description = "the date the HSM profile was created") + private Date created; + + @SerializedName(ApiConstants.DETAILS) + @Param(description = "HSM configuration details (sensitive values are encrypted)") + private Map details; + + public void setId(String id) { + this.id = id; + } + + public void setName(String name) { + this.name = name; + } + + public void setProtocol(String protocol) { + this.protocol = protocol; + } + + public void setAccountId(String accountId) { + this.accountId = accountId; + } + + @Override + public void setAccountName(String accountName) { + this.accountName = accountName; + } + + @Override + public void setDomainId(String domainId) { + this.domainId = domainId; + } + + @Override + public void setDomainName(String domainName) { + this.domainName = domainName; + } + + @Override + public void setDomainPath(String domainPath) { + this.domainPath = domainPath; + } + + @Override + public void setProjectId(String projectId) { + this.projectId = projectId; + } + + @Override + public void setProjectName(String projectName) { + this.projectName = projectName; + } + + public void setZoneId(String zoneId) { + this.zoneId = zoneId; + } + + public void setZoneName(String zoneName) { + this.zoneName = zoneName; + } + + public void setVendorName(String vendorName) { + this.vendorName = vendorName; + } + + public void setState(String state) { + this.state = state; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public void setIsPublic(Boolean isPublic) { + this.isPublic = isPublic; + } + + public void setCreated(Date created) { + this.created = created; + } + + public void setDetails(Map details) { + this.details = details; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getProtocol() { + return protocol; + } + + public String getAccountId() { + return accountId; + } + + public String getAccountName() { + return accountName; + } + + public String getDomainId() { + return domainId; + } + + public String getDomainName() { + return domainName; + } + + public String getDomainPath() { + return domainPath; + } + + public String getProjectId() { + return projectId; + } + + public String getProjectName() { + return projectName; + } + + public String getZoneId() { + return zoneId; + } + + public String getZoneName() { + return zoneName; + } + + public String getVendorName() { + return vendorName; + } + + public String getState() { + return state; + } + + public Boolean getEnabled() { + return enabled; + } + + public Boolean getPublic() { + return isPublic; + } + + public Date getCreated() { + return created; + } + + public Map getDetails() { + return details; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/KMSKeyResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/KMSKeyResponse.java new file mode 100644 index 00000000000..cd7a360a497 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/KMSKeyResponse.java @@ -0,0 +1,272 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.cloudstack.api.response; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; +import org.apache.cloudstack.kms.KMSKey; + +import java.util.Date; + +@EntityReference(value = KMSKey.class) +public class KMSKeyResponse extends BaseResponse implements ControlledViewEntityResponse { + + @SerializedName(ApiConstants.ID) + @Param(description = "the UUID of the key") + private String id; + + @SerializedName(ApiConstants.NAME) + @Param(description = "the name of the key") + private String name; + + @SerializedName(ApiConstants.DESCRIPTION) + @Param(description = "the description of the key") + private String description; + + @SerializedName(ApiConstants.ACCOUNT) + @Param(description = "the account owning the key") + private String accountName; + + @SerializedName(ApiConstants.ACCOUNT_ID) + @Param(description = "the account ID owning the key") + private String accountId; + + @SerializedName(ApiConstants.DOMAIN_ID) + @Param(description = "the domain ID of the key") + private String domainId; + + @SerializedName(ApiConstants.DOMAIN) + @Param(description = "the domain name of the key") + private String domainName; + + @SerializedName(ApiConstants.DOMAIN_PATH) + @Param(description = "the domain path of the key") + private String domainPath; + + @SerializedName(ApiConstants.ZONE_ID) + @Param(description = "the zone ID where the key is valid") + private String zoneId; + + @SerializedName(ApiConstants.ZONE_NAME) + @Param(description = "the zone name where the key is valid") + private String zoneName; + + @SerializedName(ApiConstants.HSM_PROFILE_ID) + @Param(description = "the zone ID where the key is valid") + private String hsmProfileId; + + @SerializedName(ApiConstants.HSM_PROFILE) + @Param(description = "the zone name where the key is valid") + private String hsmProfileName; + + @SerializedName(ApiConstants.ALGORITHM) + @Param(description = "the encryption algorithm") + private String algorithm; + + @SerializedName(ApiConstants.KEY_BITS) + @Param(description = "the key size in bits") + private Integer keyBits; + + @SerializedName(ApiConstants.VERSION) + @Param(description = "the key size in bits") + private Integer version; + + @SerializedName(ApiConstants.ENABLED) + @Param(description = "whether the key is enabled") + private Boolean enabled; + + @SerializedName(ApiConstants.CREATED) + @Param(description = "the creation timestamp") + private Date created; + + @SerializedName(ApiConstants.PROJECT_ID) + @Param(description = "the project ID of the key") + private String projectId; + + @SerializedName(ApiConstants.PROJECT) + @Param(description = "the project name of the key") + private String projectName; + + @SerializedName(ApiConstants.KEK_LABEL) + @Param(description = "the provider-specific KEK label (admin only)", authorized = { RoleType.Admin }) + private String kekLabel; + + 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 String getAccountName() { + return accountName; + } + + @Override + public void setAccountName(String accountName) { + this.accountName = accountName; + } + + @Override + public void setProjectId(String projectId) { + this.projectId = projectId; + } + + @Override + public void setProjectName(String projectName) { + this.projectName = projectName; + } + + public String getAccountId() { + return accountId; + } + + public void setAccountId(String accountId) { + this.accountId = accountId; + } + + public String getDomainId() { + return domainId; + } + + @Override + public void setDomainId(String domainId) { + this.domainId = domainId; + } + + public String getDomainName() { + return domainName; + } + + @Override + public void setDomainName(String domainName) { + this.domainName = domainName; + } + + public String getDomainPath() { + return domainPath; + } + + @Override + public void setDomainPath(String domainPath) { + this.domainPath = domainPath; + } + + public String getZoneId() { + return zoneId; + } + + public void setZoneId(String zoneId) { + this.zoneId = zoneId; + } + + public String getZoneName() { + return zoneName; + } + + public void setZoneName(String zoneName) { + this.zoneName = zoneName; + } + + public String getHsmProfileId() { + return hsmProfileId; + } + + public void setHsmProfileId(String hsmProfileId) { + this.hsmProfileId = hsmProfileId; + } + + public String getHsmProfileName() { + return hsmProfileName; + } + + public void setHsmProfileName(String hsmProfileName) { + this.hsmProfileName = hsmProfileName; + } + + public String getAlgorithm() { + return algorithm; + } + + public void setAlgorithm(String algorithm) { + this.algorithm = algorithm; + } + + public Integer getKeyBits() { + return keyBits; + } + + public void setKeyBits(Integer keyBits) { + this.keyBits = keyBits; + } + + public Integer getVersion() { + return version; + } + + public void setVersion(Integer version) { + this.version = version; + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } + + public String getKekLabel() { + return kekLabel; + } + + public void setKekLabel(String kekLabel) { + this.kekLabel = kekLabel; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/ResourceScheduleResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/ResourceScheduleResponse.java new file mode 100644 index 00000000000..ff799b467cf --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/ResourceScheduleResponse.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.api.response; + +import java.util.Date; +import java.util.Map; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; +import org.apache.cloudstack.schedule.ResourceSchedule; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +@EntityReference(value = ResourceSchedule.class) +public class ResourceScheduleResponse extends BaseResponse { + @SerializedName(ApiConstants.ID) + @Param(description = "The ID of resource schedule") + private String id; + + @SerializedName(ApiConstants.RESOURCE_TYPE) + @Param(description = "Type of the resource") + private ApiCommandResourceType resourceType; + + @SerializedName(ApiConstants.RESOURCE_ID) + @Param(description = "ID of the resource") + private String resourceId; + + @SerializedName(ApiConstants.DESCRIPTION) + @Param(description = "Description of resource schedule") + private String description; + + @SerializedName(ApiConstants.SCHEDULE) + @Param(description = "Cron formatted resource schedule") + private String schedule; + + @SerializedName(ApiConstants.TIMEZONE) + @Param(description = "Timezone of the schedule") + private String timeZone; + + @SerializedName(ApiConstants.ACTION) + @Param(description = "Action") + private ResourceSchedule.Action action; + + @SerializedName(ApiConstants.ENABLED) + @Param(description = "Resource schedule is enabled") + private boolean enabled; + + @SerializedName(ApiConstants.START_DATE) + @Param(description = "Date from which the schedule is active") + private Date startDate; + + @SerializedName(ApiConstants.END_DATE) + @Param(description = "Date after which the schedule becomes inactive") + private Date endDate; + + @SerializedName(ApiConstants.DETAILS) + @Param(description = "Schedule details") + private Map details; + + @SerializedName(ApiConstants.CREATED) + @Param(description = "Date when the schedule was created") + private Date created; + + public void setId(String id) { + this.id = id; + } + + public String getId() { + return id; + } + + public void setResourceType(ApiCommandResourceType resourceType) { + this.resourceType = resourceType; + } + + public ApiCommandResourceType getResourceType() { + return resourceType; + } + + public void setResourceId(String resourceId) { + this.resourceId = resourceId; + } + + public String getResourceId() { + return resourceId; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } + + public void setSchedule(String schedule) { + this.schedule = schedule; + } + + public String getSchedule() { + return schedule; + } + + public void setTimeZone(String timeZone) { + this.timeZone = timeZone; + } + + public String getTimeZone() { + return timeZone; + } + + public void setAction(ResourceSchedule.Action action) { + this.action = action; + } + + public ResourceSchedule.Action getAction() { + return action; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean getEnabled() { + return enabled; + } + + public void setStartDate(Date startDate) { + this.startDate = startDate; + } + + public Date getStartDate() { + return startDate; + } + + public void setEndDate(Date endDate) { + this.endDate = endDate; + } + + public Date getEndDate() { + return endDate; + } + + public void setDetails(Map details) { + this.details = details; + } + + public Map getDetails() { + return details; + } + + public void setCreated(Date created) { + this.created = created; + } + + public Date getCreated() { + return created; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/UserVmResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/UserVmResponse.java index a7f6dff96f8..4d6eae2fad2 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/UserVmResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/UserVmResponse.java @@ -166,6 +166,14 @@ public class UserVmResponse extends BaseResponseWithTagInformation implements Co @Param(description = "An alternate display text of the ISO attached to the Instance") private String isoDisplayText; + @SerializedName("isos") + @Param(description = "All ISOs attached to the Instance, keyed by cdrom slot. The first entry mirrors isoid/isoname for back-compat.", responseObject = AttachedIsoResponse.class, since = "4.23.0") + private List isos; + + @SerializedName("isomaxcount") + @Param(description = "Maximum number of ISOs that may be attached to this Instance, after applying the cluster-scoped vm.iso.max.count and the hypervisor's own cap.", since = "4.23.0") + private Integer isoMaxCount; + @SerializedName(ApiConstants.SERVICE_OFFERING_ID) @Param(description = "The ID of the service offering of the Instance") private String serviceOfferingId; @@ -871,6 +879,22 @@ public class UserVmResponse extends BaseResponseWithTagInformation implements Co this.isoId = isoId; } + public void setIsos(List isos) { + this.isos = isos; + } + + public List getIsos() { + return isos; + } + + public void setIsoMaxCount(Integer isoMaxCount) { + this.isoMaxCount = isoMaxCount; + } + + public Integer getIsoMaxCount() { + return isoMaxCount; + } + public void setIsoName(String isoName) { this.isoName = isoName; } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/VMScheduleResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VMScheduleResponse.java index 6800c25b023..34455b3a0bf 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/VMScheduleResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/VMScheduleResponse.java @@ -23,11 +23,11 @@ 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 org.apache.cloudstack.vm.schedule.VMSchedule; +import org.apache.cloudstack.schedule.ResourceSchedule; import java.util.Date; -@EntityReference(value = VMSchedule.class) +@EntityReference(value = ResourceSchedule.class) public class VMScheduleResponse extends BaseResponse { @SerializedName(ApiConstants.ID) @Param(description = "The ID of Instance schedule") @@ -51,7 +51,7 @@ public class VMScheduleResponse extends BaseResponse { @SerializedName(ApiConstants.ACTION) @Param(description = "Action") - private VMSchedule.Action action; + private ResourceSchedule.Action action; @SerializedName(ApiConstants.ENABLED) @Param(description = "VM schedule is enabled") @@ -69,6 +69,20 @@ public class VMScheduleResponse extends BaseResponse { @Param(description = "Date when the schedule was created") private Date created; + public VMScheduleResponse(ResourceScheduleResponse scheduleResponse) { + super("vmschedule"); + this.id = scheduleResponse.getId(); + this.vmId = scheduleResponse.getResourceId(); + this.description = scheduleResponse.getDescription(); + this.schedule = scheduleResponse.getSchedule(); + this.timeZone = scheduleResponse.getTimeZone(); + this.action = scheduleResponse.getAction(); + this.enabled = scheduleResponse.getEnabled(); + this.startDate = scheduleResponse.getStartDate(); + this.endDate = scheduleResponse.getEndDate(); + this.created = scheduleResponse.getCreated(); + } + public void setId(String id) { this.id = id; } @@ -89,7 +103,7 @@ public class VMScheduleResponse extends BaseResponse { this.timeZone = timeZone; } - public void setAction(VMSchedule.Action action) { + public void setAction(ResourceSchedule.Action action) { this.action = action; } @@ -105,5 +119,7 @@ public class VMScheduleResponse extends BaseResponse { this.endDate = endDate; } - public void setCreated(Date created) {this.created = created;} + public void setCreated(Date created) { + this.created = created; + } } diff --git a/api/src/main/java/org/apache/cloudstack/api/response/VolumeResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/VolumeResponse.java index 058ea50f991..031d9315cd0 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/VolumeResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/VolumeResponse.java @@ -309,6 +309,18 @@ public class VolumeResponse extends BaseResponseWithTagInformation implements Co @Param(description = "the format of the disk encryption if applicable", since = "4.19.1") private String encryptionFormat; + @SerializedName(ApiConstants.KMS_KEY) + @Param(description = "KMS key name of the volume", since = "4.23.0") + private String kmsKey; + + @SerializedName(ApiConstants.KMS_KEY_ID) + @Param(description = "KMS key id of the volume", since = "4.23.0") + private String kmsKeyId; + + @SerializedName(ApiConstants.KMS_KEY_VERSION) + @Param(description = "Version number of the KMS key used for disk encryption if applicable", since = "4.23.0") + private Integer kmsKeyVersion; + public String getPath() { return path; } @@ -868,7 +880,35 @@ public class VolumeResponse extends BaseResponseWithTagInformation implements Co this.volumeRepairResult = volumeRepairResult; } + public String getEncryptionFormat() { + return encryptionFormat; + } + public void setEncryptionFormat(String encryptionFormat) { this.encryptionFormat = encryptionFormat; } + + public String getKmsKey() { + return kmsKey; + } + + public void setKmsKey(String kmsKey) { + this.kmsKey = kmsKey; + } + + public String getKmsKeyId() { + return kmsKeyId; + } + + public void setKmsKeyId(String kmsKeyId) { + this.kmsKeyId = kmsKeyId; + } + + public Integer getKmsKeyVersion() { + return kmsKeyVersion; + } + + public void setKmsKeyVersion(Integer kmsKeyVersion) { + this.kmsKeyVersion = kmsKeyVersion; + } } diff --git a/api/src/main/java/org/apache/cloudstack/kms/HSMProfile.java b/api/src/main/java/org/apache/cloudstack/kms/HSMProfile.java new file mode 100644 index 00000000000..bbb8617ff24 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/kms/HSMProfile.java @@ -0,0 +1,46 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms; + +import org.apache.cloudstack.acl.ControlledEntity; +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; + +import java.util.Date; + +public interface HSMProfile extends Identity, InternalIdentity, ControlledEntity { + String getName(); + + String getProtocol(); + + long getAccountId(); + + long getDomainId(); + + Long getZoneId(); + + String getVendorName(); + + boolean isEnabled(); + + boolean getIsPublic(); + + Date getCreated(); + + Date getRemoved(); +} diff --git a/api/src/main/java/org/apache/cloudstack/kms/KMSKey.java b/api/src/main/java/org/apache/cloudstack/kms/KMSKey.java new file mode 100644 index 00000000000..c956a1ec66d --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/kms/KMSKey.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.cloudstack.kms; + +import org.apache.cloudstack.acl.ControlledEntity; +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; +import org.apache.cloudstack.framework.kms.KeyPurpose; + +import java.util.Date; + +/** + * KMS Key (Key Encryption Key) metadata. + * Represents a KEK that can be used to wrap/unwrap Data Encryption Keys (DEKs). + * KEKs are account-scoped and used for envelope encryption. + */ +public interface KMSKey extends Identity, InternalIdentity, ControlledEntity { + + String getName(); + + String getDescription(); + + /** + * Provider-specific KEK label/ID (internal identifier used by the KMS provider) + */ + String getKekLabel(); + + KeyPurpose getPurpose(); + + Long getZoneId(); + + String getAlgorithm(); + + Integer getKeyBits(); + + boolean isEnabled(); + + Date getCreated(); + + Date getRemoved(); + + Long getHsmProfileId(); +} diff --git a/api/src/main/java/org/apache/cloudstack/kms/KMSManager.java b/api/src/main/java/org/apache/cloudstack/kms/KMSManager.java new file mode 100644 index 00000000000..6e19010227d --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/kms/KMSManager.java @@ -0,0 +1,292 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms; + +import com.cloud.storage.Volume; +import com.cloud.user.Account; +import com.cloud.utils.component.Manager; +import org.apache.cloudstack.api.command.admin.kms.MigrateVolumesToKMSCmd; +import org.apache.cloudstack.api.command.user.kms.RotateKMSKeyCmd; +import org.apache.cloudstack.api.command.user.kms.CreateKMSKeyCmd; +import org.apache.cloudstack.api.command.user.kms.DeleteKMSKeyCmd; +import org.apache.cloudstack.api.command.user.kms.ListKMSKeysCmd; +import org.apache.cloudstack.api.command.user.kms.UpdateKMSKeyCmd; +import org.apache.cloudstack.api.command.user.kms.hsm.CreateHSMProfileCmd; +import org.apache.cloudstack.api.command.user.kms.hsm.DeleteHSMProfileCmd; +import org.apache.cloudstack.api.command.user.kms.hsm.ListHSMProfilesCmd; +import org.apache.cloudstack.api.command.user.kms.hsm.UpdateHSMProfileCmd; +import org.apache.cloudstack.api.response.HSMProfileResponse; +import org.apache.cloudstack.api.response.KMSKeyResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.config.Configurable; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.framework.kms.KMSProvider; +import org.apache.cloudstack.framework.kms.WrappedKey; + +import java.util.List; + +public interface KMSManager extends Manager, Configurable { + + ConfigKey KMSDekSizeBits = new ConfigKey<>( + "Advanced", + Integer.class, + "kms.dek.size.bits", + "256", + "The size of Data Encryption Keys (DEK) in bits (128, 192, or 256)", + true, + ConfigKey.Scope.Global + ); + + ConfigKey KMSRetryCount = new ConfigKey<>( + "Advanced", + Integer.class, + "kms.retry.count", + "3", + "Number of retry attempts for transient KMS failures", + true, + ConfigKey.Scope.Global + ); + + ConfigKey KMSRetryDelayMs = new ConfigKey<>( + "Advanced", + Integer.class, + "kms.retry.delay.ms", + "1000", + "Delay in milliseconds between KMS retry attempts (exponential backoff)", + true, + ConfigKey.Scope.Global + ); + + ConfigKey KMSOperationTimeoutSec = new ConfigKey<>( + "Advanced", + Integer.class, + "kms.operation.timeout.sec", + "30", + "Timeout in seconds for KMS cryptographic operations", + true, + ConfigKey.Scope.Global + ); + + ConfigKey KMSRewrapBatchSize = new ConfigKey<>( + "Advanced", + Integer.class, + "kms.rewrap.batch.size", + "50", + "Number of wrapped keys to rewrap per batch in background job", + true, + ConfigKey.Scope.Global + ); + + ConfigKey KMSRewrapIntervalMs = new ConfigKey<>( + "Advanced", + Long.class, + "kms.rewrap.interval.ms", + "300000", + "Interval in milliseconds between background rewrap job executions (default: 5 minutes)", + true, + ConfigKey.Scope.Global + ); + + ConfigKey KMSOperationPoolCoreSize = new ConfigKey<>( + "Advanced", + Integer.class, + "kms.operation.pool.core.size", + "2", + "Minimum number of threads kept alive for KMS cryptographic operations", + true, + ConfigKey.Scope.Global + ); + + ConfigKey KMSOperationPoolMaxSize = new ConfigKey<>( + "Advanced", + Integer.class, + "kms.operation.pool.max.size", + "100", + "Maximum number of concurrent threads for KMS cryptographic operations. " + + "Set this to match the concurrency limit of your HSM appliance or external KMS provider.", + true, + ConfigKey.Scope.Global + ); + + /** + * List all registered KMS providers + * + * @return list of available providers + */ + List listKMSProviders(); + + /** + * Get a specific KMS provider by name + * + * @param name provider name + * @return the provider, or null if not found + */ + KMSProvider getKMSProvider(String name); + + /** + * Check if caller has permission to use a KMS key + * + * @param callerAccountId the caller's account ID + * @param key the KMS key + * @return true if caller has permission + */ + boolean hasPermission(Long callerAccountId, KMSKey key); + + /** + * Validates that the KMS key can be used for volume encryption: key exists, not deleted, + * owner account matches the volume owner, key state is Enabled, and key purpose is VOLUME_ENCRYPTION. + * No-op if kmsKeyId is null. + * + * @param owner the account that will own the volume + * @param kmsKeyId the KMS key database ID + * @param zoneId the zone ID of the target resource (volume/VM) + * @throws InvalidParameterValueException if key not found, disabled, wrong purpose, zone mismatch, or account mismatch + */ + void checkKmsKeyForVolumeEncryption(Account owner, Long kmsKeyId, Long zoneId); + + /** + * Unwrap a DEK by wrapped key ID, trying multiple KEK versions if needed + * + * @param wrappedKeyId the wrapped key database ID + * @return plaintext DEK (caller must zeroize!) + * @throws KMSException if unwrap fails + */ + byte[] unwrapKey(Long wrappedKeyId) throws KMSException; + + /** + * Generate and wrap a DEK using a specific KMS key UUID + * + * @param kmsKey the KMS key + * @param callerAccountId the caller's account ID + * @return wrapped key ready for database storage + * @throws KMSException if operation fails + */ + WrappedKey generateVolumeKeyWithKek(KMSKey kmsKey, Long callerAccountId) throws KMSException; + + /** + * Create a KMS key and return the response object. + * Handles validation, account resolution, and permission checks. + * + * @param cmd the create command with all parameters + * @return KMSKeyResponse + * @throws KMSException if creation fails + */ + KMSKeyResponse createKMSKey(CreateKMSKeyCmd cmd) throws KMSException; + + /** + * List KMS keys and return the response object. + * Handles validation and permission checks. + * + * @param cmd the list command with all parameters + * @return ListResponse with KMSKeyResponse objects + */ + ListResponse listKMSKeys(ListKMSKeysCmd cmd); + + /** + * Update a KMS key and return the response object. + * Handles validation and permission checks. + * + * @param cmd the update command with all parameters + * @return KMSKeyResponse + * @throws KMSException if update fails + */ + KMSKeyResponse updateKMSKey(UpdateKMSKeyCmd cmd) throws KMSException; + + boolean deleteKMSWrappedKey(Volume vol) throws KMSException; + + /** + * Delete a KMS key and return the response object. + * Handles validation and permission checks. + * + * @param cmd the delete command with all parameters + * @return SuccessResponse + * @throws KMSException if deletion fails + */ + SuccessResponse deleteKMSKey(DeleteKMSKeyCmd cmd) throws KMSException; + + /** + * Rotate KEK by creating new version and scheduling gradual re-encryption + * + * @param cmd the rotate command with all parameters + * @return New KEK version UUID + * @throws KMSException if rotation fails + */ + String rotateKMSKey(RotateKMSKeyCmd cmd) throws KMSException; + + /** + * Migrate passphrase-based volumes to KMS encryption + * + * @param cmd the migrate command with all parameters + * @return Number of volumes successfully migrated + * @throws KMSException if migration fails + */ + int migrateVolumesToKMS(MigrateVolumesToKMSCmd cmd) throws KMSException; + + /** + * Delete all KMS keys owned by an account (called during account cleanup) + * + * @param accountId the account ID + * @return true if all keys were successfully deleted + */ + boolean deleteKMSKeysByAccountId(Long accountId); + + /** + * Add a new HSM profile + * + * @param cmd the add command + * @return the created HSM profile + * @throws KMSException if addition fails + */ + HSMProfile addHSMProfile(CreateHSMProfileCmd cmd) throws KMSException; + + /** + * List HSM profiles + * + * @param cmd the list command + * @return list of HSM profiles + */ + ListResponse listHSMProfiles(ListHSMProfilesCmd cmd); + + /** + * Delete an HSM profile + * + * @param cmd the delete command + * @return true if deletion was successful + * @throws KMSException if deletion fails + */ + boolean deleteHSMProfile(DeleteHSMProfileCmd cmd) throws KMSException; + + /** + * Update an HSM profile + * + * @param cmd the update command + * @return the updated HSM profile + * @throws KMSException if update fails + */ + HSMProfile updateHSMProfile(UpdateHSMProfileCmd cmd) throws KMSException; + + /** + * Create a response object for an HSM profile + * + * @param profile the HSM profile + * @return the response object + */ + HSMProfileResponse createHSMProfileResponse(HSMProfile profile); +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/schedule/VMSchedule.java b/api/src/main/java/org/apache/cloudstack/schedule/ResourceSchedule.java similarity index 66% rename from api/src/main/java/org/apache/cloudstack/vm/schedule/VMSchedule.java rename to api/src/main/java/org/apache/cloudstack/schedule/ResourceSchedule.java index b1e7df3c39c..4bd39211a27 100644 --- a/api/src/main/java/org/apache/cloudstack/vm/schedule/VMSchedule.java +++ b/api/src/main/java/org/apache/cloudstack/schedule/ResourceSchedule.java @@ -16,20 +16,31 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.cloudstack.vm.schedule; +package org.apache.cloudstack.schedule; +import org.apache.cloudstack.api.ApiCommandResourceType; import org.apache.cloudstack.api.Identity; import org.apache.cloudstack.api.InternalIdentity; import java.time.ZoneId; import java.util.Date; -public interface VMSchedule extends Identity, InternalIdentity { - enum Action { - START, STOP, REBOOT, FORCE_STOP, FORCE_REBOOT +public interface ResourceSchedule extends Identity, InternalIdentity { + + /** + * Common contract for scheduler actions. Each provider defines its own enum + * implementing this interface so the generic machinery can call {@link #name()} + * and {@link #getEventType()} without knowing the concrete type. + */ + interface Action { + String name(); + + String getEventType(); } - long getVmId(); + ApiCommandResourceType getResourceType(); + + long getResourceId(); String getDescription(); @@ -37,7 +48,7 @@ public interface VMSchedule extends Identity, InternalIdentity { String getTimeZone(); - Action getAction(); + String getActionName(); boolean getEnabled(); diff --git a/api/src/main/java/org/apache/cloudstack/schedule/ResourceScheduleManager.java b/api/src/main/java/org/apache/cloudstack/schedule/ResourceScheduleManager.java new file mode 100644 index 00000000000..fb650a6cf68 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/schedule/ResourceScheduleManager.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.schedule; + +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.ResourceScheduleResponse; + +import java.util.Date; +import java.util.List; +import java.util.Map; + +public interface ResourceScheduleManager { + + ResourceScheduleResponse createSchedule(ApiCommandResourceType resourceType, String resourceUuid, + String description, String schedule, String timeZone, String action, + Date startDate, Date endDate, boolean enabled, Map details); + + ResourceScheduleResponse updateSchedule(Long id, String description, String schedule, String timeZone, + Date startDate, Date endDate, Boolean enabled, Map details); + + ListResponse listSchedule(Long id, List ids, ApiCommandResourceType resourceType, + String resourceUuid, String action, Boolean enabled, + Long startIndex, Long pageSize); + + Long removeSchedule(ApiCommandResourceType resourceType, String resourceUuid, Long id, List ids); + + void removeSchedulesForResource(ApiCommandResourceType resourceType, long resourceId); +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/schedule/VMScheduledJob.java b/api/src/main/java/org/apache/cloudstack/schedule/ResourceScheduledJob.java similarity index 77% rename from api/src/main/java/org/apache/cloudstack/vm/schedule/VMScheduledJob.java rename to api/src/main/java/org/apache/cloudstack/schedule/ResourceScheduledJob.java index d7a18b76827..a7bf4d9aa05 100644 --- a/api/src/main/java/org/apache/cloudstack/vm/schedule/VMScheduledJob.java +++ b/api/src/main/java/org/apache/cloudstack/schedule/ResourceScheduledJob.java @@ -16,23 +16,26 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.cloudstack.vm.schedule; +package org.apache.cloudstack.schedule; +import org.apache.cloudstack.api.ApiCommandResourceType; import org.apache.cloudstack.api.Identity; import org.apache.cloudstack.api.InternalIdentity; import java.util.Date; -public interface VMScheduledJob extends Identity, InternalIdentity { - long getVmId(); +public interface ResourceScheduledJob extends Identity, InternalIdentity { + ApiCommandResourceType getResourceType(); - long getVmScheduleId(); + long getResourceId(); + + long getScheduleId(); Long getAsyncJobId(); void setAsyncJobId(long asyncJobId); - VMSchedule.Action getAction(); + String getActionName(); Date getScheduledTime(); } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/vm/schedule/dao/VMScheduledJobDao.java b/api/src/main/java/org/apache/cloudstack/schedule/autoscale/AutoScaleScheduleAction.java similarity index 59% rename from engine/schema/src/main/java/org/apache/cloudstack/vm/schedule/dao/VMScheduledJobDao.java rename to api/src/main/java/org/apache/cloudstack/schedule/autoscale/AutoScaleScheduleAction.java index 835ac696f26..c05dafe8879 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/vm/schedule/dao/VMScheduledJobDao.java +++ b/api/src/main/java/org/apache/cloudstack/schedule/autoscale/AutoScaleScheduleAction.java @@ -16,21 +16,16 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.cloudstack.vm.schedule.dao; +package org.apache.cloudstack.schedule.autoscale; -import com.cloud.utils.db.GenericDao; -import org.apache.cloudstack.vm.schedule.VMScheduledJobVO; +import com.cloud.event.EventTypes; +import org.apache.cloudstack.schedule.ResourceSchedule; -import java.util.Date; -import java.util.List; - -public interface VMScheduledJobDao extends GenericDao { - - List listJobsToStart(Date currentTimestamp); - - int expungeJobsForSchedules(List scheduleId, Date dateAfter); - - int expungeJobsBefore(Date currentTimestamp); - - VMScheduledJobVO findByScheduleAndTimestamp(long scheduleId, Date scheduledTimestamp); +public enum AutoScaleScheduleAction implements ResourceSchedule.Action { + UPDATE { + @Override + public String getEventType() { + return EventTypes.EVENT_AUTOSCALEVMGROUP_SCHEDULE_UPDATE; + } + } } diff --git a/api/src/main/java/org/apache/cloudstack/schedule/vm/VMScheduleAction.java b/api/src/main/java/org/apache/cloudstack/schedule/vm/VMScheduleAction.java new file mode 100644 index 00000000000..1648305803f --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/schedule/vm/VMScheduleAction.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.schedule.vm; + +import com.cloud.event.EventTypes; +import org.apache.cloudstack.schedule.ResourceSchedule; + +public enum VMScheduleAction implements ResourceSchedule.Action { + START { + @Override + public String getEventType() { return EventTypes.EVENT_VM_SCHEDULE_START; } + }, + STOP { + @Override + public String getEventType() { return EventTypes.EVENT_VM_SCHEDULE_STOP; } + }, + REBOOT { + @Override + public String getEventType() { return EventTypes.EVENT_VM_SCHEDULE_REBOOT; } + }, + FORCE_STOP { + @Override + public String getEventType() { return EventTypes.EVENT_VM_SCHEDULE_FORCE_STOP; } + }, + FORCE_REBOOT { + @Override + public String getEventType() { return EventTypes.EVENT_VM_SCHEDULE_FORCE_REBOOT; } + }; +} diff --git a/api/src/main/java/org/apache/cloudstack/vm/schedule/VMScheduleManager.java b/api/src/main/java/org/apache/cloudstack/vm/schedule/VMScheduleManager.java deleted file mode 100644 index 6aca05b58d5..00000000000 --- a/api/src/main/java/org/apache/cloudstack/vm/schedule/VMScheduleManager.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT 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.vm.schedule; - -import org.apache.cloudstack.api.command.user.vm.CreateVMScheduleCmd; -import org.apache.cloudstack.api.command.user.vm.DeleteVMScheduleCmd; -import org.apache.cloudstack.api.command.user.vm.ListVMScheduleCmd; -import org.apache.cloudstack.api.command.user.vm.UpdateVMScheduleCmd; -import org.apache.cloudstack.api.response.ListResponse; -import org.apache.cloudstack.api.response.VMScheduleResponse; - -public interface VMScheduleManager { - VMScheduleResponse createSchedule(CreateVMScheduleCmd createVMScheduleCmd); - - VMScheduleResponse createResponse(VMSchedule vmSchedule); - - ListResponse listSchedule(ListVMScheduleCmd listVMScheduleCmd); - - VMScheduleResponse updateSchedule(UpdateVMScheduleCmd updateVMScheduleCmd); - - long removeScheduleByVmId(long vmId, boolean expunge); - - Long removeSchedule(DeleteVMScheduleCmd deleteVMScheduleCmd); -} diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/vm/CreateVMScheduleCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/vm/CreateVMScheduleCmdTest.java index 99bc9d2b3fb..ae848b2712b 100644 --- a/api/src/test/java/org/apache/cloudstack/api/command/user/vm/CreateVMScheduleCmdTest.java +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/vm/CreateVMScheduleCmdTest.java @@ -21,8 +21,9 @@ package org.apache.cloudstack.api.command.user.vm; import com.cloud.exception.InvalidParameterValueException; import com.cloud.utils.db.EntityManager; import com.cloud.vm.VirtualMachine; +import org.apache.cloudstack.api.response.ResourceScheduleResponse; import org.apache.cloudstack.api.response.VMScheduleResponse; -import org.apache.cloudstack.vm.schedule.VMScheduleManager; +import org.apache.cloudstack.schedule.ResourceScheduleManager; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -34,9 +35,11 @@ import org.mockito.MockitoAnnotations; public class CreateVMScheduleCmdTest { @Mock - public VMScheduleManager vmScheduleManager; + public ResourceScheduleManager resourceScheduleManager; + @Mock public EntityManager entityManager; + @InjectMocks private CreateVMScheduleCmd createVMScheduleCmd = new CreateVMScheduleCmd(); @@ -59,10 +62,19 @@ public class CreateVMScheduleCmdTest { */ @Test public void testSuccessfulExecution() { - VMScheduleResponse vmScheduleResponse = Mockito.mock(VMScheduleResponse.class); - Mockito.when(vmScheduleManager.createSchedule(createVMScheduleCmd)).thenReturn(vmScheduleResponse); + ResourceScheduleResponse scheduleResponse = new ResourceScheduleResponse(); + scheduleResponse.setId("schedule-uuid"); + scheduleResponse.setResourceId("vm-uuid"); + Mockito.when(resourceScheduleManager.createSchedule( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.anyBoolean(), Mockito.any() + )).thenReturn(scheduleResponse); createVMScheduleCmd.execute(); - Assert.assertEquals(vmScheduleResponse, createVMScheduleCmd.getResponseObject()); + VMScheduleResponse response = (VMScheduleResponse) createVMScheduleCmd.getResponseObject(); + Assert.assertNotNull(response); + Assert.assertEquals("schedule-uuid", org.springframework.test.util.ReflectionTestUtils.getField(response, "id")); + Assert.assertEquals("vm-uuid", org.springframework.test.util.ReflectionTestUtils.getField(response, "vmId")); } /** @@ -72,7 +84,11 @@ public class CreateVMScheduleCmdTest { */ @Test(expected = InvalidParameterValueException.class) public void testInvalidParameterValueException() { - Mockito.when(vmScheduleManager.createSchedule(createVMScheduleCmd)).thenThrow(InvalidParameterValueException.class); + Mockito.when(resourceScheduleManager.createSchedule( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.anyBoolean(), Mockito.any() + )).thenThrow(new InvalidParameterValueException("Invalid schedule")); createVMScheduleCmd.execute(); } diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/vm/DeleteVMScheduleCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/vm/DeleteVMScheduleCmdTest.java index 1f764a84365..cec7503bd52 100644 --- a/api/src/test/java/org/apache/cloudstack/api/command/user/vm/DeleteVMScheduleCmdTest.java +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/vm/DeleteVMScheduleCmdTest.java @@ -23,8 +23,7 @@ import com.cloud.utils.db.EntityManager; import com.cloud.vm.VirtualMachine; import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.response.SuccessResponse; -import org.apache.cloudstack.vm.schedule.VMSchedule; -import org.apache.cloudstack.vm.schedule.VMScheduleManager; +import org.apache.cloudstack.schedule.ResourceScheduleManager; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -36,7 +35,8 @@ import org.mockito.MockitoAnnotations; public class DeleteVMScheduleCmdTest { @Mock - public VMScheduleManager vmScheduleManager; + public ResourceScheduleManager resourceScheduleManager; + @Mock public EntityManager entityManager; @@ -64,9 +64,11 @@ public class DeleteVMScheduleCmdTest { public void testSuccessfulExecution() { final SuccessResponse response = new SuccessResponse(); response.setResponseName(deleteVMScheduleCmd.getCommandName()); - response.setObjectName(VMSchedule.class.getSimpleName().toLowerCase()); + response.setObjectName("vmschedule"); - Mockito.when(vmScheduleManager.removeSchedule(deleteVMScheduleCmd)).thenReturn(1L); + Mockito.when(resourceScheduleManager.removeSchedule( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any() + )).thenReturn(1L); deleteVMScheduleCmd.execute(); SuccessResponse actualResponse = (SuccessResponse) deleteVMScheduleCmd.getResponseObject(); Assert.assertEquals(response.getResponseName(), actualResponse.getResponseName()); @@ -80,7 +82,9 @@ public class DeleteVMScheduleCmdTest { */ @Test(expected = ServerApiException.class) public void testServerApiException() { - Mockito.when(vmScheduleManager.removeSchedule(deleteVMScheduleCmd)).thenReturn(0L); + Mockito.when(resourceScheduleManager.removeSchedule( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any() + )).thenReturn(0L); deleteVMScheduleCmd.execute(); } @@ -91,7 +95,9 @@ public class DeleteVMScheduleCmdTest { */ @Test(expected = InvalidParameterValueException.class) public void testInvalidParameterValueException() { - Mockito.when(vmScheduleManager.removeSchedule(deleteVMScheduleCmd)).thenThrow(InvalidParameterValueException.class); + Mockito.when(resourceScheduleManager.removeSchedule( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any() + )).thenThrow(new InvalidParameterValueException("Invalid schedule")); deleteVMScheduleCmd.execute(); } diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/vm/ListVMScheduleCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/vm/ListVMScheduleCmdTest.java index f5434de3581..5449be17a57 100644 --- a/api/src/test/java/org/apache/cloudstack/api/command/user/vm/ListVMScheduleCmdTest.java +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/vm/ListVMScheduleCmdTest.java @@ -20,8 +20,9 @@ package org.apache.cloudstack.api.command.user.vm; import com.cloud.exception.InvalidParameterValueException; import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.ResourceScheduleResponse; import org.apache.cloudstack.api.response.VMScheduleResponse; -import org.apache.cloudstack.vm.schedule.VMScheduleManager; +import org.apache.cloudstack.schedule.ResourceScheduleManager; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -36,7 +37,8 @@ import java.util.Collections; public class ListVMScheduleCmdTest { @Mock - public VMScheduleManager vmScheduleManager; + public ResourceScheduleManager resourceScheduleManager; + @InjectMocks private ListVMScheduleCmd listVMScheduleCmd = new ListVMScheduleCmd(); private AutoCloseable closeable; @@ -58,12 +60,14 @@ public class ListVMScheduleCmdTest { */ @Test public void testEmptyResponse() { - ListResponse response = new ListResponse(); - response.setResponses(new ArrayList()); - Mockito.when(vmScheduleManager.listSchedule(listVMScheduleCmd)).thenReturn(response); + ListResponse response = new ListResponse(); + response.setResponses(new ArrayList()); + Mockito.when(resourceScheduleManager.listSchedule( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any() + )).thenReturn(response); listVMScheduleCmd.execute(); ListResponse actualResponseObject = (ListResponse) listVMScheduleCmd.getResponseObject(); - Assert.assertEquals(response, actualResponseObject); Assert.assertEquals(0L, actualResponseObject.getResponses().size()); } @@ -74,15 +78,22 @@ public class ListVMScheduleCmdTest { */ @Test public void testNonEmptyResponse() { - ListResponse listResponse = new ListResponse(); - VMScheduleResponse response = Mockito.mock(VMScheduleResponse.class); + ListResponse listResponse = new ListResponse(); + ResourceScheduleResponse response = new ResourceScheduleResponse(); + response.setId("schedule-uuid"); + response.setResourceId("vm-uuid"); listResponse.setResponses(Collections.singletonList(response)); - Mockito.when(vmScheduleManager.listSchedule(listVMScheduleCmd)).thenReturn(listResponse); + Mockito.when(resourceScheduleManager.listSchedule( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any() + )).thenReturn(listResponse); listVMScheduleCmd.execute(); ListResponse actualResponseObject = (ListResponse) listVMScheduleCmd.getResponseObject(); - Assert.assertEquals(listResponse, actualResponseObject); Assert.assertEquals(1L, actualResponseObject.getResponses().size()); - Assert.assertEquals(response, actualResponseObject.getResponses().get(0)); + Assert.assertEquals("schedule-uuid", + org.springframework.test.util.ReflectionTestUtils.getField(actualResponseObject.getResponses().get(0), "id")); + Assert.assertEquals("vm-uuid", + org.springframework.test.util.ReflectionTestUtils.getField(actualResponseObject.getResponses().get(0), "vmId")); } /** @@ -92,7 +103,10 @@ public class ListVMScheduleCmdTest { */ @Test(expected = InvalidParameterValueException.class) public void testInvalidParameterValueException() { - Mockito.when(vmScheduleManager.listSchedule(listVMScheduleCmd)).thenThrow(InvalidParameterValueException.class); + Mockito.when(resourceScheduleManager.listSchedule( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any() + )).thenThrow(InvalidParameterValueException.class); listVMScheduleCmd.execute(); ListResponse actualResponseObject = (ListResponse) listVMScheduleCmd.getResponseObject(); } diff --git a/api/src/test/java/org/apache/cloudstack/api/command/user/vm/UpdateVMScheduleCmdTest.java b/api/src/test/java/org/apache/cloudstack/api/command/user/vm/UpdateVMScheduleCmdTest.java index 2c6c485f25b..a24252f4a1d 100644 --- a/api/src/test/java/org/apache/cloudstack/api/command/user/vm/UpdateVMScheduleCmdTest.java +++ b/api/src/test/java/org/apache/cloudstack/api/command/user/vm/UpdateVMScheduleCmdTest.java @@ -21,9 +21,11 @@ package org.apache.cloudstack.api.command.user.vm; import com.cloud.exception.InvalidParameterValueException; import com.cloud.utils.db.EntityManager; import com.cloud.vm.VirtualMachine; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.response.ResourceScheduleResponse; import org.apache.cloudstack.api.response.VMScheduleResponse; -import org.apache.cloudstack.vm.schedule.VMSchedule; -import org.apache.cloudstack.vm.schedule.VMScheduleManager; +import org.apache.cloudstack.schedule.ResourceSchedule; +import org.apache.cloudstack.schedule.ResourceScheduleManager; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -35,9 +37,11 @@ import org.mockito.MockitoAnnotations; public class UpdateVMScheduleCmdTest { @Mock - public VMScheduleManager vmScheduleManager; + public ResourceScheduleManager resourceScheduleManager; + @Mock public EntityManager entityManager; + @InjectMocks private UpdateVMScheduleCmd updateVMScheduleCmd = new UpdateVMScheduleCmd(); @@ -60,10 +64,18 @@ public class UpdateVMScheduleCmdTest { */ @Test public void testSuccessfulExecution() { - VMScheduleResponse vmScheduleResponse = Mockito.mock(VMScheduleResponse.class); - Mockito.when(vmScheduleManager.updateSchedule(updateVMScheduleCmd)).thenReturn(vmScheduleResponse); + ResourceScheduleResponse scheduleResponse = new ResourceScheduleResponse(); + scheduleResponse.setId("schedule-uuid"); + scheduleResponse.setResourceId("vm-uuid"); + Mockito.when(resourceScheduleManager.updateSchedule( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any() + )).thenReturn(scheduleResponse); updateVMScheduleCmd.execute(); - Assert.assertEquals(vmScheduleResponse, updateVMScheduleCmd.getResponseObject()); + VMScheduleResponse response = (VMScheduleResponse) updateVMScheduleCmd.getResponseObject(); + Assert.assertNotNull(response); + Assert.assertEquals("schedule-uuid", org.springframework.test.util.ReflectionTestUtils.getField(response, "id")); + Assert.assertEquals("vm-uuid", org.springframework.test.util.ReflectionTestUtils.getField(response, "vmId")); } /** @@ -73,7 +85,10 @@ public class UpdateVMScheduleCmdTest { */ @Test(expected = InvalidParameterValueException.class) public void testInvalidParameterValueException() { - Mockito.when(vmScheduleManager.updateSchedule(updateVMScheduleCmd)).thenThrow(InvalidParameterValueException.class); + Mockito.when(resourceScheduleManager.updateSchedule( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any() + )).thenThrow(new InvalidParameterValueException("Invalid schedule")); updateVMScheduleCmd.execute(); } @@ -84,11 +99,12 @@ public class UpdateVMScheduleCmdTest { */ @Test public void testSuccessfulGetEntityOwnerId() { - VMSchedule vmSchedule = Mockito.mock(VMSchedule.class); + ResourceSchedule schedule = Mockito.mock(ResourceSchedule.class); VirtualMachine vm = Mockito.mock(VirtualMachine.class); - Mockito.when(entityManager.findById(VMSchedule.class, updateVMScheduleCmd.getId())).thenReturn(vmSchedule); - Mockito.when(entityManager.findById(VirtualMachine.class, vmSchedule.getVmId())).thenReturn(vm); + Mockito.when(schedule.getResourceType()).thenReturn(ApiCommandResourceType.VirtualMachine); + Mockito.when(entityManager.findById(ResourceSchedule.class, updateVMScheduleCmd.getId())).thenReturn(schedule); + Mockito.when(entityManager.findById(VirtualMachine.class, schedule.getResourceId())).thenReturn(vm); long ownerId = updateVMScheduleCmd.getEntityOwnerId(); Assert.assertEquals(vm.getAccountId(), ownerId); @@ -101,8 +117,7 @@ public class UpdateVMScheduleCmdTest { */ @Test(expected = InvalidParameterValueException.class) public void testFailureGetEntityOwnerId() { - VMSchedule vmSchedule = Mockito.mock(VMSchedule.class); - Mockito.when(entityManager.findById(VMSchedule.class, updateVMScheduleCmd.getId())).thenReturn(null); - long ownerId = updateVMScheduleCmd.getEntityOwnerId(); + Mockito.when(entityManager.findById(ResourceSchedule.class, updateVMScheduleCmd.getId())).thenReturn(null); + updateVMScheduleCmd.getEntityOwnerId(); } } diff --git a/api/src/test/java/org/apache/cloudstack/api/response/AttachedIsoResponseTest.java b/api/src/test/java/org/apache/cloudstack/api/response/AttachedIsoResponseTest.java new file mode 100644 index 00000000000..09d4eb598ab --- /dev/null +++ b/api/src/test/java/org/apache/cloudstack/api/response/AttachedIsoResponseTest.java @@ -0,0 +1,46 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public final class AttachedIsoResponseTest { + + @Test + public void testFullConstructorPopulatesAllFields() { + AttachedIsoResponse response = new AttachedIsoResponse("uuid-1", "alpine-iso", "Alpine boot", 3, true); + Assert.assertEquals("uuid-1", response.getId()); + Assert.assertEquals("alpine-iso", response.getName()); + Assert.assertEquals("Alpine boot", response.getDisplayText()); + Assert.assertEquals(Integer.valueOf(3), response.getDeviceSeq()); + Assert.assertTrue(response.getBootable()); + } + + @Test + public void testNoArgConstructorLeavesFieldsNull() { + AttachedIsoResponse response = new AttachedIsoResponse(); + Assert.assertNull(response.getId()); + Assert.assertNull(response.getName()); + Assert.assertNull(response.getDisplayText()); + Assert.assertNull(response.getDeviceSeq()); + Assert.assertNull(response.getBootable()); + } +} diff --git a/client/pom.xml b/client/pom.xml index b53563d61b3..90b839112ce 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -256,6 +256,16 @@ cloud-plugin-metrics ${project.version} + + org.apache.cloudstack + cloud-plugin-kms-database + ${project.version} + + + org.apache.cloudstack + cloud-plugin-kms-pkcs11 + ${project.version} + org.apache.cloudstack cloud-plugin-network-nvp diff --git a/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml b/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml index 8bca42f16bc..0a92e8a637b 100644 --- a/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml +++ b/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml @@ -367,5 +367,8 @@ + + + diff --git a/core/src/main/resources/META-INF/cloudstack/kms/module.properties b/core/src/main/resources/META-INF/cloudstack/kms/module.properties new file mode 100644 index 00000000000..98e38d7cd8f --- /dev/null +++ b/core/src/main/resources/META-INF/cloudstack/kms/module.properties @@ -0,0 +1,21 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +name=kms +parent=core diff --git a/core/src/main/resources/META-INF/cloudstack/kms/spring-core-lifecycle-kms-context-inheritable.xml b/core/src/main/resources/META-INF/cloudstack/kms/spring-core-lifecycle-kms-context-inheritable.xml new file mode 100644 index 00000000000..9226eef8fc1 --- /dev/null +++ b/core/src/main/resources/META-INF/cloudstack/kms/spring-core-lifecycle-kms-context-inheritable.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/engine/api/src/main/java/org/apache/cloudstack/engine/orchestration/service/VolumeOrchestrationService.java b/engine/api/src/main/java/org/apache/cloudstack/engine/orchestration/service/VolumeOrchestrationService.java index a55219511cb..56624df1346 100644 --- a/engine/api/src/main/java/org/apache/cloudstack/engine/orchestration/service/VolumeOrchestrationService.java +++ b/engine/api/src/main/java/org/apache/cloudstack/engine/orchestration/service/VolumeOrchestrationService.java @@ -120,7 +120,7 @@ public interface VolumeOrchestrationService { void destroyVolume(Volume volume); DiskProfile allocateRawVolume(Type type, String name, DiskOffering offering, Long size, Long minIops, Long maxIops, VirtualMachine vm, VirtualMachineTemplate template, - Account owner, Long deviceId, boolean incrementResourceCount); + Account owner, Long deviceId, Long kmsKeyId, boolean incrementResourceCount); VolumeInfo createVolumeOnPrimaryStorage(VirtualMachine vm, VolumeInfo volume, HypervisorType rootDiskHyperType, StoragePool storagePool) throws NoTransitionException; @@ -150,7 +150,7 @@ public interface VolumeOrchestrationService { * Allocate a volume or multiple volumes in case of template is registered with the 'deploy-as-is' option, allowing multiple disks */ List allocateTemplatedVolumes(Type type, String name, DiskOffering offering, Long rootDisksize, Long minIops, Long maxIops, VirtualMachineTemplate template, VirtualMachine vm, - Account owner, Volume volume, Snapshot snapshot); + Account owner, Long kmsKeyId, Volume volume, Snapshot snapshot); String getVmNameFromVolumeId(long volumeId); diff --git a/engine/api/src/main/java/org/apache/cloudstack/engine/service/api/OrchestrationService.java b/engine/api/src/main/java/org/apache/cloudstack/engine/service/api/OrchestrationService.java index 6be71b3cb25..887aeaef073 100644 --- a/engine/api/src/main/java/org/apache/cloudstack/engine/service/api/OrchestrationService.java +++ b/engine/api/src/main/java/org/apache/cloudstack/engine/service/api/OrchestrationService.java @@ -71,7 +71,7 @@ public interface OrchestrationService { @QueryParam("network-nic-map") Map> networkNicMap, @QueryParam("deploymentplan") DeploymentPlan plan, @QueryParam("root-disk-size") Long rootDiskSize, @QueryParam("extra-dhcp-option-map") Map> extraDhcpOptionMap, @QueryParam("datadisktemplate-diskoffering-map") Map datadiskTemplateToDiskOfferingMap, @QueryParam("disk-offering-id") Long diskOfferingId, - @QueryParam("root-disk-offering-id") Long rootDiskOfferingId, List dataDiskInfoList, Volume volume, Snapshot snapshot) throws InsufficientCapacityException; + @QueryParam("root-disk-offering-id") Long rootDiskOfferingId, @QueryParam("root-disk-kms-key-id") Long rootDiskKmsKeyId, List dataDiskInfoList, Volume volume, Snapshot snapshot) throws InsufficientCapacityException; @POST VirtualMachineEntity createVirtualMachineFromScratch(@QueryParam("id") String id, @QueryParam("owner") String owner, @QueryParam("iso-id") String isoId, @@ -80,7 +80,7 @@ public interface OrchestrationService { @QueryParam("compute-tags") List computeTags, @QueryParam("root-disk-tags") List rootDiskTags, @QueryParam("network-nic-map") Map> networkNicMap, @QueryParam("deploymentplan") DeploymentPlan plan, @QueryParam("extra-dhcp-option-map") Map> extraDhcpOptionMap, @QueryParam("disk-offering-id") Long diskOfferingId, - @QueryParam("data-disks-offering-info") List dataDiskInfoList, Volume volume, Snapshot snapshot) throws InsufficientCapacityException; + @QueryParam("root-disk-kms-key-id") Long rootDiskKmsKeyId, @QueryParam("data-disks-offering-info") List dataDiskInfoList, Volume volume, Snapshot snapshot) throws InsufficientCapacityException; @POST NetworkEntity createNetwork(String id, String name, String domainName, String cidr, String gateway); diff --git a/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/TemplateService.java b/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/TemplateService.java index 269eb4f1c21..2f8d57171bc 100644 --- a/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/TemplateService.java +++ b/engine/api/src/main/java/org/apache/cloudstack/engine/subsystem/api/storage/TemplateService.java @@ -67,6 +67,12 @@ public interface TemplateService { void handleTemplateSync(DataStore store); + void enforceSecStorageCopyLimit(long templateId, long zoneId); + + boolean canCopyTemplateToImageStore(long templateId, long zoneId); + + void replicateTemplateUpToCap(long templateId, long zoneId); + void downloadBootstrapSysTemplate(DataStore store); void addSystemVMTemplatesToSecondary(DataStore store); diff --git a/engine/components-api/src/main/java/com/cloud/template/TemplateManager.java b/engine/components-api/src/main/java/com/cloud/template/TemplateManager.java index f1891c774ed..8c11fe6c93a 100644 --- a/engine/components-api/src/main/java/com/cloud/template/TemplateManager.java +++ b/engine/components-api/src/main/java/com/cloud/template/TemplateManager.java @@ -45,6 +45,8 @@ import com.cloud.vm.VirtualMachineProfile; public interface TemplateManager { static final String AllowPublicUserTemplatesCK = "allow.public.user.templates"; static final String TemplatePreloaderPoolSizeCK = "template.preloader.pool.size"; + static final String PublicTemplateSecStorageCopyCK = "secstorage.public.template.copy.max"; + static final String PrivateTemplateSecStorageCopyCK = "secstorage.private.template.copy.max"; static final ConfigKey AllowPublicUserTemplates = new ConfigKey("Advanced", Boolean.class, AllowPublicUserTemplatesCK, "true", "If false, users will not be able to create public Templates.", true, ConfigKey.Scope.Account); @@ -64,6 +66,33 @@ public interface TemplateManager { true, ConfigKey.Scope.Global); + ConfigKey PublicTemplateSecStorageCopy = new ConfigKey("Advanced", Integer.class, + PublicTemplateSecStorageCopyCK, "0", + "Maximum number of secondary storage pools to which a public template is copied. " + + "0 means copy to all secondary storage pools (default behavior).", + true, ConfigKey.Scope.Zone); + + ConfigKey PrivateTemplateSecStorageCopy = new ConfigKey("Advanced", Integer.class, + PrivateTemplateSecStorageCopyCK, "1", + "Maximum number of secondary storage pools to which a private template is copied. " + + "Default is 1 to preserve existing behavior.", + true, ConfigKey.Scope.Zone); + + ConfigKey VmIsoMaxCount = new ConfigKey("Advanced", + Integer.class, + "vm.iso.max.count", "1", + "Maximum number of ISOs that may be attached to a VM.", + true, + ConfigKey.Scope.Cluster); + + // KVM/libvirt maps deviceSeq=3 to hdc (hda/hdb are taken by the root volume on i440fx/IDE). + // user_vm.iso_id has always pointed at this slot; additional cdroms live in vm_iso_map. + int CDROM_PRIMARY_DEVICE_SEQ = 3; + + // Fallback per-VM cdrom cap when the placement host hasn't advertised host.cdrom.max.count + // (older agent, never-deployed VM, etc.). + int DEFAULT_CDROM_MAX_PER_VM = 1; + static final String VMWARE_TOOLS_ISO = "vmware-tools.iso"; static final String XS_TOOLS_ISO = "xs-tools.iso"; @@ -138,6 +167,12 @@ public interface TemplateManager { List getImageStoreByTemplate(long templateId, Long zoneId); + /** + * Max number of secondary storage copies for the template in this zone; {@code 0} means no limit. + * SYSTEM/ROUTING/BUILTIN templates are always exempt (returns {@code 0}). + */ + int getSecStorageCopyLimit(VMTemplateVO template, long zoneId); + TemplateInfo prepareIso(long isoId, long dcId, Long hostId, Long poolId); 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 c27240823b1..a232bdd05da 100755 --- a/engine/orchestration/src/main/java/com/cloud/vm/VirtualMachineManagerImpl.java +++ b/engine/orchestration/src/main/java/com/cloud/vm/VirtualMachineManagerImpl.java @@ -601,7 +601,7 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac Long deviceId = dataDiskDeviceIds.get(index++); String volumeName = deviceId == null ? "DATA-" + persistedVm.getId() : "DATA-" + persistedVm.getId() + "-" + String.valueOf(deviceId); volumeMgr.allocateRawVolume(Type.DATADISK, volumeName, dataDiskOfferingInfo.getDiskOffering(), dataDiskOfferingInfo.getSize(), - dataDiskOfferingInfo.getMinIops(), dataDiskOfferingInfo.getMaxIops(), persistedVm, template, owner, deviceId, true); + dataDiskOfferingInfo.getMinIops(), dataDiskOfferingInfo.getMaxIops(), persistedVm, template, owner, deviceId, dataDiskOfferingInfo.getKmsKeyId(), true); } } if (datadiskTemplateToDiskOfferingMap != null && !datadiskTemplateToDiskOfferingMap.isEmpty()) { @@ -611,7 +611,7 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac long diskOfferingSize = diskOffering.getDiskSize() / (1024 * 1024 * 1024); VMTemplateVO dataDiskTemplate = _templateDao.findById(dataDiskTemplateToDiskOfferingMap.getKey()); volumeMgr.allocateRawVolume(Type.DATADISK, "DATA-" + persistedVm.getId() + "-" + String.valueOf( diskNumber), diskOffering, diskOfferingSize, null, null, - persistedVm, dataDiskTemplate, owner, diskNumber, true); + persistedVm, dataDiskTemplate, owner, diskNumber, null, true); diskNumber++; } } @@ -641,12 +641,12 @@ public class VirtualMachineManagerImpl extends ManagerBase implements VirtualMac String rootVolumeName = String.format("ROOT-%s", vm.getId()); if (template.getFormat() == ImageFormat.ISO) { volumeMgr.allocateRawVolume(Type.ROOT, rootVolumeName, rootDiskOfferingInfo.getDiskOffering(), rootDiskOfferingInfo.getSize(), - rootDiskOfferingInfo.getMinIops(), rootDiskOfferingInfo.getMaxIops(), vm, template, owner, null, true); + rootDiskOfferingInfo.getMinIops(), rootDiskOfferingInfo.getMaxIops(), vm, template, owner, null, rootDiskOfferingInfo.getKmsKeyId(), true); } else if (Arrays.asList(ImageFormat.BAREMETAL, ImageFormat.EXTERNAL).contains(template.getFormat())) { logger.debug("{} has format [{}]. Skipping ROOT volume [{}] allocation.", template, template.getFormat(), rootVolumeName); } else { volumeMgr.allocateTemplatedVolumes(Type.ROOT, rootVolumeName, rootDiskOfferingInfo.getDiskOffering(), rootDiskSizeFinal, - rootDiskOfferingInfo.getMinIops(), rootDiskOfferingInfo.getMaxIops(), template, vm, owner, volume, snapshot); + rootDiskOfferingInfo.getMinIops(), rootDiskOfferingInfo.getMaxIops(), template, vm, owner, rootDiskOfferingInfo.getKmsKeyId(), volume, snapshot); } } finally { // Remove volumeContext and pop vmContext back diff --git a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/CloudOrchestrator.java b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/CloudOrchestrator.java index 964265cb873..9f6d02cc123 100644 --- a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/CloudOrchestrator.java +++ b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/CloudOrchestrator.java @@ -165,7 +165,7 @@ public class CloudOrchestrator implements OrchestrationService { public VirtualMachineEntity createVirtualMachine(String id, String owner, String templateId, String hostName, String displayName, String hypervisor, int cpu, int speed, long memory, Long diskSize, List computeTags, List rootDiskTags, Map> networkNicMap, DeploymentPlan plan, Long rootDiskSize, Map> extraDhcpOptionMap, Map dataDiskTemplateToDiskOfferingMap, Long dataDiskOfferingId, Long rootDiskOfferingId, - List dataDiskInfoList, Volume volume, Snapshot snapshot) throws InsufficientCapacityException { + Long rootDiskKmsKeyId, List dataDiskInfoList, Volume volume, Snapshot snapshot) throws InsufficientCapacityException { // VirtualMachineEntityImpl vmEntity = new VirtualMachineEntityImpl(id, owner, hostName, displayName, cpu, speed, memory, computeTags, rootDiskTags, networks, // vmEntityManager); @@ -199,6 +199,7 @@ public class CloudOrchestrator implements OrchestrationService { } rootDiskOfferingInfo.setDiskOffering(rootDiskOffering); rootDiskOfferingInfo.setSize(rootDiskSize); + rootDiskOfferingInfo.setKmsKeyId(rootDiskKmsKeyId); if (rootDiskOffering.isCustomizedIops() != null && rootDiskOffering.isCustomizedIops()) { Map userVmDetails = _vmInstanceDetailsDao.listDetailsKeyPairs(vm.getId()); @@ -281,7 +282,7 @@ public class CloudOrchestrator implements OrchestrationService { @Override public VirtualMachineEntity createVirtualMachineFromScratch(String id, String owner, String isoId, String hostName, String displayName, String hypervisor, String os, int cpu, int speed, long memory, Long diskSize, List computeTags, List rootDiskTags, Map> networkNicMap, DeploymentPlan plan, - Map> extraDhcpOptionMap, Long diskOfferingId, List dataDiskInfoList, Volume volume, Snapshot snapshot) + Map> extraDhcpOptionMap, Long diskOfferingId, Long rootDiskKmsKeyId, List dataDiskInfoList, Volume volume, Snapshot snapshot) throws InsufficientCapacityException { // VirtualMachineEntityImpl vmEntity = new VirtualMachineEntityImpl(id, owner, hostName, displayName, cpu, speed, memory, computeTags, rootDiskTags, networks, vmEntityManager); @@ -317,6 +318,7 @@ public class CloudOrchestrator implements OrchestrationService { rootDiskOfferingInfo.setDiskOffering(diskOffering); rootDiskOfferingInfo.setSize(size); + rootDiskOfferingInfo.setKmsKeyId(rootDiskKmsKeyId); if (diskOffering.isCustomizedIops() != null && diskOffering.isCustomizedIops()) { Map userVmDetails = _vmInstanceDetailsDao.listDetailsKeyPairs(vm.getId()); diff --git a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java index c4a6a9dd684..b4c104da07e 100644 --- a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java +++ b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestrator.java @@ -89,6 +89,13 @@ import org.apache.cloudstack.resourcelimit.Reserver; import org.apache.cloudstack.secret.PassphraseVO; import org.apache.cloudstack.secret.dao.PassphraseDao; import org.apache.cloudstack.snapshot.SnapshotHelper; +import org.apache.cloudstack.kms.KMSManager; +import org.apache.cloudstack.kms.KMSKeyVO; +import org.apache.cloudstack.kms.KMSWrappedKeyVO; +import org.apache.cloudstack.kms.dao.KMSKeyDao; +import org.apache.cloudstack.kms.dao.KMSWrappedKeyDao; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.framework.kms.WrappedKey; import org.apache.cloudstack.storage.command.CommandResult; import org.apache.cloudstack.storage.datastore.db.ImageStoreDao; import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; @@ -287,6 +294,12 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati @Inject private DataStoreProviderManager dataStoreProviderMgr; + @Inject + private KMSManager kmsManager; + @Inject + private KMSKeyDao kmsKeyDao; + @Inject + private KMSWrappedKeyDao kmsWrappedKeyDao; private final StateMachine2 _volStateMachine; protected List _storagePoolAllocators; @@ -323,7 +336,7 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati // Find a destination storage pool with the specified criteria DiskOffering diskOffering = _entityMgr.findById(DiskOffering.class, volumeInfo.getDiskOfferingId()); DiskProfile dskCh = new DiskProfile(volumeInfo.getId(), volumeInfo.getVolumeType(), volumeInfo.getName(), diskOffering.getId(), diskOffering.getDiskSize(), diskOffering.getTagsArray(), - diskOffering.isUseLocalStorage(), diskOffering.isRecreatable(), null, (diskOffering.getEncrypt() || volumeInfo.getPassphraseId() != null)); + diskOffering.isUseLocalStorage(), diskOffering.isRecreatable(), null, (diskOffering.getEncrypt() || volumeInfo.getPassphraseId() != null || volumeInfo.getKmsKeyId() != null)); dskCh.setHyperType(dataDiskHyperType); storageMgr.setDiskProfileThrottling(dskCh, null, diskOffering); @@ -358,9 +371,13 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati newVol.setInstanceId(oldVol.getInstanceId()); newVol.setRecreatable(oldVol.isRecreatable()); newVol.setFormat(oldVol.getFormat()); - if ((diskOffering == null || diskOffering.getEncrypt()) && oldVol.getPassphraseId() != null) { - PassphraseVO passphrase = passphraseDao.persist(new PassphraseVO(true)); - newVol.setPassphraseId(passphrase.getId()); + if ((diskOffering == null || diskOffering.getEncrypt())) { + if (oldVol.getKmsKeyId() != null) { + newVol.setKmsKeyId(oldVol.getKmsKeyId()); + } else if (oldVol.getPassphraseId() != null) { + PassphraseVO passphrase = passphraseDao.persist(new PassphraseVO(true)); + newVol.setPassphraseId(passphrase.getId()); + } } return _volsDao.persist(newVol); @@ -515,7 +532,9 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati DiskOffering diskOffering = _entityMgr.findById(DiskOffering.class, volume.getDiskOfferingId()); if (diskOffering.getEncrypt()) { VolumeVO vol = (VolumeVO) volume; - volume = setPassphraseForVolumeEncryption(vol); + // Retrieve KMS key from volume's kmsKeyId if provided + KMSKeyVO kmsKey = getKmsKeyFromVolume(vol); + volume = setPassphraseForVolumeEncryption(vol, kmsKey, volume.getAccountId()); } DataCenter dc = _entityMgr.findById(DataCenter.class, volume.getDataCenterId()); DiskProfile dskCh = new DiskProfile(volume, diskOffering, snapshot.getHypervisorType()); @@ -652,7 +671,7 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati } protected DiskProfile createDiskCharacteristics(VolumeInfo volumeInfo, VirtualMachineTemplate template, DataCenter dc, DiskOffering diskOffering) { - boolean requiresEncryption = diskOffering.getEncrypt() || volumeInfo.getPassphraseId() != null; + boolean requiresEncryption = diskOffering.getEncrypt() || volumeInfo.getPassphraseId() != null || volumeInfo.getKmsKeyId() != null; if (volumeInfo.getVolumeType() == Type.ROOT && Storage.ImageFormat.ISO != template.getFormat()) { String templateToString = getReflectOnlySelectedFields(template); String zoneToString = getReflectOnlySelectedFields(dc); @@ -732,7 +751,9 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati if (diskOffering.getEncrypt()) { VolumeVO vol = _volsDao.findById(volumeInfo.getId()); - setPassphraseForVolumeEncryption(vol); + // Retrieve KMS key from volume's kmsKeyId if provided + KMSKeyVO kmsKey = getKmsKeyFromVolume(vol); + setPassphraseForVolumeEncryption(vol, kmsKey, vol.getAccountId()); volumeInfo = volFactory.getVolume(volumeInfo.getId()); } } @@ -996,8 +1017,10 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati @ActionEvent(eventType = EventTypes.EVENT_VOLUME_CREATE, eventDescription = "creating volume", create = true) @Override - public DiskProfile allocateRawVolume(Type type, String name, DiskOffering offering, Long size, Long minIops, Long maxIops, VirtualMachine vm, VirtualMachineTemplate template, Account owner, - Long deviceId, boolean incrementResourceCount) { + public DiskProfile allocateRawVolume( + Type type, String name, DiskOffering offering, Long size, Long minIops, Long maxIops, VirtualMachine vm, + VirtualMachineTemplate template, Account owner, Long deviceId, Long kmsKeyId, boolean incrementResourceCount + ) { if (size == null) { size = offering.getDiskSize(); } else { @@ -1030,6 +1053,11 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati vol.setDisplayVolume(userVm.isDisplayVm()); } + // Set KMS key ID if provided + if (kmsKeyId != null) { + vol.setKmsKeyId(kmsKeyId); + } + vol.setFormat(getSupportedImageFormatForCluster(vm.getHypervisorType())); vol = _volsDao.persist(vol); @@ -1049,7 +1077,7 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati } private DiskProfile allocateTemplatedVolume(Type type, String name, DiskOffering offering, Long rootDisksize, Long minIops, Long maxIops, VirtualMachineTemplate template, VirtualMachine vm, - Account owner, long deviceId, String configurationId, Volume volume, Snapshot snapshot) { + Account owner, long deviceId, String configurationId, Long kmsKeyId, Volume volume, Snapshot snapshot) { assert (template.getFormat() != ImageFormat.ISO) : "ISO is not a template."; if (volume != null) { @@ -1099,6 +1127,11 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati vol.setDisplayVolume(userVm.isDisplayVm()); } + // Set KMS key ID if provided + if (kmsKeyId != null) { + vol.setKmsKeyId(kmsKeyId); + } + vol = _volsDao.persist(vol); saveVolumeDetails(offering.getId(), vol.getId()); @@ -1188,7 +1221,7 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati @ActionEvent(eventType = EventTypes.EVENT_VOLUME_CREATE, eventDescription = "creating ROOT volume", create = true) @Override public List allocateTemplatedVolumes(Type type, String name, DiskOffering offering, Long rootDisksize, Long minIops, Long maxIops, VirtualMachineTemplate template, VirtualMachine vm, - Account owner, Volume volume, Snapshot snapshot) { + Account owner, Long kmsKeyId, Volume volume, Snapshot snapshot) { String templateToString = getReflectOnlySelectedFields(template); int volumesNumber = 1; @@ -1235,7 +1268,7 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati } logger.info("Adding disk object [{}] to VM [{}]", volumeName, vm); DiskProfile diskProfile = allocateTemplatedVolume(type, volumeName, offering, volumeSize, minIops, maxIops, - template, vm, owner, deviceId, configurationId, volume, snapshot); + template, vm, owner, deviceId, configurationId, kmsKeyId, volume, snapshot); profiles.add(diskProfile); } @@ -1945,7 +1978,9 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati if (vol.getState() == Volume.State.Allocated || vol.getState() == Volume.State.Creating) { DiskOffering diskOffering = _entityMgr.findById(DiskOffering.class, vol.getDiskOfferingId()); if (diskOffering.getEncrypt()) { - vol = setPassphraseForVolumeEncryption(vol); + // Retrieve KMS key from volume's kmsKeyId if provided + KMSKeyVO kmsKey = getKmsKeyFromVolume(vol); + vol = setPassphraseForVolumeEncryption(vol, kmsKey, vol.getAccountId()); } newVol = vol; } else { @@ -2081,16 +2116,72 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati return new Pair<>(newVol, destPool); } - private VolumeVO setPassphraseForVolumeEncryption(VolumeVO volume) { - if (volume.getPassphraseId() != null) { + /** + * Helper method to retrieve KMS key from volume's kmsKeyId + */ + private KMSKeyVO getKmsKeyFromVolume(VolumeVO volume) { + if (volume.getKmsKeyId() == null) { + return null; + } + return kmsKeyDao.findById(volume.getKmsKeyId()); + } + + private VolumeVO setKmsKeyForVolumeEncryption(VolumeVO volume, KMSKeyVO kmsKey, Long callerAccountId) { + // Determine caller account ID if not provided + if (callerAccountId == null) { + callerAccountId = volume.getAccountId(); + } + + // Validate permission + if (!kmsManager.hasPermission(callerAccountId, kmsKey)) { + throw new CloudRuntimeException("No permission to use KMS key: " + kmsKey); + } + + try { + logger.debug("Generating and wrapping DEK for volume {} using KMS key {}", volume.getName(), kmsKey.getUuid()); + long startTime = System.currentTimeMillis(); + + // Generate and wrap DEK using active KEK version + WrappedKey wrappedKey = kmsManager.generateVolumeKeyWithKek(kmsKey, callerAccountId); + + // The wrapped key is already persisted by generateVolumeKeyWithKek, get its ID + KMSWrappedKeyVO wrappedKeyVO = kmsWrappedKeyDao.findByUuid(wrappedKey.getUuid()); + if (wrappedKeyVO == null) { + throw new CloudRuntimeException("Failed to find persisted wrapped key: " + wrappedKey.getUuid()); + } + + // Set the wrapped key ID on the volume + volume.setKmsWrappedKeyId(wrappedKeyVO.getId()); + + long finishTime = System.currentTimeMillis(); + logger.debug("Generating and persisting wrapped key took {} ms for volume: {}", + (finishTime - startTime), volume.getName()); + + return _volsDao.persist(volume); + + } catch (KMSException e) { + throw new CloudRuntimeException("KMS failure while setting up volume encryption: " + e.getMessage(), e); + } + } + private VolumeVO setPassphraseForVolumeEncryption(VolumeVO volume, KMSKeyVO kmsKey, Long callerAccountId) { + // If volume already has encryption set up, return it + if (volume.getKmsWrappedKeyId() != null || volume.getPassphraseId() != null) { return volume; } - logger.debug("Creating passphrase for the volume: " + volume.getName()); + if (kmsKey != null) { + return setKmsKeyForVolumeEncryption(volume, kmsKey, callerAccountId); + } + // Legacy: passphrase-based encryption (fallback when KMS not enabled or KMS key not specified) + return setPassphraseForVolumeEncryption(volume); + } + + private VolumeVO setPassphraseForVolumeEncryption(VolumeVO volume) { + logger.debug("Creating passphrase for the volume: {}", volume.getName()); long startTime = System.currentTimeMillis(); PassphraseVO passphrase = passphraseDao.persist(new PassphraseVO(true)); volume.setPassphraseId(passphrase.getId()); long finishTime = System.currentTimeMillis(); - logger.debug("Creating and persisting passphrase took: " + (finishTime - startTime) + " ms for the volume: " + volume.toString()); + logger.debug("Creating and persisting passphrase took: {} ms for the volume: {}", finishTime - startTime, volume.toString()); return _volsDao.persist(volume); } @@ -2119,7 +2210,7 @@ public class VolumeOrchestrator extends ManagerBase implements VolumeOrchestrati PrimaryDataStoreDriver driver = (PrimaryDataStoreDriver) store.getDriver(); long newSize = driver.getVolumeSizeRequiredOnPool(vol.getSize(), template == null ? null : template.getSize(), - vol.getPassphraseId() != null); + vol.getPassphraseId() != null || vol.getKmsKeyId() != null); if (newSize == vol.getSize()) { return; diff --git a/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestratorTest.java b/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestratorTest.java index a184b4540a9..d61caf16ae5 100644 --- a/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestratorTest.java +++ b/engine/orchestration/src/test/java/org/apache/cloudstack/engine/orchestration/VolumeOrchestratorTest.java @@ -280,6 +280,7 @@ public class VolumeOrchestratorTest { Mockito.when(oldVol.isRecreatable()).thenReturn(false); Mockito.when(oldVol.getFormat()).thenReturn(Storage.ImageFormat.QCOW2); Mockito.when(oldVol.getPassphraseId()).thenReturn(null); // no encryption + Mockito.when(oldVol.getKmsKeyId()).thenReturn(null); // no encryption VolumeVO persistedVol = Mockito.mock(VolumeVO.class); Mockito.when(volumeDao.persist(Mockito.any(VolumeVO.class))).thenReturn(persistedVol); @@ -308,6 +309,7 @@ public class VolumeOrchestratorTest { Mockito.when(oldVol.getInstanceId()).thenReturn(7L); Mockito.when(oldVol.isRecreatable()).thenReturn(true); Mockito.when(oldVol.getFormat()).thenReturn(Storage.ImageFormat.RAW); + Mockito.when(oldVol.getKmsKeyId()).thenReturn(null); Mockito.when(oldVol.getPassphraseId()).thenReturn(42L); PassphraseVO passphrase = Mockito.mock(PassphraseVO.class); @@ -341,9 +343,6 @@ public class VolumeOrchestratorTest { VolumeVO persistedVol = Mockito.mock(VolumeVO.class); Mockito.when(volumeDao.persist(Mockito.any())).thenReturn(persistedVol); - PassphraseVO mockPassPhrase = Mockito.mock(PassphraseVO.class); - Mockito.when(passphraseDao.persist(Mockito.any())).thenReturn(mockPassPhrase); - VolumeVO result = volumeOrchestrator.allocateDuplicateVolumeVO(oldVol, null, 222L); assertNotNull(result); } diff --git a/engine/schema/pom.xml b/engine/schema/pom.xml index 654cd14a25d..664d4909e67 100644 --- a/engine/schema/pom.xml +++ b/engine/schema/pom.xml @@ -48,6 +48,11 @@ cloud-framework-db ${project.version} + + org.apache.cloudstack + cloud-framework-kms + ${project.version} + com.mysql mysql-connector-j diff --git a/engine/schema/src/main/java/com/cloud/alert/dao/AlertDaoImpl.java b/engine/schema/src/main/java/com/cloud/alert/dao/AlertDaoImpl.java index 94d01f472ba..97b7c54f084 100644 --- a/engine/schema/src/main/java/com/cloud/alert/dao/AlertDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/alert/dao/AlertDaoImpl.java @@ -20,6 +20,7 @@ import java.util.Date; import java.util.List; +import org.apache.commons.collections.CollectionUtils; import org.springframework.stereotype.Component; import com.cloud.alert.AlertVO; @@ -28,7 +29,7 @@ import com.cloud.utils.db.GenericDaoBase; import com.cloud.utils.db.SearchBuilder; import com.cloud.utils.db.SearchCriteria; import com.cloud.utils.db.SearchCriteria.Op; -import com.cloud.utils.db.TransactionLegacy; +import com.cloud.utils.db.UpdateBuilder; @Component public class AlertDaoImpl extends GenericDaoBase implements AlertDao { @@ -107,25 +108,20 @@ public class AlertDaoImpl extends GenericDaoBase implements Alert } sc.setParameters("archived", false); - boolean result = true; - ; List alerts = listBy(sc); if (ids != null && alerts.size() < ids.size()) { - result = false; - return result; + return false; } - if (alerts != null && !alerts.isEmpty()) { - TransactionLegacy txn = TransactionLegacy.currentTxn(); - txn.start(); - for (AlertVO alert : alerts) { - alert = lockRow(alert.getId(), true); - alert.setArchived(true); - update(alert.getId(), alert); - txn.commit(); - } - txn.close(); + + if (CollectionUtils.isEmpty(alerts)) { + return true; } - return result; + + AlertVO alertForUpdate = createForUpdate(); + alertForUpdate.setArchived(true); + UpdateBuilder ub = getUpdateBuilder(alertForUpdate); + update(ub, sc, null); + return true; } @Override diff --git a/engine/schema/src/main/java/com/cloud/event/dao/EventDaoImpl.java b/engine/schema/src/main/java/com/cloud/event/dao/EventDaoImpl.java index e748e98900e..9417ddd1259 100644 --- a/engine/schema/src/main/java/com/cloud/event/dao/EventDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/event/dao/EventDaoImpl.java @@ -18,8 +18,10 @@ package com.cloud.event.dao; import java.util.Date; import java.util.List; +import java.util.stream.Collectors; +import org.apache.commons.collections.CollectionUtils; import org.springframework.stereotype.Component; import com.cloud.event.Event.State; @@ -29,12 +31,13 @@ import com.cloud.utils.db.GenericDaoBase; import com.cloud.utils.db.SearchBuilder; import com.cloud.utils.db.SearchCriteria; import com.cloud.utils.db.SearchCriteria.Op; -import com.cloud.utils.db.TransactionLegacy; +import com.cloud.utils.db.UpdateBuilder; @Component public class EventDaoImpl extends GenericDaoBase implements EventDao { protected final SearchBuilder CompletedEventSearch; protected final SearchBuilder ToArchiveOrDeleteEventSearch; + protected final SearchBuilder ArchiveByIdsSearch; public EventDaoImpl() { CompletedEventSearch = createSearchBuilder(); @@ -51,6 +54,10 @@ public class EventDaoImpl extends GenericDaoBase implements Event ToArchiveOrDeleteEventSearch.and("createdDateL", ToArchiveOrDeleteEventSearch.entity().getCreateDate(), Op.LTEQ); ToArchiveOrDeleteEventSearch.and("archived", ToArchiveOrDeleteEventSearch.entity().getArchived(), Op.EQ); ToArchiveOrDeleteEventSearch.done(); + + ArchiveByIdsSearch = createSearchBuilder(); + ArchiveByIdsSearch.and("id", ArchiveByIdsSearch.entity().getId(), Op.IN); + ArchiveByIdsSearch.done(); } @Override @@ -100,16 +107,16 @@ public class EventDaoImpl extends GenericDaoBase implements Event @Override public void archiveEvents(List events) { - if (events != null && !events.isEmpty()) { - TransactionLegacy txn = TransactionLegacy.currentTxn(); - txn.start(); - for (EventVO event : events) { - event = lockRow(event.getId(), true); - event.setArchived(true); - update(event.getId(), event); - txn.commit(); - } - txn.close(); + if (CollectionUtils.isEmpty(events)) { + return; } + + List ids = events.stream().map(EventVO::getId).collect(Collectors.toList()); + SearchCriteria sc = ArchiveByIdsSearch.create(); + sc.setParameters("id", ids.toArray(new Object[ids.size()])); + EventVO eventForUpdate = createForUpdate(); + eventForUpdate.setArchived(true); + UpdateBuilder ub = getUpdateBuilder(eventForUpdate); + update(ub, sc, null); } } diff --git a/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java b/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java index cd4423dfa26..5b8a38b8e5b 100644 --- a/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java @@ -646,16 +646,22 @@ public class HostDaoImpl extends GenericDaoBase implements HostDao sc.setParameters("lastPinged", lastPingSecondsAfter); sc.setParameters("status", Status.Disconnected, Status.Down, Status.Alert); - StringBuilder sb = new StringBuilder(); - List hosts = lockRows(sc, null, true); // exclusive lock - for (HostVO host : hosts) { - host.setManagementServerId(null); - update(host.getId(), host); - sb.append(host.getId()); - sb.append(" "); + // SELECT before bulk UPDATE to preserve per-host-ID trace logging — the bulk UPDATE + // cannot return which rows it matched since the WHERE column is being set to NULL + if (logger.isTraceEnabled()) { + List hosts = listBy(sc); + StringBuilder sb = new StringBuilder(); + for (HostVO host : hosts) { + sb.append(host.getId()); + sb.append(" "); + } + logger.trace("Following hosts will be reset: {}", sb); } - logger.trace("Following hosts got reset: {}", sb); + HostVO host = createForUpdate(); + host.setManagementServerId(null); + UpdateBuilder ub = getUpdateBuilder(host); + update(ub, sc, null); } /* diff --git a/engine/schema/src/main/java/com/cloud/network/LBHealthCheckPolicyVO.java b/engine/schema/src/main/java/com/cloud/network/LBHealthCheckPolicyVO.java index ee5f67b09cd..23f0b0f15ae 100644 --- a/engine/schema/src/main/java/com/cloud/network/LBHealthCheckPolicyVO.java +++ b/engine/schema/src/main/java/com/cloud/network/LBHealthCheckPolicyVO.java @@ -16,6 +16,7 @@ // under the License. package com.cloud.network; +import java.util.Date; import java.util.UUID; import javax.persistence.Column; @@ -25,8 +26,11 @@ import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.PrimaryKeyJoinColumn; import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; import com.cloud.network.rules.HealthCheckPolicy; +import com.cloud.utils.db.GenericDao; import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; @Entity @@ -68,6 +72,10 @@ public class LBHealthCheckPolicyVO implements HealthCheckPolicy { @Column(name = "display", updatable = true, nullable = false) protected boolean display = true; + @Column(name = GenericDao.REMOVED_COLUMN) + @Temporal(value = TemporalType.TIMESTAMP) + private Date removed; + protected LBHealthCheckPolicyVO() { this.uuid = UUID.randomUUID().toString(); } diff --git a/engine/schema/src/main/java/com/cloud/network/dao/FirewallRulesDaoImpl.java b/engine/schema/src/main/java/com/cloud/network/dao/FirewallRulesDaoImpl.java index 27bf7ba6aa8..57d53f92572 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/FirewallRulesDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/FirewallRulesDaoImpl.java @@ -94,6 +94,7 @@ public class FirewallRulesDaoImpl extends GenericDaoBase i ReleaseSearch.and("ipId", ReleaseSearch.entity().getSourceIpAddressId(), Op.EQ); ReleaseSearch.and("purpose", ReleaseSearch.entity().getPurpose(), Op.EQ); ReleaseSearch.and("ports", ReleaseSearch.entity().getSourcePortStart(), Op.IN); + ReleaseSearch.and("removed", ReleaseSearch.entity().getRemoved(), Op.NULL); ReleaseSearch.done(); SystemRuleSearch = createSearchBuilder(); diff --git a/engine/schema/src/main/java/com/cloud/network/dao/LBHealthCheckPolicyDaoImpl.java b/engine/schema/src/main/java/com/cloud/network/dao/LBHealthCheckPolicyDaoImpl.java index 45927f3c377..a9b4e02ba87 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/LBHealthCheckPolicyDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/LBHealthCheckPolicyDaoImpl.java @@ -32,8 +32,9 @@ public class LBHealthCheckPolicyDaoImpl extends GenericDaoBase sc = createSearchCriteria(); sc.addAnd("loadBalancerId", SearchCriteria.Op.EQ, loadBalancerId); + sc.addAnd("removed", SearchCriteria.Op.NULL); - expunge(sc); + remove(sc); } @Override @@ -41,8 +42,9 @@ public class LBHealthCheckPolicyDaoImpl extends GenericDaoBase sc = createSearchCriteria(); sc.addAnd("loadBalancerId", SearchCriteria.Op.EQ, loadBalancerId); sc.addAnd("revoke", SearchCriteria.Op.EQ, revoke); + sc.addAnd("removed", SearchCriteria.Op.NULL); - expunge(sc); + remove(sc); } @Override diff --git a/engine/schema/src/main/java/com/cloud/network/dao/LBStickinessPolicyDao.java b/engine/schema/src/main/java/com/cloud/network/dao/LBStickinessPolicyDao.java index 6669d70824a..03fadbbd659 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/LBStickinessPolicyDao.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/LBStickinessPolicyDao.java @@ -28,4 +28,6 @@ public interface LBStickinessPolicyDao extends GenericDao listByLoadBalancerIdAndDisplayFlag(long loadBalancerId, boolean forDisplay); List listByLoadBalancerId(long loadBalancerId, boolean revoke); + + List listByLoadBalancerId(long loadBalancerId); } diff --git a/engine/schema/src/main/java/com/cloud/network/dao/LBStickinessPolicyDaoImpl.java b/engine/schema/src/main/java/com/cloud/network/dao/LBStickinessPolicyDaoImpl.java index 717458fdb99..86fd908b6b7 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/LBStickinessPolicyDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/LBStickinessPolicyDaoImpl.java @@ -31,8 +31,9 @@ public class LBStickinessPolicyDaoImpl extends GenericDaoBase sc = createSearchCriteria(); sc.addAnd("loadBalancerId", SearchCriteria.Op.EQ, loadBalancerId); + sc.addAnd("removed", SearchCriteria.Op.NULL); - expunge(sc); + remove(sc); } @Override @@ -40,8 +41,9 @@ public class LBStickinessPolicyDaoImpl extends GenericDaoBase sc = createSearchCriteria(); sc.addAnd("loadBalancerId", SearchCriteria.Op.EQ, loadBalancerId); sc.addAnd("revoke", SearchCriteria.Op.EQ, revoke); + sc.addAnd("removed", SearchCriteria.Op.NULL); - expunge(sc); + remove(sc); } @Override @@ -62,4 +64,10 @@ public class LBStickinessPolicyDaoImpl extends GenericDaoBase listByLoadBalancerId(long loadBalancerId) { + SearchCriteria sc = createSearchCriteria(); + sc.addAnd("loadBalancerId", SearchCriteria.Op.EQ, loadBalancerId); + return listBy(sc); + } } diff --git a/engine/schema/src/main/java/com/cloud/network/dao/LBStickinessPolicyVO.java b/engine/schema/src/main/java/com/cloud/network/dao/LBStickinessPolicyVO.java index 72b8fc151b7..ae658f0757f 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/LBStickinessPolicyVO.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/LBStickinessPolicyVO.java @@ -17,6 +17,7 @@ package com.cloud.network.dao; import java.util.ArrayList; +import java.util.Date; import java.util.HashMap; import java.util.Iterator; import java.util.List; @@ -30,9 +31,12 @@ import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.PrimaryKeyJoinColumn; import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; import com.cloud.network.rules.StickinessPolicy; import com.cloud.utils.Pair; +import com.cloud.utils.db.GenericDao; import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; @Entity @@ -68,6 +72,10 @@ public class LBStickinessPolicyVO implements StickinessPolicy { @Column(name = "display", updatable = true, nullable = false) protected boolean display = true; + @Column(name = GenericDao.REMOVED_COLUMN) + @Temporal(value = TemporalType.TIMESTAMP) + private Date removed; + protected LBStickinessPolicyVO() { this.uuid = UUID.randomUUID().toString(); } diff --git a/engine/schema/src/main/java/com/cloud/network/dao/LoadBalancerCertMapVO.java b/engine/schema/src/main/java/com/cloud/network/dao/LoadBalancerCertMapVO.java index f95c61733f2..7fbe58df930 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/LoadBalancerCertMapVO.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/LoadBalancerCertMapVO.java @@ -16,13 +16,17 @@ // under the License. package com.cloud.network.dao; +import java.util.Date; import java.util.UUID; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.Id; import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import com.cloud.utils.db.GenericDao; import org.apache.cloudstack.api.InternalIdentity; @Entity @@ -45,6 +49,10 @@ public class LoadBalancerCertMapVO implements InternalIdentity { @Column(name = "revoke") private boolean revoke = false; + @Column(name = GenericDao.REMOVED_COLUMN) + @Temporal(value = TemporalType.TIMESTAMP) + private Date removed; + public LoadBalancerCertMapVO() { this.uuid = UUID.randomUUID().toString(); } diff --git a/engine/schema/src/main/java/com/cloud/network/dao/LoadBalancerVMMapDaoImpl.java b/engine/schema/src/main/java/com/cloud/network/dao/LoadBalancerVMMapDaoImpl.java index dc37cdeefe3..03a1e45d917 100644 --- a/engine/schema/src/main/java/com/cloud/network/dao/LoadBalancerVMMapDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/network/dao/LoadBalancerVMMapDaoImpl.java @@ -34,8 +34,9 @@ public class LoadBalancerVMMapDaoImpl extends GenericDaoBase sc = createSearchCriteria(); sc.addAnd("loadBalancerId", SearchCriteria.Op.EQ, loadBalancerId); + sc.addAnd("removed", SearchCriteria.Op.NULL); - expunge(sc); + remove(sc); } @Override @@ -43,11 +44,12 @@ public class LoadBalancerVMMapDaoImpl extends GenericDaoBase sc = createSearchCriteria(); sc.addAnd("loadBalancerId", SearchCriteria.Op.EQ, loadBalancerId); sc.addAnd("instanceId", SearchCriteria.Op.IN, instanceIds.toArray()); + sc.addAnd("removed", SearchCriteria.Op.NULL); if (revoke != null) { sc.addAnd("revoke", SearchCriteria.Op.EQ, revoke); } - expunge(sc); + remove(sc); } @Override @@ -56,12 +58,13 @@ public class LoadBalancerVMMapDaoImpl extends GenericDaoBase sc = VmIdSeqNumSearch.create(); sc.setParameters("vmId", vmId); sc.setParameters("seqno", logSequenceNumber); - final Filter filter = new Filter(SecurityGroupWorkVO.class, null, true, 0l, 1l); - - final List vos = lockRows(sc, filter, true); - if (vos.size() == 0) { - txn.commit(); - return; - } - SecurityGroupWorkVO work = vos.get(0); - work.setStep(step); - update(work.getId(), work); - - txn.commit(); + SecurityGroupWorkVO workForUpdate = createForUpdate(); + workForUpdate.setStep(step); + // LIMIT 1 preserves the original single-row semantics: op_nwgrp_work has no + // uniqueness on (instance_id, seq_no), so without it duplicate rows would all be updated. + update(workForUpdate, sc, 1); } @Override @@ -172,21 +162,10 @@ public class SecurityGroupWorkDaoImpl extends GenericDaoBase, StateDao listVolumesByPassphraseId(long passphraseId); + /** + * List volumes with passphrase_id for migration to KMS + * + * @param zoneId Zone ID (required) + * @param accountId Account ID filter (optional, null for all accounts) + * @param domainId Domain ID filter (optional, null for all domains) + * @param limit Maximum number of volumes to return + * @return list of volumes that need migration + */ + Pair, Integer> listVolumesForKMSMigration(Long zoneId, Long accountId, Long domainId, Integer limit); + /** * Gets the Total Primary Storage space allocated for an account * @@ -169,6 +180,8 @@ public interface VolumeDao extends GenericDao, StateDao, StateDao findByKmsWrappedKeyId(Long kmsWrappedKeyId); } diff --git a/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDaoImpl.java b/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDaoImpl.java index 91f1c7f5eb6..1d5ff5e9340 100644 --- a/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/storage/dao/VolumeDaoImpl.java @@ -29,6 +29,7 @@ import javax.inject.Inject; import org.apache.cloudstack.reservation.ReservationVO; import org.apache.cloudstack.reservation.dao.ReservationDao; +import org.apache.cloudstack.kms.dao.KMSWrappedKeyDao; import org.apache.commons.collections.CollectionUtils; import org.springframework.stereotype.Component; @@ -80,11 +81,14 @@ public class VolumeDaoImpl extends GenericDaoBase implements Vol protected GenericSearchBuilder secondaryStorageSearch; private final SearchBuilder poolAndPathSearch; final GenericSearchBuilder CountByOfferingId; + private final SearchBuilder kmsMigrationSearch; @Inject ReservationDao reservationDao; @Inject ResourceTagDao tagsDao; + @Inject + KMSWrappedKeyDao kmsWrappedKeyDao; // need to account for zone-wide primary storage where storage_pool has // null-value pod and cluster, where hypervisor information is stored in @@ -412,6 +416,8 @@ public class VolumeDaoImpl extends GenericDaoBase implements Vol AllFieldsSearch.and("passphraseId", AllFieldsSearch.entity().getPassphraseId(), Op.EQ); AllFieldsSearch.and("iScsiName", AllFieldsSearch.entity().get_iScsiName(), Op.EQ); AllFieldsSearch.and("path", AllFieldsSearch.entity().getPath(), Op.EQ); + AllFieldsSearch.and("kmsKeyId", AllFieldsSearch.entity().getKmsKeyId(), Op.EQ); + AllFieldsSearch.and("kmsWrappedKeyId", AllFieldsSearch.entity().getKmsWrappedKeyId(), Op.EQ); AllFieldsSearch.done(); RootDiskStateSearch = createSearchBuilder(); @@ -528,6 +534,13 @@ public class VolumeDaoImpl extends GenericDaoBase implements Vol CountByOfferingId.select(null, Func.COUNT, CountByOfferingId.entity().getId()); CountByOfferingId.and("diskOfferingId", CountByOfferingId.entity().getDiskOfferingId(), Op.EQ); CountByOfferingId.done(); + + kmsMigrationSearch = createSearchBuilder(); + kmsMigrationSearch.and("passphraseId", kmsMigrationSearch.entity().getPassphraseId(), Op.NNULL); + kmsMigrationSearch.and("zoneId", kmsMigrationSearch.entity().getDataCenterId(), Op.EQ); + kmsMigrationSearch.and("accountId", kmsMigrationSearch.entity().getAccountId(), Op.EQ); + kmsMigrationSearch.and("domainId", kmsMigrationSearch.entity().getDomainId(), Op.EQ); + kmsMigrationSearch.done(); } @Override @@ -748,6 +761,21 @@ public class VolumeDaoImpl extends GenericDaoBase implements Vol return listBy(sc); } + @Override + public Pair, Integer> listVolumesForKMSMigration(Long zoneId, Long accountId, Long domainId, Integer limit) { + SearchCriteria sc = kmsMigrationSearch.create(); + + Filter filter = new Filter(limit); + sc.setParameters("zoneId", zoneId); + if (accountId != null) { + sc.setParameters("accountId", accountId); + } + if (domainId != null) { + sc.setParameters("domainId", domainId); + } + return searchAndCount(sc, filter); + } + @Override @DB public boolean remove(Long id) { @@ -756,6 +784,17 @@ public class VolumeDaoImpl extends GenericDaoBase implements Vol logger.debug(String.format("Removing volume %s from DB", id)); VolumeVO entry = findById(id); if (entry != null) { + // Clean up KMS wrapped key if volume was encrypted with KMS + if (entry.getKmsWrappedKeyId() != null) { + try { + kmsWrappedKeyDao.remove(entry.getKmsWrappedKeyId()); + logger.debug("Removed KMS wrapped key [id={}] for volume [id={}, uuid={}]", + entry.getKmsWrappedKeyId(), id, entry.getUuid()); + } catch (Exception e) { + logger.warn("Failed to remove KMS wrapped key [id={}] for volume [id={}, uuid={}]: {}", + entry.getKmsWrappedKeyId(), id, entry.getUuid(), e.getMessage(), e); + } + } tagsDao.removeByIdAndType(id, ResourceObjectType.Volume); } boolean result = super.remove(id); @@ -952,9 +991,24 @@ public class VolumeDaoImpl extends GenericDaoBase implements Vol } @Override + public boolean existsWithKmsKey(long kmsKeyId) { + SearchCriteria sc = AllFieldsSearch.create(); + sc.setParameters("kmsKeyId", kmsKeyId); + sc.setParameters("notDestroyed", Volume.State.Expunged, Volume.State.Destroy); + return findOneBy(sc) != null; + } + public VolumeVO findByExternalUuid(String externalUuid) { SearchCriteria sc = ExternalUuidSearch.create(); sc.setParameters("externalUuid", externalUuid); return findOneBy(sc); } + + @Override + public List findByKmsWrappedKeyId(Long kmsWrappedKeyId) { + SearchCriteria sc = AllFieldsSearch.create(); + sc.setParameters("kmsWrappedKeyId", kmsWrappedKeyId); + sc.setParameters("notDestroyed", Volume.State.Expunged); + return listBy(sc); + } } diff --git a/engine/schema/src/main/java/com/cloud/usage/dao/UsageJobDaoImpl.java b/engine/schema/src/main/java/com/cloud/usage/dao/UsageJobDaoImpl.java index 6f340501cf1..68ad8fa12ed 100644 --- a/engine/schema/src/main/java/com/cloud/usage/dao/UsageJobDaoImpl.java +++ b/engine/schema/src/main/java/com/cloud/usage/dao/UsageJobDaoImpl.java @@ -61,9 +61,6 @@ public class UsageJobDaoImpl extends GenericDaoBase implements public void updateJobSuccess(Long jobId, long startMillis, long endMillis, long execTime, boolean success) { TransactionLegacy txn = TransactionLegacy.open(TransactionLegacy.USAGE_DB); try { - txn.start(); - - UsageJobVO job = lockRow(jobId, Boolean.TRUE); UsageJobVO jobForUpdate = createForUpdate(); jobForUpdate.setStartMillis(startMillis); jobForUpdate.setEndMillis(endMillis); @@ -71,11 +68,8 @@ public class UsageJobDaoImpl extends GenericDaoBase implements jobForUpdate.setStartDate(new Date(startMillis)); jobForUpdate.setEndDate(new Date(endMillis)); jobForUpdate.setSuccess(success); - update(job.getId(), jobForUpdate); - - txn.commit(); + update(jobId, jobForUpdate); } catch (Exception ex) { - txn.rollback(); logger.error("error updating job success date", ex); throw new CloudRuntimeException(ex.getMessage()); } finally { diff --git a/engine/schema/src/main/java/com/cloud/vm/VmIsoMapVO.java b/engine/schema/src/main/java/com/cloud/vm/VmIsoMapVO.java new file mode 100644 index 00000000000..f4a3f116818 --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/VmIsoMapVO.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 com.cloud.vm; + +import java.util.Date; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; + +import org.apache.cloudstack.api.InternalIdentity; + +@Entity +@Table(name = "vm_iso_map") +public class VmIsoMapVO implements InternalIdentity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "vm_id") + private long vmId; + + @Column(name = "iso_id") + private long isoId; + + @Column(name = "device_seq") + private int deviceSeq; + + @Column(name = "created") + @Temporal(TemporalType.TIMESTAMP) + private Date created; + + public VmIsoMapVO() { + } + + public VmIsoMapVO(long vmId, long isoId, int deviceSeq) { + this.vmId = vmId; + this.isoId = isoId; + this.deviceSeq = deviceSeq; + this.created = new Date(); + } + + @Override + public long getId() { + return id; + } + + public long getVmId() { + return vmId; + } + + public long getIsoId() { + return isoId; + } + + public int getDeviceSeq() { + return deviceSeq; + } + + public Date getCreated() { + return created; + } +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmIsoMapDao.java b/engine/schema/src/main/java/com/cloud/vm/dao/VmIsoMapDao.java new file mode 100644 index 00000000000..a472a3b4dec --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmIsoMapDao.java @@ -0,0 +1,34 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.vm.dao; + +import java.util.List; + +import com.cloud.utils.db.GenericDao; +import com.cloud.vm.VmIsoMapVO; + +public interface VmIsoMapDao extends GenericDao { + List listByVmId(long vmId); + + List listByIsoId(long isoId); + + VmIsoMapVO findByVmIdDeviceSeq(long vmId, int deviceSeq); + + VmIsoMapVO findByVmIdIsoId(long vmId, long isoId); + + int removeByVmId(long vmId); +} diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VmIsoMapDaoImpl.java b/engine/schema/src/main/java/com/cloud/vm/dao/VmIsoMapDaoImpl.java new file mode 100644 index 00000000000..44749eea75f --- /dev/null +++ b/engine/schema/src/main/java/com/cloud/vm/dao/VmIsoMapDaoImpl.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 com.cloud.vm.dao; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import com.cloud.vm.VmIsoMapVO; + +@Component +public class VmIsoMapDaoImpl extends GenericDaoBase implements VmIsoMapDao { + + private SearchBuilder ListByVmId; + private SearchBuilder ListByIsoId; + private SearchBuilder ByVmIdDeviceSeq; + private SearchBuilder ByVmIdIsoId; + + protected VmIsoMapDaoImpl() { + ListByVmId = createSearchBuilder(); + ListByVmId.and("vmId", ListByVmId.entity().getVmId(), SearchCriteria.Op.EQ); + ListByVmId.done(); + + ListByIsoId = createSearchBuilder(); + ListByIsoId.and("isoId", ListByIsoId.entity().getIsoId(), SearchCriteria.Op.EQ); + ListByIsoId.done(); + + ByVmIdDeviceSeq = createSearchBuilder(); + ByVmIdDeviceSeq.and("vmId", ByVmIdDeviceSeq.entity().getVmId(), SearchCriteria.Op.EQ); + ByVmIdDeviceSeq.and("deviceSeq", ByVmIdDeviceSeq.entity().getDeviceSeq(), SearchCriteria.Op.EQ); + ByVmIdDeviceSeq.done(); + + ByVmIdIsoId = createSearchBuilder(); + ByVmIdIsoId.and("vmId", ByVmIdIsoId.entity().getVmId(), SearchCriteria.Op.EQ); + ByVmIdIsoId.and("isoId", ByVmIdIsoId.entity().getIsoId(), SearchCriteria.Op.EQ); + ByVmIdIsoId.done(); + } + + @Override + public List listByVmId(long vmId) { + SearchCriteria sc = ListByVmId.create(); + sc.setParameters("vmId", vmId); + return listBy(sc); + } + + @Override + public List listByIsoId(long isoId) { + SearchCriteria sc = ListByIsoId.create(); + sc.setParameters("isoId", isoId); + return listBy(sc); + } + + @Override + public VmIsoMapVO findByVmIdDeviceSeq(long vmId, int deviceSeq) { + SearchCriteria sc = ByVmIdDeviceSeq.create(); + sc.setParameters("vmId", vmId); + sc.setParameters("deviceSeq", deviceSeq); + return findOneBy(sc); + } + + @Override + public VmIsoMapVO findByVmIdIsoId(long vmId, long isoId) { + SearchCriteria sc = ByVmIdIsoId.create(); + sc.setParameters("vmId", vmId); + sc.setParameters("isoId", isoId); + return findOneBy(sc); + } + + @Override + public int removeByVmId(long vmId) { + SearchCriteria sc = ListByVmId.create(); + sc.setParameters("vmId", vmId); + return remove(sc); + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/HSMProfileDetailsVO.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/HSMProfileDetailsVO.java new file mode 100644 index 00000000000..cd20b8a74fe --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/HSMProfileDetailsVO.java @@ -0,0 +1,84 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms; + +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 = "kms_hsm_profile_details") +public class HSMProfileDetailsVO implements ResourceDetail { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "profile_id") + private long resourceId; + + @Column(name = "name") + private String name; + + @Column(name = "value") + private String value; + + public HSMProfileDetailsVO() { + } + + public HSMProfileDetailsVO(long profileId, String name, String value) { + this.resourceId = profileId; + this.name = name; + this.value = value; + } + + @Override + public long getId() { + return id; + } + + @Override + public long getResourceId() { + return resourceId; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getValue() { + return value; + } + + @Override + public boolean isDisplay() { + return true; + } + + public void setValue(String value) { + this.value = value; + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/HSMProfileVO.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/HSMProfileVO.java new file mode 100644 index 00000000000..86d57400c0d --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/HSMProfileVO.java @@ -0,0 +1,183 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.kms; + +import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import java.util.Date; +import java.util.UUID; + +@Entity +@Table(name = "kms_hsm_profiles") +public class HSMProfileVO implements HSMProfile { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "name") + private String name; + + @Column(name = "protocol") + private String protocol; + + @Column(name = "account_id") + private Long accountId; + + @Column(name = "domain_id") + private Long domainId; + + @Column(name = "zone_id") + private Long zoneId; + + @Column(name = "vendor_name") + private String vendorName; + + @Column(name = "enabled") + private boolean enabled; + + @Column(name = "is_public") + private boolean isPublic; + + @Column(name = "created") + private Date created; + + @Column(name = "removed") + private Date removed; + + public HSMProfileVO() { + this.uuid = UUID.randomUUID().toString(); + this.created = new Date(); + this.isPublic = false; + } + + public HSMProfileVO(String name, String protocol, Long accountId, Long domainId, Long zoneId, String vendorName) { + this.uuid = UUID.randomUUID().toString(); + this.name = name; + this.protocol = protocol; + this.accountId = accountId; + this.domainId = domainId; + this.zoneId = zoneId; + this.vendorName = vendorName; + this.enabled = true; + this.isPublic = false; + this.created = new Date(); + } + + @Override + public String toString() { + return String.format("HSMProfileVO %s", + ReflectionToStringBuilderUtils.reflectOnlySelectedFields( + this, "id", "uuid", "name", "protocol", "system", "enabled")); + } + + @Override + public long getId() { + return id; + } + + @Override + public String getUuid() { + return uuid; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getProtocol() { + return protocol; + } + + @Override + public long getAccountId() { + return accountId == null ? -1 : accountId; + } + + @Override + public long getDomainId() { + return domainId == null ? -1 : domainId; + } + + @Override + public Long getZoneId() { + return zoneId; + } + + @Override + public String getVendorName() { + return vendorName; + } + + @Override + public boolean isEnabled() { + return enabled; + } + + @Override + public Date getCreated() { + return created; + } + + @Override + public Date getRemoved() { + return removed; + } + + @Override + public Class getEntityType() { + return HSMProfile.class; + } + + public void setRemoved(Date removed) { + this.removed = removed; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public void setVendorName(String vendorName) { + this.vendorName = vendorName; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public boolean getIsPublic() { + return isPublic; + } + + public void setIsPublic(boolean isPublic) { + this.isPublic = isPublic; + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKekVersionVO.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKekVersionVO.java new file mode 100644 index 00000000000..1dacdff6802 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKekVersionVO.java @@ -0,0 +1,193 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; + +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; + +/** + * Database entity for KEK versions. + * Tracks multiple KEK versions per KMS key to support gradual rotation. + * During rotation, a new version is created (status=Active) and old versions + * are marked as Previous (still usable for decryption) or Archived (no longer used). + */ +@Entity +@Table(name = "kms_kek_versions") +public class KMSKekVersionVO { + + public enum Status { + /** + * Used for new encryption operations + */ + Active, + /** + * Still usable for decryption during key rotation + */ + Previous, + /** + * No longer used; all wrapped keys have been re-encrypted + */ + Archived + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "uuid", nullable = false) + private String uuid; + + @Column(name = "kms_key_id", nullable = false) + private Long kmsKeyId; + + @Column(name = "version_number", nullable = false) + private Integer versionNumber; + + @Column(name = "kek_label", nullable = false) + private String kekLabel; + + @Column(name = "status", nullable = false, length = 32) + @Enumerated(EnumType.STRING) + private Status status; + + @Column(name = "hsm_profile_id") + private Long hsmProfileId; + + @Column(name = GenericDao.CREATED_COLUMN, nullable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date created; + + @Column(name = GenericDao.REMOVED_COLUMN) + @Temporal(TemporalType.TIMESTAMP) + private Date removed; + + public KMSKekVersionVO(Long kmsKeyId, Integer versionNumber, String kekLabel) { + this(); + this.kmsKeyId = kmsKeyId; + this.versionNumber = versionNumber; + this.kekLabel = kekLabel; + this.status = Status.Active; + } + + public KMSKekVersionVO(Long kmsKeyId, String kekLabel) { + this(); + this.kmsKeyId = kmsKeyId; + this.kekLabel = kekLabel; + this.status = Status.Active; + } + + public KMSKekVersionVO() { + this.uuid = UUID.randomUUID().toString(); + this.created = new Date(); + this.status = Status.Active; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public Long getKmsKeyId() { + return kmsKeyId; + } + + public void setKmsKeyId(Long kmsKeyId) { + this.kmsKeyId = kmsKeyId; + } + + public Integer getVersionNumber() { + return versionNumber; + } + + public void setVersionNumber(Integer versionNumber) { + this.versionNumber = versionNumber; + } + + public String getKekLabel() { + return kekLabel; + } + + public void setKekLabel(String kekLabel) { + this.kekLabel = kekLabel; + } + + public Status getStatus() { + return status; + } + + public void setStatus(Status status) { + this.status = status; + } + + public Long getHsmProfileId() { + return hsmProfileId; + } + + public void setHsmProfileId(Long hsmProfileId) { + this.hsmProfileId = hsmProfileId; + } + + + 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; + } + + @Override + public String toString() { + return String.format("KMSKekVersion %s", + ReflectionToStringBuilderUtils.reflectOnlySelectedFields( + this, "id", "uuid", "kmsKeyId", "versionNumber", "status", "kekLabel")); + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKeyVO.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKeyVO.java new file mode 100644 index 00000000000..36c68de3744 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSKeyVO.java @@ -0,0 +1,264 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.framework.kms.KeyPurpose; +import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; + +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; + +/** + * Database entity for KMS Key (Key Encryption Key) metadata. + * Tracks ownership, purpose, and lifecycle of KEKs used in envelope encryption. + */ +@Entity +@Table(name = "kms_keys") +public class KMSKeyVO implements KMSKey { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "uuid", nullable = false) + private String uuid; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "description", length = 1024) + private String description; + + @Column(name = "kek_label", nullable = false) + private String kekLabel; + + @Column(name = "purpose", nullable = false, length = 32) + @Enumerated(EnumType.STRING) + private KeyPurpose purpose; + + @Column(name = "account_id", nullable = false) + private Long accountId; + + @Column(name = "domain_id", nullable = false) + private Long domainId; + + @Column(name = "zone_id", nullable = false) + private Long zoneId; + + @Column(name = "algorithm", nullable = false, length = 64) + private String algorithm; + + @Column(name = "key_bits", nullable = false) + private Integer keyBits; + + @Column(name = "enabled", nullable = false) + private boolean enabled; + + @Column(name = "hsm_profile_id") + private Long hsmProfileId; + + @Column(name = GenericDao.CREATED_COLUMN, nullable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date created; + + @Column(name = GenericDao.REMOVED_COLUMN) + @Temporal(TemporalType.TIMESTAMP) + private Date removed; + + public KMSKeyVO(String name, String description, String kekLabel, + KeyPurpose purpose, Long accountId, Long domainId, + Long zoneId, String algorithm, Integer keyBits + ) { + this(); + this.name = name; + this.description = description; + this.kekLabel = kekLabel; + this.purpose = purpose; + this.accountId = accountId; + this.domainId = domainId; + this.zoneId = zoneId; + this.algorithm = algorithm; + this.keyBits = keyBits; + } + + public KMSKeyVO() { + this.uuid = UUID.randomUUID().toString(); + this.created = new Date(); + this.enabled = true; + } + + @Override + public long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + @Override + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getDescription() { + return description; + } + + @Override + public String getKekLabel() { + return kekLabel; + } + + @Override + public KeyPurpose getPurpose() { + return purpose; + } + + @Override + public Long getZoneId() { + return zoneId; + } + + @Override + public String getAlgorithm() { + return algorithm; + } + + @Override + public Integer getKeyBits() { + return keyBits; + } + + @Override + public boolean isEnabled() { + return enabled; + } + + @Override + public Date getCreated() { + return created; + } + + @Override + public Date getRemoved() { + return removed; + } + + @Override + public Long getHsmProfileId() { + return hsmProfileId; + } + + public void setHsmProfileId(Long hsmProfileId) { + this.hsmProfileId = hsmProfileId; + } + + public void setRemoved(Date removed) { + this.removed = removed; + } + + public void setCreated(Date created) { + this.created = created; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public void setKeyBits(Integer keyBits) { + this.keyBits = keyBits; + } + + public void setAlgorithm(String algorithm) { + this.algorithm = algorithm; + } + + public void setZoneId(Long zoneId) { + this.zoneId = zoneId; + } + + public void setPurpose(KeyPurpose purpose) { + this.purpose = purpose; + } + + public void setKekLabel(String kekLabel) { + this.kekLabel = kekLabel; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public long getAccountId() { + return accountId; + } + + public void setAccountId(Long accountId) { + this.accountId = accountId; + } + + @Override + public long getDomainId() { + return domainId; + } + + public void setDomainId(Long domainId) { + this.domainId = domainId; + } + + @Override + public Class getEntityType() { + return KMSKey.class; + } + + @Override + public String toString() { + return String.format("KMSKey %s", ReflectionToStringBuilderUtils.reflectOnlySelectedFields( + this, "id", "uuid", "name", "purpose", + "accountId", "zoneId", "enabled" + )); + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSWrappedKeyVO.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSWrappedKeyVO.java new file mode 100644 index 00000000000..8a763623fba --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/KMSWrappedKeyVO.java @@ -0,0 +1,176 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import java.util.Arrays; +import java.util.Date; +import java.util.UUID; + +/** + * Database entity for storing wrapped (encrypted) Data Encryption Keys. + * Each entry represents a DEK that has been encrypted by a Key Encryption Key (KEK). + * KEK metadata is stored in kms_keys table via the kms_key_id foreign key. + */ +@Entity +@Table(name = "kms_wrapped_key") +public class KMSWrappedKeyVO { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "uuid", nullable = false) + private String uuid; + + @Column(name = "kms_key_id") + private Long kmsKeyId; + + @Column(name = "kek_version_id") + private Long kekVersionId; + + @Column(name = "zone_id", nullable = false) + private Long zoneId; + + @Column(name = "wrapped_blob", nullable = false) + private byte[] wrappedBlob; + + @Column(name = GenericDao.CREATED_COLUMN, nullable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date created; + + @Column(name = GenericDao.REMOVED_COLUMN) + @Temporal(TemporalType.TIMESTAMP) + private Date removed; + + public KMSWrappedKeyVO(KMSKeyVO kmsKey, byte[] wrappedBlob) { + this(); + this.kmsKeyId = kmsKey.getId(); + this.zoneId = kmsKey.getZoneId(); + this.wrappedBlob = wrappedBlob != null ? Arrays.copyOf(wrappedBlob, wrappedBlob.length) : null; + } + + public KMSWrappedKeyVO() { + this.uuid = UUID.randomUUID().toString(); + this.created = new Date(); + } + + public KMSWrappedKeyVO(KMSKeyVO kmsKey, Long kekVersionId, byte[] wrappedBlob) { + this(); + this.kmsKeyId = kmsKey.getId(); + this.kekVersionId = kekVersionId; + this.zoneId = kmsKey.getZoneId(); + this.wrappedBlob = wrappedBlob != null ? Arrays.copyOf(wrappedBlob, wrappedBlob.length) : null; + } + + public KMSWrappedKeyVO(Long kmsKeyId, Long zoneId, byte[] wrappedBlob) { + this(); + this.kmsKeyId = kmsKeyId; + this.zoneId = zoneId; + this.wrappedBlob = wrappedBlob != null ? Arrays.copyOf(wrappedBlob, wrappedBlob.length) : null; + } + + public KMSWrappedKeyVO(Long kmsKeyId, Long kekVersionId, Long zoneId, byte[] wrappedBlob) { + this(); + this.kmsKeyId = kmsKeyId; + this.kekVersionId = kekVersionId; + this.zoneId = zoneId; + this.wrappedBlob = wrappedBlob != null ? Arrays.copyOf(wrappedBlob, wrappedBlob.length) : null; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public Long getKmsKeyId() { + return kmsKeyId; + } + + public void setKmsKeyId(Long kmsKeyId) { + this.kmsKeyId = kmsKeyId; + } + + public Long getKekVersionId() { + return kekVersionId; + } + + public void setKekVersionId(Long kekVersionId) { + this.kekVersionId = kekVersionId; + } + + public Long getZoneId() { + return zoneId; + } + + public void setZoneId(Long zoneId) { + this.zoneId = zoneId; + } + + public byte[] getWrappedBlob() { + return wrappedBlob != null ? Arrays.copyOf(wrappedBlob, wrappedBlob.length) : null; + } + + public void setWrappedBlob(byte[] wrappedBlob) { + this.wrappedBlob = wrappedBlob != null ? Arrays.copyOf(wrappedBlob, wrappedBlob.length) : null; + } + + 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; + } + + @Override + public String toString() { + return String.format("KMSWrappedKey %s", ReflectionToStringBuilderUtils.reflectOnlySelectedFields( + this, "id", "uuid", "kmsKeyId", "kekVersionId", "zoneId", "created", "removed")); + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDao.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDao.java new file mode 100644 index 00000000000..a2202a18bfc --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDao.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.kms.dao; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.kms.HSMProfileVO; + +public interface HSMProfileDao extends GenericDao { +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDaoImpl.java new file mode 100644 index 00000000000..b063d2cfe74 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDaoImpl.java @@ -0,0 +1,29 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms.dao; + +import com.cloud.utils.db.GenericDaoBase; +import org.apache.cloudstack.kms.HSMProfileVO; +import org.springframework.stereotype.Component; + +@Component +public class HSMProfileDaoImpl extends GenericDaoBase implements HSMProfileDao { + public HSMProfileDaoImpl() { + super(); + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDetailsDao.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDetailsDao.java new file mode 100644 index 00000000000..0d5c71b9e5f --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDetailsDao.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.kms.dao; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.kms.HSMProfileDetailsVO; + +import java.util.List; + +public interface HSMProfileDetailsDao extends GenericDao { + List listByProfileId(long profileId); + + void persist(long profileId, String name, String value); + + HSMProfileDetailsVO findDetail(long profileId, String name); + + void deleteDetails(long profileId); +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDetailsDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDetailsDaoImpl.java new file mode 100644 index 00000000000..59c0ec43259 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/HSMProfileDetailsDaoImpl.java @@ -0,0 +1,75 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.kms.dao; + +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import com.cloud.utils.db.SearchCriteria.Op; +import org.apache.cloudstack.kms.HSMProfileDetailsVO; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class HSMProfileDetailsDaoImpl extends GenericDaoBase implements HSMProfileDetailsDao { + + protected SearchBuilder ProfileSearch; + protected SearchBuilder DetailSearch; + + public HSMProfileDetailsDaoImpl() { + super(); + + ProfileSearch = createSearchBuilder(); + ProfileSearch.and("profileId", ProfileSearch.entity().getResourceId(), Op.EQ); + ProfileSearch.done(); + + DetailSearch = createSearchBuilder(); + DetailSearch.and("profileId", DetailSearch.entity().getResourceId(), Op.EQ); + DetailSearch.and("name", DetailSearch.entity().getName(), Op.EQ); + DetailSearch.done(); + } + + @Override + public List listByProfileId(long profileId) { + SearchCriteria sc = ProfileSearch.create(); + sc.setParameters("profileId", profileId); + return listBy(sc); + } + + @Override + public void persist(long profileId, String name, String value) { + HSMProfileDetailsVO vo = new HSMProfileDetailsVO(profileId, name, value); + persist(vo); + } + + @Override + public HSMProfileDetailsVO findDetail(long profileId, String name) { + SearchCriteria sc = DetailSearch.create(); + sc.setParameters("profileId", profileId); + sc.setParameters("name", name); + return findOneBy(sc); + } + + @Override + public void deleteDetails(long profileId) { + SearchCriteria sc = ProfileSearch.create(); + sc.setParameters("profileId", profileId); + remove(sc); + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKekVersionDao.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKekVersionDao.java new file mode 100644 index 00000000000..dbe4ca5f648 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKekVersionDao.java @@ -0,0 +1,43 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms.dao; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.kms.KMSKekVersionVO; + +import java.util.List; + +public interface KMSKekVersionDao extends GenericDao { + + KMSKekVersionVO getActiveVersion(Long kmsKeyId); + + /** + * Returns Active and Previous versions (usable for decryption) + */ + List getVersionsForDecryption(Long kmsKeyId); + + List listByKmsKeyId(Long kmsKeyId); + + KMSKekVersionVO findByKmsKeyIdAndVersion(Long kmsKeyId, Integer versionNumber); + + KMSKekVersionVO findByKekLabel(String kekLabel); + + List findByStatus(KMSKekVersionVO.Status status); + + List listByHsmProfileId(Long hsmProfileId); +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKekVersionDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKekVersionDaoImpl.java new file mode 100644 index 00000000000..841ab8c03d9 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKekVersionDaoImpl.java @@ -0,0 +1,98 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms.dao; + +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import org.apache.cloudstack.kms.KMSKekVersionVO; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Component +public class KMSKekVersionDaoImpl extends GenericDaoBase implements KMSKekVersionDao { + + private final SearchBuilder allFieldSearch; + + public KMSKekVersionDaoImpl() { + allFieldSearch = createSearchBuilder(); + allFieldSearch.and("kmsKeyId", allFieldSearch.entity().getKmsKeyId(), SearchCriteria.Op.EQ); + allFieldSearch.and("status", allFieldSearch.entity().getStatus(), SearchCriteria.Op.IN); + allFieldSearch.and("versionNumber", allFieldSearch.entity().getVersionNumber(), SearchCriteria.Op.EQ); + allFieldSearch.and("kekLabel", allFieldSearch.entity().getKekLabel(), SearchCriteria.Op.EQ); + allFieldSearch.and("hsmProfileId", allFieldSearch.entity().getHsmProfileId(), SearchCriteria.Op.EQ); + allFieldSearch.done(); + } + + @Override + public KMSKekVersionVO getActiveVersion(Long kmsKeyId) { + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("kmsKeyId", kmsKeyId); + sc.setParameters("status", KMSKekVersionVO.Status.Active); + return findOneBy(sc); + } + + @Override + public List getVersionsForDecryption(Long kmsKeyId) { + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("kmsKeyId", kmsKeyId); + sc.setParameters("status", KMSKekVersionVO.Status.Active, KMSKekVersionVO.Status.Previous); + return listBy(sc); + } + + @Override + public List listByKmsKeyId(Long kmsKeyId) { + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("kmsKeyId", kmsKeyId); + return listBy(sc); + } + + @Override + public KMSKekVersionVO findByKmsKeyIdAndVersion(Long kmsKeyId, Integer versionNumber) { + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("kmsKeyId", kmsKeyId); + sc.setParameters("versionNumber", versionNumber); + return findOneBy(sc); + } + + @Override + public KMSKekVersionVO findByKekLabel(String kekLabel) { + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("kekLabel", kekLabel); + return findOneBy(sc); + } + + @Override + public List findByStatus(KMSKekVersionVO.Status status) { + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("status", status); + return listBy(sc); + } + + @Override + public List listByHsmProfileId(Long hsmProfileId) { + if (hsmProfileId == null) { + return new ArrayList<>(); + } + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("hsmProfileId", hsmProfileId); + return listBy(sc); + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDao.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDao.java new file mode 100644 index 00000000000..4a03f2ff96c --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDao.java @@ -0,0 +1,35 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.kms.dao; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.framework.kms.KeyPurpose; +import org.apache.cloudstack.kms.KMSKeyVO; + +import java.util.List; + +public interface KMSKeyDao extends GenericDao { + + List listByAccount(Long accountId, KeyPurpose purpose, Boolean enabled); + + List listByZone(Long zoneId, KeyPurpose purpose, Boolean enabled); + + long countByHsmProfileId(Long hsmProfileId); + + KMSKeyVO findByNameAndAccountId(String name, long accountId); +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDaoImpl.java new file mode 100644 index 00000000000..ac442c19d1a --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSKeyDaoImpl.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.kms.dao; + +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import org.apache.cloudstack.framework.kms.KeyPurpose; +import org.apache.cloudstack.kms.KMSKeyVO; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class KMSKeyDaoImpl extends GenericDaoBase implements KMSKeyDao { + + private final SearchBuilder allFieldSearch; + + public KMSKeyDaoImpl() { + allFieldSearch = createSearchBuilder(); + allFieldSearch.and("name", allFieldSearch.entity().getName(), SearchCriteria.Op.EQ); + allFieldSearch.and("kekLabel", allFieldSearch.entity().getKekLabel(), SearchCriteria.Op.EQ); + allFieldSearch.and("domainId", allFieldSearch.entity().getDomainId(), SearchCriteria.Op.EQ); + allFieldSearch.and("accountId", allFieldSearch.entity().getAccountId(), SearchCriteria.Op.EQ); + allFieldSearch.and("purpose", allFieldSearch.entity().getPurpose(), SearchCriteria.Op.EQ); + allFieldSearch.and("enabled", allFieldSearch.entity().isEnabled(), SearchCriteria.Op.EQ); + allFieldSearch.and("zoneId", allFieldSearch.entity().getZoneId(), SearchCriteria.Op.EQ); + allFieldSearch.and("hsmProfileId", allFieldSearch.entity().getHsmProfileId(), SearchCriteria.Op.EQ); + allFieldSearch.done(); + } + + @Override + public List listByAccount(Long accountId, KeyPurpose purpose, Boolean enabled) { + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("accountId", accountId); + sc.setParametersIfNotNull("purpose", purpose); + sc.setParametersIfNotNull("enabled", enabled); + return listBy(sc); + } + + @Override + public List listByZone(Long zoneId, KeyPurpose purpose, Boolean enabled) { + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("zoneId", zoneId); + sc.setParametersIfNotNull("purpose", purpose); + sc.setParametersIfNotNull("enabled", enabled); + return listBy(sc); + } + + @Override + public long countByHsmProfileId(Long hsmProfileId) { + if (hsmProfileId == null) { + return 0; + } + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("hsmProfileId", hsmProfileId); + Integer count = getCount(sc); + return count != null ? count : 0; + } + + @Override + public KMSKeyVO findByNameAndAccountId(String name, long accountId) { + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("name", name); + sc.setParameters("accountId", accountId); + return findOneBy(sc); + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDao.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDao.java new file mode 100644 index 00000000000..5e9e418a166 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDao.java @@ -0,0 +1,35 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.kms.dao; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.kms.KMSWrappedKeyVO; + +import java.util.List; + +public interface KMSWrappedKeyDao extends GenericDao { + + long countByKmsKeyId(Long kmsKeyId); + + /** + * Limited variant for batch processing during key rotation + */ + List listByKekVersionId(Long kekVersionId, int limit); + + long countByKekVersionId(Long kekVersionId); +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDaoImpl.java new file mode 100644 index 00000000000..fc1a7190ae8 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/kms/dao/KMSWrappedKeyDaoImpl.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.kms.dao; + +import com.cloud.utils.db.Filter; +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import org.apache.cloudstack.kms.KMSWrappedKeyVO; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class KMSWrappedKeyDaoImpl extends GenericDaoBase implements KMSWrappedKeyDao { + + private final SearchBuilder allFieldSearch; + + public KMSWrappedKeyDaoImpl() { + super(); + + allFieldSearch = createSearchBuilder(); + allFieldSearch.and("kmsKeyId", allFieldSearch.entity().getKmsKeyId(), SearchCriteria.Op.EQ); + allFieldSearch.and("kekVersionId", allFieldSearch.entity().getKekVersionId(), SearchCriteria.Op.EQ); + allFieldSearch.and("zoneId", allFieldSearch.entity().getZoneId(), SearchCriteria.Op.EQ); + allFieldSearch.done(); + } + + @Override + public long countByKmsKeyId(Long kmsKeyId) { + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("kmsKeyId", kmsKeyId); + Integer count = getCount(sc); + return count != null ? count.longValue() : 0L; + } + + @Override + public List listByKekVersionId(Long kekVersionId, int limit) { + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("kekVersionId", kekVersionId); + Filter filter = new Filter(limit); + return listBy(sc, filter); + } + + @Override + public long countByKekVersionId(Long kekVersionId) { + if (kekVersionId == null) { + return 0; + } + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("kekVersionId", kekVersionId); + Integer count = getCount(sc); + return count != null ? count.longValue() : 0L; + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/region/gslb/GlobalLoadBalancerRuleVO.java b/engine/schema/src/main/java/org/apache/cloudstack/region/gslb/GlobalLoadBalancerRuleVO.java index 4ce7033156f..47e2d130d87 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/region/gslb/GlobalLoadBalancerRuleVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/region/gslb/GlobalLoadBalancerRuleVO.java @@ -17,6 +17,7 @@ package org.apache.cloudstack.region.gslb; +import java.util.Date; import java.util.UUID; import javax.persistence.Column; @@ -27,8 +28,11 @@ 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 com.cloud.region.ha.GlobalLoadBalancerRule; +import com.cloud.utils.db.GenericDao; import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; @Entity @@ -74,6 +78,10 @@ public class GlobalLoadBalancerRuleVO implements GlobalLoadBalancerRule { @Column(name = "state") GlobalLoadBalancerRule.State state; + @Column(name = GenericDao.REMOVED_COLUMN) + @Temporal(value = TemporalType.TIMESTAMP) + private Date removed; + public GlobalLoadBalancerRuleVO() { uuid = UUID.randomUUID().toString(); } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/schedule/ResourceScheduleDetailVO.java b/engine/schema/src/main/java/org/apache/cloudstack/schedule/ResourceScheduleDetailVO.java new file mode 100644 index 00000000000..16985e6bac3 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/schedule/ResourceScheduleDetailVO.java @@ -0,0 +1,82 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.schedule; + +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 = "resource_schedule_details") +public class ResourceScheduleDetailVO implements ResourceDetail { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "schedule_id") + private long resourceId; + + @Column(name = "name") + private String name; + + @Column(name = "value", length = 1024) + private String value; + + @Column(name = "display") + private boolean display = true; + + public ResourceScheduleDetailVO() { + } + + public ResourceScheduleDetailVO(long scheduleId, String name, String value, boolean display) { + this.resourceId = scheduleId; + this.name = name; + this.value = value; + this.display = display; + } + + @Override + public long getId() { + return id; + } + + @Override + public long getResourceId() { + return resourceId; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getValue() { + return value; + } + + @Override + public boolean isDisplay() { + return display; + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/vm/schedule/VMScheduleVO.java b/engine/schema/src/main/java/org/apache/cloudstack/schedule/ResourceScheduleVO.java similarity index 71% rename from engine/schema/src/main/java/org/apache/cloudstack/vm/schedule/VMScheduleVO.java rename to engine/schema/src/main/java/org/apache/cloudstack/schedule/ResourceScheduleVO.java index e0065db1e77..33a09d79239 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/vm/schedule/VMScheduleVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/schedule/ResourceScheduleVO.java @@ -16,9 +16,10 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.cloudstack.vm.schedule; +package org.apache.cloudstack.schedule; import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.api.ApiCommandResourceType; import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; import javax.persistence.Column; @@ -37,8 +38,8 @@ import java.util.TimeZone; import java.util.UUID; @Entity -@Table(name = "vm_schedule") -public class VMScheduleVO implements VMSchedule { +@Table(name = "resource_schedule") +public class ResourceScheduleVO implements ResourceSchedule { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id", nullable = false) @@ -50,8 +51,12 @@ public class VMScheduleVO implements VMSchedule { @Column(name = "description") String description; - @Column(name = "vm_id", nullable = false) - long vmId; + @Enumerated(value = EnumType.STRING) + @Column(name = "resource_type", nullable = false) + ApiCommandResourceType resourceType; + + @Column(name = "resource_id", nullable = false) + long resourceId; @Column(name = "schedule", nullable = false) String schedule; @@ -60,8 +65,7 @@ public class VMScheduleVO implements VMSchedule { String timeZone; @Column(name = "action", nullable = false) - @Enumerated(value = EnumType.STRING) - Action action; + String actionName; @Column(name = "enabled", nullable = false) boolean enabled; @@ -80,17 +84,19 @@ public class VMScheduleVO implements VMSchedule { @Column(name = GenericDao.REMOVED_COLUMN) Date removed; - public VMScheduleVO() { + public ResourceScheduleVO() { uuid = UUID.randomUUID().toString(); } - public VMScheduleVO(long vmId, String description, String schedule, String timeZone, Action action, Date startDate, Date endDate, boolean enabled) { + public ResourceScheduleVO(ApiCommandResourceType resourceType, long resourceId, String description, String schedule, + String timeZone, String action, Date startDate, Date endDate, boolean enabled) { uuid = UUID.randomUUID().toString(); - this.vmId = vmId; + this.resourceType = resourceType; + this.resourceId = resourceId; this.description = description; this.schedule = schedule; this.timeZone = timeZone; - this.action = action; + this.actionName = action; this.startDate = startDate; this.endDate = endDate; this.enabled = enabled; @@ -98,7 +104,8 @@ public class VMScheduleVO implements VMSchedule { @Override public String toString() { - return String.format("VMSchedule %s", ReflectionToStringBuilderUtils.reflectOnlySelectedFields(this, "id", "uuid", "action", "description")); + return String.format("ResourceSchedule %s", ReflectionToStringBuilderUtils.reflectOnlySelectedFields( + this, "id", "uuid", "resourceType", "actionName", "description", "enabled")); } @Override @@ -111,14 +118,25 @@ public class VMScheduleVO implements VMSchedule { return id; } - public long getVmId() { - return vmId; + @Override + public ApiCommandResourceType getResourceType() { + return resourceType; } - public void setVmId(long vmId) { - this.vmId = vmId; + public void setResourceType(ApiCommandResourceType resourceType) { + this.resourceType = resourceType; } + @Override + public long getResourceId() { + return resourceId; + } + + public void setResourceId(long resourceId) { + this.resourceId = resourceId; + } + + @Override public String getDescription() { return description; } @@ -127,6 +145,7 @@ public class VMScheduleVO implements VMSchedule { this.description = description; } + @Override public String getSchedule() { return schedule.substring(2); } @@ -144,14 +163,16 @@ public class VMScheduleVO implements VMSchedule { this.timeZone = timeZone; } - public Action getAction() { - return action; + @Override + public String getActionName() { + return actionName; } - public void setAction(Action action) { - this.action = action; + public void setActionName(String action) { + this.actionName = action; } + @Override public boolean getEnabled() { return enabled; } @@ -183,6 +204,7 @@ public class VMScheduleVO implements VMSchedule { return TimeZone.getTimeZone(getTimeZone()).toZoneId(); } + @Override public Date getCreated() { return created; } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/vm/schedule/VMScheduledJobVO.java b/engine/schema/src/main/java/org/apache/cloudstack/schedule/ResourceScheduledJobVO.java similarity index 64% rename from engine/schema/src/main/java/org/apache/cloudstack/vm/schedule/VMScheduledJobVO.java rename to engine/schema/src/main/java/org/apache/cloudstack/schedule/ResourceScheduledJobVO.java index 775e9cfe40c..ca03056b67a 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/vm/schedule/VMScheduledJobVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/schedule/ResourceScheduledJobVO.java @@ -16,8 +16,9 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.cloudstack.vm.schedule; +package org.apache.cloudstack.schedule; +import org.apache.cloudstack.api.ApiCommandResourceType; import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; import javax.persistence.Column; @@ -34,8 +35,8 @@ import java.util.Date; import java.util.UUID; @Entity -@Table(name = "vm_scheduled_job") -public class VMScheduledJobVO implements VMScheduledJob { +@Table(name = "resource_scheduled_job") +public class ResourceScheduledJobVO implements ResourceScheduledJob { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") @@ -44,41 +45,44 @@ public class VMScheduledJobVO implements VMScheduledJob { @Column(name = "uuid", nullable = false) String uuid; - @Column(name = "vm_id", nullable = false) - long vmId; + @Enumerated(value = EnumType.STRING) + @Column(name = "resource_type", nullable = false) + ApiCommandResourceType resourceType; - @Column(name = "vm_schedule_id", nullable = false) - long vmScheduleId; + @Column(name = "resource_id", nullable = false) + long resourceId; + + @Column(name = "schedule_id", nullable = false) + long scheduleId; @Column(name = "async_job_id") Long asyncJobId; @Column(name = "action", nullable = false) - @Enumerated(value = EnumType.STRING) - VMSchedule.Action action; + String actionName; @Column(name = "scheduled_timestamp") @Temporal(value = TemporalType.TIMESTAMP) Date scheduledTime; - public VMScheduledJobVO() { + public ResourceScheduledJobVO() { uuid = UUID.randomUUID().toString(); } - public VMScheduledJobVO(long vmId, long vmScheduleId, VMSchedule.Action action, Date scheduledTime) { + public ResourceScheduledJobVO(ApiCommandResourceType resourceType, long resourceId, long scheduleId, String action, Date scheduledTime) { uuid = UUID.randomUUID().toString(); - this.vmId = vmId; - this.vmScheduleId = vmScheduleId; - this.action = action; + this.resourceType = resourceType; + this.resourceId = resourceId; + this.scheduleId = scheduleId; + this.actionName = action; this.scheduledTime = scheduledTime; } - @Override public String toString() { - return String.format("VMScheduledJob %s", + return String.format("ResourceScheduledJob %s", ReflectionToStringBuilderUtils.reflectOnlySelectedFields( - this, "id", "uuid", "action", "vmScheduleId", "vmId", "asyncJobId")); + this, "id", "uuid", "resourceType", "actionName", "scheduleId", "resourceId", "asyncJobId")); } @Override @@ -92,13 +96,18 @@ public class VMScheduledJobVO implements VMScheduledJob { } @Override - public long getVmId() { - return vmId; + public ApiCommandResourceType getResourceType() { + return resourceType; } @Override - public long getVmScheduleId() { - return vmScheduleId; + public long getResourceId() { + return resourceId; + } + + @Override + public long getScheduleId() { + return scheduleId; } @Override @@ -112,8 +121,12 @@ public class VMScheduledJobVO implements VMScheduledJob { } @Override - public VMSchedule.Action getAction() { - return action; + public String getActionName() { + return actionName; + } + + public void setActionName(String action) { + this.actionName = action; } @Override diff --git a/engine/schema/src/main/java/org/apache/cloudstack/schedule/dao/ResourceScheduleDao.java b/engine/schema/src/main/java/org/apache/cloudstack/schedule/dao/ResourceScheduleDao.java new file mode 100644 index 00000000000..ee56991fb9a --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/schedule/dao/ResourceScheduleDao.java @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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.schedule.dao; + +import com.cloud.utils.Pair; +import com.cloud.utils.db.GenericDao; +import com.cloud.utils.db.SearchCriteria; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.schedule.ResourceScheduleVO; + +import java.util.List; + +public interface ResourceScheduleDao extends GenericDao { + List listAllActiveSchedules(ApiCommandResourceType resourceType); + + long removeSchedulesForResourceAndIds(ApiCommandResourceType resourceType, long resourceId, List ids); + + long removeAllSchedulesForResource(ApiCommandResourceType resourceType, long resourceId); + + Pair, Integer> searchAndCount(List ids, ApiCommandResourceType resourceType, Long resourceId, + String action, Boolean enabled, Long offset, Long limit); + + SearchCriteria getSearchCriteriaForResource(ApiCommandResourceType resourceType, long resourceId); +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/schedule/dao/ResourceScheduleDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/schedule/dao/ResourceScheduleDaoImpl.java new file mode 100644 index 00000000000..7217d1359a8 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/schedule/dao/ResourceScheduleDaoImpl.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.schedule.dao; + +import com.cloud.utils.Pair; +import com.cloud.utils.db.Filter; +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.schedule.ResourceScheduleVO; +import org.apache.commons.collections4.CollectionUtils; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class ResourceScheduleDaoImpl extends GenericDaoBase implements ResourceScheduleDao { + + private final SearchBuilder activeScheduleSearch; + private final SearchBuilder allSearch; + + static final String RESOURCE_TYPE = "resourceType"; + static final String RESOURCE_ID = "resourceId"; + + public ResourceScheduleDaoImpl() { + super(); + + activeScheduleSearch = createSearchBuilder(); + activeScheduleSearch.and(RESOURCE_TYPE, activeScheduleSearch.entity().getResourceType(), SearchCriteria.Op.EQ); + activeScheduleSearch.and(ApiConstants.ENABLED, activeScheduleSearch.entity().getEnabled(), SearchCriteria.Op.EQ); + activeScheduleSearch.done(); + + allSearch = createSearchBuilder(); + allSearch.and(ApiConstants.ID, allSearch.entity().getId(), SearchCriteria.Op.IN); + allSearch.and(RESOURCE_TYPE, allSearch.entity().getResourceType(), SearchCriteria.Op.EQ); + allSearch.and(RESOURCE_ID, allSearch.entity().getResourceId(), SearchCriteria.Op.EQ); + allSearch.and(ApiConstants.ACTION, allSearch.entity().getActionName(), SearchCriteria.Op.EQ); + allSearch.and(ApiConstants.ENABLED, allSearch.entity().getEnabled(), SearchCriteria.Op.EQ); + allSearch.done(); + } + + @Override + public List listAllActiveSchedules(ApiCommandResourceType resourceType) { + SearchCriteria sc = activeScheduleSearch.create(); + sc.setParameters(RESOURCE_TYPE, resourceType); + sc.setParameters(ApiConstants.ENABLED, true); + return search(sc, null); + } + + @Override + public long removeSchedulesForResourceAndIds(ApiCommandResourceType resourceType, long resourceId, List ids) { + SearchCriteria sc = allSearch.create(); + if (CollectionUtils.isNotEmpty(ids)) { + sc.setParameters(ApiConstants.ID, ids.toArray()); + } + sc.setParameters(RESOURCE_TYPE, resourceType); + sc.setParameters(RESOURCE_ID, resourceId); + return remove(sc); + } + + @Override + public long removeAllSchedulesForResource(ApiCommandResourceType resourceType, long resourceId) { + SearchCriteria sc = allSearch.create(); + sc.setParameters(RESOURCE_TYPE, resourceType); + sc.setParameters(RESOURCE_ID, resourceId); + return remove(sc); + } + + @Override + public Pair, Integer> searchAndCount(List ids, ApiCommandResourceType resourceType, Long resourceId, + String action, Boolean enabled, Long offset, Long limit) { + SearchCriteria sc = allSearch.create(); + if (CollectionUtils.isNotEmpty(ids)) { + sc.setParameters(ApiConstants.ID, ids.toArray()); + } + sc.setParametersIfNotNull(ApiConstants.ENABLED, enabled); + sc.setParametersIfNotNull(ApiConstants.ACTION, action); + sc.setParametersIfNotNull(RESOURCE_TYPE, resourceType); + sc.setParametersIfNotNull(RESOURCE_ID, resourceId); + Filter filter = new Filter(ResourceScheduleVO.class, ApiConstants.ID, false, offset, limit); + return searchAndCount(sc, filter); + } + + @Override + public SearchCriteria getSearchCriteriaForResource(ApiCommandResourceType resourceType, long resourceId) { + SearchCriteria sc = allSearch.create(); + sc.setParameters(RESOURCE_TYPE, resourceType); + sc.setParameters(RESOURCE_ID, resourceId); + return sc; + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/schedule/dao/ResourceScheduleDetailsDao.java b/engine/schema/src/main/java/org/apache/cloudstack/schedule/dao/ResourceScheduleDetailsDao.java new file mode 100644 index 00000000000..f36ce8d0879 --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/schedule/dao/ResourceScheduleDetailsDao.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.schedule.dao; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.resourcedetail.ResourceDetailsDao; +import org.apache.cloudstack.schedule.ResourceScheduleDetailVO; + +public interface ResourceScheduleDetailsDao extends GenericDao, ResourceDetailsDao { +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/schedule/dao/ResourceScheduleDetailsDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/schedule/dao/ResourceScheduleDetailsDaoImpl.java new file mode 100644 index 00000000000..3757e41d0cf --- /dev/null +++ b/engine/schema/src/main/java/org/apache/cloudstack/schedule/dao/ResourceScheduleDetailsDaoImpl.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.schedule.dao; + +import org.apache.cloudstack.resourcedetail.ResourceDetailsDaoBase; +import org.apache.cloudstack.schedule.ResourceScheduleDetailVO; + +public class ResourceScheduleDetailsDaoImpl extends ResourceDetailsDaoBase implements ResourceScheduleDetailsDao { + + @Override + public void addDetail(long resourceId, String key, String value, boolean display) { + super.addDetail(new ResourceScheduleDetailVO(resourceId, key, value, display)); + } +} diff --git a/engine/schema/src/main/java/org/apache/cloudstack/vm/schedule/dao/VMScheduleDao.java b/engine/schema/src/main/java/org/apache/cloudstack/schedule/dao/ResourceScheduledJobDao.java similarity index 57% rename from engine/schema/src/main/java/org/apache/cloudstack/vm/schedule/dao/VMScheduleDao.java rename to engine/schema/src/main/java/org/apache/cloudstack/schedule/dao/ResourceScheduledJobDao.java index b8c808b5bfc..16210802235 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/vm/schedule/dao/VMScheduleDao.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/schedule/dao/ResourceScheduledJobDao.java @@ -16,22 +16,21 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.cloudstack.vm.schedule.dao; +package org.apache.cloudstack.schedule.dao; -import com.cloud.utils.Pair; import com.cloud.utils.db.GenericDao; -import com.cloud.utils.db.SearchCriteria; -import org.apache.cloudstack.vm.schedule.VMSchedule; -import org.apache.cloudstack.vm.schedule.VMScheduleVO; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.schedule.ResourceScheduledJobVO; +import java.util.Date; import java.util.List; -public interface VMScheduleDao extends GenericDao { - List listAllActiveSchedules(); +public interface ResourceScheduledJobDao extends GenericDao { + List listJobsToStart(ApiCommandResourceType resourceType, Date currentTimestamp); - long removeSchedulesForVmIdAndIds(Long vmId, List ids); + int expungeJobsForSchedules(List scheduleIds, Date dateAfter); - Pair, Integer> searchAndCount(Long id, Long vmId, VMSchedule.Action action, Boolean enabled, Long offset, Long limit); + int expungeJobsBefore(ApiCommandResourceType resourceType, Date currentTimestamp); - SearchCriteria getSearchCriteriaForVMId(Long vmId); + ResourceScheduledJobVO findByScheduleAndTimestamp(long scheduleId, Date scheduledTimestamp); } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/vm/schedule/dao/VMScheduledJobDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/schedule/dao/ResourceScheduledJobDaoImpl.java similarity index 55% rename from engine/schema/src/main/java/org/apache/cloudstack/vm/schedule/dao/VMScheduledJobDaoImpl.java rename to engine/schema/src/main/java/org/apache/cloudstack/schedule/dao/ResourceScheduledJobDaoImpl.java index 2f08a41b92e..02c1acb6c71 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/vm/schedule/dao/VMScheduledJobDaoImpl.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/schedule/dao/ResourceScheduledJobDaoImpl.java @@ -16,13 +16,14 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.cloudstack.vm.schedule.dao; +package org.apache.cloudstack.schedule.dao; import com.cloud.utils.db.Filter; import com.cloud.utils.db.GenericDaoBase; import com.cloud.utils.db.SearchBuilder; import com.cloud.utils.db.SearchCriteria; -import org.apache.cloudstack.vm.schedule.VMScheduledJobVO; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.schedule.ResourceScheduledJobVO; import org.apache.commons.lang3.time.DateUtils; import org.springframework.stereotype.Component; @@ -31,62 +32,59 @@ import java.util.Date; import java.util.List; @Component -public class VMScheduledJobDaoImpl extends GenericDaoBase implements VMScheduledJobDao { +public class ResourceScheduledJobDaoImpl extends GenericDaoBase implements ResourceScheduledJobDao { - private final SearchBuilder jobsToStartSearch; + private final SearchBuilder jobsToStartSearch; + private final SearchBuilder expungeJobsBeforeSearch; + private final SearchBuilder expungeJobForScheduleSearch; + private final SearchBuilder scheduleAndTimestampSearch; - private final SearchBuilder expungeJobsBeforeSearch; + static final String SCHEDULED_TIMESTAMP = "scheduledTimestamp"; + static final String SCHEDULE_ID = "scheduleId"; + static final String RESOURCE_TYPE = "resourceType"; - private final SearchBuilder expungeJobForScheduleSearch; - - private final SearchBuilder scheduleAndTimestampSearch; - - static final String SCHEDULED_TIMESTAMP = "scheduled_timestamp"; - - static final String VM_SCHEDULE_ID = "vm_schedule_id"; - - public VMScheduledJobDaoImpl() { + public ResourceScheduledJobDaoImpl() { super(); + jobsToStartSearch = createSearchBuilder(); + jobsToStartSearch.and(RESOURCE_TYPE, jobsToStartSearch.entity().getResourceType(), SearchCriteria.Op.EQ); jobsToStartSearch.and(SCHEDULED_TIMESTAMP, jobsToStartSearch.entity().getScheduledTime(), SearchCriteria.Op.EQ); jobsToStartSearch.and("async_job_id", jobsToStartSearch.entity().getAsyncJobId(), SearchCriteria.Op.NULL); jobsToStartSearch.done(); expungeJobsBeforeSearch = createSearchBuilder(); + expungeJobsBeforeSearch.and(RESOURCE_TYPE, expungeJobsBeforeSearch.entity().getResourceType(), SearchCriteria.Op.EQ); expungeJobsBeforeSearch.and(SCHEDULED_TIMESTAMP, expungeJobsBeforeSearch.entity().getScheduledTime(), SearchCriteria.Op.LT); expungeJobsBeforeSearch.done(); expungeJobForScheduleSearch = createSearchBuilder(); - expungeJobForScheduleSearch.and(VM_SCHEDULE_ID, expungeJobForScheduleSearch.entity().getVmScheduleId(), SearchCriteria.Op.IN); + expungeJobForScheduleSearch.and(SCHEDULE_ID, expungeJobForScheduleSearch.entity().getScheduleId(), SearchCriteria.Op.IN); expungeJobForScheduleSearch.and(SCHEDULED_TIMESTAMP, expungeJobForScheduleSearch.entity().getScheduledTime(), SearchCriteria.Op.GTEQ); expungeJobForScheduleSearch.done(); scheduleAndTimestampSearch = createSearchBuilder(); - scheduleAndTimestampSearch.and(VM_SCHEDULE_ID, scheduleAndTimestampSearch.entity().getVmScheduleId(), SearchCriteria.Op.EQ); + scheduleAndTimestampSearch.and(SCHEDULE_ID, scheduleAndTimestampSearch.entity().getScheduleId(), SearchCriteria.Op.EQ); scheduleAndTimestampSearch.and(SCHEDULED_TIMESTAMP, scheduleAndTimestampSearch.entity().getScheduledTime(), SearchCriteria.Op.EQ); scheduleAndTimestampSearch.done(); } - /** - * Execution of job wouldn't be at exact seconds. So, we round off and then execute. - */ @Override - public List listJobsToStart(Date currentTimestamp) { + public List listJobsToStart(ApiCommandResourceType resourceType, Date currentTimestamp) { if (currentTimestamp == null) { currentTimestamp = new Date(); } Date truncatedTs = DateUtils.round(currentTimestamp, Calendar.MINUTE); - - SearchCriteria sc = jobsToStartSearch.create(); + SearchCriteria sc = jobsToStartSearch.create(); + sc.setParameters(RESOURCE_TYPE, resourceType); sc.setParameters(SCHEDULED_TIMESTAMP, truncatedTs); - Filter filter = new Filter(VMScheduledJobVO.class, "vmScheduleId", true, null, null); + Filter filter = new Filter(ResourceScheduledJobVO.class, "scheduleId", true, null, null); return search(sc, filter); } @Override - public int expungeJobsForSchedules(List vmScheduleIds, Date dateAfter) { - SearchCriteria sc = expungeJobForScheduleSearch.create(); - sc.setParameters(VM_SCHEDULE_ID, vmScheduleIds.toArray()); + public int expungeJobsForSchedules(List scheduleIds, Date dateAfter) { + SearchCriteria sc = expungeJobForScheduleSearch.create(); + sc.setParameters(SCHEDULE_ID, scheduleIds.toArray()); if (dateAfter != null) { sc.setParameters(SCHEDULED_TIMESTAMP, dateAfter); } @@ -94,16 +92,17 @@ public class VMScheduledJobDaoImpl extends GenericDaoBase sc = expungeJobsBeforeSearch.create(); + public int expungeJobsBefore(ApiCommandResourceType resourceType, Date date) { + SearchCriteria sc = expungeJobsBeforeSearch.create(); + sc.setParameters(RESOURCE_TYPE, resourceType); sc.setParameters(SCHEDULED_TIMESTAMP, date); return expunge(sc); } @Override - public VMScheduledJobVO findByScheduleAndTimestamp(long scheduleId, Date scheduledTimestamp) { - SearchCriteria sc = scheduleAndTimestampSearch.create(); - sc.setParameters(VM_SCHEDULE_ID, scheduleId); + public ResourceScheduledJobVO findByScheduleAndTimestamp(long scheduleId, Date scheduledTimestamp) { + SearchCriteria sc = scheduleAndTimestampSearch.create(); + sc.setParameters(SCHEDULE_ID, scheduleId); sc.setParameters(SCHEDULED_TIMESTAMP, scheduledTimestamp); return findOneBy(sc); } diff --git a/engine/schema/src/main/java/org/apache/cloudstack/vm/schedule/dao/VMScheduleDaoImpl.java b/engine/schema/src/main/java/org/apache/cloudstack/vm/schedule/dao/VMScheduleDaoImpl.java deleted file mode 100644 index db8c0c3068f..00000000000 --- a/engine/schema/src/main/java/org/apache/cloudstack/vm/schedule/dao/VMScheduleDaoImpl.java +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT 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.vm.schedule.dao; - -import com.cloud.utils.Pair; -import com.cloud.utils.db.Filter; -import com.cloud.utils.db.GenericDaoBase; -import com.cloud.utils.db.SearchBuilder; -import com.cloud.utils.db.SearchCriteria; -import org.apache.cloudstack.api.ApiConstants; -import org.apache.cloudstack.vm.schedule.VMSchedule; -import org.apache.cloudstack.vm.schedule.VMScheduleVO; -import org.springframework.stereotype.Component; - -import java.util.Date; -import java.util.List; - -@Component -public class VMScheduleDaoImpl extends GenericDaoBase implements VMScheduleDao { - - private final SearchBuilder activeScheduleSearch; - - private final SearchBuilder scheduleSearchByVmIdAndIds; - - private final SearchBuilder scheduleSearch; - - public VMScheduleDaoImpl() { - super(); - activeScheduleSearch = createSearchBuilder(); - activeScheduleSearch.and(ApiConstants.ENABLED, activeScheduleSearch.entity().getEnabled(), SearchCriteria.Op.EQ); - activeScheduleSearch.and().op(activeScheduleSearch.entity().getEndDate(), SearchCriteria.Op.NULL); - activeScheduleSearch.or(ApiConstants.END_DATE, activeScheduleSearch.entity().getEndDate(), SearchCriteria.Op.GT); - activeScheduleSearch.cp(); - activeScheduleSearch.done(); - - scheduleSearchByVmIdAndIds = createSearchBuilder(); - scheduleSearchByVmIdAndIds.and(ApiConstants.ID, scheduleSearchByVmIdAndIds.entity().getId(), SearchCriteria.Op.IN); - scheduleSearchByVmIdAndIds.and(ApiConstants.VIRTUAL_MACHINE_ID, scheduleSearchByVmIdAndIds.entity().getVmId(), SearchCriteria.Op.EQ); - scheduleSearchByVmIdAndIds.done(); - - scheduleSearch = createSearchBuilder(); - scheduleSearch.and(ApiConstants.ID, scheduleSearch.entity().getId(), SearchCriteria.Op.EQ); - scheduleSearch.and(ApiConstants.VIRTUAL_MACHINE_ID, scheduleSearch.entity().getVmId(), SearchCriteria.Op.EQ); - scheduleSearch.and(ApiConstants.ACTION, scheduleSearch.entity().getAction(), SearchCriteria.Op.EQ); - scheduleSearch.and(ApiConstants.ENABLED, scheduleSearch.entity().getEnabled(), SearchCriteria.Op.EQ); - scheduleSearch.done(); - - } - - @Override - public List listAllActiveSchedules() { - // WHERE enabled = true AND (end_date IS NULL OR end_date > current_date) - SearchCriteria sc = activeScheduleSearch.create(); - sc.setParameters(ApiConstants.ENABLED, true); - sc.setParameters(ApiConstants.END_DATE, new Date()); - return search(sc, null); - } - - @Override - public long removeSchedulesForVmIdAndIds(Long vmId, List ids) { - SearchCriteria sc = scheduleSearchByVmIdAndIds.create(); - sc.setParameters(ApiConstants.ID, ids.toArray()); - sc.setParameters(ApiConstants.VIRTUAL_MACHINE_ID, vmId); - return remove(sc); - } - - @Override - public Pair, Integer> searchAndCount(Long id, Long vmId, VMSchedule.Action action, Boolean enabled, Long offset, Long limit) { - SearchCriteria sc = scheduleSearch.create(); - - if (id != null) { - sc.setParameters(ApiConstants.ID, id); - } - if (enabled != null) { - sc.setParameters(ApiConstants.ENABLED, enabled); - } - if (action != null) { - sc.setParameters(ApiConstants.ACTION, action); - } - sc.setParameters(ApiConstants.VIRTUAL_MACHINE_ID, vmId); - - Filter filter = new Filter(VMScheduleVO.class, ApiConstants.ID, false, offset, limit); - return searchAndCount(sc, filter); - } - - @Override - public SearchCriteria getSearchCriteriaForVMId(Long vmId) { - SearchCriteria sc = scheduleSearch.create(); - sc.setParameters(ApiConstants.VIRTUAL_MACHINE_ID, vmId); - return sc; - } -} diff --git a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml index c04833c3ab0..d7cacd09b9a 100644 --- a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml +++ b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml @@ -108,6 +108,7 @@ + @@ -284,8 +285,9 @@ - - + + + @@ -312,6 +314,11 @@ + + + + + diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql index 184f5abdd77..d6159e0adf0 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42210to42300.sql @@ -119,9 +119,178 @@ CALL `cloud`.`IDEMPOTENT_UPDATE_API_PERMISSION`('Resource Admin', 'listUserKeyRu -- Add conserve mode for VPC offerings CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vpc_offerings','conserve_mode', 'tinyint(1) unsigned NULL DEFAULT 0 COMMENT ''True if the VPC offering is IP conserve mode enabled, allowing public IP services to be used across multiple VPC tiers'' '); +-- KMS HSM Profiles (Generic table for PKCS#11, KMIP, etc.) +-- Scoped to account (user-provided) or global/zone (admin-provided) +CREATE TABLE IF NOT EXISTS `cloud`.`kms_hsm_profiles` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` VARCHAR(40) NOT NULL, + `name` VARCHAR(255) NOT NULL, + `protocol` VARCHAR(32) NOT NULL COMMENT 'Protocol for the HSM Profile', + + -- Scoping + `account_id` BIGINT UNSIGNED COMMENT 'null = admin-provided (available to all accounts)', + `domain_id` BIGINT UNSIGNED COMMENT 'null = zone/global scope', + `zone_id` BIGINT UNSIGNED COMMENT 'null = global scope', + + -- Metadata + `vendor_name` VARCHAR(64) COMMENT 'HSM vendor', + `enabled` BOOLEAN NOT NULL DEFAULT TRUE, + `is_public` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'Public profile. Available to all accounts', + `created` DATETIME NOT NULL, + `removed` DATETIME, + + PRIMARY KEY (`id`), + UNIQUE KEY `uk_uuid` (`uuid`), + UNIQUE KEY `uk_account_name` (`account_id`, `name`, `removed`), + INDEX `idx_protocol_enabled` (`protocol`, `enabled`, `removed`), + INDEX `idx_scoping` (`account_id`, `domain_id`, `zone_id`, `removed`), + CONSTRAINT `fk_kms_hsm_profiles__account_id` FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_kms_hsm_profiles__domain_id` FOREIGN KEY (`domain_id`) REFERENCES `domain`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_kms_hsm_profiles__zone_id` FOREIGN KEY (`zone_id`) REFERENCES `data_center`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='HSM profiles for KMS providers'; + +-- KMS HSM Profile Details (Protocol-specific configuration) +CREATE TABLE IF NOT EXISTS `cloud`.`kms_hsm_profile_details` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `profile_id` BIGINT UNSIGNED NOT NULL COMMENT 'HSM profile ID', + `name` VARCHAR(255) NOT NULL COMMENT 'Config key (e.g. library_path, endpoint, pin, cert_content)', + `value` TEXT NOT NULL COMMENT 'Config value (encrypted if sensitive)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_profile_name` (`profile_id`, `name`), + CONSTRAINT `fk_kms_hsm_profile_details__profile_id` FOREIGN KEY (`profile_id`) REFERENCES `kms_hsm_profiles`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Details for HSM profiles (key-value configuration)'; + +-- KMS Keys (Key Encryption Key Metadata) +-- Account-scoped KEKs for envelope encryption +CREATE TABLE IF NOT EXISTS `cloud`.`kms_keys` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` VARCHAR(40) NOT NULL COMMENT 'UUID - user-facing identifier', + `name` VARCHAR(255) NOT NULL COMMENT 'User-friendly name', + `description` VARCHAR(1024) COMMENT 'User description', + `kek_label` VARCHAR(255) NOT NULL COMMENT 'Provider-specific KEK label/ID', + `purpose` VARCHAR(32) NOT NULL COMMENT 'Key purpose (VOLUME_ENCRYPTION, TLS_CERT)', + `account_id` BIGINT UNSIGNED NOT NULL COMMENT 'Owning account', + `domain_id` BIGINT UNSIGNED NOT NULL COMMENT 'Owning domain', + `zone_id` BIGINT UNSIGNED NOT NULL COMMENT 'Zone where key is valid', + `algorithm` VARCHAR(64) NOT NULL DEFAULT 'AES/GCM/NoPadding' COMMENT 'Encryption algorithm', + `key_bits` INT NOT NULL DEFAULT 256 COMMENT 'Key size in bits', + `enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Whether the key is enabled for new cryptographic operations', + `hsm_profile_id` BIGINT UNSIGNED NOT NULL COMMENT 'Current HSM profile ID for this key', + `created` DATETIME NOT NULL COMMENT 'Creation timestamp', + `removed` DATETIME COMMENT 'Removal timestamp for soft delete', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_uuid` (`uuid`), + INDEX `idx_account_purpose` (`account_id`, `purpose`, `enabled`), + INDEX `idx_domain_purpose` (`domain_id`, `purpose`, `enabled`), + INDEX `idx_zone_enabled` (`zone_id`, `enabled`), + CONSTRAINT `fk_kms_keys__account_id` FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_kms_keys__domain_id` FOREIGN KEY (`domain_id`) REFERENCES `domain`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_kms_keys__zone_id` FOREIGN KEY (`zone_id`) REFERENCES `data_center`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_kms_keys__hsm_profile_id` FOREIGN KEY (`hsm_profile_id`) REFERENCES `kms_hsm_profiles`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='KMS Key (KEK) metadata - account-scoped keys for envelope encryption'; + +-- KMS KEK Versions (multiple KEKs per KMS key for gradual rotation) +-- Supports multiple KEK versions per logical KMS key during rotation +CREATE TABLE IF NOT EXISTS `cloud`.`kms_kek_versions` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` VARCHAR(40) NOT NULL COMMENT 'UUID', + `kms_key_id` BIGINT UNSIGNED NOT NULL COMMENT 'Reference to kms_keys table', + `version_number` INT NOT NULL COMMENT 'Version number (1, 2, 3, ...)', + `kek_label` VARCHAR(255) NOT NULL COMMENT 'Provider-specific KEK label/ID for this version', + `status` VARCHAR(32) NOT NULL DEFAULT 'Active' COMMENT 'Active, Previous, Archived', + `hsm_profile_id` BIGINT UNSIGNED COMMENT 'HSM profile where this KEK version is stored', + `created` DATETIME NOT NULL COMMENT 'Creation timestamp', + `removed` DATETIME COMMENT 'Removal timestamp for soft delete', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_uuid` (`uuid`), + UNIQUE KEY `uk_kms_key_version` (`kms_key_id`, `version_number`, `removed`), + INDEX `idx_kms_key_status` (`kms_key_id`, `status`, `removed`), + INDEX `idx_kek_label` (`kek_label`), + CONSTRAINT `fk_kms_kek_versions__kms_key_id` FOREIGN KEY (`kms_key_id`) REFERENCES `kms_keys`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_kms_kek_versions__hsm_profile_id` FOREIGN KEY (`hsm_profile_id`) REFERENCES `kms_hsm_profiles`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='KEK versions for a KMS key - supports gradual rotation'; + +-- KMS Wrapped Keys (Data Encryption Keys) +-- Generic table for wrapped DEKs - references kms_keys for metadata and kek_versions for specific KEK version +CREATE TABLE IF NOT EXISTS `cloud`.`kms_wrapped_key` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `uuid` VARCHAR(40) NOT NULL COMMENT 'UUID', + `kms_key_id` BIGINT UNSIGNED COMMENT 'Reference to kms_keys table', + `kek_version_id` BIGINT UNSIGNED COMMENT 'Reference to kms_kek_versions table', + `zone_id` BIGINT UNSIGNED NOT NULL COMMENT 'Zone ID for zone-scoped keys', + `wrapped_blob` VARBINARY(4096) NOT NULL COMMENT 'Encrypted DEK material', + `created` DATETIME NOT NULL COMMENT 'Creation timestamp', + `removed` DATETIME COMMENT 'Removal timestamp for soft delete', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_uuid` (`uuid`), + INDEX `idx_kms_key_id` (`kms_key_id`, `removed`), + INDEX `idx_kek_version_id` (`kek_version_id`, `removed`), + INDEX `idx_zone_id` (`zone_id`, `removed`), + CONSTRAINT `fk_kms_wrapped_key__kms_key_id` FOREIGN KEY (`kms_key_id`) REFERENCES `kms_keys`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_kms_wrapped_key__kek_version_id` FOREIGN KEY (`kek_version_id`) REFERENCES `kms_kek_versions`(`id`) ON DELETE CASCADE, + CONSTRAINT `fk_kms_wrapped_key__zone_id` FOREIGN KEY (`zone_id`) REFERENCES `data_center`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='KMS wrapped encryption keys (DEKs) - references kms_keys for KEK metadata and kek_versions for specific version'; + +-- Add KMS key reference to volumes table (which KMS key was used) +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.volumes', 'kms_key_id', 'BIGINT UNSIGNED COMMENT ''KMS key ID used for volume encryption'''); +CALL `cloud`.`IDEMPOTENT_ADD_FOREIGN_KEY`('cloud.volumes', 'fk_volumes__kms_key_id', '(kms_key_id)', '`kms_keys`(`id`)'); + +-- Add KMS wrapped key reference to volumes table +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.volumes', 'kms_wrapped_key_id', 'BIGINT UNSIGNED COMMENT ''KMS wrapped key ID for volume encryption'''); +CALL `cloud`.`IDEMPOTENT_ADD_FOREIGN_KEY`('cloud.volumes', 'fk_volumes__kms_wrapped_key_id', '(kms_wrapped_key_id)', '`kms_wrapped_key`(`id`)'); + +-- KMS Database Provider KEK Objects (PKCS#11-like object storage) +-- Stores KEKs for the database KMS provider in a PKCS#11-compatible format +CREATE TABLE IF NOT EXISTS `cloud`.`kms_database_kek_objects` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'Object handle (PKCS#11 CKA_HANDLE)', + `uuid` VARCHAR(40) NOT NULL COMMENT 'UUID', + -- PKCS#11 Object Class (CKA_CLASS) + `object_class` VARCHAR(32) NOT NULL DEFAULT 'CKO_SECRET_KEY' COMMENT 'PKCS#11 object class (CKO_SECRET_KEY, CKO_PRIVATE_KEY, etc.)', + -- PKCS#11 Label (CKA_LABEL) - human-readable identifier + `label` VARCHAR(255) NOT NULL COMMENT 'PKCS#11 label (CKA_LABEL) - human-readable identifier', + -- PKCS#11 ID (CKA_ID) - application-defined identifier + `object_id` VARBINARY(64) COMMENT 'PKCS#11 object ID (CKA_ID) - application-defined identifier', + -- Key Type (CKA_KEY_TYPE) + `key_type` VARCHAR(32) NOT NULL DEFAULT 'CKK_AES' COMMENT 'PKCS#11 key type (CKK_AES, CKK_RSA, etc.)', + -- Key Material (CKA_VALUE) - encrypted KEK material + `key_material` VARBINARY(512) NOT NULL COMMENT 'PKCS#11 key value (CKA_VALUE) - encrypted KEK material', + -- Key Attributes (PKCS#11 boolean attributes) + `is_sensitive` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'PKCS#11 CKA_SENSITIVE - key material is sensitive', + `is_extractable` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'PKCS#11 CKA_EXTRACTABLE - key can be extracted', + `is_token` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'PKCS#11 CKA_TOKEN - object is on token (persistent)', + `is_private` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'PKCS#11 CKA_PRIVATE - object is private', + `is_modifiable` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'PKCS#11 CKA_MODIFIABLE - object can be modified', + `is_copyable` BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'PKCS#11 CKA_COPYABLE - object can be copied', + `is_destroyable` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'PKCS#11 CKA_DESTROYABLE - object can be destroyed', + `always_sensitive` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'PKCS#11 CKA_ALWAYS_SENSITIVE - key was always sensitive', + `never_extractable` BOOLEAN NOT NULL DEFAULT TRUE COMMENT 'PKCS#11 CKA_NEVER_EXTRACTABLE - key was never extractable', + -- Key Metadata + `purpose` VARCHAR(32) NOT NULL COMMENT 'Key purpose (VOLUME_ENCRYPTION, TLS_CERT)', + `key_bits` INT NOT NULL COMMENT 'Key size in bits (128, 192, 256)', + `algorithm` VARCHAR(64) NOT NULL DEFAULT 'AES/GCM/NoPadding' COMMENT 'Encryption algorithm', + -- Validity Dates (PKCS#11 CKA_START_DATE, CKA_END_DATE) + `start_date` DATETIME COMMENT 'PKCS#11 CKA_START_DATE - key validity start', + `end_date` DATETIME COMMENT 'PKCS#11 CKA_END_DATE - key validity end', + -- Lifecycle + `created` DATETIME NOT NULL COMMENT 'Creation timestamp', + `last_used` DATETIME COMMENT 'Last usage timestamp', + `removed` DATETIME COMMENT 'Removal timestamp for soft delete', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_uuid` (`uuid`), + UNIQUE KEY `uk_label_removed` (`label`, `removed`), + INDEX `idx_purpose_removed` (`purpose`, `removed`), + INDEX `idx_key_type` (`key_type`, `removed`), + INDEX `idx_object_class` (`object_class`, `removed`), + INDEX `idx_created` (`created`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='KMS Database Provider KEK Objects - PKCS#11-like object storage for KEKs'; + --- Disable/enable NICs CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.nics','enabled', 'TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''Indicates whether the NIC is enabled or not'' '); +--- Add URLs for OAuth provider +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.oauth_provider','authorize_url', 'VARCHAR(255) DEFAULT NULL COMMENT ''Authorize URL for OAuth initialization'' '); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.oauth_provider','token_url', 'VARCHAR(255) DEFAULT NULL COMMENT ''Token URL for OAuth finalization'' '); + --- Quota tariff/usage mapping CREATE TABLE IF NOT EXISTS `cloud_usage`.`quota_tariff_usage` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, @@ -132,6 +301,20 @@ CREATE TABLE IF NOT EXISTS `cloud_usage`.`quota_tariff_usage` ( CONSTRAINT `fk_quota_tariff_usage__tariff_id` FOREIGN KEY (`tariff_id`) REFERENCES `cloud_usage`.`quota_tariff` (`id`), CONSTRAINT `fk_quota_tariff_usage__quota_usage_id` FOREIGN KEY (`quota_usage_id`) REFERENCES `cloud_usage`.`quota_usage` (`id`)); +--- Per-VM ISO attachments. user_vm.iso_id remains as the primary/bootable ISO pointer. +CREATE TABLE IF NOT EXISTS `cloud`.`vm_iso_map` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `vm_id` bigint(20) unsigned NOT NULL COMMENT 'foreign key to user_vm', + `iso_id` bigint(20) unsigned NOT NULL COMMENT 'foreign key to vm_template (ISOs are templates of format ISO)', + `device_seq` int(10) unsigned NOT NULL COMMENT 'cdrom slot index used to derive the libvirt device label (3=hdc, 4=hdd)', + `created` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uc_vm_iso_map__vm_iso` (`vm_id`, `iso_id`), + UNIQUE KEY `uc_vm_iso_map__vm_seq` (`vm_id`, `device_seq`), + CONSTRAINT `fk_vm_iso_map__vm_id` FOREIGN KEY (`vm_id`) REFERENCES `cloud`.`user_vm` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_vm_iso_map__iso_id` FOREIGN KEY (`iso_id`) REFERENCES `cloud`.`vm_template` (`id`) +); + -- Add the 'keep_mac_address_on_public_nic' column to the 'cloud.networks' and 'cloud.vpc' tables CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.networks', 'keep_mac_address_on_public_nic', 'TINYINT(1) NOT NULL DEFAULT 1'); CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.vpc', 'keep_mac_address_on_public_nic', 'TINYINT(1) NOT NULL DEFAULT 1'); @@ -154,6 +337,61 @@ FROM `cloud`.`configuration` `cfg` WHERE NOT EXISTS (SELECT 1 FROM `cloud`.`configuration` WHERE `name` = 'kvm.cpu.dynamic.scaling.capacity') AND `cfg`.`name` = 'vm.serviceoffering.cpu.cores.max'; +-- Generalise VM schedule tables into resource-agnostic resource_schedule / resource_scheduled_job. +-- Step 1: rename vm_schedule → resource_schedule, rename vm_id → resource_id, add resource_type column. +ALTER TABLE `cloud`.`vm_schedule` + DROP FOREIGN KEY `fk_vm_schedule__vm_id`, + DROP INDEX `i_vm_schedule__vm_id`, + DROP INDEX `i_vm_schedule__enabled_end_date`, + CHANGE COLUMN `vm_id` `resource_id` bigint unsigned NOT NULL COMMENT 'id of the scheduled resource', + ADD COLUMN `resource_type` varchar(64) NOT NULL DEFAULT 'VirtualMachine' COMMENT 'type of the scheduled resource' AFTER `uuid`; + +RENAME TABLE `cloud`.`vm_schedule` TO `cloud`.`resource_schedule`; + +ALTER TABLE `cloud`.`resource_schedule` + ADD INDEX `i_resource_schedule__resource` (`resource_type`, `resource_id`), + ADD INDEX `i_resource_schedule__enabled_end_date` (`enabled`, `end_date`); + +-- Step 2: rename vm_scheduled_job → resource_scheduled_job, rename columns. +ALTER TABLE `cloud`.`vm_scheduled_job` + DROP FOREIGN KEY `fk_vm_scheduled_job__vm_id`, + DROP FOREIGN KEY `fk_vm_scheduled_job__vm_schedule_id`, + DROP INDEX `i_vm_scheduled_job__vm_id`, + DROP INDEX `i_vm_scheduled_job__scheduled_timestamp`, + DROP INDEX `vm_schedule_id`, + CHANGE COLUMN `vm_id` `resource_id` bigint unsigned NOT NULL COMMENT 'id of the scheduled resource', + CHANGE COLUMN `vm_schedule_id` `schedule_id` bigint unsigned NOT NULL COMMENT 'id of the resource_schedule row', + ADD COLUMN `resource_type` varchar(64) NOT NULL DEFAULT 'VirtualMachine' COMMENT 'type of the scheduled resource' AFTER `uuid`; + +RENAME TABLE `cloud`.`vm_scheduled_job` TO `cloud`.`resource_scheduled_job`; + +ALTER TABLE `cloud`.`resource_scheduled_job` + ADD UNIQUE KEY `uc_resource_scheduled_job__schedule_timestamp` (`schedule_id`, `scheduled_timestamp`), + ADD INDEX `i_resource_scheduled_job__resource` (`resource_type`, `resource_id`), + ADD INDEX `i_resource_scheduled_job__scheduled_timestamp` (`scheduled_timestamp`), + ADD CONSTRAINT `fk_resource_scheduled_job__schedule_id` FOREIGN KEY (`schedule_id`) REFERENCES `resource_schedule`(`id`) ON DELETE CASCADE; + +-- Step 3: details table for action-specific parameters (used by the generic resource schedule API in Commit 2). +CREATE TABLE IF NOT EXISTS `cloud`.`resource_schedule_details` ( + `id` bigint unsigned NOT NULL auto_increment, + `schedule_id` bigint unsigned NOT NULL COMMENT 'id of the resource_schedule row', + `name` varchar(255) NOT NULL, + `value` varchar(1024) NOT NULL, + `display` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'should this detail be visible to the end user', + PRIMARY KEY (`id`), + INDEX `i_resource_schedule_details__schedule_id` (`schedule_id`), + CONSTRAINT `fk_resource_schedule_details__schedule_id` FOREIGN KEY (`schedule_id`) REFERENCES `resource_schedule`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- Step 4: rename CRUD event types from VM.SCHEDULE.{CREATE,UPDATE,DELETE} to the new generic SCHEDULE.{CREATE,UPDATE,DELETE}. +-- Action-execution events (VM.SCHEDULE.START, .STOP, .REBOOT, .FORCE_STOP, .FORCE_REBOOT) keep their existing names. +UPDATE `cloud`.`event` SET `type` = 'SCHEDULE.CREATE' WHERE `type` = 'VM.SCHEDULE.CREATE'; +UPDATE `cloud`.`event` SET `type` = 'SCHEDULE.UPDATE' WHERE `type` = 'VM.SCHEDULE.UPDATE'; +UPDATE `cloud`.`event` SET `type` = 'SCHEDULE.DELETE' WHERE `type` = 'VM.SCHEDULE.DELETE'; + +-- Step 5: Rename the global configuration key for the scheduler +UPDATE `cloud`.`configuration` SET name='scheduler.jobs.expire.interval' WHERE name='vmscheduler.jobs.expire.interval'; + -- Remove stale realhostip.com default values; domain has been dead since ~2015. UPDATE `cloud`.`configuration` SET value = NULL @@ -216,6 +454,25 @@ WHERE rule = 'quotaStatement' AND NOT EXISTS(SELECT 1 FROM cloud.role_permission -- Add description for secondary IP addresses CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.nic_secondary_ips', 'description', 'VARCHAR(2048) DEFAULT NULL'); +-- Soft delete port forwarding, load balancing and firewall rules +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.firewall_rules', 'removed', 'datetime DEFAULT NULL'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.load_balancer_vm_map', 'removed', 'datetime DEFAULT NULL'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.load_balancer_cert_map', 'removed', 'datetime DEFAULT NULL'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.load_balancer_healthcheck_policies', 'removed', 'datetime DEFAULT NULL'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.load_balancer_stickiness_policies', 'removed', 'datetime DEFAULT NULL'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.global_load_balancer_lb_rule_map', 'removed', 'datetime DEFAULT NULL'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.elastic_lb_vm_map', 'removed', 'datetime DEFAULT NULL'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.tungsten_lb_health_monitor', 'removed', 'datetime DEFAULT NULL'); +CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.global_load_balancing_rules', 'removed', 'datetime DEFAULT NULL'); + +ALTER TABLE `cloud`.`load_balancer_vm_map` +DROP KEY `load_balancer_id`, +ADD UNIQUE KEY `load_balancer_id` (`load_balancer_id`, `instance_id`, `instance_ip`, `removed`); + +ALTER TABLE `cloud`.`global_load_balancer_lb_rule_map` +DROP KEY `gslb_rule_id`, +ADD UNIQUE KEY `gslb_rule_id` (`gslb_rule_id`, `lb_rule_id`, `removed`); + -- ====================================================================== -- DNS Framework Schema -- ====================================================================== diff --git a/engine/schema/src/main/resources/META-INF/db/views/cloud.volume_view.sql b/engine/schema/src/main/resources/META-INF/db/views/cloud.volume_view.sql index ffeb93e8fa7..8ba7e5c6df7 100644 --- a/engine/schema/src/main/resources/META-INF/db/views/cloud.volume_view.sql +++ b/engine/schema/src/main/resources/META-INF/db/views/cloud.volume_view.sql @@ -40,6 +40,10 @@ SELECT `volumes`.`chain_info` AS `chain_info`, `volumes`.`external_uuid` AS `external_uuid`, `volumes`.`encrypt_format` AS `encrypt_format`, + `volumes`.`kms_key_id` AS `kms_key_id`, + `kms_keys`.`uuid` AS `kms_key_uuid`, + `kms_keys`.`name` AS `kms_key_name`, + `volumes`.`kms_wrapped_key_id` AS `kms_wrapped_key_id`, `volumes`.`delete_protection` AS `delete_protection`, `account`.`id` AS `account_id`, `account`.`uuid` AS `account_uuid`, @@ -116,7 +120,7 @@ SELECT `resource_tag_domain`.`uuid` AS `tag_domain_uuid`, `resource_tag_domain`.`name` AS `tag_domain_name` FROM - ((((((((((((((((((`volumes` + (((((((((((((((((((`volumes` JOIN `account`ON ((`volumes`.`account_id` = `account`.`id`))) JOIN `domain`ON @@ -129,8 +133,10 @@ LEFT JOIN `vm_instance`ON ((`volumes`.`instance_id` = `vm_instance`.`id`))) LEFT JOIN `user_vm`ON ((`user_vm`.`id` = `vm_instance`.`id`))) -LEFT JOIN `volume_store_ref`ON +LEFT JOIN `volume_store_ref` ON ((`volumes`.`id` = `volume_store_ref`.`volume_id`))) +LEFT JOIN `kms_keys` ON + ((`volumes`.`kms_key_id` = `kms_keys`.`id`))) LEFT JOIN `service_offering`ON ((`vm_instance`.`service_offering_id` = `service_offering`.`id`))) LEFT JOIN `disk_offering`ON diff --git a/engine/schema/src/test/java/com/cloud/vm/VmIsoMapVOTest.java b/engine/schema/src/test/java/com/cloud/vm/VmIsoMapVOTest.java new file mode 100644 index 00000000000..d5b1fef7a76 --- /dev/null +++ b/engine/schema/src/test/java/com/cloud/vm/VmIsoMapVOTest.java @@ -0,0 +1,41 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.vm; + +import org.junit.Assert; +import org.junit.Test; + +public class VmIsoMapVOTest { + + @Test + public void testFullConstructorPopulatesAllFields() { + VmIsoMapVO row = new VmIsoMapVO(7L, 42L, 4); + Assert.assertEquals(7L, row.getVmId()); + Assert.assertEquals(42L, row.getIsoId()); + Assert.assertEquals(4, row.getDeviceSeq()); + Assert.assertNotNull(row.getCreated()); + } + + @Test + public void testNoArgConstructorLeavesNonIdFieldsAtDefaults() { + VmIsoMapVO row = new VmIsoMapVO(); + Assert.assertEquals(0L, row.getVmId()); + Assert.assertEquals(0L, row.getIsoId()); + Assert.assertEquals(0, row.getDeviceSeq()); + Assert.assertNull(row.getCreated()); + } +} diff --git a/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/AncientDataMotionStrategy.java b/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/AncientDataMotionStrategy.java index 95362f44b13..1144a29986a 100644 --- a/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/AncientDataMotionStrategy.java +++ b/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/AncientDataMotionStrategy.java @@ -790,9 +790,9 @@ public class AncientDataMotionStrategy implements DataMotionStrategy { private boolean anyVolumeRequiresEncryption(DataObject ... objects) { for (DataObject o : objects) { // this fails code smell for returning true twice, but it is more readable than combining all tests into one statement - if (o instanceof VolumeInfo && ((VolumeInfo) o).getPassphraseId() != null) { + if (o instanceof VolumeInfo && (((VolumeInfo) o).getPassphraseId() != null || ((VolumeInfo) o).getKmsKeyId() != null)) { return true; - } else if (o instanceof SnapshotInfo && ((SnapshotInfo) o).getBaseVolume().getPassphraseId() != null) { + } else if (o instanceof SnapshotInfo && (((SnapshotInfo) o).getBaseVolume().getPassphraseId() != null || ((SnapshotInfo) o).getBaseVolume().getKmsKeyId() != null)) { return true; } } diff --git a/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java b/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java index 2c791d05980..2be0b981455 100644 --- a/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java +++ b/engine/storage/datamotion/src/main/java/org/apache/cloudstack/storage/motion/StorageSystemDataMotionStrategy.java @@ -2592,7 +2592,11 @@ public class StorageSystemDataMotionStrategy implements DataMotionStrategy { newVol.setLastPoolId(lastPoolId); newVol.setLastId(volume.getId()); - if (volume.getPassphraseId() != null) { + if (volume.getKmsKeyId() != null) { + newVol.setKmsKeyId(volume.getKmsKeyId()); + newVol.setKmsWrappedKeyId(volume.getKmsWrappedKeyId()); + newVol.setEncryptFormat(volume.getEncryptFormat()); + } else if (volume.getPassphraseId() != null) { newVol.setPassphraseId(volume.getPassphraseId()); newVol.setEncryptFormat(volume.getEncryptFormat()); } 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 e29e89cf431..6e32df5d5e3 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 @@ -295,6 +295,171 @@ public class TemplateServiceImpl implements TemplateService { } } + private int countActiveSecStorageCopies(long templateId, long zoneId) { + List stores = _storeMgr.getImageStoresByScope(new ZoneScope(zoneId)); + if (stores == null || stores.isEmpty()) { + return 0; + } + int count = 0; + for (DataStore ds : stores) { + List rows = _vmTemplateStoreDao.listByTemplateStore(templateId, ds.getId()); + if (rows == null) { + continue; + } + for (TemplateDataStoreVO row : rows) { + State st = row.getState(); + Status ds_state = row.getDownloadState(); + if (st != State.Failed && st != State.Destroyed + && ds_state != Status.ABANDONED && ds_state != Status.DOWNLOAD_ERROR) { + count++; + break; + } + } + } + return count; + } + + /** + * Central gate for the secondary storage copy limit (secstorage.public/private.template.copy.max). + * Every template-landing path (periodic sync, cross-zone copy, register, upload) should consult this + * single method before placing another copy of a template on a secondary store in a zone, so the limit + * is enforced consistently instead of being re-implemented per call site. + * + * SYSTEM/ROUTING/BUILTIN templates and a limit of 0 mean "unlimited" (return true). The per-template, + * per-zone {@link GlobalLock} serializes concurrent placement decisions so racing SSVM syncs / copies + * cannot collectively exceed the limit. + */ + @Override + public boolean canCopyTemplateToImageStore(long templateId, long zoneId) { + VMTemplateVO template = _templateDao.findById(templateId); + if (template == null) { + return false; + } + int copyLimit = _tmpltMgr.getSecStorageCopyLimit(template, zoneId); + if (copyLimit <= 0) { + logger.debug("Template [{}] has no secondary storage copy limit in zone [{}] (limit={}); copy allowed.", + template.getUniqueName(), zoneId, copyLimit); + return true; + } + int count = countActiveSecStorageCopies(templateId, zoneId); + logger.debug("Template [{}] secstorage copy check in zone [{}]: count={}, limit={}", + template.getUniqueName(), zoneId, count, copyLimit); + return count < copyLimit; + } + + private boolean hasReachedSecStorageCopyLimit(VMTemplateVO template, long zoneId) { + return !canCopyTemplateToImageStore(template.getId(), zoneId); + } + + @Override + public void replicateTemplateUpToCap(long templateId, long zoneId) { + VMTemplateVO template = _templateDao.findById(templateId); + if (template == null) { + return; + } + int copyLimit = _tmpltMgr.getSecStorageCopyLimit(template, zoneId); + if (copyLimit <= 0) { + return; + } + int needed = copyLimit - countActiveSecStorageCopies(templateId, zoneId); + if (needed <= 0) { + return; + } + List stores = _storeMgr.getImageStoresByScope(new ZoneScope(zoneId)); + if (stores == null || stores.isEmpty()) { + return; + } + int kicked = 0; + for (DataStore store : stores) { + if (kicked >= needed) { + break; + } + if (hasActiveTemplateCopyOnStore(templateId, store.getId())) { + continue; + } + try { + storageOrchestrator.orchestrateTemplateCopyFromSecondaryStores(templateId, store); + kicked++; + } catch (Exception e) { + logger.warn("Failed to proactively replicate template [{}] to image store [{}] in zone [{}]: {}", + template.getUniqueName(), store.getName(), zoneId, e.getMessage()); + } + } + } + + private boolean hasActiveTemplateCopyOnStore(long templateId, long storeId) { + List rows = _vmTemplateStoreDao.listByTemplateStore(templateId, storeId); + if (rows == null) { + return false; + } + for (TemplateDataStoreVO row : rows) { + State st = row.getState(); + Status ds = row.getDownloadState(); + if (st != State.Failed && st != State.Destroyed + && ds != Status.ABANDONED && ds != Status.DOWNLOAD_ERROR) { + return true; + } + } + return false; + } + + @Override + public void enforceSecStorageCopyLimit(long templateId, long zoneId) { + VMTemplateVO template = _templateDao.findById(templateId); + if (template == null) { + return; + } + int copyLimit = _tmpltMgr.getSecStorageCopyLimit(template, zoneId); + if (copyLimit <= 0) { + return; + } + if (_tmpltMgr.verifyHeuristicRulesForZone(template, zoneId) != null) { + return; + } + GlobalLock lock = GlobalLock.getInternLock("template.copy.limit." + templateId + "." + zoneId); + try { + if (!lock.lock(30)) { + logger.warn("Could not acquire lock to enforce secondary storage copy limit for template [{}] in zone [{}].", + template.getUniqueName(), zoneId); + return; + } + List stores = _storeMgr.getImageStoresByScope(new ZoneScope(zoneId)); + if (stores == null) { + return; + } + List removable = new ArrayList<>(); + for (DataStore ds : stores) { + TemplateDataStoreVO ref = _vmTemplateStoreDao.findByStoreTemplate(ds.getId(), templateId); + if (ref != null + && ref.getState() == State.Ready + && ref.getDownloadState() == Status.DOWNLOADED + && (ref.getRefCnt() == null || ref.getRefCnt() == 0)) { + removable.add(ref); + } + } + int excess = removable.size() - copyLimit; + if (excess <= 0) { + return; + } + logger.info("Template [{}] has [{}] removable secondary storage copies in zone [{}], limit is [{}]; removing [{}] excess copies.", + template.getUniqueName(), removable.size(), zoneId, copyLimit, excess); + for (int i = 0; i < excess; i++) { + DataStore ds = _storeMgr.getDataStore(removable.get(i).getDataStoreId(), DataStoreRole.Image); + try { + deleteTemplateAsync(_templateFactory.getTemplate(templateId, ds)); + logger.info("Removed excess copy of template [{}] from image store [{}] to honor the secondary storage copy limit.", + template.getUniqueName(), ds.getName()); + } catch (Exception e) { + logger.warn("Failed to remove excess copy of template [{}] from image store [{}]: {}", + template.getUniqueName(), ds, e.getMessage()); + } + } + } finally { + lock.unlock(); + lock.releaseRef(); + } + } + protected boolean shouldDownloadTemplateToStore(VMTemplateVO template, DataStore store) { Long zoneId = store.getScope().getScopeId(); DataStore directedStore = _tmpltMgr.verifyHeuristicRulesForZone(template, zoneId); @@ -304,6 +469,12 @@ public class TemplateServiceImpl implements TemplateService { return false; } + if (zoneId != null && hasReachedSecStorageCopyLimit(template, zoneId)) { + logger.info("Skipping sync of template [{}] to image store [{}]: zone [{}] has reached the configured copy limit.", + template.getUniqueName(), store.getName(), zoneId); + return false; + } + if (template.isPublicTemplate()) { logger.debug("Download of template [{}] to image store [{}] cannot be skipped, as it is public.", template.getUniqueName(), store.getName()); @@ -328,8 +499,9 @@ public class TemplateServiceImpl implements TemplateService { return true; } - logger.info("Skipping download of template [{}] to image store [{}].", template.getUniqueName(), store.getName()); - return false; + logger.debug("Copying template [{}] to image store [{}] to reach the configured secondary storage copy limit in zone [{}].", + template.getUniqueName(), store.getName(), zoneId); + return true; } @Override @@ -531,10 +703,13 @@ public class TemplateServiceImpl implements TemplateService { && tmpltStore.getState() == State.Ready && tmpltStore.getInstallPath() == null) { logger.info("Keep fake entry in template store table for migration of previous NFS to object store"); - } else { + } else if (tmpltStore.getDownloadState() == VMTemplateStorageResourceAssoc.Status.DOWNLOADED + || tmpltStore.getState() == State.Ready) { logger.info("Removing leftover template {} entry from template store table", tmplt); - // remove those leftover entries _vmTemplateStoreDao.remove(tmpltStore.getId()); + } else { + logger.debug("Template {} entry on store {} is in pre-download state ({}/{}); not treating as leftover.", + tmplt, store, tmpltStore.getState(), tmpltStore.getDownloadState()); } } } @@ -556,7 +731,7 @@ public class TemplateServiceImpl implements TemplateService { availHypers.add(HypervisorType.None); // bug 9809: resume ISO // download. for (VMTemplateVO tmplt : toBeDownloaded) { - // if this is private template, skip sync to a new image store + // skip stores excluded by heuristic rules or already at the configured copy limit if (!shouldDownloadTemplateToStore(tmplt, store)) { continue; } @@ -580,6 +755,12 @@ public class TemplateServiceImpl implements TemplateService { } } + if (zoneId != null) { + for (VMTemplateVO tmplt : allTemplates) { + enforceSecStorageCopyLimit(tmplt.getId(), zoneId); + } + } + for (String uniqueName : templateInfos.keySet()) { TemplateProp tInfo = templateInfos.get(uniqueName); if (_tmpltMgr.templateIsDeleteable(tInfo.getId())) { @@ -965,6 +1146,15 @@ public class TemplateServiceImpl implements TemplateService { return null; } + try { + DataStore destStore = template.getDataStore(); + if (destStore != null && destStore.getScope() != null && destStore.getScope().getScopeId() != null) { + enforceSecStorageCopyLimit(template.getId(), destStore.getScope().getScopeId()); + } + } catch (Exception e) { + logger.warn("Failed to enforce secstorage copy limit after template [{}] became Ready: {}", template.getUuid(), e.getMessage()); + } + if (parentCallback != null) { parentCallback.complete(result); } @@ -1406,6 +1596,14 @@ public class TemplateServiceImpl implements TemplateService { destTemplate.processEvent(Event.OperationFailed); } else { destTemplate.processEvent(Event.OperationSucceeded, result.getAnswer()); + try { + DataStore destStore = destTemplate.getDataStore(); + if (destStore != null && destStore.getScope() != null && destStore.getScope().getScopeId() != null) { + enforceSecStorageCopyLimit(destTemplate.getId(), destStore.getScope().getScopeId()); + } + } catch (Exception e) { + logger.warn("Failed to enforce secstorage copy limit after copy of template [{}] became Ready: {}", destTemplate.getUuid(), e.getMessage()); + } } future.complete(res); } catch (Exception e) { @@ -1431,6 +1629,15 @@ public class TemplateServiceImpl implements TemplateService { destTemplate.processEvent(Event.OperationFailed); } else { destTemplate.processEvent(Event.OperationSucceeded, result.getAnswer()); + try { + DataStore destStore = destTemplate.getDataStore(); + if (destStore != null && destStore.getScope() != null && destStore.getScope().getScopeId() != null) { + replicateTemplateUpToCap(destTemplate.getId(), destStore.getScope().getScopeId()); + } + } catch (Exception e) { + logger.warn("Failed to schedule additional copies for cross-zone copied template [{}]: {}", + destTemplate.getUuid(), e.getMessage()); + } } future.complete(res); } catch (Exception e) { diff --git a/engine/storage/image/src/test/java/org/apache/cloudstack/storage/image/TemplateServiceImplTest.java b/engine/storage/image/src/test/java/org/apache/cloudstack/storage/image/TemplateServiceImplTest.java index e9eac045869..315fb697894 100644 --- a/engine/storage/image/src/test/java/org/apache/cloudstack/storage/image/TemplateServiceImplTest.java +++ b/engine/storage/image/src/test/java/org/apache/cloudstack/storage/image/TemplateServiceImplTest.java @@ -119,6 +119,7 @@ public class TemplateServiceImplTest { Mockito.doReturn(templateInfoMock).when(templateDataFactoryMock).getTemplate(2L, sourceStoreMock); Mockito.doReturn(3L).when(dataStoreMock).getId(); Mockito.doReturn(zoneScopeMock).when(dataStoreMock).getScope(); + Mockito.lenient().doReturn(tmpltMock).when(templateDao).findById(2L); } @Test @@ -153,11 +154,37 @@ public class TemplateServiceImplTest { } @Test - public void shouldDownloadTemplateToStoreTestSkipsPrivateExistingTemplate() { + public void shouldDownloadTemplateToStoreTestReplicatesPrivateTemplateUnderCopyLimit() { + DataStore storeWithCopy = Mockito.mock(DataStore.class); + Mockito.doReturn(10L).when(storeWithCopy).getId(); + Mockito.when(templateManagerMock.getSecStorageCopyLimit(tmpltMock, zoneScopeMock.getScopeId())).thenReturn(2); + Mockito.doReturn(List.of(storeWithCopy)).when(dataStoreManagerMock).getImageStoresByScope(Mockito.any()); + Mockito.doReturn(List.of(Mockito.mock(TemplateDataStoreVO.class))).when(templateDataStoreDao).listByTemplateStore(2L, 10L); Mockito.when(templateDataStoreDao.findByTemplateZone(tmpltMock.getId(), zoneScopeMock.getScopeId(), DataStoreRole.Image)).thenReturn(Mockito.mock(TemplateDataStoreVO.class)); + Assert.assertTrue(templateService.shouldDownloadTemplateToStore(tmpltMock, dataStoreMock)); + } + + @Test + public void shouldDownloadTemplateToStoreTestSkipsPrivateTemplateAtCopyLimit() { + DataStore storeWithCopy = Mockito.mock(DataStore.class); + Mockito.doReturn(10L).when(storeWithCopy).getId(); + Mockito.when(templateManagerMock.getSecStorageCopyLimit(tmpltMock, zoneScopeMock.getScopeId())).thenReturn(1); + Mockito.doReturn(List.of(storeWithCopy)).when(dataStoreManagerMock).getImageStoresByScope(Mockito.any()); + Mockito.doReturn(List.of(Mockito.mock(TemplateDataStoreVO.class))).when(templateDataStoreDao).listByTemplateStore(2L, 10L); Assert.assertFalse(templateService.shouldDownloadTemplateToStore(tmpltMock, dataStoreMock)); } + @Test + public void canCopyTemplateToImageStoreTestUnlimitedWhenLimitIsZero() { + Mockito.when(templateManagerMock.getSecStorageCopyLimit(tmpltMock, 1L)).thenReturn(0); + Assert.assertTrue(templateService.canCopyTemplateToImageStore(2L, 1L)); + } + + // The under-limit / at-limit behavior of canCopyTemplateToImageStore is exercised through + // shouldDownloadTemplateToStore above (Replicates*UnderCopyLimit / Skips*AtCopyLimit), which run it via + // the real call path. Calling the GlobalLock-wrapped method directly on the Mockito spy is not reliable + // in the unit-test JVM, so it is not duplicated here. + @Test public void tryDownloadingTemplateToImageStoreTestDownloadsTemplateWhenUrlIsNotNull() { Mockito.doReturn("url").when(tmpltMock).getUrl(); diff --git a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeObject.java b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeObject.java index 20d677f9013..de85067aae5 100644 --- a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeObject.java +++ b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeObject.java @@ -30,6 +30,7 @@ import com.cloud.utils.db.Transaction; import com.cloud.utils.db.TransactionCallbackNoReturn; import com.cloud.utils.db.TransactionStatus; import org.apache.cloudstack.engine.orchestration.service.VolumeOrchestrationService; +import org.apache.cloudstack.framework.kms.KMSException; import org.apache.cloudstack.secret.dao.PassphraseDao; import org.apache.cloudstack.secret.PassphraseVO; import com.cloud.service.dao.ServiceOfferingDetailsDao; @@ -47,6 +48,8 @@ import org.apache.cloudstack.engine.subsystem.api.storage.DataObjectInStore; import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; import org.apache.cloudstack.engine.subsystem.api.storage.ObjectInDataStoreStateMachine; import org.apache.cloudstack.engine.subsystem.api.storage.VolumeInfo; +import org.apache.cloudstack.kms.KMSManager; +import org.apache.cloudstack.kms.dao.KMSWrappedKeyDao; import org.apache.cloudstack.storage.command.CopyCmdAnswer; import org.apache.cloudstack.storage.command.CreateObjectAnswer; import org.apache.cloudstack.storage.datastore.ObjectInDataStoreManager; @@ -99,6 +102,10 @@ public class VolumeObject implements VolumeInfo { @Inject VolumeDataStoreDao volumeStoreDao; @Inject + KMSManager kmsManager; + @Inject + KMSWrappedKeyDao kmsWrappedKeyDao; + @Inject ObjectInDataStoreManager objectInStoreMgr; @Inject ResourceLimitService resourceLimitMgr; @@ -926,6 +933,26 @@ public class VolumeObject implements VolumeInfo { volumeVO.setPassphraseId(id); } + @Override + public Long getKmsKeyId() { + return volumeVO.getKmsKeyId(); + } + + @Override + public void setKmsKeyId(Long id) { + volumeVO.setKmsKeyId(id); + } + + @Override + public Long getKmsWrappedKeyId() { + return volumeVO.getKmsWrappedKeyId(); + } + + @Override + public void setKmsWrappedKeyId(Long id) { + volumeVO.setKmsWrappedKeyId(id); + } + /** * Removes passphrase reference from underlying volume. Also removes the associated passphrase entry if it is the last user. */ @@ -955,9 +982,29 @@ public class VolumeObject implements VolumeInfo { /** * Looks up passphrase from underlying volume. - * @return passphrase as bytes + * Supports both legacy passphrase-based encryption and KMS-based encryption. + * @return passphrase/DEK as base64-encoded bytes (UTF-8 bytes of base64 string) */ public byte[] getPassphrase() { + // First check for KMS-encrypted volume + if (volumeVO.getKmsWrappedKeyId() != null) { + try { + // Unwrap the DEK from KMS (returns raw binary bytes) + byte[] dekBytes = kmsManager.unwrapKey(volumeVO.getKmsWrappedKeyId()); + // Base64-encode the DEK for consistency with legacy passphrases + // and for use with qemu-img which expects base64 format + String base64Dek = java.util.Base64.getEncoder().encodeToString(dekBytes); + // Zeroize the raw DEK bytes + java.util.Arrays.fill(dekBytes, (byte) 0); + // Return UTF-8 bytes of the base64 string + return base64Dek.getBytes(java.nio.charset.StandardCharsets.UTF_8); + } catch (KMSException e) { + logger.error("Failed to unwrap KMS key for volume {}: {}", volumeVO, e.getMessage(), e); + throw e; + } + } + + // Fallback to legacy passphrase-based encryption PassphraseVO passphrase = passphraseDao.findById(volumeVO.getPassphraseId()); if (passphrase != null) { return passphrase.getPassphrase(); diff --git a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java index 6fe4c2708c5..6c7ce95769b 100644 --- a/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java +++ b/engine/storage/volume/src/main/java/org/apache/cloudstack/storage/volume/VolumeServiceImpl.java @@ -66,6 +66,7 @@ import org.apache.cloudstack.framework.async.AsyncCallbackDispatcher; import org.apache.cloudstack.framework.async.AsyncCompletionCallback; import org.apache.cloudstack.framework.async.AsyncRpcContext; import org.apache.cloudstack.framework.config.dao.ConfigurationDao; +import org.apache.cloudstack.kms.KMSManager; import org.apache.cloudstack.secret.dao.PassphraseDao; import org.apache.cloudstack.storage.RemoteHostEndPoint; import org.apache.cloudstack.storage.command.CommandResult; @@ -226,6 +227,9 @@ public class VolumeServiceImpl implements VolumeService { @Inject ClvmPoolManager clvmPoolManager; + @Inject + private KMSManager kmsManager; + public VolumeServiceImpl() { } @@ -506,6 +510,13 @@ public class VolumeServiceImpl implements VolumeService { if (vo.getPassphraseId() != null) { vo.deletePassphrase(); } + if (vo.getKmsWrappedKeyId() != null) { + try { + kmsManager.deleteKMSWrappedKey(vo); + } catch (Exception e) { + logger.warn("Failed to delete KMS wrapped key for volume {}", vo, e); + } + } if (canVolumeBeRemoved(vo.getId())) { logger.info("Volume {} is not referred anywhere, remove it from volumes table", vo); @@ -1758,6 +1769,11 @@ public class VolumeServiceImpl implements VolumeService { newVol.setPoolType(pool.getPoolType()); newVol.setLastPoolId(lastPoolId); newVol.setPodId(pool.getPodId()); + if (volume.getKmsKeyId() != null) { + newVol.setKmsKeyId(volume.getKmsKeyId()); + newVol.setKmsWrappedKeyId(volume.getKmsWrappedKeyId()); + newVol.setEncryptFormat(volume.getEncryptFormat()); + } if (volume.getPassphraseId() != null) { newVol.setPassphraseId(volume.getPassphraseId()); newVol.setEncryptFormat(volume.getEncryptFormat()); diff --git a/framework/kms/pom.xml b/framework/kms/pom.xml new file mode 100644 index 00000000000..719072ac493 --- /dev/null +++ b/framework/kms/pom.xml @@ -0,0 +1,46 @@ + + + 4.0.0 + cloud-framework-kms + Apache CloudStack Framework - Key Management Service + Core KMS framework with provider-agnostic interfaces + + + org.apache.cloudstack + cloudstack-framework + 4.23.0.0-SNAPSHOT + ../pom.xml + + + + + org.apache.cloudstack + cloud-utils + ${project.version} + + + org.apache.cloudstack + cloud-framework-config + ${project.version} + + + diff --git a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSException.java b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSException.java new file mode 100644 index 00000000000..8f15ad24ac6 --- /dev/null +++ b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSException.java @@ -0,0 +1,181 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms; + +import com.cloud.utils.exception.CloudRuntimeException; + +/** + * Exception class for KMS-related errors with structured error types + * to enable proper retry logic and error handling. + */ +public class KMSException extends CloudRuntimeException { + + /** + * Error types for KMS operations to enable intelligent retry logic + */ + public enum ErrorType { + CONNECTION_FAILED(true), + /** + * Authentication failed (e.g., incorrect PIN) + */ + AUTHENTICATION_FAILED(false), + /** + * Provider not initialized or unavailable + */ + PROVIDER_NOT_INITIALIZED(false), + + /** + * KEK not found in backend + */ + KEK_NOT_FOUND(false), + + /** + * KEK with given label already exists + */ + KEY_ALREADY_EXISTS(false), + + /** + * Invalid parameters provided + */ + INVALID_PARAMETER(false), + + /** + * Wrap/unwrap operation failed + */ + WRAP_UNWRAP_FAILED(true), + + /** + * KEK operation (create/delete) failed + */ + KEK_OPERATION_FAILED(true), + + /** + * Health check failed + */ + HEALTH_CHECK_FAILED(true), + + /** + * Transient network or communication error + */ + TRANSIENT_ERROR(true), + + /** + * Unknown error + */ + UNKNOWN(false); + + private final boolean retryable; + + ErrorType(boolean retryable) { + this.retryable = retryable; + } + + public boolean isRetryable() { + return retryable; + } + } + + private final ErrorType errorType; + + public KMSException(String message) { + super(message); + this.errorType = ErrorType.UNKNOWN; + } + + public KMSException(String message, Throwable cause) { + super(message, cause); + this.errorType = ErrorType.UNKNOWN; + } + + public KMSException(ErrorType errorType, String message) { + super(message); + this.errorType = errorType; + } + + public KMSException(ErrorType errorType, String message, Throwable cause) { + super(message, cause); + this.errorType = errorType; + } + + public static KMSException providerNotInitialized(String details) { + return new KMSException(ErrorType.PROVIDER_NOT_INITIALIZED, + "KMS provider not initialized: " + details); + } + + public static KMSException kekNotFound(String kekId) { + return new KMSException(ErrorType.KEK_NOT_FOUND, + "KEK not found: " + kekId); + } + + public static KMSException keyAlreadyExists(String details) { + return new KMSException(ErrorType.KEY_ALREADY_EXISTS, + "Key already exists: " + details); + } + + public static KMSException invalidParameter(String details) { + return new KMSException(ErrorType.INVALID_PARAMETER, + "Invalid parameter: " + details); + } + + public static KMSException wrapUnwrapFailed(String details, Throwable cause) { + return new KMSException(ErrorType.WRAP_UNWRAP_FAILED, + "Wrap/unwrap operation failed: " + details, cause); + } + + public static KMSException wrapUnwrapFailed(String details) { + return new KMSException(ErrorType.WRAP_UNWRAP_FAILED, + "Wrap/unwrap operation failed: " + details); + } + + public static KMSException kekOperationFailed(String details, Throwable cause) { + return new KMSException(ErrorType.KEK_OPERATION_FAILED, + "KEK operation failed: " + details, cause); + } + + public static KMSException kekOperationFailed(String details) { + return new KMSException(ErrorType.KEK_OPERATION_FAILED, + "KEK operation failed: " + details); + } + + public static KMSException healthCheckFailed(String details, Throwable cause) { + return new KMSException(ErrorType.HEALTH_CHECK_FAILED, + "Health check failed: " + details, cause); + } + + public static KMSException transientError(String details, Throwable cause) { + return new KMSException(ErrorType.TRANSIENT_ERROR, + "Transient error: " + details, cause); + } + + public ErrorType getErrorType() { + return errorType; + } + + @Override + public String toString() { + return "KMSException{" + + "errorType=" + errorType + + ", retryable=" + isRetryable() + + ", message='" + getMessage() + '\'' + + '}'; + } + + public boolean isRetryable() { + return errorType.isRetryable(); + } +} diff --git a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSProvider.java b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSProvider.java new file mode 100644 index 00000000000..388d464caa7 --- /dev/null +++ b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KMSProvider.java @@ -0,0 +1,255 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms; + +import com.cloud.utils.component.Adapter; +import org.apache.cloudstack.framework.config.Configurable; + +/** + * Abstract provider contract for Key Management Service operations. + *

+ * Implementations provide the cryptographic backend (HSM via PKCS#11, database, cloud KMS, etc.) + * for secure key wrapping/unwrapping using envelope encryption. + *

+ * Design principles: + * - KEKs (Key Encryption Keys) never leave the secure backend + * - DEKs (Data Encryption Keys) are wrapped by KEKs for storage + * - Plaintext DEKs exist only transiently in memory during wrap/unwrap + * - All operations are purpose-scoped to prevent key reuse + *

+ * Thread-safety: Implementations must be thread-safe for concurrent operations. + */ +public interface KMSProvider extends Configurable, Adapter { + + /** + * Returns {@code true} if the given HSM profile configuration key name refers + * to a + * sensitive value (PIN, password, secret, or private key) that must be + * encrypted at + * rest and masked in API responses. + * + *

+ * This is a shared naming-convention helper used by both KMS providers (when + * loading/storing profile details) and the KMS manager (when building API + * responses). + * + * @param key configuration key name (case-insensitive); null returns false + * @return true if the key is considered sensitive + */ + static boolean isSensitiveKey(String key) { + if (key == null) { + return false; + } + return key.equalsIgnoreCase("pin") || + key.equalsIgnoreCase("password") || + key.toLowerCase().contains("secret") || + key.equalsIgnoreCase("private_key"); + } + + /** + * Get the unique name of this provider + * + * @return provider name (e.g., "database", "pkcs11") + */ + String getProviderName(); + + /** + * Create a new Key Encryption Key (KEK) in the secure backend. + * Delegates to {@link #createKek(KeyPurpose, String, int, Long)} with null profile ID. + * + * @param purpose the purpose/scope for this KEK + * @param label human-readable label for the KEK (must be unique within purpose) + * @param keyBits key size in bits (typically 128, 192, or 256) + * @return the KEK identifier (label or handle) for later reference + * @throws KMSException if KEK creation fails + */ + default String createKek(KeyPurpose purpose, String label, int keyBits) throws KMSException { + return createKek(purpose, label, keyBits, null); + } + + /** + * Create a new Key Encryption Key (KEK) in the secure backend with explicit HSM profile. + * + * @param purpose the purpose/scope for this KEK + * @param label human-readable label for the KEK (must be unique within purpose) + * @param keyBits key size in bits (typically 128, 192, or 256) + * @param hsmProfileId optional HSM profile ID to create the KEK in (null for auto-resolution/default) + * @return the KEK identifier (label or handle) for later reference + * @throws KMSException if KEK creation fails + */ + String createKek(KeyPurpose purpose, String label, int keyBits, Long hsmProfileId) throws KMSException; + + /** + * Delete a KEK from the secure backend. + * WARNING: This will make all DEKs wrapped by this KEK unrecoverable. + * + * @param kekId the KEK identifier to delete + * @throws KMSException if deletion fails or KEK not found + */ + void deleteKek(String kekId) throws KMSException; + + /** + * Validates the configuration details for this provider before saving an HSM + * profile. + * Implementations should override this to perform provider-specific validation. + * + * @param details the configuration details to validate + * @throws KMSException if validation fails + */ + default void validateProfileConfig(java.util.Map details) throws KMSException { + // default no-op + } + + /** + * Check if a KEK exists and is accessible + * + * @param kekId the KEK identifier to check + * @return true if KEK is available + * @throws KMSException if check fails + */ + boolean isKekAvailable(String kekId) throws KMSException; + + /** + * Wrap (encrypt) a plaintext Data Encryption Key with a KEK. + * Delegates to {@link #wrapKey(byte[], KeyPurpose, String, Long)} with null profile ID. + * + * @param plainDek the plaintext DEK to wrap (caller must zeroize after call) + * @param purpose the intended purpose of this DEK + * @param kekLabel the label of the KEK to use for wrapping + * @return WrappedKey containing the encrypted DEK and metadata + * @throws KMSException if wrapping fails or KEK not found + */ + default WrappedKey wrapKey(byte[] plainDek, KeyPurpose purpose, String kekLabel) throws KMSException { + return wrapKey(plainDek, purpose, kekLabel, null); + } + + /** + * Wrap (encrypt) a plaintext Data Encryption Key with a KEK using explicit HSM profile. + * + * @param plainDek the plaintext DEK to wrap (caller must zeroize after call) + * @param purpose the intended purpose of this DEK + * @param kekLabel the label of the KEK to use for wrapping + * @param hsmProfileId optional HSM profile ID to use (null for auto-resolution/default) + * @return WrappedKey containing the encrypted DEK and metadata + * @throws KMSException if wrapping fails or KEK not found + */ + WrappedKey wrapKey(byte[] plainDek, KeyPurpose purpose, String kekLabel, Long hsmProfileId) throws KMSException; + + /** + * Unwrap (decrypt) a wrapped DEK to obtain the plaintext key. + * Delegates to {@link #unwrapKey(WrappedKey, Long)} with null profile ID. + *

+ * SECURITY: Caller MUST zeroize the returned byte array after use + * + * @param wrappedKey the wrapped key to decrypt + * @return plaintext DEK (caller must zeroize!) + * @throws KMSException if unwrapping fails or KEK not found + */ + default byte[] unwrapKey(WrappedKey wrappedKey) throws KMSException { + return unwrapKey(wrappedKey, null); + } + + /** + * Unwrap (decrypt) a wrapped DEK to obtain the plaintext key using explicit HSM profile. + *

+ * SECURITY: Caller MUST zeroize the returned byte array after use + * + * @param wrappedKey the wrapped key to decrypt + * @param hsmProfileId optional HSM profile ID to use (null for auto-resolution/default) + * @return plaintext DEK (caller must zeroize!) + * @throws KMSException if unwrapping fails or KEK not found + */ + byte[] unwrapKey(WrappedKey wrappedKey, Long hsmProfileId) throws KMSException; + + /** + * Generate a new random DEK and immediately wrap it with a KEK. + * Delegates to {@link #generateAndWrapDek(KeyPurpose, String, int, Long)} with null profile ID. + * (convenience method combining generation + wrapping) + * + * @param purpose the intended purpose of the new DEK + * @param kekLabel the label of the KEK to use for wrapping + * @param keyBits DEK size in bits (typically 128, 192, or 256) + * @return WrappedKey containing the newly generated and wrapped DEK + * @throws KMSException if generation or wrapping fails + */ + default WrappedKey generateAndWrapDek(KeyPurpose purpose, String kekLabel, int keyBits) throws KMSException { + return generateAndWrapDek(purpose, kekLabel, keyBits, null); + } + + /** + * Generate a new random DEK and immediately wrap it with a KEK using explicit HSM profile. + * (convenience method combining generation + wrapping) + * + * @param purpose the intended purpose of the new DEK + * @param kekLabel the label of the KEK to use for wrapping + * @param keyBits DEK size in bits (typically 128, 192, or 256) + * @param hsmProfileId optional HSM profile ID to use (null for auto-resolution/default) + * @return WrappedKey containing the newly generated and wrapped DEK + * @throws KMSException if generation or wrapping fails + */ + WrappedKey generateAndWrapDek(KeyPurpose purpose, String kekLabel, int keyBits, + Long hsmProfileId) throws KMSException; + + /** + * Rewrap a DEK with a different KEK (used during key rotation). + * Delegates to {@link #rewrapKey(WrappedKey, String, Long)} with null profile ID. + * This unwraps with the old KEK and wraps with the new KEK without exposing the plaintext DEK. + * + * @param oldWrappedKey the currently wrapped key + * @param newKekLabel the label of the new KEK to wrap with + * @return new WrappedKey encrypted with the new KEK + * @throws KMSException if rewrapping fails + */ + default WrappedKey rewrapKey(WrappedKey oldWrappedKey, String newKekLabel) throws KMSException { + return rewrapKey(oldWrappedKey, newKekLabel, null); + } + + /** + * Rewrap a DEK with a different KEK (used during key rotation) using explicit target HSM profile. + * This unwraps with the old KEK and wraps with the new KEK without exposing the plaintext DEK. + * + * @param oldWrappedKey the currently wrapped key + * @param newKekLabel the label of the new KEK to wrap with + * @param targetHsmProfileId optional target HSM profile ID to wrap with (null for auto-resolution/default) + * @return new WrappedKey encrypted with the new KEK + * @throws KMSException if rewrapping fails + */ + WrappedKey rewrapKey(WrappedKey oldWrappedKey, String newKekLabel, Long targetHsmProfileId) throws KMSException; + + /** + * Perform health check on the provider backend + * + * @return true if provider is healthy and operational + * @throws KMSException if health check fails with critical error + */ + boolean healthCheck() throws KMSException; + + /** + * Invalidates any cached state (config, sessions) associated with the given HSM profile. + * Must be called after an HSM profile is updated or deleted so that the next operation + * re-reads the profile details from the database instead of using stale cached values. + * + *

Providers that do not cache per-profile state (e.g. the database provider) can + * leave this as a no-op. + * + * @param profileId the HSM profile ID whose cache should be evicted + */ + default void invalidateProfileCache(Long profileId) { + // no-op for providers that don't cache per-profile state + } +} diff --git a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KeyPurpose.java b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KeyPurpose.java new file mode 100644 index 00000000000..41f5cf461fc --- /dev/null +++ b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/KeyPurpose.java @@ -0,0 +1,79 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms; + +/** + * Defines the purpose/usage scope for cryptographic keys in the KMS system. + * This enables proper key segregation and prevents key reuse across different contexts. + */ +public enum KeyPurpose { + /** + * Keys used for encrypting VM disk volumes (LUKS, encrypted storage) + */ + VOLUME_ENCRYPTION("volume", "Volume disk encryption keys"), + + /** + * Keys used for protecting TLS certificate private keys + */ + TLS_CERT("tls", "TLS certificate private keys"); + + private final String name; + private final String description; + + KeyPurpose(String name, String description) { + this.name = name; + this.description = description; + } + + /** + * Convert string name to KeyPurpose enum + * + * @param name the string representation of the purpose + * @return matching KeyPurpose + * @throws IllegalArgumentException if no matching purpose found + */ + public static KeyPurpose fromString(String name) { + for (KeyPurpose purpose : KeyPurpose.values()) { + if (purpose.getName().equalsIgnoreCase(name)) { + return purpose; + } + } + throw new IllegalArgumentException("Unknown KeyPurpose: " + name); + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + /** + * Generate a globally unique, collision-resistant KEK label with context + * + * @param domainId the domain ID associated with this key + * @param accountId the account ID associated with this key + * @param uuid the unique identifier of the key entity + * @param version the version number of the key + * @return formatted KEK label (e.g., "volume-kek-1-2-a8054d8f-...-1") + */ + public String generateKekLabel(long domainId, long accountId, String uuid, int version) { + return name + "-kek-" + domainId + "-" + accountId + "-" + uuid.replace("-", "") + "-v" + version; + } +} diff --git a/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/WrappedKey.java b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/WrappedKey.java new file mode 100644 index 00000000000..e70c5e32c46 --- /dev/null +++ b/framework/kms/src/main/java/org/apache/cloudstack/framework/kms/WrappedKey.java @@ -0,0 +1,131 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms; + +import java.util.Arrays; +import java.util.Date; +import java.util.Objects; + +/** + * Immutable Data Transfer Object representing an encrypted (wrapped) Data Encryption Key. + * The wrapped key material contains the DEK encrypted by a Key Encryption Key (KEK) + * stored in a secure backend (HSM, database, etc.). + *

+ * This follows the envelope encryption pattern: + * - DEK: encrypts actual data (e.g., disk volume) + * - KEK: encrypts the DEK (never leaves secure storage) + * - Wrapped Key: DEK encrypted by KEK, safe to store in database + */ +public class WrappedKey { + private final String uuid; + private final String kekId; + private final KeyPurpose purpose; + private final String algorithm; + private final byte[] wrappedKeyMaterial; + private final String providerName; + private final Date created; + private final Long zoneId; + + /** + * Create a new WrappedKey instance + * + * @param kekId ID/label of the KEK used to wrap this key + * @param purpose the intended use of this key + * @param algorithm encryption algorithm (e.g., "AES/GCM/NoPadding") + * @param wrappedKeyMaterial the encrypted DEK blob + * @param providerName name of the KMS provider that created this key + * @param created timestamp when key was wrapped + * @param zoneId optional zone ID for zone-scoped keys + */ + public WrappedKey(String kekId, KeyPurpose purpose, String algorithm, + byte[] wrappedKeyMaterial, String providerName, + Date created, Long zoneId) { + this(null, kekId, purpose, algorithm, wrappedKeyMaterial, providerName, created, zoneId); + } + + /** + * Constructor for database-loaded keys with ID + */ + public WrappedKey(String uuid, String kekId, KeyPurpose purpose, String algorithm, + byte[] wrappedKeyMaterial, String providerName, + Date created, Long zoneId) { + this.uuid = uuid; + this.kekId = Objects.requireNonNull(kekId, "kekId cannot be null"); + this.purpose = Objects.requireNonNull(purpose, "purpose cannot be null"); + this.algorithm = Objects.requireNonNull(algorithm, "algorithm cannot be null"); + this.providerName = providerName; + + if (wrappedKeyMaterial == null || wrappedKeyMaterial.length == 0) { + throw new IllegalArgumentException("wrappedKeyMaterial cannot be null or empty"); + } + this.wrappedKeyMaterial = Arrays.copyOf(wrappedKeyMaterial, wrappedKeyMaterial.length); + + this.created = created != null ? new Date(created.getTime()) : new Date(); + this.zoneId = zoneId; + } + + public String getUuid() { + return uuid; + } + + public String getKekId() { + return kekId; + } + + public KeyPurpose getPurpose() { + return purpose; + } + + public String getAlgorithm() { + return algorithm; + } + + /** + * Get wrapped key material. Returns a defensive copy to prevent modification. + * Caller is responsible for zeroizing the returned array after use. + */ + public byte[] getWrappedKeyMaterial() { + return Arrays.copyOf(wrappedKeyMaterial, wrappedKeyMaterial.length); + } + + public String getProviderName() { + return providerName; + } + + public Date getCreated() { + return created != null ? new Date(created.getTime()) : null; + } + + public Long getZoneId() { + return zoneId; + } + + @Override + public String toString() { + return "WrappedKey{" + + "uuid='" + uuid + '\'' + + ", kekId='" + kekId + '\'' + + ", purpose=" + purpose + + ", algorithm='" + algorithm + '\'' + + ", providerName='" + providerName + '\'' + + ", materialLength=" + (wrappedKeyMaterial != null ? wrappedKeyMaterial.length : 0) + + ", created=" + created + + ", zoneId=" + zoneId + + '}'; + } +} diff --git a/framework/pom.xml b/framework/pom.xml index 337e5b0268b..95d0bd0694c 100644 --- a/framework/pom.xml +++ b/framework/pom.xml @@ -54,6 +54,7 @@ extensions ipc jobs + kms managed-context quota rest diff --git a/framework/spring/lifecycle/src/main/java/org/apache/cloudstack/spring/lifecycle/CloudStackExtendedLifeCycle.java b/framework/spring/lifecycle/src/main/java/org/apache/cloudstack/spring/lifecycle/CloudStackExtendedLifeCycle.java index 170e3b40e94..bd3e424f767 100644 --- a/framework/spring/lifecycle/src/main/java/org/apache/cloudstack/spring/lifecycle/CloudStackExtendedLifeCycle.java +++ b/framework/spring/lifecycle/src/main/java/org/apache/cloudstack/spring/lifecycle/CloudStackExtendedLifeCycle.java @@ -73,7 +73,8 @@ public class CloudStackExtendedLifeCycle extends AbstractBeanCollector { try { lifecycle.start(); } catch (Exception e) { - logger.error("Error on starting bean {} - {}", lifecycle.getName(), e.getMessage(), e); + logger.error("Error on starting bean [{}] due to: {}", lifecycle.getName(), e); + throw new CloudRuntimeException("Failed to start bean [" + lifecycle.getName() + "]"); } if (lifecycle instanceof ManagementBean) { diff --git a/framework/spring/module/src/main/java/org/apache/cloudstack/spring/module/model/impl/DefaultModuleDefinitionSet.java b/framework/spring/module/src/main/java/org/apache/cloudstack/spring/module/model/impl/DefaultModuleDefinitionSet.java index 2a6d0b63e5c..78693f72140 100644 --- a/framework/spring/module/src/main/java/org/apache/cloudstack/spring/module/model/impl/DefaultModuleDefinitionSet.java +++ b/framework/spring/module/src/main/java/org/apache/cloudstack/spring/module/model/impl/DefaultModuleDefinitionSet.java @@ -112,10 +112,8 @@ public class DefaultModuleDefinitionSet implements ModuleDefinitionSet { logger.debug(String.format("Could not get module [%s] context bean.", moduleDefinitionName)); } } catch (BeansException e) { - logger.warn(String.format("Failed to start module [%s] due to: [%s].", moduleDefinitionName, e.getMessage())); - if (logger.isDebugEnabled()) { - logger.debug(String.format("module start failure of module [%s] was due to: ", moduleDefinitionName), e); - } + logger.error("Failed to start module [{}] due to: {}", def.getName(), e); + throw new RuntimeException(String.format("Failed to start module [%s]", def.getName())); } } catch (EmptyStackException e) { logger.warn(String.format("Failed to obtain module context due to [%s]. Using root context instead.", e.getMessage())); @@ -147,10 +145,8 @@ public class DefaultModuleDefinitionSet implements ModuleDefinitionSet { logger.debug("Failed to obtain module context: ", e); } } catch (BeansException e) { - logger.warn(String.format("Failed to start module [%s] due to: [%s].", def.getName(), e.getMessage())); - if (logger.isDebugEnabled()) { - logger.debug(String.format("module start failure of module [%s] was due to: ", def.getName()), e); - } + logger.error("Failed to load module [{}] due to: {}", def.getName(), e); + throw new RuntimeException(String.format("Failed to load module [%s]", def.getName())); } } }); diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java index 4a93b1bce4a..41716881fa4 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResource.java @@ -16,6 +16,7 @@ // under the License. package com.cloud.hypervisor.kvm.resource; +import static com.cloud.host.Host.HOST_CDROM_MAX_COUNT; import static com.cloud.host.Host.HOST_INSTANCE_CONVERSION; import static com.cloud.host.Host.HOST_OVFTOOL_VERSION; import static com.cloud.host.Host.HOST_VDDK_LIB_DIR; @@ -226,6 +227,7 @@ import com.cloud.resource.ResourceStatusUpdater; import com.cloud.resource.ServerResource; import com.cloud.resource.ServerResourceBase; import com.cloud.storage.JavaStorageLayer; +import com.cloud.template.TemplateManager; import com.cloud.storage.Storage; import com.cloud.storage.Storage.StoragePoolType; import com.cloud.storage.StorageLayer; @@ -3696,6 +3698,7 @@ public class LibvirtComputingResource extends ServerResourceBase implements Serv if (vmSpec.getOs().toLowerCase().contains("window")) { isWindowsTemplate = true; } + final Set definedCdromSlots = new HashSet<>(); for (final DiskTO volume : disks) { KVMPhysicalDisk physicalDisk = null; KVMStoragePool pool = null; @@ -3774,6 +3777,7 @@ public class LibvirtComputingResource extends ServerResourceBase implements Serv if (volume.getType() == Volume.Type.ISO) { final DiskDef.DiskType diskType = getDiskType(physicalDisk); disk.defISODisk(volPath, devId, isUefiEnabled, diskType); + definedCdromSlots.add(devId); if (guestCpuArch != null && (guestCpuArch.equals("aarch64") || guestCpuArch.equals("s390x"))) { disk.setBusType(DiskDef.DiskBus.SCSI); @@ -3871,6 +3875,17 @@ public class LibvirtComputingResource extends ServerResourceBase implements Serv vm.getDevices().addDevice(disk); } + if (vmSpec.getType() == VirtualMachine.Type.User) { + for (int slot = TemplateManager.CDROM_PRIMARY_DEVICE_SEQ; + slot < TemplateManager.CDROM_PRIMARY_DEVICE_SEQ + LibvirtVMDef.MAX_CDROMS_PER_VM; slot++) { + if (!definedCdromSlots.contains(slot)) { + final DiskDef emptyCdrom = new DiskDef(); + emptyCdrom.defISODisk(null, slot, isUefiEnabled, DiskDef.DiskType.FILE); + vm.getDevices().addDevice(emptyCdrom); + } + } + } + if (vmSpec.getType() != VirtualMachine.Type.User) { final DiskDef iso = new DiskDef(); iso.defISODisk(sysvmISOPath, DiskDef.DiskType.FILE); @@ -4381,6 +4396,7 @@ public class LibvirtComputingResource extends ServerResourceBase implements Serv boolean instanceConversionSupported = hostSupportsInstanceConversion(); cmd.getHostDetails().put(HOST_INSTANCE_CONVERSION, String.valueOf(instanceConversionSupported)); cmd.getHostDetails().put(HOST_VDDK_SUPPORT, String.valueOf(hostSupportsVddk())); + cmd.getHostDetails().put(HOST_CDROM_MAX_COUNT, String.valueOf(LibvirtVMDef.MAX_CDROMS_PER_VM)); if (StringUtils.isNotBlank(vddkLibDir)) { cmd.getHostDetails().put(HOST_VDDK_LIB_DIR, vddkLibDir); } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtVMDef.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtVMDef.java index bf8b1af6c18..7f6725b6d15 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtVMDef.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/LibvirtVMDef.java @@ -57,6 +57,10 @@ import static java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME; public class LibvirtVMDef { protected static Logger LOGGER = LogManager.getLogger(LibvirtVMDef.class); + // CD-ROM slot allocation: getDevLabel() maps deviceSeq=3,4 to hdc and hdd on the IDE bus. + // Bumping this requires extending getDevLabel() (e.g. to spill onto SATA or a second IDE controller). + public static final int MAX_CDROMS_PER_VM = 2; + private String _hvsType; private static long s_libvirtVersion; private static long s_qemuVersion; diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java index 4a77f7e9e19..009e1decee2 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/storage/KVMStorageProcessor.java @@ -21,6 +21,8 @@ package com.cloud.hypervisor.kvm.storage; import static com.cloud.utils.NumbersUtil.toHumanReadableSize; import static com.cloud.utils.storage.S3.S3Utils.putFile; +import com.cloud.template.TemplateManager; + import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; @@ -1346,10 +1348,11 @@ public class KVMStorageProcessor implements StorageProcessor { } } - protected synchronized void attachOrDetachISO(final Connect conn, final String vmName, String isoPath, final boolean isAttach, Map params, DataStoreTO store) throws + protected synchronized void attachOrDetachISO(final Connect conn, final String vmName, String isoPath, final boolean isAttach, Map params, DataStoreTO store, Integer deviceSeq) throws LibvirtException, InternalErrorException { DiskDef iso = new DiskDef(); boolean isUefiEnabled = MapUtils.isNotEmpty(params) && params.containsKey("UEFI"); + Integer devId = (deviceSeq != null) ? deviceSeq : TemplateManager.CDROM_PRIMARY_DEVICE_SEQ; if (isoPath != null && isAttach) { final int index = isoPath.lastIndexOf("/"); final String path = isoPath.substring(0, index); @@ -1365,9 +1368,9 @@ public class KVMStorageProcessor implements StorageProcessor { final DiskDef.DiskType isoDiskType = LibvirtComputingResource.getDiskType(isoVol); isoPath = isoVol.getPath(); - iso.defISODisk(isoPath, isUefiEnabled, isoDiskType); + iso.defISODisk(isoPath, devId, isUefiEnabled, isoDiskType); } else { - iso.defISODisk(null, isUefiEnabled, DiskDef.DiskType.FILE); + iso.defISODisk(null, devId, isUefiEnabled, DiskDef.DiskType.FILE); } final List disks = resource.getDisks(conn, vmName); @@ -1387,11 +1390,12 @@ public class KVMStorageProcessor implements StorageProcessor { final DiskTO disk = cmd.getDisk(); final TemplateObjectTO isoTO = (TemplateObjectTO)disk.getData(); final DataStoreTO store = isoTO.getDataStore(); + final Integer deviceSeq = (disk.getDiskSeq() != null) ? disk.getDiskSeq().intValue() : null; try { String dataStoreUrl = getDataStoreUrlFromStore(store); final Connect conn = LibvirtConnection.getConnectionByVmName(cmd.getVmName()); - attachOrDetachISO(conn, cmd.getVmName(), dataStoreUrl + File.separator + isoTO.getPath(), true, cmd.getControllerInfo(), store); + attachOrDetachISO(conn, cmd.getVmName(), dataStoreUrl + File.separator + isoTO.getPath(), true, cmd.getControllerInfo(), store, deviceSeq); } catch (final LibvirtException e) { return new Answer(cmd, false, e.toString()); } catch (final InternalErrorException e) { @@ -1408,11 +1412,12 @@ public class KVMStorageProcessor implements StorageProcessor { final DiskTO disk = cmd.getDisk(); final TemplateObjectTO isoTO = (TemplateObjectTO)disk.getData(); final DataStoreTO store = isoTO.getDataStore(); + final Integer deviceSeq = (disk.getDiskSeq() != null) ? disk.getDiskSeq().intValue() : null; try { String dataStoreUrl = getDataStoreUrlFromStore(store); final Connect conn = LibvirtConnection.getConnectionByVmName(cmd.getVmName()); - attachOrDetachISO(conn, cmd.getVmName(), dataStoreUrl + File.separator + isoTO.getPath(), false, cmd.getParams(), store); + attachOrDetachISO(conn, cmd.getVmName(), dataStoreUrl + File.separator + isoTO.getPath(), false, cmd.getParams(), store, deviceSeq); } catch (final LibvirtException e) { return new Answer(cmd, false, e.toString()); } catch (final InternalErrorException e) { diff --git a/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterResourceModifierActionWorker.java b/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterResourceModifierActionWorker.java index 1ca86ed8c83..a1b2294bcd4 100644 --- a/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterResourceModifierActionWorker.java +++ b/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterResourceModifierActionWorker.java @@ -188,7 +188,7 @@ public class KubernetesClusterResourceModifierActionWorker extends KubernetesClu hosts = hosts.stream().filter(host -> !constraints.antiAffinityOccupiedHosts.contains(host.getId())).collect(Collectors.toList()); if (CollectionUtils.isEmpty(hosts)) { String msg = String.format("Cannot find capacity for Kubernetes cluster: host anti-affinity requires each VM on a separate host, " + - "but all %d available hosts in zone %s are already occupied by existing cluster VMs", + "but all %d available hosts in zone %s are already occupied by existing cluster VMs", constraints.antiAffinityOccupiedHosts.size(), zone.getName()); throw new InsufficientServerCapacityException(msg, DataCenter.class, zone.getId()); } @@ -198,8 +198,8 @@ public class KubernetesClusterResourceModifierActionWorker extends KubernetesClu } protected DeployDestination plan(final long nodesCount, final DataCenter zone, final ServiceOffering offering, - final Long domainId, final Long accountId, final Hypervisor.HypervisorType hypervisorType, - CPU.CPUArch arch, KubernetesClusterNodeType nodeType) throws InsufficientServerCapacityException { + final Long domainId, final Long accountId, final Hypervisor.HypervisorType hypervisorType, + CPU.CPUArch arch, KubernetesClusterNodeType nodeType) throws InsufficientServerCapacityException { final int cpu_requested = offering.getCpu() * offering.getSpeed(); final long ram_requested = offering.getRamSize() * 1024L * 1024L; boolean useDedicatedHosts = false; @@ -295,7 +295,7 @@ public class KubernetesClusterResourceModifierActionWorker extends KubernetesClu String msg; if (affinityConstraints.hasHostAntiAffinity) { msg = String.format("Cannot find enough capacity for Kubernetes cluster (requested cpu=%d memory=%s) with offering: %s. " + - "Host anti-affinity requires %d separate hosts but not enough suitable hosts are available in zone %s", + "Host anti-affinity requires %d separate hosts but not enough suitable hosts are available in zone %s", cpu_requested * nodesCount, toHumanReadableSize(ram_requested * nodesCount), offering.getName(), nodesCount, zone.getName()); } else { @@ -399,7 +399,7 @@ public class KubernetesClusterResourceModifierActionWorker extends KubernetesClu } protected List provisionKubernetesClusterNodeVms(final long nodeCount, final int offset, - final String controlIpAddress, final Long domainId, final Long accountId) throws ManagementServerException, + final String controlIpAddress, final Long domainId, final Long accountId) throws ManagementServerException, ResourceUnavailableException, InsufficientCapacityException { List nodes = new ArrayList<>(); for (int i = offset + 1; i <= nodeCount; i++) { @@ -468,12 +468,14 @@ public class KubernetesClusterResourceModifierActionWorker extends KubernetesClu nodeVm = userVmService.createAdvancedSecurityGroupVirtualMachine(zone, serviceOffering, workerNodeTemplate, networkIds, securityGroupIds, owner, hostName, hostName, null, null, null, null, Hypervisor.HypervisorType.None, BaseCmd.HTTPMethod.POST,base64UserData, null, null, keypairs, null, addrs, null, null, affinityGroupIds, customParameterMap, null, null, null, - null, true, null, UserVmManager.CKS_NODE, null, null); + null, true, null, null, UserVmManager.CKS_NODE, null, null); } else { nodeVm = userVmService.createAdvancedVirtualMachine(zone, serviceOffering, workerNodeTemplate, networkIds, owner, hostName, hostName, null, null, null, null, Hypervisor.HypervisorType.None, BaseCmd.HTTPMethod.POST, base64UserData, null, null, keypairs, - null, addrs, null, null, affinityGroupIds, customParameterMap, null, null, null, null, true, UserVmManager.CKS_NODE, null, null, null); + null, addrs, null, null, affinityGroupIds, customParameterMap, + null, null, null, null, true, + UserVmManager.CKS_NODE, null, null, null, null); } if (logger.isInfoEnabled()) { logger.info("Created node VM : {}, {} in the Kubernetes cluster : {}", hostName, nodeVm, kubernetesCluster.getName()); @@ -504,7 +506,7 @@ public class KubernetesClusterResourceModifierActionWorker extends KubernetesClu } protected void provisionPublicIpPortForwardingRule(IpAddress publicIp, Network network, Account account, - final long vmId, final int sourcePort, final int destPort) throws NetworkRuleConflictException, ResourceUnavailableException { + final long vmId, final int sourcePort, final int destPort) throws NetworkRuleConflictException, ResourceUnavailableException { final long publicIpId = publicIp.getId(); final long networkId = network.getId(); final long accountId = account.getId(); @@ -543,7 +545,7 @@ public class KubernetesClusterResourceModifierActionWorker extends KubernetesClu * @throws NetworkRuleConflictException */ protected void provisionSshPortForwardingRules(IpAddress publicIp, Network network, Account account, - List clusterVMIds, Map vmIdPortMap) throws ResourceUnavailableException, + List clusterVMIds, Map vmIdPortMap) throws ResourceUnavailableException, NetworkRuleConflictException { if (!CollectionUtils.isEmpty(clusterVMIds)) { int defaultNodesCount = clusterVMIds.size() - vmIdPortMap.size(); @@ -566,7 +568,7 @@ public class KubernetesClusterResourceModifierActionWorker extends KubernetesClu Integer startPort = firewallRule.getSourcePortStart(); Integer endPort = firewallRule.getSourcePortEnd(); if (startPort != null && startPort == CLUSTER_API_PORT && - endPort != null && endPort == CLUSTER_API_PORT) { + endPort != null && endPort == CLUSTER_API_PORT) { rule = firewallRule; firewallService.revokeIngressFwRule(firewallRule.getId(), true); logger.debug("The API firewall rule [%s] with the id [%s] was revoked",firewallRule.getName(),firewallRule.getId()); @@ -613,7 +615,7 @@ public class KubernetesClusterResourceModifierActionWorker extends KubernetesClu } protected void removePortForwardingRules(final IpAddress publicIp, final Network network, final Account account, int startPort, int endPort) - throws ResourceUnavailableException { + throws ResourceUnavailableException { List pfRules = portForwardingRulesDao.listByNetwork(network.getId()); for (PortForwardingRuleVO pfRule : pfRules) { if (startPort <= pfRule.getSourcePortStart() && pfRule.getSourcePortStart() <= endPort) { @@ -626,10 +628,10 @@ public class KubernetesClusterResourceModifierActionWorker extends KubernetesClu } protected void removeLoadBalancingRule(final IpAddress publicIp, final Network network, - final Account account) throws ResourceUnavailableException { + final Account account) throws ResourceUnavailableException { List loadBalancerRules = loadBalancerDao.listByIpAddress(publicIp.getId()); loadBalancerRules.stream().filter(lbRules -> lbRules.getNetworkId() == network.getId() && lbRules.getAccountId() == account.getId() && lbRules.getSourcePortStart() == CLUSTER_API_PORT - && lbRules.getSourcePortEnd() == CLUSTER_API_PORT).forEach(lbRule -> { + && lbRules.getSourcePortEnd() == CLUSTER_API_PORT).forEach(lbRule -> { lbService.deleteLoadBalancerRule(lbRule.getId(), true); logger.debug("The load balancing rule with the Id: {} was removed",lbRule.getId()); }); @@ -665,12 +667,12 @@ public class KubernetesClusterResourceModifierActionWorker extends KubernetesClu IllegalAccessException, ResourceUnavailableException { List aclItems = networkACLItemDao.listByACL(network.getNetworkACLId()); aclItems = aclItems.stream().filter(networkACLItem -> (networkACLItem.getProtocol() != null && - networkACLItem.getProtocol().equals("TCP") && - networkACLItem.getSourcePortStart() != null && - networkACLItem.getSourcePortStart().equals(startPort) && - networkACLItem.getSourcePortEnd() != null && - networkACLItem.getSourcePortEnd().equals(endPort) && - networkACLItem.getAction().equals(NetworkACLItem.Action.Allow))) + networkACLItem.getProtocol().equals("TCP") && + networkACLItem.getSourcePortStart() != null && + networkACLItem.getSourcePortStart().equals(startPort) && + networkACLItem.getSourcePortEnd() != null && + networkACLItem.getSourcePortEnd().equals(endPort) && + networkACLItem.getAction().equals(NetworkACLItem.Action.Allow))) .collect(Collectors.toList()); for (NetworkACLItemVO aclItem : aclItems) { @@ -679,7 +681,7 @@ public class KubernetesClusterResourceModifierActionWorker extends KubernetesClu } protected void provisionLoadBalancerRule(final IpAddress publicIp, final Network network, - final Account account, final List clusterVMIds, final int port) throws NetworkRuleConflictException, + final Account account, final List clusterVMIds, final int port) throws NetworkRuleConflictException, InsufficientAddressCapacityException { LoadBalancer lb = lbService.createPublicLoadBalancerRule(null, "api-lb", "LB rule for API access", port, port, port, port, @@ -879,11 +881,11 @@ public class KubernetesClusterResourceModifierActionWorker extends KubernetesClu } protected KubernetesClusterVO updateKubernetesClusterEntry(final Long cores, final Long memory, final Long size, - final Long serviceOfferingId, final Boolean autoscaleEnabled, - final Long minSize, final Long maxSize, - final KubernetesClusterNodeType nodeType, - final boolean updateNodeOffering, - final boolean updateClusterOffering) { + final Long serviceOfferingId, final Boolean autoscaleEnabled, + final Long minSize, final Long maxSize, + final KubernetesClusterNodeType nodeType, + final boolean updateNodeOffering, + final boolean updateClusterOffering) { return Transaction.execute((TransactionCallback) status -> { KubernetesClusterVO updatedCluster = kubernetesClusterDao.createForUpdate(kubernetesCluster.getId()); @@ -941,9 +943,9 @@ public class KubernetesClusterResourceModifierActionWorker extends KubernetesClu try { if (enable) { String command = String.format("sudo /opt/bin/autoscale-kube-cluster -i %s -e -M %d -m %d", - kubernetesCluster.getUuid(), maxSize, minSize); + kubernetesCluster.getUuid(), maxSize, minSize); Pair result = SshHelper.sshExecute(publicIpAddress, sshPort, getControlNodeLoginUser(), - pkFile, null, command, 10000, 10000, 60000); + pkFile, null, command, 10000, 10000, 60000); // Maybe the file isn't present. Try and copy it if (!result.first()) { @@ -953,12 +955,12 @@ public class KubernetesClusterResourceModifierActionWorker extends KubernetesClu if (!createCloudStackSecret(keys)) { logTransitStateAndThrow(Level.ERROR, String.format("Failed to setup keys for Kubernetes cluster %s", - kubernetesCluster.getName()), kubernetesCluster.getId(), KubernetesCluster.Event.OperationFailed); + kubernetesCluster.getName()), kubernetesCluster.getId(), KubernetesCluster.Event.OperationFailed); } // If at first you don't succeed ... result = SshHelper.sshExecute(publicIpAddress, sshPort, getControlNodeLoginUser(), - pkFile, null, command, 10000, 10000, 60000); + pkFile, null, command, 10000, 10000, 60000); if (!result.first()) { throw new CloudRuntimeException(result.second()); } @@ -966,7 +968,7 @@ public class KubernetesClusterResourceModifierActionWorker extends KubernetesClu updateKubernetesClusterEntry(true, minSize, maxSize); } else { Pair result = SshHelper.sshExecute(publicIpAddress, sshPort, getControlNodeLoginUser(), - pkFile, null, String.format("sudo /opt/bin/autoscale-kube-cluster -d"), + pkFile, null, String.format("sudo /opt/bin/autoscale-kube-cluster -d"), 10000, 10000, 60000); if (!result.first()) { throw new CloudRuntimeException(result.second()); diff --git a/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterStartWorker.java b/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterStartWorker.java index 4ed5ff0167c..308fc07223d 100644 --- a/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterStartWorker.java +++ b/plugins/integrations/kubernetes-service/src/main/java/com/cloud/kubernetes/cluster/actionworkers/KubernetesClusterStartWorker.java @@ -142,8 +142,8 @@ public class KubernetesClusterStartWorker extends KubernetesClusterResourceModif } private Pair getKubernetesControlNodeConfig(final String controlNodeIp, final String serverIp, - final List etcdIps, final String hostName, final boolean haSupported, - final boolean ejectIso, final boolean externalCni, final boolean setupCsi) throws IOException { + final List etcdIps, final String hostName, final boolean haSupported, + final boolean ejectIso, final boolean externalCni, final boolean setupCsi) throws IOException { String k8sControlNodeConfig = readK8sConfigFile("/conf/k8s-control-node.yml"); final String apiServerCert = "{{ k8s_control_node.apiserver.crt }}"; final String apiServerKey = "{{ k8s_control_node.apiserver.key }}"; @@ -273,19 +273,21 @@ public class KubernetesClusterStartWorker extends KubernetesClusterResourceModif List affinityGroupIds = getMergedAffinityGroupIds(CONTROL, domainId, accountId); String userDataDetails = kubernetesCluster.getCniConfigDetails(); if (kubernetesCluster.getSecurityGroupId() != null && - networkModel.checkSecurityGroupSupportForNetwork(owner, zone, networkIds, - List.of(kubernetesCluster.getSecurityGroupId()))) { + networkModel.checkSecurityGroupSupportForNetwork(owner, zone, networkIds, + List.of(kubernetesCluster.getSecurityGroupId()))) { List securityGroupIds = new ArrayList<>(); securityGroupIds.add(kubernetesCluster.getSecurityGroupId()); controlVm = userVmService.createAdvancedSecurityGroupVirtualMachine(zone, serviceOffering, controlNodeTemplate, networkIds, securityGroupIds, owner, - hostName, hostName, null, null, null, null, Hypervisor.HypervisorType.None, BaseCmd.HTTPMethod.POST,base64UserData, userDataId, userDataDetails, keypairs, + hostName, hostName, null, null, null, null, Hypervisor.HypervisorType.None, BaseCmd.HTTPMethod.POST,base64UserData, userDataId, userDataDetails, keypairs, requestedIps, addrs, null, null, affinityGroupIds, customParameterMap, null, null, null, - null, true, null, UserVmManager.CKS_NODE, null, null); + null, true, null, null, UserVmManager.CKS_NODE, null, null); } else { controlVm = userVmService.createAdvancedVirtualMachine(zone, serviceOffering, controlNodeTemplate, networkIds, owner, hostName, hostName, null, null, null, null, Hypervisor.HypervisorType.None, BaseCmd.HTTPMethod.POST, base64UserData, userDataId, userDataDetails, keypairs, - requestedIps, addrs, null, null, affinityGroupIds, customParameterMap, null, null, null, null, true, UserVmManager.CKS_NODE, null, null, null); + requestedIps, addrs, null, null, affinityGroupIds, customParameterMap, + null, null, null, null, true, + UserVmManager.CKS_NODE, null, null, null, null); } if (logger.isInfoEnabled()) { logger.info("Created control VM: {}, {} in the Kubernetes cluster: {}", controlVm, hostName, kubernetesCluster); @@ -372,7 +374,7 @@ public class KubernetesClusterStartWorker extends KubernetesClusterResourceModif } private String getEtcdNodeConfig(final List ipAddresses, final List hostnames, final int etcdNodeIndex, - final boolean ejectIso) throws IOException { + final boolean ejectIso) throws IOException { String k8sEtcdNodeConfig = readK8sConfigFile("/conf/etcd-node.yml"); final String sshPubKey = "{{ k8s.ssh.pub.key }}"; final String ejectIsoKey = "{{ k8s.eject.iso }}"; @@ -406,7 +408,7 @@ public class KubernetesClusterStartWorker extends KubernetesClusterResourceModif } private UserVm createKubernetesAdditionalControlNode(final String joinIp, final int additionalControlNodeInstance, - final Long domainId, final Long accountId) throws ManagementServerException, + final Long domainId, final Long accountId) throws ManagementServerException, ResourceUnavailableException, InsufficientCapacityException { UserVm additionalControlVm = null; DataCenter zone = dataCenterDao.findById(kubernetesCluster.getZoneId()); @@ -439,19 +441,21 @@ public class KubernetesClusterStartWorker extends KubernetesClusterResourceModif List affinityGroupIds = getMergedAffinityGroupIds(CONTROL, domainId, accountId); if (kubernetesCluster.getSecurityGroupId() != null && - networkModel.checkSecurityGroupSupportForNetwork(owner, zone, networkIds, - List.of(kubernetesCluster.getSecurityGroupId()))) { + networkModel.checkSecurityGroupSupportForNetwork(owner, zone, networkIds, + List.of(kubernetesCluster.getSecurityGroupId()))) { List securityGroupIds = new ArrayList<>(); securityGroupIds.add(kubernetesCluster.getSecurityGroupId()); additionalControlVm = userVmService.createAdvancedSecurityGroupVirtualMachine(zone, serviceOffering, controlNodeTemplate, networkIds, securityGroupIds, owner, hostName, hostName, null, null, null, null, Hypervisor.HypervisorType.None, BaseCmd.HTTPMethod.POST,base64UserData, null, null, keypairs, null, addrs, null, null, affinityGroupIds, customParameterMap, null, null, null, - null, true, null, UserVmManager.CKS_NODE, null, null); + null, true, null, null, UserVmManager.CKS_NODE, null, null); } else { additionalControlVm = userVmService.createAdvancedVirtualMachine(zone, serviceOffering, controlNodeTemplate, networkIds, owner, hostName, hostName, null, null, null, null, Hypervisor.HypervisorType.None, BaseCmd.HTTPMethod.POST, base64UserData, null, null, keypairs, - null, addrs, null, null, affinityGroupIds, customParameterMap, null, null, null, null, true, UserVmManager.CKS_NODE, null, null, null); + null, addrs, null, null, affinityGroupIds, customParameterMap, + null, null, null, null, true, + UserVmManager.CKS_NODE, null, null, null, null); } if (logger.isInfoEnabled()) { @@ -488,12 +492,14 @@ public class KubernetesClusterStartWorker extends KubernetesClusterResourceModif etcdNode = userVmService.createAdvancedSecurityGroupVirtualMachine(zone, serviceOffering, etcdTemplate, networkIds, securityGroupIds, owner, hostName, hostName, null, null, null, null, Hypervisor.HypervisorType.None, BaseCmd.HTTPMethod.POST,base64UserData, null, null, keypairs, Map.of(kubernetesCluster.getNetworkId(), requestedIps.get(etcdNodeIndex)), addrs, null, null, affinityGroupIds, customParameterMap, null, null, null, - null, true, null, null, null, null); + null, true, null, null, null, null, null); } else { etcdNode = userVmService.createAdvancedVirtualMachine(zone, serviceOffering, etcdTemplate, networkIds, owner, hostName, hostName, null, null, null, null, Hypervisor.HypervisorType.None, BaseCmd.HTTPMethod.POST, base64UserData, null, null, keypairs, - Map.of(kubernetesCluster.getNetworkId(), requestedIps.get(etcdNodeIndex)), addrs, null, null, affinityGroupIds, customParameterMap, null, null, null, null, true, UserVmManager.CKS_NODE, null, null, null); + Map.of(kubernetesCluster.getNetworkId(), requestedIps.get(etcdNodeIndex)), addrs, null, null, + affinityGroupIds, customParameterMap, null, null, null, null, + true, UserVmManager.CKS_NODE, null, null, null, null); } if (logger.isInfoEnabled()) { @@ -503,7 +509,7 @@ public class KubernetesClusterStartWorker extends KubernetesClusterResourceModif } private Pair provisionKubernetesClusterControlVm(final Network network, final String publicIpAddress, final List etcdIps, - final Long domainId, final Long accountId, Long asNumber) throws + final Long domainId, final Long accountId, Long asNumber) throws ManagementServerException, InsufficientCapacityException, ResourceUnavailableException { UserVm k8sControlVM = null; Pair k8sControlVMAndControlIP; @@ -525,7 +531,7 @@ public class KubernetesClusterStartWorker extends KubernetesClusterResourceModif } private List provisionKubernetesClusterAdditionalControlVms(final String controlIpAddress, final Long domainId, - final Long accountId) throws + final Long accountId) throws InsufficientCapacityException, ManagementServerException, ResourceUnavailableException { List additionalControlVms = new ArrayList<>(); if (kubernetesCluster.getControlNodeCount() > 1) { @@ -762,7 +768,7 @@ public class KubernetesClusterStartWorker extends KubernetesClusterResourceModif } publicIpAddress = publicIpSshPort.first(); if (StringUtils.isEmpty(publicIpAddress) && - (!manager.isDirectAccess(network) || kubernetesCluster.getControlNodeCount() > 1)) { // Shared network, single-control node cluster won't have an IP yet + (!manager.isDirectAccess(network) || kubernetesCluster.getControlNodeCount() > 1)) { // Shared network, single-control node cluster won't have an IP yet logTransitStateAndThrow(Level.ERROR, String.format("Failed to start Kubernetes cluster : %s as no public IP found for the cluster" , kubernetesCluster.getName()), kubernetesCluster.getId(), KubernetesCluster.Event.CreateFailed); } // Allow account creating the kubernetes cluster to access systemVM template diff --git a/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/adapter/ServerAdapter.java b/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/adapter/ServerAdapter.java index 510ad74d155..109fe71a8d7 100644 --- a/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/adapter/ServerAdapter.java +++ b/plugins/integrations/veeam-control-service/src/main/java/org/apache/cloudstack/veeam/adapter/ServerAdapter.java @@ -1016,7 +1016,7 @@ public class ServerAdapter extends ManagerBase { Volume volume; try { volume = volumeApiService.allocVolume(serviceAccount.getId(), pool.getDataCenterId(), diskOfferingId, null, - null, name, sizeInGb, null, null, null, null); + null, name, sizeInGb, null, null, null, null, null); } catch (ResourceAllocationException e) { throw new CloudRuntimeException(e.getMessage(), e); } diff --git a/plugins/kms/database/pom.xml b/plugins/kms/database/pom.xml new file mode 100644 index 00000000000..2bbeb2dc75b --- /dev/null +++ b/plugins/kms/database/pom.xml @@ -0,0 +1,73 @@ + + + + 4.0.0 + cloud-plugin-kms-database + Apache CloudStack Plugin - KMS Database Provider + Database-backed KMS provider for encrypted key storage + + + org.apache.cloudstack + cloudstack-kms-plugins + 4.23.0.0-SNAPSHOT + ../pom.xml + + + + + org.apache.cloudstack + cloud-framework-kms + ${project.version} + + + org.apache.cloudstack + cloud-framework-config + ${project.version} + + + org.apache.cloudstack + cloud-utils + ${project.version} + + + com.google.crypto.tink + tink + ${cs.tink.version} + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + true + + + + + + + diff --git a/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/DatabaseKMSProvider.java b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/DatabaseKMSProvider.java new file mode 100644 index 00000000000..d2dbe396927 --- /dev/null +++ b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/DatabaseKMSProvider.java @@ -0,0 +1,386 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.kms.provider; + +import com.cloud.utils.component.AdapterBase; +import com.cloud.utils.crypt.DBEncryptionUtil; +import com.google.crypto.tink.subtle.AesGcmJce; + +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.framework.kms.KMSProvider; +import org.apache.cloudstack.framework.kms.KeyPurpose; +import org.apache.cloudstack.framework.kms.WrappedKey; +import org.apache.cloudstack.kms.HSMProfileVO; +import org.apache.cloudstack.kms.dao.HSMProfileDao; +import org.apache.cloudstack.kms.provider.database.KMSDatabaseKekObjectVO; +import org.apache.cloudstack.kms.provider.database.dao.KMSDatabaseKekObjectDao; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import javax.inject.Inject; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; +import java.util.Date; +import java.util.List; + +/** + * Database-backed KMS provider that stores master KEKs in a PKCS#11-like object table. + * Uses AES-256-GCM for all cryptographic operations. + *

+ * This provider is suitable for deployments that don't have access to HSM hardware. + * The master KEKs are stored encrypted in the kms_database_kek_objects table using + * CloudStack's existing DBEncryptionUtil, with PKCS#11-compatible attributes. + */ +public class DatabaseKMSProvider extends AdapterBase implements KMSProvider { + private static final Logger logger = LogManager.getLogger(DatabaseKMSProvider.class); + private static final String PROVIDER_NAME = "database"; + private static final int GCM_IV_LENGTH = 12; // 96 bits recommended for GCM + private static final int GCM_TAG_LENGTH = 16; // 128 bits + private static final String ALGORITHM = "AES/GCM/NoPadding"; + private static final String CKO_SECRET_KEY = "CKO_SECRET_KEY"; + private static final String CKK_AES = "CKK_AES"; + + private static final String DEFAULT_PROFILE_NAME = "HSM Database Provider"; + private static final long SYSTEM_ACCOUNT_ID = 1L; + private static final long ROOT_DOMAIN_ID = 1L; + + private final SecureRandom secureRandom = new SecureRandom(); + @Inject + private KMSDatabaseKekObjectDao kekObjectDao; + @Inject + private HSMProfileDao hsmProfileDao; + + @Override + public boolean start() { + super.start(); + ensureDefaultHSMProfile(); + return true; + } + + @Override + public String getProviderName() { + return PROVIDER_NAME; + } + + @Override + public String createKek(KeyPurpose purpose, String label, int keyBits, Long hsmProfileId) throws KMSException { + // Database provider ignores hsmProfileId + return createKek(purpose, label, keyBits); + } + + @Override + public String createKek(KeyPurpose purpose, String label, int keyBits) throws KMSException { + if (keyBits != 128 && keyBits != 192 && keyBits != 256) { + throw KMSException.invalidParameter("Key size must be 128, 192, or 256 bits"); + } + + if (StringUtils.isEmpty(label)) { + throw KMSException.invalidParameter("KEK label cannot be empty"); + } + + if (kekObjectDao.existsByLabel(label)) { + throw KMSException.keyAlreadyExists("KEK with label " + label + " already exists"); + } + + byte[] kekBytes = new byte[keyBits / 8]; + try { + secureRandom.nextBytes(kekBytes); + + // Base64 encode then encrypt the KEK material using DBEncryptionUtil + String kekBase64 = Base64.getEncoder().encodeToString(kekBytes); + String encryptedKek = DBEncryptionUtil.encrypt(kekBase64); + byte[] encryptedKekBytes = encryptedKek.getBytes(StandardCharsets.UTF_8); + + KMSDatabaseKekObjectVO kekObject = new KMSDatabaseKekObjectVO(label, purpose, keyBits, encryptedKekBytes); + kekObject.setObjectClass(CKO_SECRET_KEY); + kekObject.setKeyType(CKK_AES); + kekObject.setObjectId(label.getBytes(StandardCharsets.UTF_8)); + kekObject.setAlgorithm(ALGORITHM); + kekObject.setIsSensitive(true); + kekObject.setIsExtractable(false); + kekObject.setIsToken(true); + kekObject.setIsPrivate(true); + kekObject.setIsModifiable(false); + kekObject.setIsCopyable(false); + kekObject.setIsDestroyable(true); + kekObject.setAlwaysSensitive(true); + kekObject.setNeverExtractable(true); + + kekObjectDao.persist(kekObject); + + logger.info("Created KEK with label {} for purpose {} (PKCS#11 object ID: {})", label, purpose, + kekObject.getId()); + return label; + + } catch (Exception e) { + throw KMSException.kekOperationFailed("Failed to create KEK: " + e.getMessage(), e); + } finally { + Arrays.fill(kekBytes, (byte) 0); + } + } + + @Override + public void deleteKek(String kekId) throws KMSException { + KMSDatabaseKekObjectVO kekObject = kekObjectDao.findByLabel(kekId); + if (kekObject == null) { + throw KMSException.kekNotFound("KEK with label " + kekId + " not found"); + } + + try { + kekObjectDao.remove(kekObject.getId()); + + if (kekObject.getKeyMaterial() != null) { + Arrays.fill(kekObject.getKeyMaterial(), (byte) 0); + } + + logger.warn("Deleted KEK with label {}. All DEKs wrapped with this KEK are now unrecoverable!", kekId); + } catch (Exception e) { + throw KMSException.kekOperationFailed("Failed to delete KEK: " + e.getMessage(), e); + } + } + + @Override + public boolean isKekAvailable(String kekId) throws KMSException { + try { + KMSDatabaseKekObjectVO kekObject = kekObjectDao.findByLabel(kekId); + return kekObject != null && kekObject.getRemoved() == null && kekObject.getKeyMaterial() != null; + } catch (Exception e) { + logger.warn("Error checking KEK availability: {}", e.getMessage()); + return false; + } + } + + @Override + public WrappedKey wrapKey(byte[] plainKey, KeyPurpose purpose, String kekLabel, + Long hsmProfileId) throws KMSException { + // Database provider ignores hsmProfileId + return wrapKey(plainKey, purpose, kekLabel); + } + + @Override + public WrappedKey wrapKey(byte[] plainKey, KeyPurpose purpose, String kekLabel) throws KMSException { + if (plainKey == null || plainKey.length == 0) { + throw KMSException.invalidParameter("Plain key cannot be null or empty"); + } + + byte[] kekBytes = loadKek(kekLabel); + + try { + // Tink's AesGcmJce automatically generates a random IV and prepends it to the ciphertext + AesGcmJce aesgcm = new AesGcmJce(kekBytes); + byte[] wrappedBlob = aesgcm.encrypt(plainKey, new byte[0]); + + WrappedKey wrapped = new WrappedKey(kekLabel, purpose, ALGORITHM, wrappedBlob, PROVIDER_NAME, new Date(), + null); + + logger.debug("Wrapped {} key with KEK {}", purpose, kekLabel); + return wrapped; + } catch (Exception e) { + throw KMSException.wrapUnwrapFailed("Failed to wrap key: " + e.getMessage(), e); + } finally { + // Zeroize KEK + Arrays.fill(kekBytes, (byte) 0); + } + } + + @Override + public byte[] unwrapKey(WrappedKey wrappedKey, Long hsmProfileId) throws KMSException { + // Database provider ignores hsmProfileId + return unwrapKey(wrappedKey); + } + + @Override + public byte[] unwrapKey(WrappedKey wrappedKey) throws KMSException { + if (wrappedKey == null) { + throw KMSException.invalidParameter("Wrapped key cannot be null"); + } + + byte[] kekBytes = loadKek(wrappedKey.getKekId()); + + try { + AesGcmJce aesgcm = new AesGcmJce(kekBytes); + // Tink's decrypt expects [IV][ciphertext+tag] format (same as encrypt returns) + byte[] blob = wrappedKey.getWrappedKeyMaterial(); + if (blob.length < GCM_IV_LENGTH + GCM_TAG_LENGTH) { + throw new KMSException(KMSException.ErrorType.WRAP_UNWRAP_FAILED, + "Invalid wrapped key format: too short"); + } + + byte[] plainKey = aesgcm.decrypt(blob, new byte[0]); + + logger.debug("Unwrapped {} key with KEK {}", wrappedKey.getPurpose(), wrappedKey.getKekId()); + return plainKey; + + } catch (KMSException e) { + throw e; + } catch (Exception e) { + throw KMSException.wrapUnwrapFailed("Failed to unwrap key: " + e.getMessage(), e); + } finally { + // Zeroize KEK + Arrays.fill(kekBytes, (byte) 0); + } + } + + @Override + public WrappedKey generateAndWrapDek(KeyPurpose purpose, String kekLabel, int keyBits, + Long hsmProfileId) throws KMSException { + // Database provider ignores hsmProfileId + return generateAndWrapDek(purpose, kekLabel, keyBits); + } + + @Override + public WrappedKey generateAndWrapDek(KeyPurpose purpose, String kekLabel, int keyBits) throws KMSException { + if (keyBits != 128 && keyBits != 192 && keyBits != 256) { + throw KMSException.invalidParameter("DEK size must be 128, 192, or 256 bits"); + } + + byte[] dekBytes = new byte[keyBits / 8]; + secureRandom.nextBytes(dekBytes); + + try { + return wrapKey(dekBytes, purpose, kekLabel); + } finally { + // Zeroize DEK (wrapped version is in WrappedKey) + Arrays.fill(dekBytes, (byte) 0); + } + } + + @Override + public WrappedKey rewrapKey(WrappedKey oldWrappedKey, String newKekLabel, + Long targetHsmProfileId) throws KMSException { + // Database provider ignores targetHsmProfileId + return rewrapKey(oldWrappedKey, newKekLabel); + } + + @Override + public WrappedKey rewrapKey(WrappedKey oldWrappedKey, String newKekLabel) throws KMSException { + byte[] plainKey = unwrapKey(oldWrappedKey); + try { + return wrapKey(plainKey, oldWrappedKey.getPurpose(), newKekLabel); + } finally { + // Zeroize plaintext DEK + Arrays.fill(plainKey, (byte) 0); + } + } + + @Override + public boolean healthCheck() throws KMSException { + try { + if (kekObjectDao == null) { + logger.error("KMSDatabaseKekObjectDao is not initialized"); + return false; + } + return true; + + } catch (Exception e) { + throw KMSException.healthCheckFailed("Health check failed: " + e.getMessage(), e); + } + } + + private byte[] loadKek(String kekLabel) throws KMSException { + KMSDatabaseKekObjectVO kekObject = kekObjectDao.findByLabel(kekLabel); + + if (kekObject == null || kekObject.getRemoved() != null) { + throw KMSException.kekNotFound("KEK with label " + kekLabel + " not found"); + } + + try { + byte[] encryptedKekBytes = kekObject.getKeyMaterial(); + if (encryptedKekBytes == null || encryptedKekBytes.length == 0) { + throw KMSException.kekNotFound("KEK value is empty for label " + kekLabel); + } + + String encryptedKek = new String(encryptedKekBytes, StandardCharsets.UTF_8); + String kekBase64 = DBEncryptionUtil.decrypt(encryptedKek); + byte[] kekBytes = Base64.getDecoder().decode(kekBase64); + + updateLastUsed(kekLabel); + + return kekBytes; + + } catch (IllegalArgumentException e) { + throw KMSException.kekOperationFailed("Invalid KEK encoding for label " + kekLabel, e); + } catch (Exception e) { + throw KMSException.kekOperationFailed("Failed to decrypt KEK for label " + kekLabel + ": " + e.getMessage(), + e); + } + } + + private void updateLastUsed(String kekLabel) { + try { + KMSDatabaseKekObjectVO kekObject = kekObjectDao.findByLabel(kekLabel); + if (kekObject != null && kekObject.getRemoved() == null) { + kekObject.setLastUsed(new Date()); + kekObjectDao.update(kekObject.getId(), kekObject); + } + } catch (Exception e) { + logger.debug("Failed to update last used timestamp for KEK {}: {}", kekLabel, e.getMessage()); + } + } + + /** + * Seeds the default database HSM profile if it does not already exist. + * This runs at provider startup to avoid FK constraint issues that occur + * when the INSERT is placed in the schema upgrade SQL script (the account + * table may not yet be populated when the upgrade script executes on a + * fresh install). + */ + private void ensureDefaultHSMProfile() { + try { + SearchBuilder sb = hsmProfileDao.createSearchBuilder(); + sb.and("protocol", sb.entity().getProtocol(), SearchCriteria.Op.EQ); + sb.done(); + + SearchCriteria sc = sb.create(); + sc.setParameters("protocol", PROVIDER_NAME); + + List existing = hsmProfileDao.customSearchIncludingRemoved(sc, null); + if (existing != null && !existing.isEmpty()) { + logger.debug("Default database HSM profile already exists (id={})", existing.get(0).getId()); + return; + } + + HSMProfileVO profile = new HSMProfileVO(DEFAULT_PROFILE_NAME, PROVIDER_NAME, + SYSTEM_ACCOUNT_ID, ROOT_DOMAIN_ID, null, null); + profile.setEnabled(false); + profile.setIsPublic(true); + hsmProfileDao.persist(profile); + logger.info("Seeded default database HSM profile (id={}, uuid={})", profile.getId(), profile.getUuid()); + } catch (Exception e) { + logger.warn("Failed to seed default database HSM profile: {}", e.getMessage(), e); + } + } + + + @Override + public String getConfigComponentName() { + return DatabaseKMSProvider.class.getSimpleName(); + } + + @Override + public ConfigKey[] getConfigKeys() { + return new ConfigKey[0]; + } +} diff --git a/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/KMSDatabaseKekObjectVO.java b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/KMSDatabaseKekObjectVO.java new file mode 100644 index 00000000000..c1c91c9cef1 --- /dev/null +++ b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/KMSDatabaseKekObjectVO.java @@ -0,0 +1,357 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms.provider.database; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.framework.kms.KeyPurpose; +import org.apache.cloudstack.utils.reflectiontostringbuilderutils.ReflectionToStringBuilderUtils; + +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; + +/** + * Database entity for KEK objects stored by the database KMS provider. + * Models PKCS#11 object attributes for cryptographic key storage. + *

+ * This table stores KEKs (Key Encryption Keys) in a PKCS#11-compatible format, + * allowing the database provider to mock PKCS#11 interface behavior. + */ +@Entity +@Table(name = "kms_database_kek_objects") +public class KMSDatabaseKekObjectVO { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "uuid", nullable = false) + private String uuid; + + // PKCS#11 Object Class (CKA_CLASS) + @Column(name = "object_class", nullable = false, length = 32) + private String objectClass = "CKO_SECRET_KEY"; + + // PKCS#11 Label (CKA_LABEL) - human-readable identifier + @Column(name = "label", nullable = false, length = 255) + private String label; + + // PKCS#11 ID (CKA_ID) - application-defined identifier + @Column(name = "object_id", length = 64) + private byte[] objectId; + + // PKCS#11 Key Type (CKA_KEY_TYPE) + @Column(name = "key_type", nullable = false, length = 32) + private String keyType = "CKK_AES"; + + // PKCS#11 Key Value (CKA_VALUE) - encrypted KEK material + @Column(name = "key_material", nullable = false, length = 512) + private byte[] keyMaterial; + + // PKCS#11 Boolean Attributes + @Column(name = "is_sensitive", nullable = false) + private Boolean isSensitive = true; + + @Column(name = "is_extractable", nullable = false) + private Boolean isExtractable = false; + + @Column(name = "is_token", nullable = false) + private Boolean isToken = true; + + @Column(name = "is_private", nullable = false) + private Boolean isPrivate = true; + + @Column(name = "is_modifiable", nullable = false) + private Boolean isModifiable = false; + + @Column(name = "is_copyable", nullable = false) + private Boolean isCopyable = false; + + @Column(name = "is_destroyable", nullable = false) + private Boolean isDestroyable = true; + + @Column(name = "always_sensitive", nullable = false) + private Boolean alwaysSensitive = true; + + @Column(name = "never_extractable", nullable = false) + private Boolean neverExtractable = true; + + // Key Metadata + @Column(name = "purpose", nullable = false, length = 32) + @Enumerated(EnumType.STRING) + private KeyPurpose purpose; + + @Column(name = "key_bits", nullable = false) + private Integer keyBits; + + @Column(name = "algorithm", nullable = false, length = 64) + private String algorithm = "AES/GCM/NoPadding"; + + // PKCS#11 Validity Dates + @Column(name = "start_date") + @Temporal(TemporalType.TIMESTAMP) + private Date startDate; + + @Column(name = "end_date") + @Temporal(TemporalType.TIMESTAMP) + private Date endDate; + + // Lifecycle + @Column(name = GenericDao.CREATED_COLUMN, nullable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date created; + + @Column(name = "last_used") + @Temporal(TemporalType.TIMESTAMP) + private Date lastUsed; + + @Column(name = GenericDao.REMOVED_COLUMN) + @Temporal(TemporalType.TIMESTAMP) + private Date removed; + + /** + * Constructor for creating a new KEK object + * + * @param label PKCS#11 label (CKA_LABEL) + * @param purpose key purpose + * @param keyBits key size in bits + * @param keyMaterial encrypted key material (CKA_VALUE) + */ + public KMSDatabaseKekObjectVO(String label, KeyPurpose purpose, Integer keyBits, byte[] keyMaterial) { + this(); + this.label = label; + this.purpose = purpose; + this.keyBits = keyBits; + this.keyMaterial = keyMaterial; + this.objectId = label.getBytes(); // Use label as object ID by default + this.startDate = new Date(); + } + + public KMSDatabaseKekObjectVO() { + this.uuid = UUID.randomUUID().toString(); + this.created = new Date(); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public String getObjectClass() { + return objectClass; + } + + public void setObjectClass(String objectClass) { + this.objectClass = objectClass; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public byte[] getObjectId() { + return objectId; + } + + public void setObjectId(byte[] objectId) { + this.objectId = objectId; + } + + public String getKeyType() { + return keyType; + } + + public void setKeyType(String keyType) { + this.keyType = keyType; + } + + public byte[] getKeyMaterial() { + return keyMaterial; + } + + public void setKeyMaterial(byte[] keyMaterial) { + this.keyMaterial = keyMaterial; + } + + public Boolean getIsSensitive() { + return isSensitive; + } + + public void setIsSensitive(Boolean isSensitive) { + this.isSensitive = isSensitive; + } + + public Boolean getIsExtractable() { + return isExtractable; + } + + public void setIsExtractable(Boolean isExtractable) { + this.isExtractable = isExtractable; + } + + public Boolean getIsToken() { + return isToken; + } + + public void setIsToken(Boolean isToken) { + this.isToken = isToken; + } + + public Boolean getIsPrivate() { + return isPrivate; + } + + public void setIsPrivate(Boolean isPrivate) { + this.isPrivate = isPrivate; + } + + public Boolean getIsModifiable() { + return isModifiable; + } + + public void setIsModifiable(Boolean isModifiable) { + this.isModifiable = isModifiable; + } + + public Boolean getIsCopyable() { + return isCopyable; + } + + public void setIsCopyable(Boolean isCopyable) { + this.isCopyable = isCopyable; + } + + public Boolean getIsDestroyable() { + return isDestroyable; + } + + public void setIsDestroyable(Boolean isDestroyable) { + this.isDestroyable = isDestroyable; + } + + public Boolean getAlwaysSensitive() { + return alwaysSensitive; + } + + public void setAlwaysSensitive(Boolean alwaysSensitive) { + this.alwaysSensitive = alwaysSensitive; + } + + public Boolean getNeverExtractable() { + return neverExtractable; + } + + public void setNeverExtractable(Boolean neverExtractable) { + this.neverExtractable = neverExtractable; + } + + public KeyPurpose getPurpose() { + return purpose; + } + + public void setPurpose(KeyPurpose purpose) { + this.purpose = purpose; + } + + public Integer getKeyBits() { + return keyBits; + } + + public void setKeyBits(Integer keyBits) { + this.keyBits = keyBits; + } + + public String getAlgorithm() { + return algorithm; + } + + public void setAlgorithm(String algorithm) { + this.algorithm = algorithm; + } + + public Date getStartDate() { + return startDate; + } + + public void setStartDate(Date startDate) { + this.startDate = startDate; + } + + public Date getEndDate() { + return endDate; + } + + public void setEndDate(Date endDate) { + this.endDate = endDate; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } + + public Date getLastUsed() { + return lastUsed; + } + + public void setLastUsed(Date lastUsed) { + this.lastUsed = lastUsed; + } + + public Date getRemoved() { + return removed; + } + + public void setRemoved(Date removed) { + this.removed = removed; + } + + @Override + public String toString() { + return String.format("KMSDatabaseKekObject %s", ReflectionToStringBuilderUtils.reflectOnlySelectedFields( + this, "id", "uuid", "label", "purpose", "keyBits", "objectClass", "keyType", "algorithm")); + } +} diff --git a/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/dao/KMSDatabaseKekObjectDao.java b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/dao/KMSDatabaseKekObjectDao.java new file mode 100644 index 00000000000..582c1179ec4 --- /dev/null +++ b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/dao/KMSDatabaseKekObjectDao.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 org.apache.cloudstack.kms.provider.database.dao; + +import com.cloud.utils.db.GenericDao; +import org.apache.cloudstack.framework.kms.KeyPurpose; +import org.apache.cloudstack.kms.provider.database.KMSDatabaseKekObjectVO; + +import java.util.List; + +/** + * DAO for KMSDatabaseKekObject entities + * Provides PKCS#11-like object storage operations for KEKs + */ +public interface KMSDatabaseKekObjectDao extends GenericDao { + + /** + * Find a KEK object by label (PKCS#11 CKA_LABEL) + */ + KMSDatabaseKekObjectVO findByLabel(String label); + + /** + * Find a KEK object by object ID (PKCS#11 CKA_ID) + */ + KMSDatabaseKekObjectVO findByObjectId(byte[] objectId); + + /** + * List all KEK objects by purpose + */ + List listByPurpose(KeyPurpose purpose); + + /** + * List all KEK objects by key type (PKCS#11 CKA_KEY_TYPE) + */ + List listByKeyType(String keyType); + + /** + * List all KEK objects by object class (PKCS#11 CKA_CLASS) + */ + List listByObjectClass(String objectClass); + + /** + * Check if a KEK object exists with the given label + */ + boolean existsByLabel(String label); +} diff --git a/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/dao/KMSDatabaseKekObjectDaoImpl.java b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/dao/KMSDatabaseKekObjectDaoImpl.java new file mode 100644 index 00000000000..ae65f3248b3 --- /dev/null +++ b/plugins/kms/database/src/main/java/org/apache/cloudstack/kms/provider/database/dao/KMSDatabaseKekObjectDaoImpl.java @@ -0,0 +1,84 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms.provider.database.dao; + +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import org.apache.cloudstack.framework.kms.KeyPurpose; +import org.apache.cloudstack.kms.provider.database.KMSDatabaseKekObjectVO; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class KMSDatabaseKekObjectDaoImpl extends GenericDaoBase implements KMSDatabaseKekObjectDao { + + private final SearchBuilder allFieldSearch; + + public KMSDatabaseKekObjectDaoImpl() { + allFieldSearch = createSearchBuilder(); + allFieldSearch.and("uuid", allFieldSearch.entity().getUuid(), SearchCriteria.Op.EQ); + allFieldSearch.and("label", allFieldSearch.entity().getLabel(), SearchCriteria.Op.EQ); + allFieldSearch.and("objectId", allFieldSearch.entity().getObjectId(), SearchCriteria.Op.EQ); + allFieldSearch.and("purpose", allFieldSearch.entity().getPurpose(), SearchCriteria.Op.EQ); + allFieldSearch.and("keyType", allFieldSearch.entity().getKeyType(), SearchCriteria.Op.EQ); + allFieldSearch.and("objectClass", allFieldSearch.entity().getObjectClass(), SearchCriteria.Op.EQ); + allFieldSearch.done(); + } + + @Override + public KMSDatabaseKekObjectVO findByLabel(String label) { + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("label", label); + return findOneBy(sc); + } + + @Override + public KMSDatabaseKekObjectVO findByObjectId(byte[] objectId) { + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("objectId", objectId); + return findOneBy(sc); + } + + @Override + public List listByPurpose(KeyPurpose purpose) { + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("purpose", purpose); + return listBy(sc); + } + + @Override + public List listByKeyType(String keyType) { + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("keyType", keyType); + return listBy(sc); + } + + @Override + public List listByObjectClass(String objectClass) { + SearchCriteria sc = allFieldSearch.create(); + sc.setParameters("objectClass", objectClass); + return listBy(sc); + } + + @Override + public boolean existsByLabel(String label) { + return findByLabel(label) != null; + } +} diff --git a/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/module.properties b/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/module.properties new file mode 100644 index 00000000000..8d43cd9e08b --- /dev/null +++ b/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/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=database-kms +parent=kms diff --git a/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/spring-database-kms-context.xml b/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/spring-database-kms-context.xml new file mode 100644 index 00000000000..186e8adfa71 --- /dev/null +++ b/plugins/kms/database/src/main/resources/META-INF/cloudstack/database-kms/spring-database-kms-context.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/plugins/kms/pkcs11/pom.xml b/plugins/kms/pkcs11/pom.xml new file mode 100644 index 00000000000..1aaa8841576 --- /dev/null +++ b/plugins/kms/pkcs11/pom.xml @@ -0,0 +1,73 @@ + + + + 4.0.0 + cloud-plugin-kms-pkcs11 + Apache CloudStack Plugin - KMS PKCS#11 Provider + PKCS#11-backed KMS provider for HSM integration + + + org.apache.cloudstack + cloudstack-kms-plugins + 4.23.0.0-SNAPSHOT + ../pom.xml + + + + + org.apache.cloudstack + cloud-framework-kms + ${project.version} + + + org.apache.cloudstack + cloud-framework-config + ${project.version} + + + org.apache.cloudstack + cloud-utils + ${project.version} + + + org.apache.cloudstack + cloud-engine-schema + ${project.version} + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + true + + + + + + + diff --git a/plugins/kms/pkcs11/src/main/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProvider.java b/plugins/kms/pkcs11/src/main/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProvider.java new file mode 100644 index 00000000000..622a943acd1 --- /dev/null +++ b/plugins/kms/pkcs11/src/main/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProvider.java @@ -0,0 +1,1140 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms.provider.pkcs11; + +import com.cloud.utils.component.AdapterBase; +import com.cloud.utils.crypt.DBEncryptionUtil; +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.framework.kms.KMSProvider; +import org.apache.cloudstack.framework.kms.KeyPurpose; +import org.apache.cloudstack.framework.kms.WrappedKey; +import org.apache.cloudstack.kms.HSMProfileDetailsVO; +import org.apache.cloudstack.kms.KMSKekVersionVO; +import org.apache.cloudstack.kms.dao.HSMProfileDetailsDao; +import org.apache.cloudstack.kms.dao.KMSKekVersionDao; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import javax.annotation.PostConstruct; +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.KeyGenerator; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; +import javax.inject.Inject; +import java.io.Closeable; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.Key; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.Provider; +import java.security.ProviderException; +import java.security.SecureRandom; +import java.security.Security; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; +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.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +public class PKCS11HSMProvider extends AdapterBase implements KMSProvider { + private static final Logger logger = LogManager.getLogger(PKCS11HSMProvider.class); + private static final String PROVIDER_NAME = "pkcs11"; + // Security note: AES-CBC provides confidentiality but not authenticity (no + // HMAC). + // While AES-GCM is preferred, SunPKCS11 support for GCM is often buggy or + // missing + // depending on the underlying driver. We rely on the HSM/storage for tamper + // resistance. + // AES-CBC with PKCS5Padding: FIPS-compliant (NIST SP 800-38A) with universal PKCS#11 support + private static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding"; + + private static final long SESSION_ACQUIRE_TIMEOUT_MS = 5000L; + + private static final int[] VALID_KEY_SIZES = {128, 192, 256}; + private final Map sessionPools = new ConcurrentHashMap<>(); + @Inject + private HSMProfileDetailsDao hsmProfileDetailsDao; + @Inject + private KMSKekVersionDao kmsKekVersionDao; + + @PostConstruct + public void init() { + logger.info("Initializing PKCS11HSMProvider"); + } + + @Override + public String getProviderName() { + return PROVIDER_NAME; + } + + @Override + public String createKek(KeyPurpose purpose, String label, int keyBits, Long hsmProfileId) throws KMSException { + if (hsmProfileId == null) { + throw KMSException.invalidParameter("HSM Profile ID is required for PKCS#11 provider"); + } + if (StringUtils.isEmpty(label)) { + throw KMSException.invalidParameter("KEK label cannot be empty"); + } + return executeWithSession(hsmProfileId, session -> session.generateKey(label, keyBits, purpose)); + } + + @Override + public void deleteKek(String kekId) throws KMSException { + Long hsmProfileId = resolveProfileId(kekId); + executeWithSession(hsmProfileId, session -> { + session.deleteKey(kekId); + return null; + }); + } + + Long resolveProfileId(String kekLabel) throws KMSException { + KMSKekVersionVO version = kmsKekVersionDao.findByKekLabel(kekLabel); + if (version != null && version.getHsmProfileId() != null) { + return version.getHsmProfileId(); + } + throw new KMSException(KMSException.ErrorType.KEK_NOT_FOUND, + "Could not resolve HSM profile for KEK: " + kekLabel); + } + + /** + * Validates HSM profile configuration for PKCS#11 provider. + * + *

+ * Validates: + *

    + *
  • {@code library}: Required, should point to PKCS#11 library
  • + *
  • {@code slot}, {@code slot_list_index}, or {@code token_label}: At least + * one required
  • + *
  • {@code pin}: Required for HSM authentication
  • + *
  • {@code max_sessions}: Optional, must be positive integer if provided
  • + *
+ * + * @param config Configuration map from HSM profile details + * @throws KMSException with {@code INVALID_PARAMETER} if validation fails + */ + @Override + public void validateProfileConfig(Map config) throws KMSException { + String libraryPath = config.get("library"); + if (StringUtils.isBlank(libraryPath)) { + throw KMSException.invalidParameter("library is required for PKCS#11 HSM profile"); + } + + String slot = config.get("slot"); + String slotListIndex = config.get("slot_list_index"); + String tokenLabel = config.get("token_label"); + if (StringUtils.isAllBlank(slot, slotListIndex, tokenLabel)) { + throw KMSException.invalidParameter( + "One of 'slot', 'slot_list_index', or 'token_label' is required for PKCS#11 HSM profile"); + } + + if (StringUtils.isNotBlank(slot)) { + try { + Integer.parseInt(slot); + } catch (NumberFormatException e) { + throw KMSException.invalidParameter("slot must be a valid integer: " + slot); + } + } + + if (StringUtils.isNotBlank(slotListIndex)) { + try { + int idx = Integer.parseInt(slotListIndex); + if (idx < 0) { + throw KMSException.invalidParameter("slot_list_index must be a non-negative integer"); + } + } catch (NumberFormatException e) { + throw KMSException.invalidParameter("slot_list_index must be a valid integer: " + slotListIndex); + } + } + + File libraryFile = new File(libraryPath); + if (!libraryFile.exists() && !libraryFile.isAbsolute()) { + // The HSM library might be in the system library path + logger.debug("Library path {} does not exist as absolute path, will rely on system library path", + libraryPath); + } + + String max_sessions = config.get("max_sessions"); + if (StringUtils.isNotBlank(max_sessions)) { + try { + int idx = Integer.parseInt(max_sessions); + if (idx <= 0) { + throw KMSException.invalidParameter("max_sessions must be greater than 0"); + } + } catch (NumberFormatException e) { + throw KMSException.invalidParameter("max_sessions must be a valid integer: " + max_sessions); + } + } + } + + @Override + public boolean isKekAvailable(String kekId) throws KMSException { + try { + Long hsmProfileId = resolveProfileId(kekId); + return executeWithSession(hsmProfileId, session -> session.checkKeyExists(kekId)); + } catch (Exception e) { + return false; + } + } + + @Override + public WrappedKey wrapKey(byte[] plainDek, KeyPurpose purpose, String kekLabel, + Long hsmProfileId) throws KMSException { + if (hsmProfileId == null) { + hsmProfileId = resolveProfileId(kekLabel); + } + + byte[] wrappedBlob = executeWithSession(hsmProfileId, session -> session.wrapKey(plainDek, kekLabel)); + return new WrappedKey(kekLabel, purpose, CIPHER_ALGORITHM, wrappedBlob, PROVIDER_NAME, new Date(), null); + } + + @Override + public byte[] unwrapKey(WrappedKey wrappedKey, Long hsmProfileId) throws KMSException { + if (hsmProfileId == null) { + hsmProfileId = resolveProfileId(wrappedKey.getKekId()); + } + + return executeWithSession(hsmProfileId, + session -> session.unwrapKey(wrappedKey.getWrappedKeyMaterial(), wrappedKey.getKekId())); + } + + @Override + public WrappedKey generateAndWrapDek(KeyPurpose purpose, String kekLabel, int keyBits, + Long hsmProfileId) throws KMSException { + byte[] dekBytes = new byte[keyBits / 8]; + new SecureRandom().nextBytes(dekBytes); + + try { + return wrapKey(dekBytes, purpose, kekLabel, hsmProfileId); + } finally { + Arrays.fill(dekBytes, (byte) 0); + } + } + + @Override + public WrappedKey rewrapKey(WrappedKey oldWrappedKey, String newKekLabel, + Long targetHsmProfileId) throws KMSException { + byte[] plainKey = unwrapKey(oldWrappedKey, null); + try { + Long profileId = targetHsmProfileId != null ? targetHsmProfileId : resolveProfileId(newKekLabel); + return wrapKey(plainKey, oldWrappedKey.getPurpose(), newKekLabel, profileId); + } finally { + Arrays.fill(plainKey, (byte) 0); + } + } + + /** + * Performs health check on all configured HSM profiles. + * + *

For each configured HSM profile: + *

    + *
  1. Attempts to acquire a test session
  2. + *
  3. Verifies HSM is responsive (lightweight KeyStore operation)
  4. + *
  5. Releases the session
  6. + *
+ * + *

If any HSM profile fails the health check, this method throws an exception. + * If no profiles are configured, returns true (nothing to check). + * + * @return true if all configured HSM profiles are healthy + * @throws KMSException with {@code HEALTH_CHECK_FAILED} if any HSM profile is unhealthy + */ + @Override + public boolean healthCheck() throws KMSException { + if (sessionPools.isEmpty()) { + logger.debug("No HSM profiles configured for health check"); + return true; + } + + boolean allHealthy = true; + for (Long profileId : sessionPools.keySet()) { + if (!checkProfileHealth(profileId)) { + allHealthy = false; + } + } + + if (!allHealthy) { + throw KMSException.healthCheckFailed("One or more HSM profiles failed health check", null); + } + + return true; + } + + private boolean checkProfileHealth(Long profileId) { + try { + Boolean result = executeWithSession(profileId, session -> { + try { + session.keyStore.size(); // Verify the HSM token is currently reachable + } catch (KeyStoreException e) { + return false; + } + return true; + }); + logger.debug("Health check {} for HSM profile {}", result ? "passed" : "failed", profileId); + return result; + } catch (Exception e) { + logger.warn("Health check failed for HSM profile {}: {}", profileId, e.getMessage(), e); + return false; + } + } + + @Override + public void invalidateProfileCache(Long profileId) { + HSMSessionPool pool = sessionPools.remove(profileId); + if (pool != null) { + pool.invalidate(); + } + logger.info("Invalidated HSM session pool for profile {}", profileId); + } + + /** + * Executes an operation with a session from the pool, handling acquisition and release. + * + * @param hsmProfileId HSM profile ID + * @param operation Operation to execute with the session + * @return Result of the operation + * @throws KMSException if session acquisition fails or operation throws an exception + */ + private T executeWithSession(Long hsmProfileId, SessionOperation operation) throws KMSException { + HSMSessionPool pool = getSessionPool(hsmProfileId); + PKCS11Session session = null; + try { + session = pool.acquireSession(SESSION_ACQUIRE_TIMEOUT_MS); + return operation.execute(session); + } catch (KMSException e) { + // Only tear down the pool when a session we actually acquired failed with a + // connection error (e.g. the HSM restarted mid-operation). This proactively + // drops all the other now-stale idle sessions so concurrent callers don't each + // rediscover the breakage one session at a time. + // + // Deliberately NOT invalidating when session == null: acquireSession() reports + // CONNECTION_FAILED for capacity timeouts and interrupts too, which are not HSM + // failures — tearing down a healthy pool there would cause needless churn. An + // acquire-time connect() failure self-heals via the hsmRestartCount bump and the + // isValid() check that discards stale idle sessions on the next poll. + if (session != null && e.getErrorType() == KMSException.ErrorType.CONNECTION_FAILED) { + invalidateProfileCache(hsmProfileId); + } + throw e; + } finally { + // releaseSession is safe even after invalidateProfileCache: the invalidated + // flag causes it to close the session and release the semaphore permit. + pool.releaseSession(session); + } + } + + HSMSessionPool getSessionPool(Long profileId) { + return sessionPools.computeIfAbsent(profileId, id -> { + Map config = loadProfileConfig(id); + int maxSessions = Integer.parseInt(config.getOrDefault("max_sessions", "10")); + return new HSMSessionPool(id, maxSessions, this); + }); + } + + Map loadProfileConfig(Long profileId) { + List details = hsmProfileDetailsDao.listByProfileId(profileId); + Map config = new HashMap<>(); + for (HSMProfileDetailsVO detail : details) { + String value = detail.getValue(); + if (isSensitiveKey(detail.getName())) { + value = DBEncryptionUtil.decrypt(value); + } + config.put(detail.getName(), value); + } + validateProfileConfig(config); + return config; + } + + boolean isSensitiveKey(String key) { + return KMSProvider.isSensitiveKey(key); + } + + + @Override + public String getConfigComponentName() { + return PKCS11HSMProvider.class.getSimpleName(); + } + + @Override + public ConfigKey[] getConfigKeys() { + return new ConfigKey[0]; + } + + @FunctionalInterface + private interface SessionOperation { + T execute(PKCS11Session session) throws KMSException; + } + + private static class HSMSessionPool { + private final BlockingQueue availableSessions; + private final Long profileId; + private final PKCS11HSMProvider provider; + private final int maxSessions; + // Counts total sessions (idle + active). Acquired on creation, released on close. + private final Semaphore sessionPermits; + private volatile boolean invalidated = false; + + HSMSessionPool(Long profileId, int maxSessions, PKCS11HSMProvider provider) { + this.profileId = profileId; + this.provider = provider; + this.maxSessions = maxSessions; + this.sessionPermits = new Semaphore(maxSessions); + this.availableSessions = new ArrayBlockingQueue<>(maxSessions); + } + + PKCS11Session acquireSession(long timeoutMs) throws KMSException { + // Try to get an existing idle session first (no semaphore change: it already owns a permit). + PKCS11Session session = availableSessions.poll(); + if (session != null) { + if (session.isValid()) { + return session; + } + // Stale idle session: discard it and free its permit so a new one can be created. + logger.debug("Discarding stale idle PKCS#11 session for profile {}", profileId); + session.close(); + sessionPermits.release(); + } + + // Acquire a permit to create a new session, blocking up to timeoutMs if at capacity. + try { + if (!sessionPermits.tryAcquire(timeoutMs, TimeUnit.MILLISECONDS)) { + // One last try: a session may have been returned while we were waiting. + session = availableSessions.poll(); + if (session != null && session.isValid()) { + return session; + } + if (session != null) { + session.close(); + sessionPermits.release(); + } + throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED, + "Timed out waiting for an available HSM session for profile " + profileId + + " (max=" + maxSessions + ", timeout=" + timeoutMs + "ms)"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED, + "Interrupted while waiting to acquire HSM session for profile " + profileId, e); + } + + try { + return createNewSession(); + } catch (KMSException e) { + sessionPermits.release(); + throw e; + } + } + + private PKCS11Session createNewSession() throws KMSException { + // Config (including decrypted PIN) is loaded fresh each time and not stored. + return new PKCS11Session(provider.loadProfileConfig(profileId)); + } + + void releaseSession(PKCS11Session session) { + if (session == null) return; + if (!invalidated && session.isValid() && availableSessions.offer(session)) { + return; // session returned to the idle pool; permit stays consumed + } + // Pool is invalidated, session is stale, or the idle queue is full: close immediately. + session.close(); + sessionPermits.release(); + } + + /** + * Marks the pool as invalidated and closes all idle sessions. + * Any session currently checked out will be closed (and its permit released) when + * it is returned via {@link #releaseSession} — the invalidated flag prevents re-pooling. + */ + void invalidate() { + invalidated = true; + PKCS11Session session; + while ((session = availableSessions.poll()) != null) { + session.close(); + sessionPermits.release(); + } + } + } + + /** + * Inner class representing an active PKCS#11 session with an HSM. + * This class manages the connection to the HSM, key operations, and session lifecycle. + * + *

Key operations supported: + *

    + *
  • Key generation: Generate AES keys directly in the HSM
  • + *
  • Key wrapping: Encrypt DEKs using KEKs stored in the HSM (AES-CBC/PKCS5Padding)
  • + *
  • Key unwrapping: Decrypt DEKs using KEKs stored in the HSM (AES-CBC/PKCS5Padding)
  • + *
  • Key deletion: Remove keys from the HSM
  • + *
  • Key existence check: Verify if a key exists in the HSM
  • + *
+ * + *

Configuration requirements: + *

    + *
  • {@code library}: Path to PKCS#11 library (required)
  • + *
  • {@code slot} or {@code token_label}: HSM slot/token selection (at least one required)
  • + *
  • {@code pin}: PIN for HSM authentication (required, sensitive)
  • + *
+ * + *

Error handling: PKCS#11 specific error codes are mapped to appropriate + * {@link KMSException.ErrorType} values for proper retry logic and error reporting. + */ + private static class PKCS11Session { + private static final int IV_LENGTH = 16; // 128 bits for CBC mode + + // Counter to bypass JDK SunPKCS11 caching when the HSM restarts. The JDK's + // PKCS11.getInstance caches the native module in a static map keyed by the library + // path string and never removes entries, so a module attached to a dead HSM can + // never be re-initialized under its original path. Each bump adds an extra slash + // to the path (a distinct map key for the same file), forcing a fresh C_Initialize. + private static final AtomicInteger hsmRestartCount = new AtomicInteger(0); + + private KeyStore keyStore; + private Provider provider; + private String providerName; + private Path tempConfigFile; + + /** + * Creates a new PKCS#11 session and connects to the HSM. + * The config map (including any sensitive values such as the PIN) is used only + * during connection setup and is not retained as a field. + * + * @param config HSM profile configuration containing library, slot/token_label, and pin + * @throws KMSException if connection fails or configuration is invalid + */ + PKCS11Session(Map config) throws KMSException { + connect(config); + } + + /** + * Establishes connection to the PKCS#11 HSM. + * + *

This method: + *

    + *
  1. Validates required configuration (library, slot/token_label, pin)
  2. + *
  3. Creates a SunPKCS11 provider with the HSM library
  4. + *
  5. Loads the PKCS#11 KeyStore
  6. + *
  7. Authenticates using the provided PIN
  8. + *
+ * + *

Slot/token selection: + *

    + *
  • If {@code token_label} is provided, it is used (more reliable)
  • + *
  • Otherwise, {@code slot} (numeric ID) is used
  • + *
+ * + * @throws KMSException with appropriate ErrorType: + *
    + *
  • {@code AUTHENTICATION_FAILED} if PIN is incorrect
  • + *
  • {@code INVALID_PARAMETER} if configuration is missing or invalid
  • + *
  • {@code CONNECTION_FAILED} if HSM is unreachable or device error occurs
  • + *
+ */ + private void connect(Map config) throws KMSException { + boolean connected = false; + try { + // Unique suffix ensures each session gets its own provider name in java.security.Security, + // allowing Security.removeProvider() in close() to target exactly this session's provider. + String nameSuffix = UUID.randomUUID().toString().substring(0, 8); + + String configString = buildSunPKCS11Config(config, nameSuffix); + + // Java 9+ API: write config to temp file, then configure the provider + tempConfigFile = Files.createTempFile("pkcs11-config-", ".cfg"); + try (FileWriter writer = new FileWriter(tempConfigFile.toFile(), StandardCharsets.UTF_8)) { + writer.write(configString); + } + + Provider baseProvider = Security.getProvider("SunPKCS11"); + if (baseProvider == null) { + throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED, + "SunPKCS11 provider not available in this JVM"); + } + + provider = baseProvider.configure(tempConfigFile.toAbsolutePath().toString()); + + // Use the actual provider name so Security.removeProvider() in close() works correctly. + providerName = provider.getName(); + + // Security.addProvider returns -1 if a provider with this name is already registered. + // With the UUID-based suffix this should be impossible in practice; guard defensively. + if (Security.addProvider(provider) < 0) { + throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED, + "Failed to register PKCS#11 provider '" + providerName + "': name already in use"); + } + + keyStore = KeyStore.getInstance("PKCS11", provider); + + String pin = config.get("pin"); + if (StringUtils.isEmpty(pin)) { + throw KMSException.invalidParameter("pin is required"); + } + char[] pinChars = pin.toCharArray(); + try { + keyStore.load(null, pinChars); + } finally { + // Wipe the PIN copy on every path, including load() failures + // (wrong PIN, CRYPTOKI_NOT_INITIALIZED after an HSM restart, etc.). + Arrays.fill(pinChars, '\0'); + } + + // The temp file is only needed during configure()/load(); delete it immediately + // rather than holding it until the session is eventually closed. + Files.deleteIfExists(tempConfigFile); + tempConfigFile = null; + + connected = true; + + logger.debug("Successfully connected to PKCS#11 HSM at {}", config.get("library")); + } catch (KeyStoreException | CertificateException | NoSuchAlgorithmException e) { + handlePKCS11Exception(e, "Failed to initialize PKCS#11 connection"); + } catch (IOException e) { + String errorMsg = e.getMessage(); + if (errorMsg != null && errorMsg.contains("CKR_PIN_INCORRECT")) { + throw new KMSException(KMSException.ErrorType.AUTHENTICATION_FAILED, + "Incorrect PIN for HSM authentication", e); + } else if (errorMsg != null && errorMsg.contains("CKR_SLOT_ID_INVALID")) { + throw KMSException.invalidParameter("Invalid slot ID: " + config.get("slot")); + } else { + handlePKCS11Exception(e, "I/O error during PKCS#11 connection"); + } + } catch (Exception e) { + handlePKCS11Exception(e, "Unexpected error during PKCS#11 connection"); + } finally { + if (!connected) { + // connect that failed after Security.addProvider() would otherwise leave + // the half-initialized provider registered in java.security.Security. + close(); + } + } + } + + /** + * Builds SunPKCS11 provider configuration string. + * + * @param config HSM profile configuration + * @return Configuration string for SunPKCS11 provider + * @throws KMSException if required configuration is missing + */ + private String buildSunPKCS11Config(Map config, String nameSuffix) throws KMSException { + String libraryPath = config.get("library"); + if (StringUtils.isBlank(libraryPath)) { + throw KMSException.invalidParameter("library is required"); + } + + // Bypass JDK SunPKCS11 native wrapper caching by slightly altering the path string + // when an HSM restart is detected. The OS dlopen() ignores extra slashes. + int bypass = hsmRestartCount.get(); + if (bypass > 0) { + String extraSlashes = "/".repeat(bypass); + if (libraryPath.startsWith("/")) { + libraryPath = "/" + extraSlashes + libraryPath.substring(1); + } else { + libraryPath = "./" + extraSlashes + libraryPath; + } + } + StringBuilder configBuilder = new StringBuilder(); + // Include the unique suffix so that each session is registered under a distinct + // provider name (SunPKCS11-CloudStackHSM-{suffix}), preventing name collisions + // across concurrent sessions and allowing clean removal via Security.removeProvider(). + configBuilder.append("name=CloudStackHSM-").append(nameSuffix).append("\n"); + configBuilder.append("library=").append(libraryPath).append("\n"); + + String tokenLabel = config.get("token_label"); + String slotListIndex = config.get("slot_list_index"); + String slot = config.get("slot"); + + if (StringUtils.isNotBlank(tokenLabel)) { + configBuilder.append("tokenLabel=").append(tokenLabel).append("\n"); + } else if (StringUtils.isNotBlank(slotListIndex)) { + configBuilder.append("slotListIndex=").append(slotListIndex).append("\n"); + } else if (StringUtils.isNotBlank(slot)) { + configBuilder.append("slot=").append(slot).append("\n"); + } else { + throw KMSException.invalidParameter("One of 'slot', 'slot_list_index', or 'token_label' is required"); + } + + // Explicitly configure SunPKCS11 to generate AES keys as Data Encryption Keys. + // Strict HSMs (like Thales Luna in FIPS mode) forbid a key from having both + // CKA_WRAP and CKA_ENCRYPT attributes. Because CloudStack uses Cipher.ENCRYPT_MODE + // (which maps to C_Encrypt) to protect the DEK, the KEK must have CKA_ENCRYPT=true. + configBuilder.append("\nattributes(generate, CKO_SECRET_KEY, CKK_AES) = {\n"); + configBuilder.append(" CKA_ENCRYPT = true\n"); + configBuilder.append(" CKA_DECRYPT = true\n"); + configBuilder.append(" CKA_WRAP = false\n"); + configBuilder.append(" CKA_UNWRAP = false\n"); + configBuilder.append("}\n"); + + return configBuilder.toString(); + } + + /** + * Maps PKCS#11 specific exceptions to appropriate KMSException.ErrorType. + * + *

PKCS#11 error codes are parsed from exception messages and mapped as follows: + *

    + *
  • {@code CKR_PIN_INCORRECT} → {@code AUTHENTICATION_FAILED}
  • + *
  • {@code CKR_SLOT_ID_INVALID} → {@code INVALID_PARAMETER}
  • + *
  • {@code CKR_KEY_NOT_FOUND} → {@code KEK_NOT_FOUND}
  • + *
  • {@code CKR_DEVICE_ERROR} → {@code CONNECTION_FAILED}
  • + *
  • {@code CKR_SESSION_HANDLE_INVALID} → {@code CONNECTION_FAILED}
  • + *
  • {@code CKR_KEY_ALREADY_EXISTS} → {@code KEY_ALREADY_EXISTS}
  • + *
  • {@code KeyStoreException} → {@code WRAP_UNWRAP_FAILED}
  • + *
  • Other errors → {@code KEK_OPERATION_FAILED}
  • + *
+ * + * @param e The exception to map + * @param context Context description for the error message + * @throws KMSException with appropriate ErrorType and detailed message + */ + private void handlePKCS11Exception(Exception e, String context) throws KMSException { + String errorMsg = e.getMessage(); + if (errorMsg == null) { + errorMsg = e.getClass().getSimpleName(); + } + logger.warn("PKCS#11 error: {} - {}", errorMsg, context, e); + + // The PKCS#11 error code (CKR_*) may be on the exception itself or its cause, so + // match against both. KeyStore.getKey/load wrap the real code in a ProviderException. + String causeMsg = e.getCause() != null ? e.getCause().getMessage() : null; + String combinedMsg = errorMsg + " " + (causeMsg != null ? causeMsg : ""); + + if (combinedMsg.contains("CRYPTOKI_NOT_INITIALIZED")) { + hsmRestartCount.incrementAndGet(); + throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED, + context + ": HSM requires re-initialization (CRYPTOKI_NOT_INITIALIZED)", e); + } else if (combinedMsg.contains("PIN_INCORRECT")) { + throw new KMSException(KMSException.ErrorType.AUTHENTICATION_FAILED, + context + ": Incorrect PIN", e); + } else if (combinedMsg.contains("SLOT_ID_INVALID")) { + throw KMSException.invalidParameter(context + ": Invalid slot ID"); + } else if (combinedMsg.contains("KEY_NOT_FOUND")) { + throw KMSException.kekNotFound(context + ": Key not found"); + } else if (combinedMsg.contains("DEVICE_ERROR")) { + hsmRestartCount.incrementAndGet(); + throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED, + context + ": HSM device error", e); + } else if (combinedMsg.contains("SESSION_HANDLE_INVALID")) { + hsmRestartCount.incrementAndGet(); + throw new KMSException(KMSException.ErrorType.CONNECTION_FAILED, + context + ": Invalid session handle", e); + } else if (combinedMsg.contains("KEY_ALREADY_EXISTS")) { + throw KMSException.keyAlreadyExists(context); + } else if (e instanceof KeyStoreException) { + throw new KMSException(KMSException.ErrorType.WRAP_UNWRAP_FAILED, + context + ": " + errorMsg, e); + } else { + throw new KMSException(KMSException.ErrorType.KEK_OPERATION_FAILED, + context + ": " + errorMsg, e); + } + } + + /** + * Validates that the PKCS#11 session is still active and connected to the HSM. + * + *

Checks performed: + *

    + *
  • KeyStore object is not null
  • + *
  • Provider is still registered in Security
  • + *
  • HSM is responsive (lightweight operation: get KeyStore size)
  • + *
+ * + * @return true if session is valid and HSM is accessible, false otherwise + */ + boolean isValid() { + try { + if (keyStore == null) { + return false; + } + + if (provider == null || Security.getProvider(provider.getName()) == null) { + return false; + } + + keyStore.size(); + return true; + } catch (Exception e) { + logger.debug("Session validation failed: {}", e.getMessage()); + return false; + } + } + + /** + * Closes the PKCS#11 session and cleans up resources. + * + *

+ * Note: Errors during cleanup are logged but do not throw exceptions + * to ensure cleanup continues even if some steps fail. + */ + void close() { + try { + if (keyStore instanceof Closeable) { + ((Closeable) keyStore).close(); + } + + if (provider != null && providerName != null) { + try { + Security.removeProvider(providerName); + } catch (Exception e) { + logger.debug("Failed to remove provider {}: {}", providerName, e.getMessage()); + } + } + + if (tempConfigFile != null) { + try { + Files.deleteIfExists(tempConfigFile); + } catch (IOException e) { + logger.debug("Failed to delete temporary config file {}: {}", tempConfigFile, e.getMessage()); + } + } + } catch (Exception e) { + logger.warn("Error during session close: {}", e.getMessage()); + } finally { + keyStore = null; + provider = null; + providerName = null; + tempConfigFile = null; + } + } + + /** + * Generates an AES key directly in the HSM with the specified label. + * + *

+ * This method generates the key natively inside the HSM using a + * {@link KeyGenerator} configured with the PKCS#11 provider, so the key + * material never leaves the HSM boundary. The returned PKCS#11-native key + * reference ({@code P11Key}) is then stored in the KeyStore under the + * requested label. + * + *

+ * Using {@code KeyGenerator} with the HSM provider is required for + * HSMs such as NetHSM that do not support importing raw secret-key bytes + * via {@code KeyStore.setKeyEntry()}. By generating the key on the HSM first, + * the value passed to {@code setKeyEntry()} is already a PKCS#11 token object, + * so no raw-bytes import is attempted. + * + *

+ * Once stored, the key: + *

    + *
  • Resides permanently in the HSM token storage
  • + *
  • Is marked as non-extractable (CKA_EXTRACTABLE=false) by the HSM
  • + *
  • Can only be used for cryptographic operations via the HSM
  • + *
+ * + * @param label Unique label for the key in the HSM + * @param keyBits Key size in bits (128, 192, or 256) + * @param purpose Key purpose (for logging/auditing) + * @return The label of the generated key + * @throws KMSException if generation fails or key already exists + */ + String generateKey(String label, int keyBits, KeyPurpose purpose) throws KMSException { + validateKeySize(keyBits); + + try { + // Check if key with this label already exists + if (keyStore.containsAlias(label)) { + throw KMSException.keyAlreadyExists("Key with label '" + label + "' already exists in HSM"); + } + + // Generate the AES key natively inside the HSM using the PKCS#11 provider. + // This avoids importing raw key bytes into the HSM, which is not supported + // by all HSMs (e.g. NetHSM rejects SecretKeySpec via storeSkey()). + // The resulting key is a PKCS#11-native P11Key that lives inside the token. + KeyGenerator keyGen = KeyGenerator.getInstance("AES", provider); + keyGen.init(keyBits); + SecretKey hsmKey = keyGen.generateKey(); + + // Associate the HSM-generated key with the requested label by storing + // it in the PKCS#11 KeyStore. Because hsmKey is already a P11Key + // (not a software SecretKeySpec), P11KeyStore.storeSkey() stores it + // as a persistent token object (CKA_TOKEN=true) with CKA_LABEL=label + // without attempting any raw-bytes conversion. + keyStore.setKeyEntry(label, hsmKey, null, null); + + logger.info("Generated AES-{} key '{}' in HSM (purpose: {})", + keyBits, label, purpose); + return label; + + } catch (KeyStoreException e) { + if (e.getMessage() != null + && e.getMessage().contains("found multiple secret keys sharing same CKA_LABEL")) { + logger.warn("Multiple duplicate keys found with label '{}' in HSM. Reusing the existing key. " + + "Please purge duplicate keys manually if possible.", label); + return label; + } + handlePKCS11Exception(e, "Failed to store key in HSM KeyStore"); + } catch (NoSuchAlgorithmException e) { + handlePKCS11Exception(e, "AES KeyGenerator not available via PKCS#11 provider"); + } catch (Exception e) { + String errorMsg = e.getMessage(); + if (errorMsg != null && (errorMsg.contains("CKR_OBJECT_HANDLE_INVALID") + || errorMsg.contains("already exists"))) { + throw KMSException.keyAlreadyExists("Key with label '" + label + "' already exists in HSM"); + } else { + handlePKCS11Exception(e, "Failed to generate key in HSM"); + } + } + return null; + } + + /** + * Validates that the key size is one of the supported AES key sizes. + * + * @param keyBits Key size in bits + * @throws KMSException if key size is invalid + */ + private void validateKeySize(int keyBits) throws KMSException { + if (Arrays.stream(VALID_KEY_SIZES).noneMatch(size -> size == keyBits)) { + throw KMSException.invalidParameter("Key size must be 128, 192, or 256 bits"); + } + } + + /** + * Wraps (encrypts) a plaintext DEK using a KEK stored in the HSM. + * + *

Uses AES-CBC with PKCS5Padding (FIPS 197 + NIST SP 800-38A): + *

    + *
  • Generates a random 128-bit IV
  • + *
  • Encrypts the DEK using AES-CBC with the KEK from HSM
  • + *
  • Returns format: [IV (16 bytes)][ciphertext]
  • + *
+ * + *

Security: The plaintext DEK should be zeroized by the caller after wrapping. + * + * @param plainDek Plaintext DEK to wrap (will be encrypted) + * @param kekLabel Label of the KEK stored in the HSM + * @return Wrapped key blob: [IV][ciphertext] + * @throws KMSException with appropriate ErrorType: + *

    + *
  • {@code INVALID_PARAMETER} if plainDek is null or empty
  • + *
  • {@code KEK_NOT_FOUND} if KEK with label doesn't exist or is not accessible
  • + *
  • {@code WRAP_UNWRAP_FAILED} if wrapping operation fails
  • + *
+ */ + byte[] wrapKey(byte[] plainDek, String kekLabel) throws KMSException { + if (plainDek == null || plainDek.length == 0) { + throw KMSException.invalidParameter("Plain DEK cannot be null or empty"); + } + + SecretKey kek = getKekFromKeyStore(kekLabel); + try { + byte[] iv = new byte[IV_LENGTH]; + new SecureRandom().nextBytes(iv); + + Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM, provider); + cipher.init(Cipher.ENCRYPT_MODE, kek, new IvParameterSpec(iv)); + byte[] ciphertext = cipher.doFinal(plainDek); + + byte[] result = new byte[IV_LENGTH + ciphertext.length]; + System.arraycopy(iv, 0, result, 0, IV_LENGTH); + System.arraycopy(ciphertext, 0, result, IV_LENGTH, ciphertext.length); + + logger.debug("Wrapped key with KEK '{}' using AES-CBC", kekLabel); + return result; + } catch (IllegalBlockSizeException | BadPaddingException | InvalidKeyException e) { + handlePKCS11Exception(e, "Invalid key or data for wrapping"); + } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { + handlePKCS11Exception(e, "AES-CBC not supported by HSM"); + } catch (InvalidAlgorithmParameterException e) { + handlePKCS11Exception(e, "Invalid IV for CBC mode"); + } catch (Exception e) { + handlePKCS11Exception(e, "Failed to wrap key with HSM"); + } + return null; + } + + /** + * Retrieves a KEK (Key Encryption Key) from the HSM KeyStore. + * + * @param kekLabel Label of the KEK to retrieve + * @return SecretKey representing the KEK + * @throws KMSException if KEK is not found or not accessible + */ + private SecretKey getKekFromKeyStore(String kekLabel) throws KMSException { + try { + Key key = keyStore.getKey(kekLabel, null); + if (key == null) { + throw KMSException.kekNotFound("KEK with label '" + kekLabel + "' not found in HSM"); + } + if (!(key instanceof SecretKey)) { + throw KMSException.kekNotFound("Key with label '" + kekLabel + "' is not a secret key"); + } + return (SecretKey) key; + } catch (UnrecoverableKeyException e) { + throw KMSException.kekNotFound("KEK with label '" + kekLabel + "' is not accessible"); + } catch (NoSuchAlgorithmException e) { + handlePKCS11Exception(e, "Algorithm not supported"); + } catch (KeyStoreException e) { + handlePKCS11Exception(e, "Failed to retrieve KEK from HSM"); + } catch (ProviderException e) { + // KeyStore.getKey() wraps PKCS#11 errors (e.g. CKR_CRYPTOKI_NOT_INITIALIZED after + // an HSM restart) in ProviderException, an unchecked RuntimeException. Route it + // through handlePKCS11Exception so hsmRestartCount is bumped and the caller retries. + handlePKCS11Exception(e, "Failed to retrieve KEK from HSM"); + } + return null; + } + + /** + * Unwraps (decrypts) a wrapped DEK using a KEK stored in the HSM. + * + *

+ * Uses AES-CBC with PKCS5Padding. Expected format: [IV (16 bytes)][ciphertext]. + * + *

+ * Security: The returned plaintext DEK must be zeroized by the caller after + * use. + * + * @param wrappedBlob Wrapped DEK blob (IV + ciphertext) + * @param kekLabel Label of the KEK stored in the HSM + * @return Plaintext DEK + * @throws KMSException with appropriate ErrorType: + *

    + *
  • {@code INVALID_PARAMETER} if wrappedBlob is null, + * empty, or too short
  • + *
  • {@code KEK_NOT_FOUND} if KEK with label doesn't + * exist or is not accessible
  • + *
  • {@code WRAP_UNWRAP_FAILED} if unwrapping fails
  • + *
+ */ + byte[] unwrapKey(byte[] wrappedBlob, String kekLabel) throws KMSException { + if (wrappedBlob == null || wrappedBlob.length == 0) { + throw KMSException.invalidParameter("Wrapped blob cannot be null or empty"); + } + + // Minimum size: IV (16 bytes) + at least one AES block (16 bytes) + if (wrappedBlob.length < IV_LENGTH + 16) { + throw KMSException.invalidParameter("Wrapped blob too short: expected at least " + + (IV_LENGTH + 16) + " bytes"); + } + + SecretKey kek = getKekFromKeyStore(kekLabel); + try { + byte[] iv = new byte[IV_LENGTH]; + System.arraycopy(wrappedBlob, 0, iv, 0, IV_LENGTH); + byte[] ciphertext = new byte[wrappedBlob.length - IV_LENGTH]; + System.arraycopy(wrappedBlob, IV_LENGTH, ciphertext, 0, ciphertext.length); + + Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM, provider); + cipher.init(Cipher.DECRYPT_MODE, kek, new IvParameterSpec(iv)); + byte[] plainDek = cipher.doFinal(ciphertext); + + logger.debug("Unwrapped key with KEK '{}' using AES-CBC", kekLabel); + return plainDek; + } catch (BadPaddingException e) { + throw KMSException.wrapUnwrapFailed( + "Decryption failed: wrapped key may be corrupted or KEK is incorrect", e); + } catch (IllegalBlockSizeException | InvalidKeyException e) { + handlePKCS11Exception(e, "Invalid key or data for unwrapping"); + } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { + handlePKCS11Exception(e, "AES-CBC not supported by HSM"); + } catch (InvalidAlgorithmParameterException e) { + handlePKCS11Exception(e, "Invalid IV for CBC mode"); + } catch (Exception e) { + handlePKCS11Exception(e, "Failed to unwrap key with HSM"); + } + return null; + } + + /** + * Deletes a key from the HSM. + * + *

Warning: Deleting a KEK makes all DEKs wrapped with that KEK + * permanently unrecoverable. This operation should be used with extreme caution. + * + * @param label Label of the key to delete + * @throws KMSException with appropriate ErrorType: + *

    + *
  • {@code KEK_NOT_FOUND} if key with label doesn't exist
  • + *
  • {@code KEK_OPERATION_FAILED} if deletion fails (e.g., key is in use)
  • + *
+ */ + void deleteKey(String label) throws KMSException { + try { + if (!keyStore.containsAlias(label)) { + throw KMSException.kekNotFound("Key with label '" + label + "' not found in HSM"); + } + + keyStore.deleteEntry(label); + + logger.debug("Deleted key '{}' from HSM", label); + } catch (KeyStoreException e) { + String errorMsg = e.getMessage(); + if (errorMsg != null && errorMsg.contains("not found")) { + throw KMSException.kekNotFound("Key with label '" + label + "' not found in HSM"); + } else if (errorMsg != null && errorMsg.contains("in use")) { + throw KMSException.kekOperationFailed( + "Key with label '" + label + "' is in use and cannot be deleted"); + } else { + handlePKCS11Exception(e, "Failed to delete key from HSM"); + } + } catch (Exception e) { + handlePKCS11Exception(e, "Failed to delete key from HSM"); + } + } + + /** + * Checks if a key with the given label exists and is accessible in the HSM. + * + * @param label Label of the key to check + * @return true if key exists and is accessible, false otherwise + * @throws KMSException only for unexpected errors (KeyStoreException, etc.) + * Returns false for expected cases (key not found, unrecoverable key) + */ + boolean checkKeyExists(String label) throws KMSException { + try { + Key key = keyStore.getKey(label, null); + return key != null; + } catch (KeyStoreException e) { + logger.debug("KeyStore error while checking key existence: {}", e.getMessage()); + return false; + } catch (UnrecoverableKeyException e) { + // Key exists but is not accessible (might be a different key type) + logger.debug("Key '{}' exists but is not accessible: {}", label, e.getMessage()); + return false; + } catch (NoSuchAlgorithmException e) { + logger.debug("Algorithm error while checking key existence: {}", e.getMessage()); + return false; + } catch (Exception e) { + logger.debug("Unexpected error while checking key existence: {}", e.getMessage()); + return false; + } + } + } +} diff --git a/plugins/kms/pkcs11/src/main/resources/META-INF/cloudstack/pkcs11-kms/module.properties b/plugins/kms/pkcs11/src/main/resources/META-INF/cloudstack/pkcs11-kms/module.properties new file mode 100644 index 00000000000..aa7a5160757 --- /dev/null +++ b/plugins/kms/pkcs11/src/main/resources/META-INF/cloudstack/pkcs11-kms/module.properties @@ -0,0 +1,21 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +name=pkcs11-kms +parent=kms diff --git a/plugins/kms/pkcs11/src/main/resources/META-INF/cloudstack/pkcs11-kms/spring-pkcs11-kms-context.xml b/plugins/kms/pkcs11/src/main/resources/META-INF/cloudstack/pkcs11-kms/spring-pkcs11-kms-context.xml new file mode 100644 index 00000000000..cdd29d2cf24 --- /dev/null +++ b/plugins/kms/pkcs11/src/main/resources/META-INF/cloudstack/pkcs11-kms/spring-pkcs11-kms-context.xml @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/plugins/kms/pkcs11/src/test/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProviderTest.java b/plugins/kms/pkcs11/src/test/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProviderTest.java new file mode 100644 index 00000000000..7d539b41fb2 --- /dev/null +++ b/plugins/kms/pkcs11/src/test/java/org/apache/cloudstack/kms/provider/pkcs11/PKCS11HSMProviderTest.java @@ -0,0 +1,294 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.kms.provider.pkcs11; + +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.framework.kms.KeyPurpose; +import org.apache.cloudstack.kms.HSMProfileDetailsVO; +import org.apache.cloudstack.kms.KMSKekVersionVO; +import org.apache.cloudstack.kms.dao.HSMProfileDetailsDao; +import org.apache.cloudstack.kms.dao.KMSKekVersionDao; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.Arrays; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for PKCS11HSMProvider + * Tests provider-specific logic: config loading, profile resolution, sensitive key detection + */ +@RunWith(MockitoJUnitRunner.class) +public class PKCS11HSMProviderTest { + + @Spy + @InjectMocks + private PKCS11HSMProvider provider; + + @Mock + private HSMProfileDetailsDao hsmProfileDetailsDao; + + @Mock + private KMSKekVersionDao kmsKekVersionDao; + + private Long testProfileId = 1L; + private String testKekLabel = "test-kek-label"; + + @Before + public void setUp() { + // Minimal setup + } + + /** + * Test: resolveProfileId successfully finds profile from KEK label + */ + @Test + public void testResolveProfileId_FindsFromKekLabel() throws KMSException { + // Setup: KEK version with profile ID + KMSKekVersionVO kekVersion = mock(KMSKekVersionVO.class); + when(kekVersion.getHsmProfileId()).thenReturn(testProfileId); + when(kmsKekVersionDao.findByKekLabel(testKekLabel)).thenReturn(kekVersion); + + // Test + Long result = provider.resolveProfileId(testKekLabel); + + // Verify + assertNotNull("Should return profile ID", result); + assertEquals("Should return correct profile ID", testProfileId, result); + verify(kmsKekVersionDao).findByKekLabel(testKekLabel); + } + + /** + * Test: resolveProfileId throws exception when KEK version not found + */ + @Test(expected = KMSException.class) + public void testResolveProfileId_ThrowsExceptionWhenVersionNotFound() throws KMSException { + // Setup: No KEK version found + when(kmsKekVersionDao.findByKekLabel(testKekLabel)).thenReturn(null); + + // Test - should throw exception + provider.resolveProfileId(testKekLabel); + } + + /** + * Test: resolveProfileId throws exception when profile ID is null + */ + @Test(expected = KMSException.class) + public void testResolveProfileId_ThrowsExceptionWhenProfileIdNull() throws KMSException { + // Setup: KEK version exists but has null profile ID + KMSKekVersionVO kekVersion = mock(KMSKekVersionVO.class); + when(kekVersion.getHsmProfileId()).thenReturn(null); + when(kmsKekVersionDao.findByKekLabel(testKekLabel)).thenReturn(kekVersion); + + // Test - should throw exception + provider.resolveProfileId(testKekLabel); + } + + /** + * Test: loadProfileConfig loads and decrypts sensitive values + */ + @Test + public void testLoadProfileConfig_DecryptsSensitiveValues() { + // Setup: Profile details with encrypted pin + HSMProfileDetailsVO detail1 = mock(HSMProfileDetailsVO.class); + when(detail1.getName()).thenReturn("library"); + when(detail1.getValue()).thenReturn("/path/to/lib.so"); + + HSMProfileDetailsVO detail2 = mock(HSMProfileDetailsVO.class); + when(detail2.getName()).thenReturn("pin"); + when(detail2.getValue()).thenReturn("ENC(encrypted_pin)"); + + HSMProfileDetailsVO detail3 = mock(HSMProfileDetailsVO.class); + when(detail3.getName()).thenReturn("slot"); + when(detail3.getValue()).thenReturn("0"); + + when(hsmProfileDetailsDao.listByProfileId(testProfileId)).thenReturn( + Arrays.asList(detail1, detail2, detail3)); + + // Test + Map config = provider.loadProfileConfig(testProfileId); + + // Verify + assertNotNull("Config should not be null", config); + assertEquals(3, config.size()); + assertEquals("/path/to/lib.so", config.get("library")); + // Note: In real code, DBEncryptionUtil.decrypt would be called + // Here we just verify the structure is correct + assertTrue("Config should contain pin", config.containsKey("pin")); + assertEquals("0", config.get("slot")); + + verify(hsmProfileDetailsDao).listByProfileId(testProfileId); + } + + /** + * Test: loadProfileConfig handles empty details + */ + @Test(expected = KMSException.class) + public void testLoadProfileConfig_HandlesEmptyDetails() { + // Setup + when(hsmProfileDetailsDao.listByProfileId(testProfileId)).thenReturn(Arrays.asList()); + + // Test + Map config = provider.loadProfileConfig(testProfileId); + } + + /** + * Test: isSensitiveKey correctly identifies sensitive keys + */ + @Test + public void testIsSensitiveKey_IdentifiesSensitiveKeys() { + // Test + assertTrue(provider.isSensitiveKey("pin")); + assertTrue(provider.isSensitiveKey("password")); + assertTrue(provider.isSensitiveKey("api_secret")); + assertTrue(provider.isSensitiveKey("private_key")); + assertTrue(provider.isSensitiveKey("PIN")); // Case-insensitive + } + + /** + * Test: isSensitiveKey correctly identifies non-sensitive keys + */ + @Test + public void testIsSensitiveKey_IdentifiesNonSensitiveKeys() { + // Test + assertFalse(provider.isSensitiveKey("library")); + assertFalse(provider.isSensitiveKey("slot_id")); + assertFalse(provider.isSensitiveKey("endpoint")); + assertFalse(provider.isSensitiveKey("max_sessions")); + } + + /** + * Test: getProviderName returns correct name + */ + @Test + public void testGetProviderName() { + assertEquals("pkcs11", provider.getProviderName()); + } + + /** + * Test: createKek requires hsmProfileId + */ + @Test(expected = KMSException.class) + public void testCreateKek_RequiresProfileId() throws KMSException { + provider.createKek( + KeyPurpose.VOLUME_ENCRYPTION, + "test-label", + 256, + null // null profile ID should throw exception + ); + } + + /** + * Test: getSessionPool creates pool for new profile + */ + @Test + public void testGetSessionPool_CreatesPoolForNewProfile() { + // Setup + HSMProfileDetailsVO libraryDetail = mock(HSMProfileDetailsVO.class); + when(libraryDetail.getName()).thenReturn("library"); + when(libraryDetail.getValue()).thenReturn("/path/to/lib.so"); + HSMProfileDetailsVO slotDetail = mock(HSMProfileDetailsVO.class); + when(slotDetail.getName()).thenReturn("slot"); + when(slotDetail.getValue()).thenReturn("1"); + HSMProfileDetailsVO pinDetail = mock(HSMProfileDetailsVO.class); + when(pinDetail.getName()).thenReturn("pin"); + when(pinDetail.getValue()).thenReturn("1234"); + when(hsmProfileDetailsDao.listByProfileId(testProfileId)).thenReturn( + Arrays.asList(libraryDetail, slotDetail, pinDetail)); + + // Test + Object pool = provider.getSessionPool(testProfileId); + + // Verify + assertNotNull("Pool should be created", pool); + verify(hsmProfileDetailsDao).listByProfileId(testProfileId); + } + + /** + * Test: getSessionPool reuses pool for same profile + */ + @Test + public void testGetSessionPool_ReusesPoolForSameProfile() { + // Setup + HSMProfileDetailsVO libraryDetail = mock(HSMProfileDetailsVO.class); + when(libraryDetail.getName()).thenReturn("library"); + when(libraryDetail.getValue()).thenReturn("/path/to/lib.so"); + HSMProfileDetailsVO slotDetail = mock(HSMProfileDetailsVO.class); + when(slotDetail.getName()).thenReturn("slot"); + when(slotDetail.getValue()).thenReturn("1"); + HSMProfileDetailsVO pinDetail = mock(HSMProfileDetailsVO.class); + when(pinDetail.getName()).thenReturn("pin"); + when(pinDetail.getValue()).thenReturn("1234"); + when(hsmProfileDetailsDao.listByProfileId(testProfileId)).thenReturn( + Arrays.asList(libraryDetail, slotDetail, pinDetail)); + + // Test + Object pool1 = provider.getSessionPool(testProfileId); + Object pool2 = provider.getSessionPool(testProfileId); + + // Verify + assertNotNull("Pool should be created", pool1); + assertEquals("Should reuse same pool", pool1, pool2); + // Config should only be loaded once + verify(hsmProfileDetailsDao, times(1)).listByProfileId(testProfileId); + } + + @Test + public void testInvalidateProfileCache_ForcesFreshPool() { + // Setup + HSMProfileDetailsVO libraryDetail = mock(HSMProfileDetailsVO.class); + when(libraryDetail.getName()).thenReturn("library"); + when(libraryDetail.getValue()).thenReturn("/path/to/lib.so"); + HSMProfileDetailsVO slotDetail = mock(HSMProfileDetailsVO.class); + when(slotDetail.getName()).thenReturn("slot"); + when(slotDetail.getValue()).thenReturn("1"); + HSMProfileDetailsVO pinDetail = mock(HSMProfileDetailsVO.class); + when(pinDetail.getName()).thenReturn("pin"); + when(pinDetail.getValue()).thenReturn("1234"); + when(hsmProfileDetailsDao.listByProfileId(testProfileId)).thenReturn( + Arrays.asList(libraryDetail, slotDetail, pinDetail)); + + // Test: build a pool, invalidate it, then build again + Object poolBefore = provider.getSessionPool(testProfileId); + provider.invalidateProfileCache(testProfileId); + Object poolAfter = provider.getSessionPool(testProfileId); + + // Verify: a brand-new pool instance is created after invalidation + assertNotNull("Pool should be created before invalidation", poolBefore); + assertNotNull("Pool should be recreated after invalidation", poolAfter); + assertNotSame("Invalidation must force a fresh pool, not reuse the stale one", + poolBefore, poolAfter); + // Config is reloaded for each freshly-built pool (twice total) + verify(hsmProfileDetailsDao, times(2)).listByProfileId(testProfileId); + } +} diff --git a/plugins/kms/pom.xml b/plugins/kms/pom.xml new file mode 100644 index 00000000000..8436242447d --- /dev/null +++ b/plugins/kms/pom.xml @@ -0,0 +1,40 @@ + + + + 4.0.0 + cloudstack-kms-plugins + pom + Apache CloudStack Plugin - KMS + Key Management Service providers + + + org.apache.cloudstack + cloudstack-plugins + 4.23.0.0-SNAPSHOT + ../pom.xml + + + + database + pkcs11 + + diff --git a/plugins/network-elements/elastic-loadbalancer/src/main/java/com/cloud/network/ElasticLbVmMapVO.java b/plugins/network-elements/elastic-loadbalancer/src/main/java/com/cloud/network/ElasticLbVmMapVO.java index 96a43dfb19e..f4fa1cb776a 100644 --- a/plugins/network-elements/elastic-loadbalancer/src/main/java/com/cloud/network/ElasticLbVmMapVO.java +++ b/plugins/network-elements/elastic-loadbalancer/src/main/java/com/cloud/network/ElasticLbVmMapVO.java @@ -27,11 +27,16 @@ import javax.persistence.PrimaryKeyJoinColumn; import javax.persistence.SecondaryTable; import javax.persistence.SecondaryTables; import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import com.cloud.utils.db.GenericDao; import org.apache.cloudstack.api.InternalIdentity; import com.cloud.utils.net.Ip; +import java.util.Date; + @Entity @Table(name = "elastic_lb_vm_map") @SecondaryTables({@SecondaryTable(name = "user_ip_address", pkJoinColumns = {@PrimaryKeyJoinColumn(name = "ip_addr_id", referencedColumnName = "id")})}) @@ -57,6 +62,10 @@ public class ElasticLbVmMapVO implements InternalIdentity { @Enumerated(value = EnumType.STRING) private Ip address = null; + @Column(name = GenericDao.REMOVED_COLUMN) + @Temporal(value = TemporalType.TIMESTAMP) + private Date removed; + public ElasticLbVmMapVO() { } diff --git a/plugins/network-elements/tungsten/src/main/java/org/apache/cloudstack/network/tungsten/dao/TungstenFabricLBHealthMonitorVO.java b/plugins/network-elements/tungsten/src/main/java/org/apache/cloudstack/network/tungsten/dao/TungstenFabricLBHealthMonitorVO.java index e7b93798942..70263f9cc41 100644 --- a/plugins/network-elements/tungsten/src/main/java/org/apache/cloudstack/network/tungsten/dao/TungstenFabricLBHealthMonitorVO.java +++ b/plugins/network-elements/tungsten/src/main/java/org/apache/cloudstack/network/tungsten/dao/TungstenFabricLBHealthMonitorVO.java @@ -16,9 +16,11 @@ // under the License. package org.apache.cloudstack.network.tungsten.dao; +import com.cloud.utils.db.GenericDao; import org.apache.cloudstack.api.Identity; import org.apache.cloudstack.api.InternalIdentity; +import java.util.Date; import java.util.UUID; import javax.persistence.Column; @@ -28,6 +30,8 @@ import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.PrimaryKeyJoinColumn; import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; @Entity @Table(name = "tungsten_lb_health_monitor") @@ -66,6 +70,10 @@ public class TungstenFabricLBHealthMonitorVO implements InternalIdentity, Identi @Column(name = "url_path") private String urlPath; + @Column(name = GenericDao.REMOVED_COLUMN) + @Temporal(value = TemporalType.TIMESTAMP) + private Date removed = null; + public TungstenFabricLBHealthMonitorVO() { this.uuid = UUID.randomUUID().toString(); } diff --git a/plugins/pom.xml b/plugins/pom.xml index f4897de0bd3..e1e1ba08889 100755 --- a/plugins/pom.xml +++ b/plugins/pom.xml @@ -96,6 +96,8 @@ integrations/kubernetes-service integrations/veeam-control-service + kms + metrics network-elements/bigswitch diff --git a/plugins/storage/sharedfs/storagevm/src/main/java/org/apache/cloudstack/storage/sharedfs/lifecycle/StorageVmSharedFSLifeCycle.java b/plugins/storage/sharedfs/storagevm/src/main/java/org/apache/cloudstack/storage/sharedfs/lifecycle/StorageVmSharedFSLifeCycle.java index ac8d6a58f0c..f47a35ced44 100644 --- a/plugins/storage/sharedfs/storagevm/src/main/java/org/apache/cloudstack/storage/sharedfs/lifecycle/StorageVmSharedFSLifeCycle.java +++ b/plugins/storage/sharedfs/storagevm/src/main/java/org/apache/cloudstack/storage/sharedfs/lifecycle/StorageVmSharedFSLifeCycle.java @@ -199,7 +199,7 @@ public class StorageVmSharedFSLifeCycle implements SharedFSLifeCycle { diskOfferingId, size, null, null, Hypervisor.HypervisorType.None, BaseCmd.HTTPMethod.POST, base64UserData, null, null, keypairs, null, addrs, null, null, null, customParameterMap, null, null, null, null, - true, UserVmManager.SHAREDFSVM, null, null, null); + true, UserVmManager.SHAREDFSVM, null, null, null, null); vmContext.setEventResourceId(vm.getId()); userVmService.startVirtualMachine(vm, null); } catch (InsufficientCapacityException ex) { diff --git a/plugins/storage/sharedfs/storagevm/src/test/java/org/apache/cloudstack/storage/sharedfs/lifecycle/StorageVmSharedFSLifeCycleTest.java b/plugins/storage/sharedfs/storagevm/src/test/java/org/apache/cloudstack/storage/sharedfs/lifecycle/StorageVmSharedFSLifeCycleTest.java index c64e8c05c99..82d055b9a35 100644 --- a/plugins/storage/sharedfs/storagevm/src/test/java/org/apache/cloudstack/storage/sharedfs/lifecycle/StorageVmSharedFSLifeCycleTest.java +++ b/plugins/storage/sharedfs/storagevm/src/test/java/org/apache/cloudstack/storage/sharedfs/lifecycle/StorageVmSharedFSLifeCycleTest.java @@ -257,7 +257,7 @@ public class StorageVmSharedFSLifeCycleTest { anyString(), anyLong(), anyLong(), any(), isNull(), any(Hypervisor.HypervisorType.class), any(BaseCmd.HTTPMethod.class), anyString(), isNull(), isNull(), anyList(), isNull(), any(Network.IpAddresses.class), isNull(), isNull(), isNull(), anyMap(), isNull(), isNull(), isNull(), isNull(), - anyBoolean(), anyString(), isNull(), isNull(), isNull())).thenReturn(vm); + anyBoolean(), anyString(), isNull(), isNull(), isNull(), isNull())).thenReturn(vm); VolumeVO rootVol = mock(VolumeVO.class); when(rootVol.getVolumeType()).thenReturn(Volume.Type.ROOT); diff --git a/plugins/storage/volume/default/src/main/java/org/apache/cloudstack/storage/datastore/driver/CloudStackPrimaryDataStoreDriverImpl.java b/plugins/storage/volume/default/src/main/java/org/apache/cloudstack/storage/datastore/driver/CloudStackPrimaryDataStoreDriverImpl.java index 9174aaa3869..3e9fa8a5438 100644 --- a/plugins/storage/volume/default/src/main/java/org/apache/cloudstack/storage/datastore/driver/CloudStackPrimaryDataStoreDriverImpl.java +++ b/plugins/storage/volume/default/src/main/java/org/apache/cloudstack/storage/datastore/driver/CloudStackPrimaryDataStoreDriverImpl.java @@ -633,11 +633,25 @@ public class CloudStackPrimaryDataStoreDriverImpl implements PrimaryDataStoreDri */ private boolean anyVolumeRequiresEncryption(DataObject ... objects) { for (DataObject o : objects) { - // this fails code smell for returning true twice, but it is more readable than combining all tests into one statement - if (o instanceof VolumeInfo && ((VolumeInfo) o).getPassphraseId() != null) { - return true; - } else if (o instanceof SnapshotInfo && ((SnapshotInfo) o).getBaseVolume().getPassphraseId() != null) { - return true; + // Check for legacy passphrase-based encryption + if (o instanceof VolumeInfo) { + VolumeInfo vol = (VolumeInfo) o; + if (vol.getPassphraseId() != null) { + return true; + } + // Check for KMS-based encryption + if (vol.getKmsWrappedKeyId() != null || vol.getKmsKeyId() != null) { + return true; + } + } else if (o instanceof SnapshotInfo) { + VolumeInfo baseVol = ((SnapshotInfo) o).getBaseVolume(); + if (baseVol.getPassphraseId() != null) { + return true; + } + // Check for KMS-based encryption + if (baseVol.getKmsWrappedKeyId() != null || baseVol.getKmsKeyId() != null) { + return true; + } } } return false; diff --git a/plugins/storage/volume/linstor/src/main/java/org/apache/cloudstack/storage/datastore/driver/LinstorPrimaryDataStoreDriverImpl.java b/plugins/storage/volume/linstor/src/main/java/org/apache/cloudstack/storage/datastore/driver/LinstorPrimaryDataStoreDriverImpl.java index d7451fab18f..d9dff5c35ec 100644 --- a/plugins/storage/volume/linstor/src/main/java/org/apache/cloudstack/storage/datastore/driver/LinstorPrimaryDataStoreDriverImpl.java +++ b/plugins/storage/volume/linstor/src/main/java/org/apache/cloudstack/storage/datastore/driver/LinstorPrimaryDataStoreDriverImpl.java @@ -436,7 +436,7 @@ public class LinstorPrimaryDataStoreDriverImpl implements PrimaryDataStoreDriver logger.info("Clone resource definition {} to {}", cloneRes, rscName); ResourceDefinitionCloneRequest cloneRequest = new ResourceDefinitionCloneRequest(); cloneRequest.setName(rscName); - if (volumeInfo.getPassphraseId() != null) { + if (volumeInfo.getPassphraseId() != null || volumeInfo.getKmsKeyId() != null) { List encryptionLayer = LinstorUtil.getEncryptedLayerList( linstorApi, LinstorUtil.getRscGrp(storagePoolVO)); cloneRequest.setLayerList(encryptionLayer); diff --git a/plugins/storage/volume/linstor/src/main/java/org/apache/cloudstack/storage/motion/LinstorDataMotionStrategy.java b/plugins/storage/volume/linstor/src/main/java/org/apache/cloudstack/storage/motion/LinstorDataMotionStrategy.java index aa450a8290f..4c9082751b6 100644 --- a/plugins/storage/volume/linstor/src/main/java/org/apache/cloudstack/storage/motion/LinstorDataMotionStrategy.java +++ b/plugins/storage/volume/linstor/src/main/java/org/apache/cloudstack/storage/motion/LinstorDataMotionStrategy.java @@ -399,7 +399,7 @@ public class LinstorDataMotionStrategy implements DataMotionStrategy { VolumeVO srcVolume = _volumeDao.findById(srcVolumeInfo.getId()); StoragePoolVO destStoragePool = _storagePool.findById(destDataStore.getId()); - if (srcVolumeInfo.getPassphraseId() != null) { + if (srcVolumeInfo.getPassphraseId() != null || srcVolumeInfo.getKmsKeyId() != null) { throw new CloudRuntimeException( String.format("Cannot live migrate encrypted volume: %s", srcVolumeInfo.getVolume())); } diff --git a/plugins/storage/volume/scaleio/src/main/java/org/apache/cloudstack/storage/datastore/driver/ScaleIOPrimaryDataStoreDriver.java b/plugins/storage/volume/scaleio/src/main/java/org/apache/cloudstack/storage/datastore/driver/ScaleIOPrimaryDataStoreDriver.java index 2c6460422a4..14cb82a4c2b 100644 --- a/plugins/storage/volume/scaleio/src/main/java/org/apache/cloudstack/storage/datastore/driver/ScaleIOPrimaryDataStoreDriver.java +++ b/plugins/storage/volume/scaleio/src/main/java/org/apache/cloudstack/storage/datastore/driver/ScaleIOPrimaryDataStoreDriver.java @@ -1601,7 +1601,7 @@ public class ScaleIOPrimaryDataStoreDriver implements PrimaryDataStoreDriver { */ protected boolean anyVolumeRequiresEncryption(DataObject ... objects) { for (DataObject o : objects) { - if (o instanceof VolumeInfo && ((VolumeInfo) o).getPassphraseId() != null) { + if (o instanceof VolumeInfo && (((VolumeInfo) o).getPassphraseId() != null || ((VolumeInfo) o).getKmsKeyId() != null)) { return true; } } diff --git a/plugins/storage/volume/storpool/src/main/java/org/apache/cloudstack/storage/datastore/driver/StorPoolPrimaryDataStoreDriver.java b/plugins/storage/volume/storpool/src/main/java/org/apache/cloudstack/storage/datastore/driver/StorPoolPrimaryDataStoreDriver.java index f666711f744..7d37d5b6a4f 100644 --- a/plugins/storage/volume/storpool/src/main/java/org/apache/cloudstack/storage/datastore/driver/StorPoolPrimaryDataStoreDriver.java +++ b/plugins/storage/volume/storpool/src/main/java/org/apache/cloudstack/storage/datastore/driver/StorPoolPrimaryDataStoreDriver.java @@ -290,7 +290,7 @@ public class StorPoolPrimaryDataStoreDriver implements PrimaryDataStoreDriver { try { VolumeInfo vinfo = (VolumeInfo)data; String name = vinfo.getUuid(); - Long size = vinfo.getPassphraseId() == null ? vinfo.getSize() : vinfo.getSize() + 2097152; + Long size = (vinfo.getPassphraseId() == null && vinfo.getKmsKeyId() == null) ? vinfo.getSize() : vinfo.getSize() + 2097152; Long vmId = vinfo.getInstanceId(); SpConnectionDesc conn = StorPoolUtil.getSpConnection(dataStore.getUuid(), dataStore.getId(), storagePoolDetailsDao, primaryStoreDao); @@ -309,7 +309,7 @@ public class StorPoolPrimaryDataStoreDriver implements PrimaryDataStoreDriver { updateVolume(dataStore, path, vinfo); - if (vinfo.getPassphraseId() != null) { + if (vinfo.getPassphraseId() != null || vinfo.getKmsKeyId() != null) { VolumeObjectTO volume = updateVolumeObjectTO(vinfo, resp); answer = createEncryptedVolume(dataStore, data, vinfo, size, volume, null, true); } else { @@ -360,11 +360,11 @@ public class StorPoolPrimaryDataStoreDriver implements PrimaryDataStoreDriver { StorPoolSetVolumeEncryptionAnswer ans; EndPoint ep = null; if (parentName == null) { - ep = selector.select(data, vinfo.getPassphraseId() != null); + ep = selector.select(data, vinfo.getPassphraseId() != null || vinfo.getKmsKeyId() != null); } else { Long clusterId = StorPoolHelper.findClusterIdByGlobalId(parentName, clusterDao); if (clusterId == null) { - ep = selector.select(data, vinfo.getPassphraseId() != null); + ep = selector.select(data, vinfo.getPassphraseId() != null || vinfo.getKmsKeyId() != null); } else { List hosts = hostDao.findByClusterIdAndEncryptionSupport(clusterId); ep = CollectionUtils.isNotEmpty(hosts) ? RemoteHostEndPoint.getHypervisorHostEndPoint(hosts.get(0)) : ep; @@ -558,10 +558,10 @@ public class StorPoolPrimaryDataStoreDriver implements PrimaryDataStoreDriver { private void tryToSnapshotVolumeBeforeDelete(VolumeInfo vinfo, DataStore dataStore, String name, SpConnectionDesc conn) { Integer deleteAfter = StorPoolConfigurationManager.DeleteAfterInterval.valueIn(dataStore.getId()); - if (deleteAfter != null && deleteAfter > 0 && vinfo.getPassphraseId() == null) { + if (deleteAfter != null && deleteAfter > 0 && vinfo.getPassphraseId() == null && vinfo.getKmsKeyId() == null) { createTemporarySnapshot(vinfo, name, deleteAfter, conn); } else { - StorPoolUtil.spLog("The volume [%s] is not marked to be snapshot. Check the global setting `storpool.delete.after.interval` or the volume is encrypted [%s]", name, deleteAfter, vinfo.getPassphraseId() != null); + StorPoolUtil.spLog("The volume [%s] is not marked to be snapshot. Check the global setting `storpool.delete.after.interval` or the volume is encrypted [%s]", name, deleteAfter, vinfo.getPassphraseId() != null || vinfo.getKmsKeyId() != null); } } @@ -862,7 +862,7 @@ public class StorPoolPrimaryDataStoreDriver implements PrimaryDataStoreDriver { vinfo.getDataStore().getId(), storagePoolDetailsDao, primaryStoreDao); Long snapshotSize = templStoragePoolVO.getTemplateSize(); - boolean withoutEncryption = vinfo.getPassphraseId() == null; + boolean withoutEncryption = vinfo.getPassphraseId() == null && vinfo.getKmsKeyId() == null; long size = withoutEncryption ? vinfo.getSize() : vinfo.getSize() + 2097152; if (snapshotSize != null && size < snapshotSize) { StorPoolUtil.spLog(String.format("provided size is too small for snapshot. Provided %d, snapshot %d. Using snapshot size", size, snapshotSize)); diff --git a/plugins/storage/volume/storpool/src/main/java/org/apache/cloudstack/storage/motion/StorPoolDataMotionStrategy.java b/plugins/storage/volume/storpool/src/main/java/org/apache/cloudstack/storage/motion/StorPoolDataMotionStrategy.java index c231290bc7c..5725d6013db 100644 --- a/plugins/storage/volume/storpool/src/main/java/org/apache/cloudstack/storage/motion/StorPoolDataMotionStrategy.java +++ b/plugins/storage/volume/storpool/src/main/java/org/apache/cloudstack/storage/motion/StorPoolDataMotionStrategy.java @@ -298,7 +298,7 @@ public class StorPoolDataMotionStrategy implements DataMotionStrategy { for (Map.Entry entry : volumeDataStoreMap.entrySet()) { VolumeInfo srcVolumeInfo = entry.getKey(); - if (srcVolumeInfo.getPassphraseId() != null) { + if (srcVolumeInfo.getPassphraseId() != null || srcVolumeInfo.getKmsKeyId() != null) { throw new CloudRuntimeException(String.format("Cannot live migrate encrypted volume [%s] to StorPool", srcVolumeInfo.getVolume())); } DataStore destDataStore = entry.getValue(); diff --git a/plugins/user-authenticators/oauth2/pom.xml b/plugins/user-authenticators/oauth2/pom.xml index 6ab7b9f5fab..89694440591 100644 --- a/plugins/user-authenticators/oauth2/pom.xml +++ b/plugins/user-authenticators/oauth2/pom.xml @@ -38,6 +38,11 @@ cloud-framework-config ${project.version}
+ + org.apache.cxf + cxf-rt-rs-security-jose + ${cs.cxf.version} + com.google.apis google-api-services-docs diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2AuthManagerImpl.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2AuthManagerImpl.java index b65027d6a24..b1bb8292f24 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2AuthManagerImpl.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/OAuth2AuthManagerImpl.java @@ -18,10 +18,14 @@ // package org.apache.cloudstack.oauth2; -import com.cloud.user.dao.UserDao; -import com.cloud.utils.component.Manager; -import com.cloud.utils.component.ManagerBase; -import com.cloud.utils.exception.CloudRuntimeException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; + import org.apache.cloudstack.auth.UserOAuth2Authenticator; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.Configurable; @@ -35,16 +39,11 @@ import org.apache.cloudstack.oauth2.dao.OauthProviderDao; import org.apache.cloudstack.oauth2.vo.OauthProviderVO; import org.apache.commons.lang3.StringUtils; -import javax.inject.Inject; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import com.cloud.utils.component.Manager; +import com.cloud.utils.component.ManagerBase; +import com.cloud.utils.exception.CloudRuntimeException; public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthManager, Manager, Configurable { - @Inject - private UserDao _userDao; @Inject protected OauthProviderDao _oauthProviderDao; @@ -55,7 +54,7 @@ public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthMana @Override public List> getAuthCommands() { - List> cmdList = new ArrayList>(); + List> cmdList = new ArrayList<>(); cmdList.add(OauthLoginAPIAuthenticatorCmd.class); cmdList.add(ListOAuthProvidersCmd.class); cmdList.add(VerifyOAuthCodeAndGetUserCmd.class); @@ -84,7 +83,7 @@ public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthMana @Override public List> getCommands() { - List> cmdList = new ArrayList>(); + List> cmdList = new ArrayList<>(); cmdList.add(RegisterOAuthProviderCmd.class); cmdList.add(DeleteOAuthProviderCmd.class); cmdList.add(UpdateOAuthProviderCmd.class); @@ -127,9 +126,7 @@ public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthMana @Override public String verifyCodeAndFetchEmail(String code, String provider) { UserOAuth2Authenticator authenticator = getUserOAuth2AuthenticationProvider(provider); - String email = authenticator.verifyCodeAndFetchEmail(code); - - return email; + return authenticator.verifyCodeAndFetchEmail(code); } @Override @@ -139,6 +136,8 @@ public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthMana String clientId = StringUtils.trim(cmd.getClientId()); String redirectUri = StringUtils.trim(cmd.getRedirectUri()); String secretKey = StringUtils.trim(cmd.getSecretKey()); + String authorizeUrl = StringUtils.trim(cmd.getAuthorizeUrl()); + String tokenUrl = StringUtils.trim(cmd.getTokenUrl()); if (!isOAuthPluginEnabled()) { throw new CloudRuntimeException("OAuth is not enabled, please enable to register"); @@ -148,7 +147,7 @@ public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthMana throw new CloudRuntimeException(String.format("Provider with the name %s is already registered", provider)); } - return saveOauthProvider(provider, description, clientId, secretKey, redirectUri); + return saveOauthProvider(provider, description, clientId, secretKey, redirectUri, authorizeUrl, tokenUrl); } @Override @@ -171,6 +170,8 @@ public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthMana String clientId = StringUtils.trim(cmd.getClientId()); String redirectUri = StringUtils.trim(cmd.getRedirectUri()); String secretKey = StringUtils.trim(cmd.getSecretKey()); + String authorizeUrl = StringUtils.trim(cmd.getAuthorizeUrl()); + String tokenUrl = StringUtils.trim(cmd.getTokenUrl()); Boolean enabled = cmd.getEnabled(); OauthProviderVO providerVO = _oauthProviderDao.findById(id); @@ -190,6 +191,12 @@ public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthMana if (StringUtils.isNotEmpty(secretKey)) { providerVO.setSecretKey(secretKey); } + if (StringUtils.isNotEmpty(authorizeUrl)) { + providerVO.setAuthorizeUrl(authorizeUrl); + } + if (StringUtils.isNotEmpty(tokenUrl)) { + providerVO.setTokenUrl(tokenUrl); + } if (enabled != null) { providerVO.setEnabled(enabled); } @@ -199,7 +206,7 @@ public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthMana return _oauthProviderDao.findById(id); } - private OauthProviderVO saveOauthProvider(String provider, String description, String clientId, String secretKey, String redirectUri) { + private OauthProviderVO saveOauthProvider(String provider, String description, String clientId, String secretKey, String redirectUri, String authorizeUrl, String tokenUrl) { final OauthProviderVO oauthProviderVO = new OauthProviderVO(); oauthProviderVO.setProvider(provider); @@ -207,6 +214,8 @@ public class OAuth2AuthManagerImpl extends ManagerBase implements OAuth2AuthMana oauthProviderVO.setClientId(clientId); oauthProviderVO.setSecretKey(secretKey); oauthProviderVO.setRedirectUri(redirectUri); + oauthProviderVO.setAuthorizeUrl(authorizeUrl); + oauthProviderVO.setTokenUrl(tokenUrl); oauthProviderVO.setEnabled(true); _oauthProviderDao.persist(oauthProviderVO); diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/ListOAuthProvidersCmd.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/ListOAuthProvidersCmd.java index abdbf65dbb4..9b91a1d879c 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/ListOAuthProvidersCmd.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/ListOAuthProvidersCmd.java @@ -21,8 +21,10 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import com.cloud.api.response.ApiResponseSerializer; -import com.cloud.user.Account; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; + import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; @@ -40,9 +42,8 @@ import org.apache.cloudstack.oauth2.api.response.OauthProviderResponse; import org.apache.cloudstack.oauth2.vo.OauthProviderVO; import org.apache.commons.lang.ArrayUtils; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import javax.servlet.http.HttpSession; +import com.cloud.api.response.ApiResponseSerializer; +import com.cloud.user.Account; @APICommand(name = "listOauthProvider", description = "List OAuth providers registered", responseObject = OauthProviderResponse.class, entityType = {}, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, @@ -108,7 +109,7 @@ public class ListOAuthProvidersCmd extends BaseListCmd implements APIAuthenticat List responses = new ArrayList<>(); for (OauthProviderVO result : resultList) { OauthProviderResponse r = new OauthProviderResponse(result.getUuid(), result.getProvider(), - result.getDescription(), result.getClientId(), result.getSecretKey(), result.getRedirectUri()); + result.getDescription(), result.getClientId(), result.getSecretKey(), result.getRedirectUri(), result.getAuthorizeUrl(), result.getTokenUrl()); if (OAuth2AuthManager.OAuth2IsPluginEnabled.value() && authenticatorPluginNames.contains(result.getProvider()) && result.isEnabled()) { r.setEnabled(true); } else { diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/RegisterOAuthProviderCmd.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/RegisterOAuthProviderCmd.java index b31cbde97c5..8eb4493d76d 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/RegisterOAuthProviderCmd.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/RegisterOAuthProviderCmd.java @@ -14,26 +14,29 @@ // limitations under the License. package org.apache.cloudstack.oauth2.api.command; +import java.util.Collection; +import java.util.Map; + import javax.inject.Inject; import javax.persistence.EntityExistsException; -import org.apache.cloudstack.api.response.SuccessResponse; -import org.apache.cloudstack.oauth2.OAuth2AuthManager; -import org.apache.cloudstack.oauth2.api.response.OauthProviderResponse; -import org.apache.cloudstack.oauth2.vo.OauthProviderVO; -import org.apache.commons.collections.MapUtils; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; import org.apache.cloudstack.api.BaseCmd; import org.apache.cloudstack.api.Parameter; import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.SuccessResponse; import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.oauth2.OAuth2AuthManager; +import org.apache.cloudstack.oauth2.api.response.OauthProviderResponse; +import org.apache.cloudstack.oauth2.keycloak.KeycloakOAuth2Provider; +import org.apache.cloudstack.oauth2.vo.OauthProviderVO; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.StringUtils; import com.cloud.exception.ConcurrentOperationException; -import java.util.Collection; -import java.util.Map; - @APICommand(name = "registerOauthProvider", responseObject = SuccessResponse.class, description = "Register the OAuth2 provider in CloudStack", since = "4.19.0") public class RegisterOAuthProviderCmd extends BaseCmd { @@ -56,6 +59,12 @@ public class RegisterOAuthProviderCmd extends BaseCmd { @Parameter(name = ApiConstants.REDIRECT_URI, type = CommandType.STRING, description = "Redirect URI pre-registered in the specific OAuth provider", required = true) private String redirectUri; + @Parameter(name = ApiConstants.AUTHORIZE_URL, type = CommandType.STRING, description = "Authorize URL for OAuth initialization (only required for keycloak provider)") + private String authorizeUrl; + + @Parameter(name = ApiConstants.TOKEN_URL, type = CommandType.STRING, description = "Token URL for OAuth finalization (only required for keycloak provider)") + private String tokenUrl; + @Parameter(name = ApiConstants.DETAILS, type = CommandType.MAP, description = "Any OAuth provider details in key/value pairs using format details[i].keyname=keyvalue. Example: details[0].clientsecret=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") protected Map details; @@ -85,6 +94,14 @@ public class RegisterOAuthProviderCmd extends BaseCmd { return redirectUri; } + public String getAuthorizeUrl() { + return authorizeUrl; + } + + public String getTokenUrl() { + return tokenUrl; + } + public Map getDetails() { if (MapUtils.isEmpty(details)) { return null; @@ -98,10 +115,20 @@ public class RegisterOAuthProviderCmd extends BaseCmd { @Override public void execute() throws ServerApiException, ConcurrentOperationException, EntityExistsException { + if (StringUtils.equals(KeycloakOAuth2Provider.KEYCLOAK_PROVIDER, getProvider())) { + if (StringUtils.isBlank(getAuthorizeUrl())) { + throw new ServerApiException(ApiErrorCode.BAD_REQUEST, "Parameter authorizeurl is mandatory for keycloak OAuth Provider"); + } + if (StringUtils.isBlank(getTokenUrl())) { + throw new ServerApiException(ApiErrorCode.BAD_REQUEST, "Parameter tokenurl is mandatory for keycloak OAuth Provider"); + } + } + OauthProviderVO provider = _oauth2mgr.registerOauthProvider(this); OauthProviderResponse response = new OauthProviderResponse(provider.getUuid(), provider.getProvider(), - provider.getDescription(), provider.getClientId(), provider.getSecretKey(), provider.getRedirectUri()); + provider.getDescription(), provider.getClientId(), provider.getSecretKey(), provider.getRedirectUri(), + provider.getAuthorizeUrl(), provider.getTokenUrl()); response.setResponseName(getCommandName()); response.setObjectName(ApiConstants.OAUTH_PROVIDER); setResponseObject(response); diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/UpdateOAuthProviderCmd.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/UpdateOAuthProviderCmd.java index 1c79b7b144c..a8b0604a9bb 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/UpdateOAuthProviderCmd.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/command/UpdateOAuthProviderCmd.java @@ -16,23 +16,23 @@ // under the License. package org.apache.cloudstack.oauth2.api.command; -import org.apache.cloudstack.api.ApiCommandResourceType; -import org.apache.cloudstack.auth.UserOAuth2Authenticator; -import org.apache.cloudstack.oauth2.OAuth2AuthManager; -import org.apache.cloudstack.oauth2.api.response.OauthProviderResponse; -import org.apache.cloudstack.oauth2.vo.OauthProviderVO; +import java.util.ArrayList; +import java.util.List; + +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.ApiErrorCode; import org.apache.cloudstack.api.BaseCmd; import org.apache.cloudstack.api.Parameter; import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.auth.UserOAuth2Authenticator; import org.apache.cloudstack.context.CallContext; - -import javax.inject.Inject; -import java.util.ArrayList; -import java.util.List; +import org.apache.cloudstack.oauth2.OAuth2AuthManager; +import org.apache.cloudstack.oauth2.api.response.OauthProviderResponse; +import org.apache.cloudstack.oauth2.vo.OauthProviderVO; @APICommand(name = "updateOauthProvider", description = "Updates the registered OAuth provider details", responseObject = OauthProviderResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "4.19.0") @@ -57,6 +57,12 @@ public final class UpdateOAuthProviderCmd extends BaseCmd { @Parameter(name = ApiConstants.REDIRECT_URI, type = CommandType.STRING, description = "Redirect URI pre-registered in the specific OAuth provider") private String redirectUri; + @Parameter(name = ApiConstants.AUTHORIZE_URL, type = CommandType.STRING, description = "Authorize URL pre-registered in the specific OAuth provider") + private String authorizeUrl; + + @Parameter(name = ApiConstants.TOKEN_URL, type = CommandType.STRING, description = "Token URL pre-registered in the specific OAuth provider") + private String tokenUrl; + @Parameter(name = ApiConstants.ENABLED, type = CommandType.BOOLEAN, description = "OAuth provider will be enabled or disabled based on this value") private Boolean enabled; @@ -87,6 +93,14 @@ public final class UpdateOAuthProviderCmd extends BaseCmd { return redirectUri; } + public String getAuthorizeUrl() { + return authorizeUrl; + } + + public String getTokenUrl() { + return tokenUrl; + } + public Boolean getEnabled() { return enabled; } @@ -115,7 +129,8 @@ public final class UpdateOAuthProviderCmd extends BaseCmd { OauthProviderVO result = _oauthMgr.updateOauthProvider(this); if (result != null) { OauthProviderResponse r = new OauthProviderResponse(result.getUuid(), result.getProvider(), - result.getDescription(), result.getClientId(), result.getSecretKey(), result.getRedirectUri()); + result.getDescription(), result.getClientId(), result.getSecretKey(), result.getRedirectUri(), + result.getAuthorizeUrl(), result.getTokenUrl()); List userOAuth2AuthenticatorPlugins = _oauthMgr.listUserOAuth2AuthenticationProviders(); List authenticatorPluginNames = new ArrayList<>(); diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/response/OauthProviderResponse.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/response/OauthProviderResponse.java index e0c40bef9b4..289dc665013 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/response/OauthProviderResponse.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/api/response/OauthProviderResponse.java @@ -16,13 +16,14 @@ // under the License. package org.apache.cloudstack.oauth2.api.response; -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 org.apache.cloudstack.oauth2.vo.OauthProviderVO; +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + @EntityReference(value = OauthProviderVO.class) public class OauthProviderResponse extends BaseResponse { @@ -54,18 +55,28 @@ public class OauthProviderResponse extends BaseResponse { @Param(description = "Redirect URI registered in the OAuth provider") private String redirectUri; + @SerializedName(ApiConstants.AUTHORIZE_URL) + @Param(description = "Authorize URL registered in the OAuth provider") + private String authorizeUrl; + + @SerializedName(ApiConstants.TOKEN_URL) + @Param(description = "Token URL registered in the OAuth provider") + private String tokenUrl; + @SerializedName(ApiConstants.ENABLED) @Param(description = "Whether the OAuth provider is enabled or not") private boolean enabled; - public OauthProviderResponse(String id, String provider, String description, String clientId, String secretKey, String redirectUri) { + public OauthProviderResponse(String id, String provider, String description, String clientId, String secretKey, String redirectUri, String authorizeUrl, String tokenUrl) { this.id = id; this.provider = provider; this.name = provider; this.description = description; this.clientId = clientId; this.secretKey = secretKey; - this.redirectUri = redirectUri; + this.redirectUri = redirectUri; + this.authorizeUrl = authorizeUrl; + this.tokenUrl = tokenUrl; } public String getId() { @@ -117,6 +128,22 @@ public class OauthProviderResponse extends BaseResponse { this.redirectUri = redirectUri; } + public String getAuthorizeUrl() { + return authorizeUrl; + } + + public void setAuthorizeUrl(String authorizeUrl) { + this.authorizeUrl = authorizeUrl; + } + + public String getTokenUrl() { + return tokenUrl; + } + + public void setTokenUrl(String tokenUrl) { + this.tokenUrl = tokenUrl; + } + public String getSecretKey() { return secretKey; } diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/github/GithubOAuth2Provider.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/github/GithubOAuth2Provider.java index e4a7fae101f..4d426181a94 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/github/GithubOAuth2Provider.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/github/GithubOAuth2Provider.java @@ -16,17 +16,6 @@ //under the License. package org.apache.cloudstack.oauth2.github; -import com.cloud.utils.component.AdapterBase; -import com.cloud.utils.exception.CloudRuntimeException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.apache.cloudstack.auth.UserOAuth2Authenticator; -import org.apache.cloudstack.oauth2.dao.OauthProviderDao; -import org.apache.cloudstack.oauth2.vo.OauthProviderVO; -import org.apache.commons.lang3.StringUtils; - -import javax.inject.Inject; - import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; @@ -36,6 +25,18 @@ import java.net.URL; import java.util.regex.Matcher; import java.util.regex.Pattern; +import javax.inject.Inject; + +import org.apache.cloudstack.auth.UserOAuth2Authenticator; +import org.apache.cloudstack.oauth2.dao.OauthProviderDao; +import org.apache.cloudstack.oauth2.vo.OauthProviderVO; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.utils.component.AdapterBase; +import com.cloud.utils.exception.CloudRuntimeException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + public class GithubOAuth2Provider extends AdapterBase implements UserOAuth2Authenticator { @Inject diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/google/GoogleOAuth2Provider.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/google/GoogleOAuth2Provider.java index 42ed1451ccd..885930181c9 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/google/GoogleOAuth2Provider.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/google/GoogleOAuth2Provider.java @@ -16,6 +16,17 @@ //under the License. package org.apache.cloudstack.oauth2.google; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +import javax.inject.Inject; + +import org.apache.cloudstack.auth.UserOAuth2Authenticator; +import org.apache.cloudstack.oauth2.dao.OauthProviderDao; +import org.apache.cloudstack.oauth2.vo.OauthProviderVO; +import org.apache.commons.lang3.StringUtils; + import com.cloud.exception.CloudAuthenticationException; import com.cloud.utils.component.AdapterBase; import com.cloud.utils.exception.CloudRuntimeException; @@ -28,15 +39,6 @@ import com.google.api.client.json.JsonFactory; import com.google.api.client.json.jackson2.JacksonFactory; import com.google.api.services.oauth2.Oauth2; import com.google.api.services.oauth2.model.Userinfo; -import org.apache.cloudstack.auth.UserOAuth2Authenticator; -import org.apache.cloudstack.oauth2.dao.OauthProviderDao; -import org.apache.cloudstack.oauth2.vo.OauthProviderVO; -import org.apache.commons.lang3.StringUtils; - -import javax.inject.Inject; -import java.io.IOException; -import java.util.Arrays; -import java.util.List; public class GoogleOAuth2Provider extends AdapterBase implements UserOAuth2Authenticator { @@ -78,10 +80,10 @@ public class GoogleOAuth2Provider extends AdapterBase implements UserOAuth2Authe @Override public String verifyCodeAndFetchEmail(String secretCode) { - OauthProviderVO githubProvider = _oauthProviderDao.findByProvider(getName()); - String clientId = githubProvider.getClientId(); - String secret = githubProvider.getSecretKey(); - String redirectURI = githubProvider.getRedirectUri(); + OauthProviderVO googleProvider = _oauthProviderDao.findByProvider(getName()); + String clientId = googleProvider.getClientId(); + String secret = googleProvider.getSecretKey(); + String redirectURI = googleProvider.getRedirectUri(); GoogleClientSecrets clientSecrets = new GoogleClientSecrets() .setWeb(new GoogleClientSecrets.Details() .setClientId(clientId) @@ -122,7 +124,7 @@ public class GoogleOAuth2Provider extends AdapterBase implements UserOAuth2Authe try { userinfo = oauth2.userinfo().get().execute(); } catch (IOException e) { - throw new CloudRuntimeException(String.format("Failed to fetch the email address with the provided secret: %s" + e.getMessage())); + throw new CloudRuntimeException(String.format("Failed to fetch the email address with the provided secret: %s", e.getMessage())); } return userinfo.getEmail(); } diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.java new file mode 100644 index 00000000000..3f537b1984d --- /dev/null +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2Provider.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.oauth2.keycloak; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +import javax.inject.Inject; +import javax.ws.rs.core.HttpHeaders; + +import org.apache.cloudstack.auth.UserOAuth2Authenticator; +import org.apache.cloudstack.oauth2.dao.OauthProviderDao; +import org.apache.cloudstack.oauth2.vo.OauthProviderVO; +import org.apache.commons.lang3.StringUtils; +import org.apache.cxf.rs.security.jose.jws.JwsJwtCompactConsumer; +import org.apache.cxf.rs.security.jose.jwt.JwtClaims; +import org.apache.http.NameValuePair; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.message.BasicNameValuePair; +import org.apache.http.util.EntityUtils; + +import com.cloud.exception.CloudAuthenticationException; +import com.cloud.utils.component.AdapterBase; +import com.cloud.utils.exception.CloudRuntimeException; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +public class KeycloakOAuth2Provider extends AdapterBase implements UserOAuth2Authenticator { + + public static final String KEYCLOAK_PROVIDER = "keycloak"; + + protected String idToken = null; + + @Inject + OauthProviderDao oauthProviderDao; + + private CloseableHttpClient httpClient; + + public KeycloakOAuth2Provider() { + this(HttpClientBuilder.create().build()); + } + + public KeycloakOAuth2Provider(CloseableHttpClient httpClient) { + this.httpClient = httpClient; + } + + @Override + public String getName() { + return KEYCLOAK_PROVIDER; + } + + @Override + public String getDescription() { + return "Keycloak OAuth2 Provider Plugin"; + } + + @Override + public boolean verifyUser(String email, String secretCode) { + if (StringUtils.isAnyEmpty(email, secretCode)) { + throw new CloudAuthenticationException("Either email or secret code should not be null/empty"); + } + + OauthProviderVO providerVO = oauthProviderDao.findByProvider(getName()); + if (providerVO == null) { + throw new CloudAuthenticationException("Keycloak provider is not registered, so user cannot be verified"); + } + + String verifiedEmail = verifyCodeAndFetchEmail(secretCode); + if (StringUtils.isBlank(verifiedEmail) || !email.equals(verifiedEmail)) { + throw new CloudRuntimeException("Unable to verify the email address with the provided secret"); + } + clearIdToken(); + + return true; + } + + @Override + public String verifyCodeAndFetchEmail(String secretCode) { + OauthProviderVO provider = oauthProviderDao.findByProvider(getName()); + if (provider == null) { + throw new CloudAuthenticationException("Keycloak provider is not registered, so user cannot be verified"); + } + + if (StringUtils.isBlank(idToken)) { + String auth = provider.getClientId() + ":" + provider.getSecretKey(); + String encodedAuth = Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8)); + + List params = new ArrayList<>(); + params.add(new BasicNameValuePair("grant_type", "authorization_code")); + params.add(new BasicNameValuePair("code", secretCode)); + params.add(new BasicNameValuePair("redirect_uri", provider.getRedirectUri())); + + HttpPost post = new HttpPost(provider.getTokenUrl()); + post.setHeader(HttpHeaders.AUTHORIZATION, "Basic " + encodedAuth); + + try { + post.setEntity(new UrlEncodedFormEntity(params)); + } catch (UnsupportedEncodingException e) { + throw new CloudRuntimeException("Unable to generate URL parameters: " + e.getMessage()); + } + + try (CloseableHttpResponse response = httpClient.execute(post)) { + String body = EntityUtils.toString(response.getEntity()); + + if (response.getStatusLine().getStatusCode() != 200) { + throw new CloudRuntimeException("Keycloak error during token generation: " + body); + } + + JsonObject json = JsonParser.parseString(body).getAsJsonObject(); + JsonElement fetchedIdToken = json.get("id_token"); + if (fetchedIdToken == null) { + throw new CloudRuntimeException("No id_token found in token"); + } + String idTokenAsString = fetchedIdToken.getAsString(); + validateIdToken(idTokenAsString , provider); + + this.idToken = idTokenAsString ; + } catch (IOException e) { + throw new CloudRuntimeException("Unable to connect to Keycloak server", e); + } + } + + return obtainEmail(idToken, provider); + } + + @Override + public String getUserEmailAddress() throws CloudRuntimeException { + return null; + } + + private void validateIdToken(String idTokenStr, OauthProviderVO provider) { + JwsJwtCompactConsumer jwtConsumer = new JwsJwtCompactConsumer(idTokenStr); + JwtClaims claims = jwtConsumer.getJwtToken().getClaims(); + + if (!claims.getAudiences().contains(provider.getClientId())) { + throw new CloudAuthenticationException("Audience mismatch"); + } + } + + private String obtainEmail(String idTokenStr, OauthProviderVO provider) { + JwsJwtCompactConsumer jwtConsumer = new JwsJwtCompactConsumer(idTokenStr); + JwtClaims claims = jwtConsumer.getJwtToken().getClaims(); + + if (!claims.getAudiences().contains(provider.getClientId())) { + throw new CloudAuthenticationException("Audience mismatch"); + } + + return (String) claims.getClaim("email"); + } + + protected void clearIdToken() { + idToken = null; + } + + public void setHttpClient(CloseableHttpClient httpClient) { + this.httpClient = httpClient; + } + +} diff --git a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/vo/OauthProviderVO.java b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/vo/OauthProviderVO.java index efd6004e8f9..54d667bc914 100644 --- a/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/vo/OauthProviderVO.java +++ b/plugins/user-authenticators/oauth2/src/main/java/org/apache/cloudstack/oauth2/vo/OauthProviderVO.java @@ -16,9 +16,8 @@ // under the License. package org.apache.cloudstack.oauth2.vo; -import com.cloud.utils.db.GenericDao; -import org.apache.cloudstack.api.Identity; -import org.apache.cloudstack.api.InternalIdentity; +import java.util.Date; +import java.util.UUID; import javax.persistence.Column; import javax.persistence.Entity; @@ -26,8 +25,11 @@ import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.Table; -import java.util.Date; -import java.util.UUID; + +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; + +import com.cloud.utils.db.GenericDao; @Entity @Table(name = "oauth_provider") @@ -55,6 +57,12 @@ public class OauthProviderVO implements Identity, InternalIdentity { @Column(name = "redirect_uri") private String redirectUri; + @Column(name = "authorize_url") + private String authorizeUrl; + + @Column(name = "token_url") + private String tokenUrl; + @Column(name = GenericDao.CREATED_COLUMN) private Date created; @@ -110,6 +118,22 @@ public class OauthProviderVO implements Identity, InternalIdentity { this.redirectUri = redirectUri; } + public String getAuthorizeUrl() { + return authorizeUrl; + } + + public void setAuthorizeUrl(String authorizeUrl) { + this.authorizeUrl = authorizeUrl; + } + + public String getTokenUrl() { + return tokenUrl; + } + + public void setTokenUrl(String tokenUrl) { + this.tokenUrl = tokenUrl; + } + public String getSecretKey() { return secretKey; } diff --git a/plugins/user-authenticators/oauth2/src/main/resources/META-INF/cloudstack/oauth2/spring-oauth2-context.xml b/plugins/user-authenticators/oauth2/src/main/resources/META-INF/cloudstack/oauth2/spring-oauth2-context.xml index 04a6c8dabfe..06fe60f4c25 100644 --- a/plugins/user-authenticators/oauth2/src/main/resources/META-INF/cloudstack/oauth2/spring-oauth2-context.xml +++ b/plugins/user-authenticators/oauth2/src/main/resources/META-INF/cloudstack/oauth2/spring-oauth2-context.xml @@ -35,6 +35,9 @@ + + + @@ -45,7 +48,7 @@ class="org.apache.cloudstack.spring.lifecycle.registry.ExtensionRegistry"> - + diff --git a/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2ProviderTest.java b/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2ProviderTest.java new file mode 100644 index 00000000000..df390f449ca --- /dev/null +++ b/plugins/user-authenticators/oauth2/src/test/java/org/apache/cloudstack/oauth2/keycloak/KeycloakOAuth2ProviderTest.java @@ -0,0 +1,225 @@ +//Licensed to the Apache Software Foundation (ASF) under one +//or more contributor license agreements. See the NOTICE file +//distributed with this work for additional information +//regarding copyright ownership. The ASF licenses this file +//to you under the Apache License, Version 2.0 (the +//"License"); you may not use this file except in compliance +//the License. You may obtain a copy of the License at +// +//http://www.apache.org/licenses/LICENSE-2.0 +// +//Unless required by applicable law or agreed to in writing, +//software distributed under the License is distributed on an +//"AS IS" BASIS, WITHOUT 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.oauth2.keycloak; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.apache.cloudstack.oauth2.dao.OauthProviderDao; +import org.apache.cloudstack.oauth2.vo.OauthProviderVO; +import org.apache.http.HttpEntity; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.impl.client.CloseableHttpClient; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import com.cloud.exception.CloudAuthenticationException; +import com.cloud.utils.exception.CloudRuntimeException; + +public class KeycloakOAuth2ProviderTest { + + @Mock + private OauthProviderDao oauthProviderDao; + + @Mock + private CloseableHttpClient httpClient; + + private KeycloakOAuth2Provider provider; + + private OauthProviderVO mockProviderVO; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + + provider = new KeycloakOAuth2Provider(httpClient); + provider.oauthProviderDao = oauthProviderDao; + + mockProviderVO = new OauthProviderVO(); + mockProviderVO.setClientId("test-client"); + mockProviderVO.setSecretKey("test-secret"); + mockProviderVO.setTokenUrl("http://localhost/token"); + mockProviderVO.setRedirectUri("http://localhost/redirect"); + } + + @Test + public void testGetName() { + assertEquals("keycloak", provider.getName()); + } + + @Test(expected = CloudAuthenticationException.class) + public void testVerifyUserEmptyParams() { + provider.verifyUser("", ""); + } + + @Test(expected = CloudAuthenticationException.class) + public void testVerifyUserProviderNotFound() { + when(oauthProviderDao.findByProvider("keycloak")).thenReturn(null); + provider.verifyUser("test@example.com", "code123"); + } + + @Test(expected = CloudRuntimeException.class) + public void testVerifyCodeAndFetchEmailHttpError() throws IOException { + when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO); + + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + StatusLine statusLine = mock(StatusLine.class); + + when(statusLine.getStatusCode()).thenReturn(400); + when(response.getStatusLine()).thenReturn(statusLine); + + HttpEntity entity = mock(HttpEntity.class); + when(entity.getContent()).thenReturn(new ByteArrayInputStream("error".getBytes())); + when(response.getEntity()).thenReturn(entity); + + when(httpClient.execute(any(HttpPost.class))).thenReturn(response); + + provider.verifyCodeAndFetchEmail("invalid-code"); + } + + @Test(expected = CloudRuntimeException.class) + public void testVerifyCodeAndFetchEmailNetworkFailure() throws IOException { + when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO); + when(httpClient.execute(any(HttpPost.class))).thenThrow(new IOException("Connection refused")); + + provider.verifyCodeAndFetchEmail("code"); + } + + @Test(expected = CloudRuntimeException.class) + public void testVerifyUserWithMismatchedEmail() throws IOException { + when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO); + + String testEmail = "anotheruser@example.com"; + String secretCode = "valid-auth-code"; + + String header = "{\"alg\":\"none\"}"; + String payload = "{" + + "\"aud\":[\"test-client\"]," + + "\"email\":\"" + testEmail + "\"," + + "\"iss\":\"http://keycloak\"," + + "\"sub\":\"12345\"" + + "}"; + + String encodedHeader = Base64.getUrlEncoder().withoutPadding().encodeToString(header.getBytes()); + String encodedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes()); + String fakeJwt = encodedHeader + "." + encodedPayload + ".not-checked-signature"; + + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + StatusLine statusLine = mock(StatusLine.class); + HttpEntity entity = mock(HttpEntity.class); + + when(statusLine.getStatusCode()).thenReturn(200); + when(response.getStatusLine()).thenReturn(statusLine); + + String jsonResponseBody = "{\"id_token\":\"" + fakeJwt + "\", \"access_token\":\"acc-123\"}"; + when(entity.getContent()).thenReturn(new ByteArrayInputStream(jsonResponseBody.getBytes(StandardCharsets.UTF_8))); + when(response.getEntity()).thenReturn(entity); + + when(httpClient.execute(any(HttpPost.class))).thenReturn(response); + + provider.verifyUser("user@example.com", secretCode); + } + + @Test(expected = CloudRuntimeException.class) + public void testVerifyUserWithMismatchedClient() throws IOException { + when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO); + + String testEmail = "anotheruser@example.com"; + String secretCode = "valid-auth-code"; + + String header = "{\"alg\":\"none\"}"; + String payload = "{" + + "\"aud\":[\"anothertest-client\"]," + + "\"email\":\"" + testEmail + "\"," + + "\"iss\":\"http://keycloak\"," + + "\"sub\":\"12345\"" + + "}"; + + String encodedHeader = Base64.getUrlEncoder().withoutPadding().encodeToString(header.getBytes()); + String encodedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes()); + String fakeJwt = encodedHeader + "." + encodedPayload + ".not-checked-signature"; + + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + StatusLine statusLine = mock(StatusLine.class); + HttpEntity entity = mock(HttpEntity.class); + + when(statusLine.getStatusCode()).thenReturn(200); + when(response.getStatusLine()).thenReturn(statusLine); + + String jsonResponseBody = "{\"id_token\":\"" + fakeJwt + "\", \"access_token\":\"acc-123\"}"; + when(entity.getContent()).thenReturn(new ByteArrayInputStream(jsonResponseBody.getBytes(StandardCharsets.UTF_8))); + when(response.getEntity()).thenReturn(entity); + + when(httpClient.execute(any(HttpPost.class))).thenReturn(response); + + provider.verifyUser(testEmail, secretCode); + } + + @Test + public void testVerifyUserEmail() throws IOException { + when(oauthProviderDao.findByProvider("keycloak")).thenReturn(mockProviderVO); + + String testEmail = "user@example.com"; + String secretCode = "valid-auth-code"; + + String header = "{\"alg\":\"none\"}"; + String payload = "{" + + "\"aud\":[\"test-client\"]," + + "\"email\":\"" + testEmail + "\"," + + "\"iss\":\"http://keycloak\"," + + "\"sub\":\"12345\"" + + "}"; + + String encodedHeader = Base64.getUrlEncoder().withoutPadding().encodeToString(header.getBytes()); + String encodedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes()); + String fakeJwt = encodedHeader + "." + encodedPayload + ".not-checked-signature"; + + CloseableHttpResponse response = mock(CloseableHttpResponse.class); + StatusLine statusLine = mock(StatusLine.class); + HttpEntity entity = mock(HttpEntity.class); + + when(statusLine.getStatusCode()).thenReturn(200); + when(response.getStatusLine()).thenReturn(statusLine); + + String jsonResponseBody = "{\"id_token\":\"" + fakeJwt + "\", \"access_token\":\"acc-123\"}"; + when(entity.getContent()).thenReturn(new ByteArrayInputStream(jsonResponseBody.getBytes(StandardCharsets.UTF_8))); + when(response.getEntity()).thenReturn(entity); + + when(httpClient.execute(any(HttpPost.class))).thenReturn(response); + + boolean result = provider.verifyUser(testEmail, secretCode); + + assertTrue("User successfully verified", result); + } + + @Test + public void testGetDescription() { + assertEquals("Keycloak OAuth2 Provider Plugin", provider.getDescription()); + } +} diff --git a/pom.xml b/pom.xml index 38a559ba831..cc14a86fb8b 100644 --- a/pom.xml +++ b/pom.xml @@ -196,6 +196,8 @@ 3.1.7 3.25.5 8.6.0 + 1.51.0 + 2.16.0 @@ -434,6 +436,16 @@ commons-validator ${cs.commons-validator.version} + + io.opentelemetry + opentelemetry-api + ${cs.opentelemetry.version} + + + io.opentelemetry.instrumentation + opentelemetry-instrumentation-annotations + ${cs.opentelemetry-instrumentation.version} + javax.annotation javax.annotation-api diff --git a/server/pom.xml b/server/pom.xml index 2b35a0f42ac..af0d3272572 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -69,6 +69,11 @@ cloud-framework-ca ${project.version} + + org.apache.cloudstack + cloud-framework-kms + ${project.version} + org.apache.cloudstack cloud-framework-jobs @@ -197,6 +202,14 @@ 4.23.0.0-SNAPSHOT compile + + io.opentelemetry.instrumentation + opentelemetry-instrumentation-annotations + + + io.opentelemetry + opentelemetry-api + diff --git a/server/src/main/java/com/cloud/api/ApiServer.java b/server/src/main/java/com/cloud/api/ApiServer.java index 1bda053ec19..1aaf593a8da 100644 --- a/server/src/main/java/com/cloud/api/ApiServer.java +++ b/server/src/main/java/com/cloud/api/ApiServer.java @@ -62,6 +62,9 @@ import javax.naming.ConfigurationException; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.instrumentation.annotations.WithSpan; + import org.apache.cloudstack.acl.APIChecker; import org.apache.cloudstack.acl.ApiKeyPairManagerImpl; import org.apache.cloudstack.acl.apikeypair.ApiKeyPair; @@ -627,6 +630,7 @@ public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiSer } @Override + @WithSpan("ApiServer.handleRequest") @SuppressWarnings("rawtypes") public String handleRequest(final Map params, final String responseType, final StringBuilder auditTrailSb) throws ServerApiException { checkCharacterInkParams(params); @@ -636,6 +640,10 @@ public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiSer try { command = (String[])params.get("command"); + if (command != null && command.length > 0) { + Span.current().updateName("ApiServer.handleRequest " + command[0]); + Span.current().setAttribute("api.command", command[0]); + } if (command == null) { logger.error("invalid request, no command sent"); if (logger.isTraceEnabled()) { diff --git a/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java b/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java index 051af377c6d..616e75bfc36 100644 --- a/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java +++ b/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java @@ -2666,6 +2666,7 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q Long clusterId = cmd.getClusterId(); Long serviceOfferingId = cmd.getServiceOfferingId(); Long diskOfferingId = cmd.getDiskOfferingId(); + Long kmsKeyId = cmd.getKmsKeyId(); Boolean display = cmd.getDisplay(); String state = cmd.getState(); boolean shouldListSystemVms = shouldListSystemVms(cmd, caller.getId()); @@ -2702,6 +2703,7 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q volumeSearchBuilder.and("uuid", volumeSearchBuilder.entity().getUuid(), SearchCriteria.Op.NNULL); volumeSearchBuilder.and("instanceId", volumeSearchBuilder.entity().getInstanceId(), SearchCriteria.Op.EQ); volumeSearchBuilder.and("dataCenterId", volumeSearchBuilder.entity().getDataCenterId(), SearchCriteria.Op.EQ); + volumeSearchBuilder.and("kmsKeyId", volumeSearchBuilder.entity().getKmsKeyId(), SearchCriteria.Op.EQ); if (cmd.isEncrypted() != null) { if (cmd.isEncrypted()) { volumeSearchBuilder.and("encryptFormat", volumeSearchBuilder.entity().getEncryptFormat(), SearchCriteria.Op.NNULL); @@ -2826,6 +2828,9 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q if (vmInstanceId != null) { sc.setParameters("instanceId", vmInstanceId); } + if (kmsKeyId != null) { + sc.setParameters("kmsKeyId", kmsKeyId); + } if (zoneId != null) { sc.setParameters("dataCenterId", zoneId); } diff --git a/server/src/main/java/com/cloud/api/query/dao/UserVmJoinDaoImpl.java b/server/src/main/java/com/cloud/api/query/dao/UserVmJoinDaoImpl.java index 9a1d27d1e8c..3f7f459c2a1 100644 --- a/server/src/main/java/com/cloud/api/query/dao/UserVmJoinDaoImpl.java +++ b/server/src/main/java/com/cloud/api/query/dao/UserVmJoinDaoImpl.java @@ -39,6 +39,7 @@ import org.apache.cloudstack.annotation.dao.AnnotationDao; import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.ApiConstants.VMDetails; import org.apache.cloudstack.api.ResponseObject.ResponseView; +import org.apache.cloudstack.api.response.AttachedIsoResponse; import org.apache.cloudstack.api.response.NicExtraDhcpOptionResponse; import org.apache.cloudstack.api.response.NicResponse; import org.apache.cloudstack.api.response.NicSecondaryIpResponse; @@ -62,6 +63,11 @@ import com.cloud.gpu.GPU; import com.cloud.gpu.dao.VgpuProfileDao; import com.cloud.host.ControlState; import com.cloud.hypervisor.Hypervisor; +import com.cloud.host.DetailVO; +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.network.IpAddress; import com.cloud.network.vpc.VpcVO; import com.cloud.network.vpc.dao.VpcDao; @@ -72,6 +78,7 @@ import com.cloud.storage.GuestOS; import com.cloud.storage.Storage.TemplateType; import com.cloud.storage.VMTemplateVO; import com.cloud.storage.VnfTemplateDetailVO; +import com.cloud.template.TemplateManager; import com.cloud.storage.VnfTemplateNicVO; import com.cloud.storage.Volume; import com.cloud.storage.dao.VMTemplateDao; @@ -93,10 +100,12 @@ import com.cloud.vm.UserVmManager; import com.cloud.vm.VMInstanceDetailVO; import com.cloud.vm.VirtualMachine; import com.cloud.vm.VirtualMachine.State; +import com.cloud.vm.VmIsoMapVO; import com.cloud.vm.VmStats; import com.cloud.vm.dao.NicExtraDhcpOptionDao; import com.cloud.vm.dao.NicSecondaryIpVO; import com.cloud.vm.dao.VMInstanceDetailsDao; +import com.cloud.vm.dao.VmIsoMapDao; @Component public class UserVmJoinDaoImpl extends GenericDaoBaseWithTagInformation implements UserVmJoinDao { @@ -130,6 +139,12 @@ public class UserVmJoinDaoImpl extends GenericDaoBaseWithTagInformation VmDetailSearch; @@ -246,6 +261,23 @@ public class UserVmJoinDaoImpl extends GenericDaoBaseWithTagInformation attachedIsos = new ArrayList<>(); + if (userVm.getIsoUuid() != null) { + VMTemplateVO bootIso = vmTemplateDao.findById(userVm.getIsoId()); + boolean bootIsoBootable = bootIso != null && bootIso.isBootable(); + attachedIsos.add(new AttachedIsoResponse(userVm.getIsoUuid(), userVm.getIsoName(), + userVm.getIsoDisplayText(), TemplateManager.CDROM_PRIMARY_DEVICE_SEQ, bootIsoBootable)); + } + for (VmIsoMapVO row : vmIsoMapDao.listByVmId(userVm.getId())) { + VMTemplateVO tmpl = vmTemplateDao.findById(row.getIsoId()); + if (tmpl != null) { + attachedIsos.add(new AttachedIsoResponse(tmpl.getUuid(), tmpl.getName(), + tmpl.getDisplayText(), row.getDeviceSeq(), false)); + } + } + userVmResponse.setIsos(attachedIsos); + userVmResponse.setIsoMaxCount(effectiveCdromMaxCount(userVm)); } if (details.contains(VMDetails.all) || details.contains(VMDetails.servoff)) { userVmResponse.setServiceOfferingId(userVm.getServiceOfferingUuid()); @@ -541,6 +573,44 @@ public class UserVmJoinDaoImpl extends GenericDaoBaseWithTagInformation 0 + ? userVm.getHostId() : userVm.getLastHostId(); + if (hostId == null && userVm.getHypervisorType() != null) { + List candidates = hostDao.listByDataCenterIdAndHypervisorType(userVm.getDataCenterId(), userVm.getHypervisorType()); + if (!candidates.isEmpty()) { + hostId = candidates.get(0).getId(); + } + } + Long clusterId = userVm.getClusterId(); + if (clusterId == null && hostId != null) { + HostVO host = hostDao.findById(hostId); + if (host != null) { + clusterId = host.getClusterId(); + } + } + int configuredCap = TemplateManager.VmIsoMaxCount.valueIn(clusterId); + int hypervisorCap = advertisedCdromCap(hostId); + // List endpoint clamps for display robustness; the action paths in TemplateManagerImpl + // throw on misconfiguration so operators still see the loud error when they try to attach. + return Math.min(configuredCap, hypervisorCap); + } + + int advertisedCdromCap(Long hostId) { + if (hostId == null) { + return TemplateManager.DEFAULT_CDROM_MAX_PER_VM; + } + DetailVO detail = hostDetailsDao.findDetail(hostId, Host.HOST_CDROM_MAX_COUNT); + if (detail == null || detail.getValue() == null) { + return TemplateManager.DEFAULT_CDROM_MAX_PER_VM; + } + try { + return Integer.parseInt(detail.getValue()); + } catch (NumberFormatException e) { + return TemplateManager.DEFAULT_CDROM_MAX_PER_VM; + } + } + private void addVnfInfoToserVmResponse(UserVmJoinVO userVm, UserVmResponse userVmResponse) { List vnfNics = vnfTemplateNicDao.listByTemplateId(userVm.getTemplateId()); for (VnfTemplateNicVO nic : vnfNics) { diff --git a/server/src/main/java/com/cloud/api/query/dao/VolumeJoinDaoImpl.java b/server/src/main/java/com/cloud/api/query/dao/VolumeJoinDaoImpl.java index 94d06b2ed6b..e82ec525f01 100644 --- a/server/src/main/java/com/cloud/api/query/dao/VolumeJoinDaoImpl.java +++ b/server/src/main/java/com/cloud/api/query/dao/VolumeJoinDaoImpl.java @@ -1,4 +1,4 @@ -// Licensed to the Apache Software Foundation (ASF) under one + // Licensed to the Apache Software Foundation (ASF) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. The ASF licenses this file @@ -29,6 +29,10 @@ import org.apache.cloudstack.api.ResponseObject.ResponseView; import org.apache.cloudstack.api.response.VolumeResponse; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.framework.config.dao.ConfigurationDao; +import org.apache.cloudstack.kms.KMSKekVersionVO; +import org.apache.cloudstack.kms.KMSWrappedKeyVO; +import org.apache.cloudstack.kms.dao.KMSKekVersionDao; +import org.apache.cloudstack.kms.dao.KMSWrappedKeyDao; import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; import org.apache.commons.collections.CollectionUtils; @@ -64,6 +68,10 @@ public class VolumeJoinDaoImpl extends GenericDaoBaseWithTagInformation volSearch; @@ -294,6 +302,18 @@ public class VolumeJoinDaoImpl extends GenericDaoBaseWithTagInformation, Configurable { logger.debug("This VM has last host_id: {}", vm.getLastHostId()); HostVO lastHost = _hostDao.findById(vm.getLastHostId()); + _hostDao.loadDetails(lastHost); if (canUseLastHost(lastHost, avoids, plan, vm, offering, volumesRequireEncryption)) { _hostDao.loadHostTags(lastHost); - _hostDao.loadDetails(lastHost); if (lastHost.getStatus() != Status.Up) { logger.debug("Cannot deploy VM [{}] to the last host [{}] because this host is not in UP state or is not enabled. Host current status [{}] and resource status [{}].", vm, lastHost, lastHost.getState().name(), lastHost.getResourceState()); @@ -682,7 +682,7 @@ StateListener, Configurable { protected boolean anyVolumeRequiresEncryption(List volumes) { for (Volume volume : volumes) { - if (volume.getPassphraseId() != null) { + if (volume.getPassphraseId() != null || volume.getKmsKeyId() != null) { return true; } } diff --git a/server/src/main/java/com/cloud/network/as/AutoScaleManager.java b/server/src/main/java/com/cloud/network/as/AutoScaleManager.java index eec1eec2ff1..cb1c577b934 100644 --- a/server/src/main/java/com/cloud/network/as/AutoScaleManager.java +++ b/server/src/main/java/com/cloud/network/as/AutoScaleManager.java @@ -48,6 +48,8 @@ public interface AutoScaleManager extends AutoScaleService { void checkAutoScaleUser(Long autoscaleUserId, long accountId); + void validateMinMaxMembers(int minMembers, int maxMembers); + boolean deleteAutoScaleVmGroupsByAccount(Account account); void cleanUpAutoScaleResources(Account account); diff --git a/server/src/main/java/com/cloud/network/as/AutoScaleManagerImpl.java b/server/src/main/java/com/cloud/network/as/AutoScaleManagerImpl.java index 805ac4aed86..33ad8adb207 100644 --- a/server/src/main/java/com/cloud/network/as/AutoScaleManagerImpl.java +++ b/server/src/main/java/com/cloud/network/as/AutoScaleManagerImpl.java @@ -73,6 +73,7 @@ import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.Configurable; import org.apache.cloudstack.framework.config.dao.ConfigurationDao; import org.apache.cloudstack.managed.context.ManagedContextRunnable; +import org.apache.cloudstack.schedule.ResourceScheduleManager; import org.apache.cloudstack.userdata.UserDataManager; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.collections.MapUtils; @@ -285,6 +286,8 @@ public class AutoScaleManagerImpl extends ManagerBase implements AutoScaleManage private VirtualMachineManager virtualMachineManager; @Inject GuestOSDao guestOSDao; + @Inject + private ResourceScheduleManager resourceScheduleManager; private static final String PARAM_ROOT_DISK_SIZE = "rootdisksize"; private static final String PARAM_DISK_OFFERING_ID = "diskofferingid"; @@ -1098,7 +1101,11 @@ public class AutoScaleManagerImpl extends ManagerBase implements AutoScaleManage if (autoScaleVmGroupVO.getState().equals(AutoScaleVmGroup.State.NEW)) { /* This condition is for handling failures during creation command */ - return autoScaleVmGroupDao.remove(id); + boolean removed = autoScaleVmGroupDao.remove(id); + if (removed) { + resourceScheduleManager.removeSchedulesForResource(ApiCommandResourceType.AutoScaleVmGroup, id); + } + return removed; } if (!autoScaleVmGroupVO.getState().equals(AutoScaleVmGroup.State.DISABLED) && !Boolean.TRUE.equals(cleanup)) { @@ -1168,6 +1175,8 @@ public class AutoScaleManagerImpl extends ManagerBase implements AutoScaleManage return false; } + resourceScheduleManager.removeSchedulesForResource(ApiCommandResourceType.AutoScaleVmGroup, id); + logger.info("Successfully deleted autoscale vm group: {}", autoScaleVmGroupVO); return success; // Successfull } @@ -1231,6 +1240,22 @@ public class AutoScaleManagerImpl extends ManagerBase implements AutoScaleManage return searchWrapper.search(); } + @Override + public void validateMinMaxMembers(int minMembers, int maxMembers) { + if (minMembers <= 0) { + throw new InvalidParameterValueException(ApiConstants.MIN_MEMBERS + " is an invalid value: " + minMembers); + } + + if (maxMembers <= 0) { + throw new InvalidParameterValueException(ApiConstants.MAX_MEMBERS + " is an invalid value: " + maxMembers); + } + + if (minMembers > maxMembers) { + throw new InvalidParameterValueException(ApiConstants.MIN_MEMBERS + " (" + minMembers + + ") cannot be greater than " + ApiConstants.MAX_MEMBERS + " (" + maxMembers + ")"); + } + } + @DB protected AutoScaleVmGroupVO checkValidityAndPersist(final AutoScaleVmGroupVO vmGroup, final List passedScaleUpPolicyIds, final List passedScaleDownPolicyIds) { @@ -1249,18 +1274,7 @@ public class AutoScaleManagerImpl extends ManagerBase implements AutoScaleManage ApiDBUtils.getAutoScaleVmGroupPolicyIds(vmGroup.getId(), currentScaleUpPolicyIds, currentScaleDownPolicyIds); } - if (minMembers <= 0) { - throw new InvalidParameterValueException(ApiConstants.MIN_MEMBERS + " is an invalid value: " + minMembers); - } - - if (maxMembers <= 0) { - throw new InvalidParameterValueException(ApiConstants.MAX_MEMBERS + " is an invalid value: " + maxMembers); - } - - if (minMembers > maxMembers) { - throw new InvalidParameterValueException(ApiConstants.MIN_MEMBERS + " (" + minMembers + ")cannot be greater than " + ApiConstants.MAX_MEMBERS + " (" + - maxMembers + ")"); - } + validateMinMaxMembers(minMembers, maxMembers); if (interval <= 0) { throw new InvalidParameterValueException("interval is an invalid value: " + interval); @@ -1341,10 +1355,10 @@ public class AutoScaleManagerImpl extends ManagerBase implements AutoScaleManage AutoScaleVmGroupVO vmGroupVO = getEntityInDatabase(CallContext.current().getCallingAccount(), "AutoScale Vm Group", vmGroupId, autoScaleVmGroupDao); int currentInterval = vmGroupVO.getInterval(); - boolean physicalParametersUpdate = (minMembers != null || maxMembers != null || (interval != null && interval != currentInterval) || CollectionUtils.isNotEmpty(scaleUpPolicyIds) || CollectionUtils.isNotEmpty(scaleDownPolicyIds)); + boolean physicalParametersUpdate = ((interval != null && interval != currentInterval) || CollectionUtils.isNotEmpty(scaleUpPolicyIds) || CollectionUtils.isNotEmpty(scaleDownPolicyIds)); if (physicalParametersUpdate && !vmGroupVO.getState().equals(AutoScaleVmGroup.State.DISABLED)) { - throw new InvalidParameterValueException("An AutoScale Vm Group can be updated with minMembers/maxMembers/Interval only when it is in disabled state"); + throw new InvalidParameterValueException("An AutoScale Vm Group can be updated with Interval/Policies only when it is in disabled state"); } if (StringUtils.isNotBlank(name)) { @@ -1833,7 +1847,7 @@ public class AutoScaleManagerImpl extends ManagerBase implements AutoScaleManage vmDisplayName, diskOfferingId, dataDiskSize, null, null, hypervisorType, HTTPMethod.GET, userData, userDataId, userDataDetails, sshKeyPairs, null, null, true, null, affinityGroupIdList, customParameters, null, null, null, - null, true, overrideDiskOfferingId, null, null); + null, true, overrideDiskOfferingId, null, null, null); } else { if (networkModel.checkSecurityGroupSupportForNetwork(owner, zone, networkIds, Collections.emptyList())) { @@ -1841,13 +1855,13 @@ public class AutoScaleManagerImpl extends ManagerBase implements AutoScaleManage owner, vmHostName, vmDisplayName, diskOfferingId, dataDiskSize, null, null, hypervisorType, HTTPMethod.GET, userData, userDataId, userDataDetails, sshKeyPairs, null, null, true, null, affinityGroupIdList, customParameters, null, null, null, - null, true, overrideDiskOfferingId, null, null, null); + null, true, overrideDiskOfferingId, null, null, null, null); } else { vm = userVmService.createAdvancedVirtualMachine(zone, serviceOffering, template, networkIds, owner, vmHostName, vmDisplayName, diskOfferingId, dataDiskSize, null, null, hypervisorType, HTTPMethod.GET, userData, userDataId, userDataDetails, sshKeyPairs, null, addrs, true, null, affinityGroupIdList, customParameters, null, null, null, - null, true, null, overrideDiskOfferingId, null, null); + null, true, null, overrideDiskOfferingId, null, null, null); } } diff --git a/server/src/main/java/com/cloud/network/lb/LoadBalancingRulesManagerImpl.java b/server/src/main/java/com/cloud/network/lb/LoadBalancingRulesManagerImpl.java index 5f00261f329..37c219b750d 100644 --- a/server/src/main/java/com/cloud/network/lb/LoadBalancingRulesManagerImpl.java +++ b/server/src/main/java/com/cloud/network/lb/LoadBalancingRulesManagerImpl.java @@ -862,7 +862,7 @@ public class LoadBalancingRulesManagerImpl extends ManagerBase implements success = false; } } else { - _lb2stickinesspoliciesDao.expunge(stickinessPolicyId); + _lb2stickinesspoliciesDao.remove(stickinessPolicyId); } return success; } @@ -1664,6 +1664,12 @@ public class LoadBalancingRulesManagerImpl extends ManagerBase implements _lb2healthcheckDao.persist(lbHealthCheck); } + List stickinessPolicies = _lb2stickinesspoliciesDao.listByLoadBalancerId(loadBalancerId); + for (LBStickinessPolicyVO stickinessPolicy : stickinessPolicies) { + stickinessPolicy.setRevoke(true); + _lb2stickinesspoliciesDao.persist(stickinessPolicy); + } + if (generateUsageEvent) { // Generate usage event right after all rules were marked for revoke Network network = _networkModel.getNetwork(lb.getNetworkId()); @@ -2678,7 +2684,12 @@ public class LoadBalancingRulesManagerImpl extends ManagerBase implements @Override public void removeLBRule(LoadBalancer rule) { - // remove the rule + FirewallRule relatedFirewallRule = _firewallDao.findByRelatedId(rule.getId()); + if (relatedFirewallRule != null) { + logger.debug("Load balancer [{}] has a related firewall rule [{}]. Removing it.", rule.getUuid(), relatedFirewallRule.getUuid()); + _firewallDao.remove(relatedFirewallRule.getId()); + } + _lbDao.remove(rule.getId()); } diff --git a/server/src/main/java/com/cloud/server/StatsCollector.java b/server/src/main/java/com/cloud/server/StatsCollector.java index 049ed454361..293b28c6cb4 100644 --- a/server/src/main/java/com/cloud/server/StatsCollector.java +++ b/server/src/main/java/com/cloud/server/StatsCollector.java @@ -166,6 +166,7 @@ import com.codahale.metrics.JvmAttributeGaugeSet; import com.codahale.metrics.Metric; import com.codahale.metrics.MetricRegistry; import com.codahale.metrics.MetricSet; +import com.codahale.metrics.JmxReporter; import com.codahale.metrics.jvm.BufferPoolMetricSet; import com.codahale.metrics.jvm.GarbageCollectorMetricSet; import com.codahale.metrics.jvm.MemoryUsageGaugeSet; @@ -387,7 +388,11 @@ public class StatsCollector extends ManagerBase implements ComponentMethodInterc private boolean _dailyOrHourly = false; protected long managementServerNodeId = ManagementServerNode.getManagementServerId(); protected long msId = managementServerNodeId; - final static MetricRegistry METRIC_REGISTRY = new MetricRegistry(); + public static final MetricRegistry METRIC_REGISTRY = new MetricRegistry(); + + public static void registerMetric(String name, Metric metric) { + METRIC_REGISTRY.register(name, metric); + } public static StatsCollector getInstance() { return s_instance; @@ -410,6 +415,11 @@ public class StatsCollector extends ManagerBase implements ComponentMethodInterc registerAll("memory", new MemoryUsageGaugeSet(), METRIC_REGISTRY); registerAll("threads", new ThreadStatesGaugeSet(), METRIC_REGISTRY); registerAll("jvm", new JvmAttributeGaugeSet(), METRIC_REGISTRY); + try { + JmxReporter.forRegistry(METRIC_REGISTRY).inDomain("vm-extra").build().start(); + } catch (Exception e) { + logger.warn("Failed to start JMX reporter for METRIC_REGISTRY, metrics will not be visible via JMX", e); + } return true; } @Override diff --git a/server/src/main/java/com/cloud/storage/ImageStoreUploadMonitorImpl.java b/server/src/main/java/com/cloud/storage/ImageStoreUploadMonitorImpl.java index c670b631645..64e066f0c91 100755 --- a/server/src/main/java/com/cloud/storage/ImageStoreUploadMonitorImpl.java +++ b/server/src/main/java/com/cloud/storage/ImageStoreUploadMonitorImpl.java @@ -574,6 +574,12 @@ public class ImageStoreUploadMonitorImpl extends ManagerBase implements ImageSto if (logger.isDebugEnabled()) { logger.debug("Template {} uploaded successfully", tmpTemplate); } + try { + templateService.replicateTemplateUpToCap(tmpTemplate.getId(), vo.getDataCenterId()); + } catch (Exception e) { + logger.warn("Failed to schedule additional copies for uploaded template [{}] in zone [{}]: {}", + tmpTemplate.getUuid(), vo.getDataCenterId(), e.getMessage()); + } break; case IN_PROGRESS: if (!checkAndUpdateTemplateResourceLimit(tmpTemplate, tmpTemplateDataStore, answer)) { diff --git a/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java b/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java index 1bb8c8ee0db..aec0f0cea20 100644 --- a/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java +++ b/server/src/main/java/com/cloud/storage/VolumeApiServiceImpl.java @@ -95,6 +95,7 @@ import org.apache.cloudstack.resourcedetail.DiskOfferingDetailVO; import org.apache.cloudstack.resourcedetail.SnapshotPolicyDetailVO; import org.apache.cloudstack.resourcedetail.dao.DiskOfferingDetailsDao; import org.apache.cloudstack.resourcedetail.dao.SnapshotPolicyDetailsDao; +import org.apache.cloudstack.kms.KMSManager; import org.apache.cloudstack.resourcelimit.Reserver; import org.apache.cloudstack.snapshot.SnapshotHelper; import org.apache.cloudstack.storage.command.AttachAnswer; @@ -376,6 +377,8 @@ public class VolumeApiServiceImpl extends ManagerBase implements VolumeApiServic private ReservationDao reservationDao; @Inject private VMSnapshotDetailsDao vmSnapshotDetailsDao; + @Inject + private KMSManager kmsManager; public static final String KVM_FILE_BASED_STORAGE_SNAPSHOT = "kvmFileBasedStorageSnapshot"; @@ -771,15 +774,15 @@ public class VolumeApiServiceImpl extends ManagerBase implements VolumeApiServic public VolumeVO allocVolume(CreateVolumeCmd cmd) throws ResourceAllocationException { return allocVolume(cmd.getEntityOwnerId(), cmd.getZoneId(), cmd.getDiskOfferingId(), cmd.getVirtualMachineId(), cmd.getSnapshotId(), getVolumeNameFromCommand(cmd.getVolumeName()), cmd.getSize(), - cmd.getDisplayVolume(), cmd.getMinIops(), cmd.getMaxIops(), cmd.getCustomId()); + cmd.getDisplayVolume(), cmd.getMinIops(), cmd.getMaxIops(), cmd.getCustomId(), cmd.getKmsKeyId()); } @Override @DB @ActionEvent(eventType = EventTypes.EVENT_VOLUME_CREATE, eventDescription = "creating volume", create = true) public VolumeVO allocVolume(long ownerId, Long zoneId, Long diskOfferingId, Long vmId, Long snapshotId, - String name, Long cmdSize, Boolean displayVolume, Long cmdMinIops, Long cmdMaxIops, String customId) - throws ResourceAllocationException { + String name, Long cmdSize, Boolean displayVolume, Long cmdMinIops, Long cmdMaxIops, + String customId, Long kmsKeyId) throws ResourceAllocationException { Account caller = CallContext.current().getCallingAccount(); Account owner = _accountMgr.getActiveAccountById(ownerId); @@ -920,7 +923,7 @@ public class VolumeApiServiceImpl extends ManagerBase implements VolumeApiServic parentVolume = _volsDao.findByIdIncludingRemoved(snapshotCheck.getVolumeId()); // Don't support creating templates from encrypted volumes (yet) - if (parentVolume.getPassphraseId() != null) { + if (parentVolume.getPassphraseId() != null || parentVolume.getKmsKeyId() != null) { throw new UnsupportedOperationException("Cannot create new volumes from encrypted volume snapshots"); } @@ -999,8 +1002,12 @@ public class VolumeApiServiceImpl extends ManagerBase implements VolumeApiServic String userSpecifiedName = getVolumeNameFromCommand(name); + if (kmsKeyId != null) { + kmsManager.checkKmsKeyForVolumeEncryption(owner, kmsKeyId, zoneId); + } + return commitVolume(snapshotId, caller, owner, displayVolume, zoneId, diskOfferingId, provisioningType, size, minIops, maxIops, parentVolume, userSpecifiedName, - _uuidMgr.generateUuid(Volume.class, customId), details); + _uuidMgr.generateUuid(Volume.class, customId), details, kmsKeyId); } finally { ReservationHelper.closeAll(reservations); } @@ -1017,7 +1024,7 @@ public class VolumeApiServiceImpl extends ManagerBase implements VolumeApiServic } private VolumeVO commitVolume(final Long snapshotId, final Account caller, final Account owner, final Boolean displayVolume, final Long zoneId, final Long diskOfferingId, - final Storage.ProvisioningType provisioningType, final Long size, final Long minIops, final Long maxIops, final VolumeVO parentVolume, final String userSpecifiedName, final String uuid, final Map details) { + final Storage.ProvisioningType provisioningType, final Long size, final Long minIops, final Long maxIops, final VolumeVO parentVolume, final String userSpecifiedName, final String uuid, final Map details, final Long kmsKeyId) { return Transaction.execute(new TransactionCallback() { @Override public VolumeVO doInTransaction(TransactionStatus status) { @@ -1063,6 +1070,12 @@ public class VolumeApiServiceImpl extends ManagerBase implements VolumeApiServic } } + // Store KMS key ID if provided (for volume encryption) + if (volume != null && kmsKeyId != null) { + volume.setKmsKeyId(kmsKeyId); + _volsDao.update(volume.getId(), volume); + } + CallContext.current().setEventDetails("Volume ID: " + volume.getUuid()); CallContext.current().putContextParameter(Volume.class, volume.getId()); // Increment resource count during allocation; if actual creation fails, @@ -1359,7 +1372,7 @@ public class VolumeApiServiceImpl extends ManagerBase implements VolumeApiServic long currentSize = volume.getSize(); VolumeInfo volInfo = volFactory.getVolume(volume.getId()); - boolean isEncryptionRequired = volume.getPassphraseId() != null; + boolean isEncryptionRequired = volume.getPassphraseId() != null || volume.getKmsKeyId() != null; if (newDiskOffering != null) { isEncryptionRequired = newDiskOffering.getEncrypt(); } @@ -3023,7 +3036,7 @@ public class VolumeApiServiceImpl extends ManagerBase implements VolumeApiServic } DiskOfferingVO diskOffering = _diskOfferingDao.findById(volumeToAttach.getDiskOfferingId()); - if (diskOffering.getEncrypt() && rootDiskHyperType != HypervisorType.KVM) { + if (diskOffering.getEncrypt() && !(rootDiskHyperType == HypervisorType.KVM)) { throw new InvalidParameterValueException("Volume's disk offering has encryption enabled, but volume encryption is not supported for hypervisor type " + rootDiskHyperType); } @@ -3776,7 +3789,7 @@ public class VolumeApiServiceImpl extends ManagerBase implements VolumeApiServic } } - if (vol.getPassphraseId() != null && !srcAndDestOnStorPool && !srcStoragePoolVO.getPoolType().equals(Storage.StoragePoolType.PowerFlex)) { + if ((vol.getPassphraseId() != null || vol.getKmsKeyId() != null) && !srcAndDestOnStorPool && !srcStoragePoolVO.getPoolType().equals(Storage.StoragePoolType.PowerFlex)) { throw new InvalidParameterValueException("Migration of encrypted volumes is unsupported"); } @@ -4559,7 +4572,7 @@ public class VolumeApiServiceImpl extends ManagerBase implements VolumeApiServic throw ex; } - if (volume.getPassphraseId() != null) { + if (volume.getPassphraseId() != null || volume.getKmsKeyId() != null) { throw new InvalidParameterValueException("Extraction of encrypted volumes is unsupported"); } @@ -5044,7 +5057,7 @@ public class VolumeApiServiceImpl extends ManagerBase implements VolumeApiServic if (host != null) { _hostDao.loadDetails(host); boolean hostSupportsEncryption = Boolean.parseBoolean(host.getDetail(Host.HOST_VOLUME_ENCRYPTION)); - if (volumeToAttach.getPassphraseId() != null && !hostSupportsEncryption) { + if ((volumeToAttach.getPassphraseId() != null || volumeToAttach.getKmsKeyId() != null) && !hostSupportsEncryption) { throw new CloudRuntimeException(errorMsg + " because target host " + host + " doesn't support volume encryption"); } } diff --git a/server/src/main/java/com/cloud/template/HypervisorTemplateAdapter.java b/server/src/main/java/com/cloud/template/HypervisorTemplateAdapter.java index fdde8f47a67..9417d63c594 100644 --- a/server/src/main/java/com/cloud/template/HypervisorTemplateAdapter.java +++ b/server/src/main/java/com/cloud/template/HypervisorTemplateAdapter.java @@ -19,10 +19,10 @@ package com.cloud.template; import java.util.ArrayList; import java.util.Collections; import java.util.Date; -import java.util.HashSet; +import java.util.HashMap; import java.util.LinkedList; import java.util.List; -import java.util.Set; +import java.util.Map; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; @@ -264,9 +264,10 @@ public class HypervisorTemplateAdapter extends TemplateAdapterBase { if (imageStore == null) { List imageStores = getImageStoresThrowsExceptionIfNotFound(zoneId, profile); - standardImageStoreAllocation(imageStores, template); + standardImageStoreAllocation(imageStores, template, zoneId); } else { - validateSecondaryStorageAndCreateTemplate(List.of(imageStore), template, null); + int copyLimit = getSecStorageCopyLimit(template, zoneId); + validateSecondaryStorageAndCreateTemplate(List.of(imageStore), template, new HashMap<>(), copyLimit); } } } @@ -279,17 +280,17 @@ public class HypervisorTemplateAdapter extends TemplateAdapterBase { return imageStores; } - protected void standardImageStoreAllocation(List imageStores, VMTemplateVO template) { - Set zoneSet = new HashSet(); + protected void standardImageStoreAllocation(List imageStores, VMTemplateVO template, long zoneId) { + int copyLimit = getSecStorageCopyLimit(template, zoneId); Collections.shuffle(imageStores); - validateSecondaryStorageAndCreateTemplate(imageStores, template, zoneSet); + validateSecondaryStorageAndCreateTemplate(imageStores, template, new HashMap<>(), copyLimit); } - protected void validateSecondaryStorageAndCreateTemplate(List imageStores, VMTemplateVO template, Set zoneSet) { + protected void validateSecondaryStorageAndCreateTemplate(List imageStores, VMTemplateVO template, Map zoneCopyCount, int copyLimit) { for (DataStore imageStore : imageStores) { Long zoneId = imageStore.getScope().getScopeId(); - if (!isZoneAndImageStoreAvailable(imageStore, zoneId, zoneSet, isPrivateTemplate(template))) { + if (!isZoneAndImageStoreAvailable(imageStore, zoneId, zoneCopyCount, copyLimit)) { continue; } diff --git a/server/src/main/java/com/cloud/template/TemplateAdapterBase.java b/server/src/main/java/com/cloud/template/TemplateAdapterBase.java index 8f508135605..1aa3bedbdf0 100644 --- a/server/src/main/java/com/cloud/template/TemplateAdapterBase.java +++ b/server/src/main/java/com/cloud/template/TemplateAdapterBase.java @@ -20,11 +20,9 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Set; import javax.inject.Inject; @@ -169,7 +167,11 @@ public abstract class TemplateAdapterBase extends AdapterBase implements Templat return heuristicRuleHelper.getImageStoreIfThereIsHeuristicRule(zoneId, heuristicType, template); } - protected boolean isZoneAndImageStoreAvailable(DataStore imageStore, Long zoneId, Set zoneSet, boolean isTemplatePrivate) { + protected int getSecStorageCopyLimit(VMTemplateVO template, long zoneId) { + return templateMgr.getSecStorageCopyLimit(template, zoneId); + } + + protected boolean isZoneAndImageStoreAvailable(DataStore imageStore, Long zoneId, Map zoneCopyCount, int copyLimit) { if (zoneId == null) { logger.warn(String.format("Zone ID is null, cannot allocate ISO/template in image store [%s].", imageStore)); return false; @@ -191,33 +193,30 @@ public abstract class TemplateAdapterBase extends AdapterBase implements Templat return false; } - if (zoneSet == null) { - logger.info(String.format("Zone set is null; therefore, the ISO/template should be allocated in every secondary storage of zone [%s].", zone)); - return true; - } - - if (isTemplatePrivate && zoneSet.contains(zoneId)) { - logger.info(String.format("The template is private and it is already allocated in a secondary storage in zone [%s]; therefore, image store [%s] will be skipped.", - zone, imageStore)); + int currentCount = zoneCopyCount.getOrDefault(zoneId, 0); + if (copyLimit > 0 && currentCount >= copyLimit) { + logger.info("Copy limit of {} reached for zone [{}]; skipping image store [{}].", copyLimit, zone, imageStore); return false; } - logger.info(String.format("Private template will be allocated in image store [%s] in zone [%s].", imageStore, zone)); - zoneSet.add(zoneId); + zoneCopyCount.put(zoneId, currentCount + 1); return true; } /** - * If the template/ISO is marked as private, then it is allocated to a random secondary storage; otherwise, allocates to every storage pool in every zone given by the - * {@link TemplateProfile#getZoneIdList()}. + * Allocates the template/ISO to a single image store - the one the file will be uploaded to. The upload can only + * target one secondary store, so additional copies (up to the configured secstorage.public/private.template.copy.max) + * are propagated later by template sync instead of being pre-allocated here as empty placeholder entries that never + * receive the data. */ protected void postUploadAllocation(List imageStores, VMTemplateVO template, List payloads) { - Set zoneSet = new HashSet<>(); + Map zoneCopyCount = new HashMap<>(); Collections.shuffle(imageStores); for (DataStore imageStore : imageStores) { Long zoneId_is = imageStore.getScope().getScopeId(); + int copyLimit = zoneId_is == null ? 0 : getSecStorageCopyLimit(template, zoneId_is); - if (!isZoneAndImageStoreAvailable(imageStore, zoneId_is, zoneSet, isPrivateTemplate(template))) { + if (!isZoneAndImageStoreAvailable(imageStore, zoneId_is, zoneCopyCount, copyLimit)) { continue; } @@ -251,15 +250,11 @@ public abstract class TemplateAdapterBase extends AdapterBase implements Templat payload.setRequiresHvm(template.requiresHvm()); payload.setDescription(template.getDisplayText()); payloads.add(payload); - } - } - protected boolean isPrivateTemplate(VMTemplateVO template){ - // if public OR featured OR system template - if (template.isPublicTemplate() || template.isFeatured() || template.getTemplateType() == TemplateType.SYSTEM) { - return false; - } else { - return true; + // The file can only be uploaded to a single secondary store. Allocate just this one; additional copies + // up to the configured secondary storage copy limit are propagated afterwards by template sync, so we do + // not create empty placeholder template_store_ref rows on the other stores. + break; } } diff --git a/server/src/main/java/com/cloud/template/TemplateManagerImpl.java b/server/src/main/java/com/cloud/template/TemplateManagerImpl.java index 3aaebc69130..41668470719 100755 --- a/server/src/main/java/com/cloud/template/TemplateManagerImpl.java +++ b/server/src/main/java/com/cloud/template/TemplateManagerImpl.java @@ -21,6 +21,7 @@ import java.net.URISyntaxException; import java.net.URL; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.List; @@ -145,8 +146,11 @@ import com.cloud.exception.InvalidParameterValueException; import com.cloud.exception.PermissionDeniedException; import com.cloud.exception.ResourceAllocationException; import com.cloud.exception.StorageUnavailableException; +import com.cloud.host.DetailVO; +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.Hypervisor; import com.cloud.hypervisor.Hypervisor.HypervisorType; import com.cloud.hypervisor.HypervisorGuru; @@ -222,7 +226,9 @@ import com.cloud.vm.VirtualMachine.State; import com.cloud.vm.VirtualMachineProfile; import com.cloud.vm.VirtualMachineProfileImpl; import com.cloud.vm.VmDetailConstants; +import com.cloud.vm.VmIsoMapVO; import com.cloud.vm.dao.UserVmDao; +import com.cloud.vm.dao.VmIsoMapDao; import com.cloud.vm.dao.VMInstanceDao; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -252,10 +258,14 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, @Inject private HostDao _hostDao; @Inject + private HostDetailsDao _hostDetailsDao; + @Inject private DataCenterDao _dcDao; @Inject private UserVmDao _userVmDao; @Inject + private VmIsoMapDao _vmIsoMapDao; + @Inject private VolumeDao _volumeDao; @Inject private SnapshotDao _snapshotDao; @@ -679,47 +689,75 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, @Override public void prepareIsoForVmProfile(VirtualMachineProfile profile, DeployDestination dest) { UserVmVO vm = _userVmDao.findById(profile.getId()); - if (vm.getIsoId() != null) { - Map storageForDisks = dest.getStorageForDisks(); - Long poolId = null; - TemplateInfo template; - if (MapUtils.isNotEmpty(storageForDisks)) { - for (StoragePool storagePool : storageForDisks.values()) { - if (poolId != null && storagePool.getId() != poolId) { - throw new CloudRuntimeException("Cannot determine where to download ISO"); - } - poolId = storagePool.getId(); - } - } - template = prepareIso(vm.getIsoId(), vm.getDataCenterId(), dest.getHost().getId(), poolId); + Map slotToIsoId = loadAttachedIsoSlots(vm); + Long poolId = slotToIsoId.isEmpty() ? null : singleStoragePoolId(dest); - if (template == null){ - logger.error("Failed to prepare ISO on secondary or cache storage"); - throw new CloudRuntimeException("Failed to prepare ISO on secondary or cache storage"); - } - if (template.isBootable()) { - profile.setBootLoaderType(BootloaderType.CD); - } - - GuestOSVO guestOS = _guestOSDao.findById(template.getGuestOSId()); - String displayName = null; - if (guestOS != null) { - displayName = guestOS.getDisplayName(); - } - - TemplateObjectTO iso = (TemplateObjectTO)template.getTO(); - iso.setDirectDownload(template.isDirectDownload()); - iso.setGuestOsType(displayName); - DiskTO disk = new DiskTO(iso, 3L, null, Volume.Type.ISO); - profile.addDisk(disk); - } else { - TemplateObjectTO iso = new TemplateObjectTO(); - iso.setFormat(ImageFormat.ISO); - DiskTO disk = new DiskTO(iso, 3L, null, Volume.Type.ISO); - profile.addDisk(disk); + // Pre-allocate every cdrom slot at boot. QEMU/IDE refuses to hot-add new cdrom drives, so + // runtime attachIso can only media-swap into a slot the domain already owns. + int totalSlots = Math.max(effectiveMaxCdroms(vm, dest.getHost().getId()), slotsNeededFor(slotToIsoId)); + for (int i = 0; i < totalSlots; i++) { + int diskSeq = CDROM_PRIMARY_DEVICE_SEQ + i; + Long isoId = slotToIsoId.get(diskSeq); + profile.addDisk(isoId != null + ? buildIsoDisk(profile, vm, dest, poolId, diskSeq, isoId) + : buildEmptyCdromDisk(diskSeq)); } } + private Long singleStoragePoolId(DeployDestination dest) { + Long poolId = null; + Map storageForDisks = dest.getStorageForDisks(); + if (MapUtils.isNotEmpty(storageForDisks)) { + for (StoragePool pool : storageForDisks.values()) { + if (poolId != null && pool.getId() != poolId) { + throw new CloudRuntimeException("Cannot determine where to download ISO"); + } + poolId = pool.getId(); + } + } + return poolId; + } + + private Map loadAttachedIsoSlots(UserVmVO vm) { + Map slots = new HashMap<>(); + if (vm.getIsoId() != null) { + slots.put(CDROM_PRIMARY_DEVICE_SEQ, vm.getIsoId()); + } + for (VmIsoMapVO row : _vmIsoMapDao.listByVmId(vm.getId())) { + slots.put(row.getDeviceSeq(), row.getIsoId()); + } + return slots; + } + + private int slotsNeededFor(Map slotToIsoId) { + if (slotToIsoId.isEmpty()) { + return 0; + } + return Collections.max(slotToIsoId.keySet()) - CDROM_PRIMARY_DEVICE_SEQ + 1; + } + + private DiskTO buildIsoDisk(VirtualMachineProfile profile, UserVmVO vm, DeployDestination dest, Long poolId, int diskSeq, long isoId) { + TemplateInfo template = prepareIso(isoId, vm.getDataCenterId(), dest.getHost().getId(), poolId); + if (template == null) { + logger.error("Failed to prepare ISO on secondary or cache storage"); + throw new CloudRuntimeException("Failed to prepare ISO on secondary or cache storage"); + } + if (diskSeq == CDROM_PRIMARY_DEVICE_SEQ && template.isBootable()) { + profile.setBootLoaderType(BootloaderType.CD); + } + GuestOSVO guestOS = _guestOSDao.findById(template.getGuestOSId()); + TemplateObjectTO iso = (TemplateObjectTO) template.getTO(); + iso.setDirectDownload(template.isDirectDownload()); + iso.setGuestOsType(guestOS != null ? guestOS.getDisplayName() : null); + return new DiskTO(iso, (long) diskSeq, null, Volume.Type.ISO); + } + + private DiskTO buildEmptyCdromDisk(int diskSeq) { + TemplateObjectTO empty = new TemplateObjectTO(); + empty.setFormat(ImageFormat.ISO); + return new DiskTO(empty, (long) diskSeq, null, Volume.Type.ISO); + } + private void prepareTemplateInOneStoragePool(final VMTemplateVO template, final StoragePoolVO pool) { logger.info("Schedule to preload Template {} into primary storage {}", template, pool); if (pool.getPoolType() == Storage.StoragePoolType.DatastoreCluster) { @@ -905,6 +943,12 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, _tmplStoreDao.removeByTemplateStore(tmpltId, dstSecStore.getId()); } + if (!_tmpltSvr.canCopyTemplateToImageStore(tmpltId, dstZoneId)) { + logger.info("Not copying template {} to image store {}: zone {} has reached the configured secondary storage copy limit.", + template, dstSecStore, dstZone); + continue; + } + AsyncCallFuture future = _tmpltSvr.copyTemplate(srcTemplate, dstSecStore); try { TemplateApiResult result = future.get(); @@ -1206,17 +1250,20 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, @Override public boolean templateIsDeleteable(long templateId) { + // ISO can only be referenced by user_vm.iso_id (primary cdrom slot) or vm_iso_map (extra slots). + // Templates always live on primary storage and aren't tracked here. List userVmUsingIso = _userVmJoinDao.listActiveByIsoId(templateId); - // check if there is any Vm using this ISO. We only need to check the - // case where templateId is an ISO since - // VM can be launched from ISO in secondary storage, while template will - // always be copied to - // primary storage before deploying VM. if (!userVmUsingIso.isEmpty()) { - logger.debug("ISO " + templateId + " is not deleteable because it is attached to " + userVmUsingIso.size() + " Instances"); + logger.debug("Unable to delete ISO {} because it is attached to {} Instances", templateId, userVmUsingIso.size()); return false; } - + for (VmIsoMapVO row : _vmIsoMapDao.listByIsoId(templateId)) { + UserVmVO vm = _userVmDao.findById(row.getVmId()); + if (vm != null && vm.getState() != State.Error && vm.getState() != State.Expunging) { + logger.debug("Unable to delete ISO {} because it is attached to Instance {} at slot {}", templateId, vm.getUuid(), row.getDeviceSeq()); + return false; + } + } return true; } @@ -1237,7 +1284,14 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, _accountMgr.checkAccess(caller, null, true, virtualMachine); - Long isoId = !isVirtualRouter ? ((UserVm) virtualMachine).getIsoId() : isoParamId; + Long isoId; + if (isVirtualRouter) { + isoId = isoParamId; + } else { + Long primaryIsoId = ((UserVm) virtualMachine).getIsoId(); + List extras = _vmIsoMapDao.listByVmId(vmId); + isoId = resolveIsoIdForDetach(primaryIsoId, extras, isoParamId); + } if (isoId == null) { throw new InvalidParameterValueException("The specified instance has no ISO attached to it."); } @@ -1321,6 +1375,9 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, if (VMWARE_TOOLS_ISO.equals(iso.getUniqueName()) && vm.getHypervisorType() != Hypervisor.HypervisorType.VMware) { throw new InvalidParameterValueException("Cannot attach VMware tools drivers to incompatible hypervisor " + vm.getHypervisorType()); } + if (!isVirtualRouter) { + enforceCdromAttachLimits(vmId, (UserVm) vm, isoId); + } boolean result = attachISOToVM(vmId, userId, isoId, true, forced, isVirtualRouter); if (result) { return result; @@ -1360,7 +1417,7 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, } } - private boolean attachISOToVM(long vmId, long isoId, boolean attach, boolean forced, boolean isVirtualRouter) { + private boolean attachISOToVM(long vmId, long isoId, int deviceSeq, boolean attach, boolean forced, boolean isVirtualRouter) { VirtualMachine vm = !isVirtualRouter ? _userVmDao.findById(vmId) : _vmInstanceDao.findById(vmId); if (vm == null || (isVirtualRouter && vm.getType() != VirtualMachine.Type.DomainRouter)) { @@ -1384,7 +1441,7 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, } DataTO isoTO = tmplt.getTO(); - DiskTO disk = new DiskTO(isoTO, null, null, Volume.Type.ISO); + DiskTO disk = new DiskTO(isoTO, (long) deviceSeq, null, Volume.Type.ISO); HypervisorGuru hvGuru = _hvGuruMgr.getGuru(vm.getHypervisorType()); VirtualMachineProfile profile = new VirtualMachineProfileImpl(vm); @@ -1402,22 +1459,150 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, return (a != null && a.getResult()); } - private boolean attachISOToVM(long vmId, long userId, long isoId, boolean attach, boolean forced, boolean isVirtualRouter) { + boolean attachISOToVM(long vmId, long userId, long isoId, boolean attach, boolean forced, boolean isVirtualRouter) { UserVmVO vm = _userVmDao.findById(vmId); VMTemplateVO iso = _tmpltDao.findById(isoId); - boolean success = attachISOToVM(vmId, isoId, attach, forced, isVirtualRouter); - if (success && attach && !isVirtualRouter) { - vm.setIsoId(iso.getId()); - _userVmDao.update(vmId, vm); + int targetSlot = attach ? chooseAttachSlot(vmId, vm) : findAttachedSlot(vmId, vm, isoId); + boolean success = attachISOToVM(vmId, isoId, targetSlot, attach, forced, isVirtualRouter); + if (!success || isVirtualRouter) { + return success; } - if (success && !attach && !isVirtualRouter) { - vm.setIsoId(null); - _userVmDao.update(vmId, vm); + if (attach) { + persistIsoAttachment(vmId, vm, iso, targetSlot); + } else { + persistIsoDetachment(vmId, vm, isoId, targetSlot); } return success; } + private int chooseAttachSlot(long vmId, UserVmVO vm) { + if (vm.getIsoId() == null) { + return CDROM_PRIMARY_DEVICE_SEQ; + } + VmIsoMapVO highest = highestCdromMapEntry(vmId); + return highest == null ? CDROM_PRIMARY_DEVICE_SEQ + 1 : highest.getDeviceSeq() + 1; + } + + private int findAttachedSlot(long vmId, UserVmVO vm, long isoId) { + if (vm.getIsoId() != null && vm.getIsoId() == isoId) { + return CDROM_PRIMARY_DEVICE_SEQ; + } + VmIsoMapVO entry = _vmIsoMapDao.findByVmIdIsoId(vmId, isoId); + return entry != null ? entry.getDeviceSeq() : CDROM_PRIMARY_DEVICE_SEQ; + } + + private void persistIsoAttachment(long vmId, UserVmVO vm, VMTemplateVO iso, int slot) { + if (slot == CDROM_PRIMARY_DEVICE_SEQ) { + vm.setIsoId(iso.getId()); + _userVmDao.update(vmId, vm); + } else { + _vmIsoMapDao.persist(new VmIsoMapVO(vmId, iso.getId(), slot)); + } + } + + private void persistIsoDetachment(long vmId, UserVmVO vm, long isoId, int slot) { + if (slot == CDROM_PRIMARY_DEVICE_SEQ) { + vm.setIsoId(null); + _userVmDao.update(vmId, vm); + return; + } + VmIsoMapVO entry = _vmIsoMapDao.findByVmIdIsoId(vmId, isoId); + if (entry != null) { + _vmIsoMapDao.remove(entry.getId()); + } + } + + VmIsoMapVO highestCdromMapEntry(long vmId) { + VmIsoMapVO highest = null; + for (VmIsoMapVO row : _vmIsoMapDao.listByVmId(vmId)) { + if (highest == null || row.getDeviceSeq() > highest.getDeviceSeq()) { + highest = row; + } + } + return highest; + } + + Long resolveIsoIdForDetach(Long primaryIsoId, List extras, Long isoParamId) { + if (isoParamId != null) { + boolean attached = (primaryIsoId != null && primaryIsoId.equals(isoParamId)) + || extras.stream().anyMatch(r -> r.getIsoId() == isoParamId); + if (!attached) { + throw new InvalidParameterValueException("The specified ISO is not attached to this Instance."); + } + return isoParamId; + } + int totalAttached = (primaryIsoId != null ? 1 : 0) + extras.size(); + if (totalAttached == 0) { + throw new InvalidParameterValueException("The specified instance has no ISO attached to it."); + } + if (totalAttached > 1) { + throw new InvalidParameterValueException("Instance has more than one ISO attached; specify the 'id' parameter to choose which to detach."); + } + return primaryIsoId != null ? primaryIsoId : extras.get(0).getIsoId(); + } + + boolean isIsoAlreadyAttached(long vmId, Long primaryIsoId, long isoId) { + if (primaryIsoId != null && primaryIsoId.equals(isoId)) { + return true; + } + return _vmIsoMapDao.findByVmIdIsoId(vmId, isoId) != null; + } + + void enforceCdromAttachLimits(long vmId, UserVm vm, long isoId) { + Long primaryIsoId = vm.getIsoId(); + if (isIsoAlreadyAttached(vmId, primaryIsoId, isoId)) { + throw new InvalidParameterValueException("The specified ISO is already attached to this Instance."); + } + int effectiveMax = effectiveMaxCdroms(vm, hostIdForVm(vm)); + int attached = (primaryIsoId != null ? 1 : 0) + _vmIsoMapDao.listByVmId(vmId).size(); + if (attached >= effectiveMax) { + throw new InvalidParameterValueException(String.format( + "Instance has reached the maximum of %d attached CD-ROM(s); detach one before attaching another.", effectiveMax)); + } + } + + int effectiveMaxCdroms(VirtualMachine vm, Long hostId) { + HostVO host = hostId != null ? _hostDao.findById(hostId) : null; + Long clusterId = host != null ? host.getClusterId() : null; + int configuredCap = VmIsoMaxCount.valueIn(clusterId); + int hypervisorCap = advertisedCdromCap(hostId); + if (configuredCap > hypervisorCap) { + logger.warn("{} is set to {} but the placement host supports a maximum of {} CD-ROM(s) per Instance. Clamping to {}.", + VmIsoMaxCount.key(), configuredCap, hypervisorCap, hypervisorCap); + return hypervisorCap; + } + return configuredCap; + } + + int advertisedCdromCap(Long hostId) { + if (hostId == null) { + return DEFAULT_CDROM_MAX_PER_VM; + } + DetailVO detail = _hostDetailsDao.findDetail(hostId, Host.HOST_CDROM_MAX_COUNT); + if (detail == null || detail.getValue() == null) { + return DEFAULT_CDROM_MAX_PER_VM; + } + try { + return Integer.parseInt(detail.getValue()); + } catch (NumberFormatException e) { + logger.warn("Invalid {} value '{}' for host {}; using default {}.", + Host.HOST_CDROM_MAX_COUNT, detail.getValue(), hostId, DEFAULT_CDROM_MAX_PER_VM); + return DEFAULT_CDROM_MAX_PER_VM; + } + } + + Long hostIdForVm(VirtualMachine vm) { + Long hostId = vm.getHostId() != null ? vm.getHostId() : vm.getLastHostId(); + if (hostId == null && vm.getHypervisorType() != null) { + List candidates = _hostDao.listByDataCenterIdAndHypervisorType(vm.getDataCenterId(), vm.getHypervisorType()); + if (!candidates.isEmpty()) { + hostId = candidates.get(0).getId(); + } + } + return hostId; + } + @Override @ActionEvent(eventType = EventTypes.EVENT_TEMPLATE_DELETE, eventDescription = "Deleting Template", async = true) public boolean deleteTemplate(DeleteTemplateCmd cmd) { @@ -1735,6 +1920,13 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, _launchPermissionDao.removeAllPermissions(id); _messageBus.publish(_name, TemplateManager.MESSAGE_RESET_TEMPLATE_PERMISSION_EVENT, PublishScope.LOCAL, template.getId()); } + + if (isPublic != null || isFeatured != null || "reset".equalsIgnoreCase(operation)) { + for (VMTemplateZoneVO templateZone : _tmpltZoneDao.listByTemplateId(template.getId())) { + _tmpltSvr.enforceSecStorageCopyLimit(template.getId(), templateZone.getZoneId()); + _tmpltSvr.replicateTemplateUpToCap(template.getId(), templateZone.getZoneId()); + } + } return true; } @@ -1752,10 +1944,10 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, Account caller = CallContext.current().getCallingAccount(); boolean kvmSnapshotOnlyInPrimaryStorage = false; SnapshotInfo snapInfo = null; + long zoneId = 0; try { TemplateInfo tmplInfo = _tmplFactory.getTemplate(templateId, DataStoreRole.Image); - long zoneId = 0; if (snapshotId != null) { snapshot = _snapshotDao.findById(snapshotId); if (command.getZoneId() == null) { @@ -1895,6 +2087,12 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, } if (privateTemplate != null) { + try { + _tmpltSvr.replicateTemplateUpToCap(privateTemplate.getId(), zoneId); + } catch (Exception e) { + logger.warn("Failed to schedule additional copies for template [{}] in zone [{}]: {}", + privateTemplate.getUniqueName(), zoneId, e.getMessage()); + } return privateTemplate; } else { throw new CloudRuntimeException("Failed to create a Template"); @@ -1970,7 +2168,7 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, _accountMgr.checkAccess(caller, null, true, volume); // Don't support creating templates from encrypted volumes (yet) - if (volume.getPassphraseId() != null) { + if (volume.getPassphraseId() != null || volume.getKmsKeyId() != null) { throw new UnsupportedOperationException("Cannot create Templates from encrypted volumes"); } @@ -1998,7 +2196,7 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, volume = _volumeDao.findByIdIncludingRemoved(snapshot.getVolumeId()); // Don't support creating templates from encrypted volumes (yet) - if (volume != null && volume.getPassphraseId() != null) { + if (volume != null && (volume.getPassphraseId() != null || volume.getKmsKeyId() != null)) { throw new UnsupportedOperationException("Cannot create Templates from Snapshots of encrypted volumes"); } @@ -2218,6 +2416,20 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, return stores; } + @Override + public int getSecStorageCopyLimit(VMTemplateVO template, long zoneId) { + if (template == null) { + return 0; + } + TemplateType type = template.getTemplateType(); + if (type == TemplateType.SYSTEM || type == TemplateType.ROUTING || type == TemplateType.BUILTIN) { + return 0; + } + return template.isPublicTemplate() + ? PublicTemplateSecStorageCopy.valueIn(zoneId) + : PrivateTemplateSecStorageCopy.valueIn(zoneId); + } + @Override @ActionEvent(eventType = EventTypes.EVENT_ISO_UPDATE, eventDescription = "Updating ISO", async = false) public VMTemplateVO updateTemplate(UpdateIsoCmd cmd) { @@ -2538,7 +2750,10 @@ public class TemplateManagerImpl extends ManagerBase implements TemplateManager, return new ConfigKey[] {AllowPublicUserTemplates, TemplatePreloaderPoolSize, ValidateUrlIsResolvableBeforeRegisteringTemplate, - TemplateDeleteFromPrimaryStorage}; + TemplateDeleteFromPrimaryStorage, + PublicTemplateSecStorageCopy, + PrivateTemplateSecStorageCopy, + VmIsoMaxCount}; } public List getTemplateAdapters() { diff --git a/server/src/main/java/com/cloud/user/AccountManagerImpl.java b/server/src/main/java/com/cloud/user/AccountManagerImpl.java index c8b65827788..2a49680e8fd 100644 --- a/server/src/main/java/com/cloud/user/AccountManagerImpl.java +++ b/server/src/main/java/com/cloud/user/AccountManagerImpl.java @@ -103,6 +103,7 @@ import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.dao.ConfigurationDao; import org.apache.cloudstack.framework.messagebus.MessageBus; import org.apache.cloudstack.framework.messagebus.PublishScope; +import org.apache.cloudstack.kms.KMSManager; import org.apache.cloudstack.managed.context.ManagedContextRunnable; import org.apache.cloudstack.query.QueryService; import org.apache.cloudstack.network.RoutedIpv4Manager; @@ -351,6 +352,8 @@ public class AccountManagerImpl extends ManagerBase implements AccountManager, M private NetworkPermissionDao networkPermissionDao; @Inject private SslCertDao sslCertDao; + @Inject + private KMSManager kmsManager; private List _querySelectors; @@ -1252,6 +1255,17 @@ public class AccountManagerImpl extends ManagerBase implements AccountManager, M // Delete Webhooks deleteWebhooksForAccount(accountId); + // Delete KMS keys + try { + if (!kmsManager.deleteKMSKeysByAccountId(accountId)) { + logger.warn("Failed to delete all KMS keys for account {}", account); + accountCleanupNeeded = true; + } + } catch (Exception e) { + logger.error("Error deleting KMS keys for account {}: {}", account, e.getMessage(), e); + accountCleanupNeeded = true; + } + return true; } catch (Exception ex) { logger.warn("Failed to cleanup account " + account + " due to ", ex); diff --git a/server/src/main/java/com/cloud/vm/UserVmManagerImpl.java b/server/src/main/java/com/cloud/vm/UserVmManagerImpl.java index 6581b88f5e4..de08ccbc7fd 100644 --- a/server/src/main/java/com/cloud/vm/UserVmManagerImpl.java +++ b/server/src/main/java/com/cloud/vm/UserVmManagerImpl.java @@ -114,7 +114,9 @@ import org.apache.cloudstack.backup.BackupScheduleVO; import org.apache.cloudstack.backup.BackupVO; import org.apache.cloudstack.backup.dao.BackupDao; import org.apache.cloudstack.backup.dao.BackupScheduleDao; +import org.apache.cloudstack.schedule.ResourceScheduleManager; import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.kms.KMSManager; import org.apache.cloudstack.engine.cloud.entity.api.VirtualMachineEntity; import org.apache.cloudstack.engine.cloud.entity.api.db.dao.VMNetworkMapDao; import org.apache.cloudstack.engine.orchestration.service.NetworkOrchestrationService; @@ -439,6 +441,8 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir @Inject private EntityManager _entityMgr; @Inject + private ResourceScheduleManager resourceScheduleManager; + @Inject private HostDao _hostDao; @Inject private ServiceOfferingDao serviceOfferingDao; @@ -491,6 +495,8 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir @Inject private AccountManager _accountMgr; @Inject + private KMSManager kmsManager; + @Inject private AccountService _accountService; @Inject private ClusterDao _clusterDao; @@ -2674,6 +2680,8 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir autoScaleManager.removeVmFromVmGroup(vm.getId()); + resourceScheduleManager.removeSchedulesForResource(ApiCommandResourceType.VirtualMachine, vm.getId()); + releaseNetworkResourcesOnExpunge(vm.getId()); List rootVol = _volsDao.findByInstanceAndType(vm.getId(), Volume.Type.ROOT); @@ -3957,7 +3965,7 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir Account owner, String hostName, String displayName, Long diskOfferingId, Long diskSize, List dataDiskInfoList, String group, HypervisorType hypervisor, HTTPMethod httpmethod, String userData, Long userDataId, String userDataDetails, List sshKeyPairs, Map requestedIps, IpAddresses defaultIps, Boolean displayVm, String keyboard, List affinityGroupIdList, Map customParametes, String customId, Map> dhcpOptionMap, - Map dataDiskTemplateToDiskOfferingMap, Map userVmOVFProperties, boolean dynamicScalingEnabled, Long overrideDiskOfferingId, Volume volume, Snapshot snapshot) throws InsufficientCapacityException, ConcurrentOperationException, ResourceUnavailableException, + Map dataDiskTemplateToDiskOfferingMap, Map userVmOVFProperties, boolean dynamicScalingEnabled, Long overrideDiskOfferingId, Long rootDiskKmsKeyId, Volume volume, Snapshot snapshot) throws InsufficientCapacityException, ConcurrentOperationException, ResourceUnavailableException, StorageUnavailableException, ResourceAllocationException { Account caller = CallContext.current().getCallingAccount(); @@ -3988,8 +3996,7 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir return createVirtualMachine(zone, serviceOffering, template, hostName, displayName, owner, diskOfferingId, diskSize, dataDiskInfoList, networkList, securityGroupIdList, group, httpmethod, userData, userDataId, userDataDetails, sshKeyPairs, hypervisor, caller, requestedIps, defaultIps, displayVm, keyboard, affinityGroupIdList, customParametes, customId, dhcpOptionMap, - dataDiskTemplateToDiskOfferingMap, userVmOVFProperties, dynamicScalingEnabled, null, overrideDiskOfferingId, volume, snapshot); - + dataDiskTemplateToDiskOfferingMap, userVmOVFProperties, dynamicScalingEnabled, null, overrideDiskOfferingId, rootDiskKmsKeyId, volume, snapshot); } @Nullable @@ -4027,7 +4034,7 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir List securityGroupIdList, Account owner, String hostName, String displayName, Long diskOfferingId, Long diskSize, List dataDiskInfoList, String group, HypervisorType hypervisor, HTTPMethod httpmethod, String userData, Long userDataId, String userDataDetails, List sshKeyPairs, Map requestedIps, IpAddresses defaultIps, Boolean displayVm, String keyboard, List affinityGroupIdList, Map customParameters, String customId, Map> dhcpOptionMap, - Map dataDiskTemplateToDiskOfferingMap, Map userVmOVFProperties, boolean dynamicScalingEnabled, Long overrideDiskOfferingId, String vmType, Volume volume, Snapshot snapshot) throws InsufficientCapacityException, ConcurrentOperationException, ResourceUnavailableException, StorageUnavailableException, ResourceAllocationException { + Map dataDiskTemplateToDiskOfferingMap, Map userVmOVFProperties, boolean dynamicScalingEnabled, Long overrideDiskOfferingId, Long rootDiskKmsKeyId, String vmType, Volume volume, Snapshot snapshot) throws InsufficientCapacityException, ConcurrentOperationException, ResourceUnavailableException, StorageUnavailableException, ResourceAllocationException { Account caller = CallContext.current().getCallingAccount(); List networkList = new ArrayList<>(); @@ -4132,7 +4139,7 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir return createVirtualMachine(zone, serviceOffering, template, hostName, displayName, owner, diskOfferingId, diskSize, dataDiskInfoList, networkList, securityGroupIdList, group, httpmethod, userData, userDataId, userDataDetails, sshKeyPairs, hypervisor, caller, requestedIps, defaultIps, displayVm, keyboard, affinityGroupIdList, customParameters, customId, dhcpOptionMap, dataDiskTemplateToDiskOfferingMap, - userVmOVFProperties, dynamicScalingEnabled, vmType, overrideDiskOfferingId, volume, snapshot); + userVmOVFProperties, dynamicScalingEnabled, vmType, overrideDiskOfferingId, rootDiskKmsKeyId, volume, snapshot); } @Override @@ -4141,7 +4148,7 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir String hostName, String displayName, Long diskOfferingId, Long diskSize, List dataDiskInfoList, String group, HypervisorType hypervisor, HTTPMethod httpmethod, String userData, Long userDataId, String userDataDetails, List sshKeyPairs, Map requestedIps, IpAddresses defaultIps, Boolean displayvm, String keyboard, List affinityGroupIdList, Map customParametrs, String customId, Map> dhcpOptionsMap, Map dataDiskTemplateToDiskOfferingMap, - Map userVmOVFPropertiesMap, boolean dynamicScalingEnabled, String vmType, Long overrideDiskOfferingId, Volume volume, Snapshot snapshot) throws InsufficientCapacityException, ConcurrentOperationException, ResourceUnavailableException, + Map userVmOVFPropertiesMap, boolean dynamicScalingEnabled, String vmType, Long overrideDiskOfferingId, Long rootDiskKmsKeyId, Volume volume, Snapshot snapshot) throws InsufficientCapacityException, ConcurrentOperationException, ResourceUnavailableException, StorageUnavailableException, ResourceAllocationException { Account caller = CallContext.current().getCallingAccount(); @@ -4196,7 +4203,7 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir verifyExtraDhcpOptionsNetwork(dhcpOptionsMap, networkList); return createVirtualMachine(zone, serviceOffering, template, hostName, displayName, owner, diskOfferingId, diskSize, dataDiskInfoList, networkList, null, group, httpmethod, userData, userDataId, userDataDetails, sshKeyPairs, hypervisor, caller, requestedIps, defaultIps, displayvm, keyboard, affinityGroupIdList, customParametrs, customId, dhcpOptionsMap, - dataDiskTemplateToDiskOfferingMap, userVmOVFPropertiesMap, dynamicScalingEnabled, vmType, overrideDiskOfferingId, volume, snapshot); + dataDiskTemplateToDiskOfferingMap, userVmOVFPropertiesMap, dynamicScalingEnabled, vmType, overrideDiskOfferingId, rootDiskKmsKeyId, volume, snapshot); } @Override @@ -4328,7 +4335,7 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir Long userDataId, String userDataDetails, List sshKeyPairs, HypervisorType hypervisor, Account caller, Map requestedIps, IpAddresses defaultIps, Boolean isDisplayVm, String keyboard, List affinityGroupIdList, Map customParameters, String customId, Map> dhcpOptionMap, Map datadiskTemplateToDiskOfferringMap, - Map userVmOVFPropertiesMap, boolean dynamicScalingEnabled, String vmType, Long overrideDiskOfferingId, Volume volume, Snapshot snapshot) throws InsufficientCapacityException, ResourceUnavailableException, + Map userVmOVFPropertiesMap, boolean dynamicScalingEnabled, String vmType, Long overrideDiskOfferingId, Long rootDiskKmsKeyId, Volume volume, Snapshot snapshot) throws InsufficientCapacityException, ResourceUnavailableException, ConcurrentOperationException, StorageUnavailableException, ResourceAllocationException { _accountMgr.checkAccess(caller, null, true, owner); @@ -4415,7 +4422,14 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir throw new InvalidParameterValueException("Root volume encryption is not supported for hypervisor type " + hypervisorType); } - UserVm vm = getCheckedUserVmResource(zone, hostName, displayName, owner, diskOfferingId, diskSize, dataDiskInfoList, networkList, securityGroupIdList, group, httpmethod, userData, userDataId, userDataDetails, sshKeyPairs, caller, requestedIps, defaultIps, isDisplayVm, keyboard, affinityGroupIdList, customParameters, customId, dhcpOptionMap, datadiskTemplateToDiskOfferringMap, userVmOVFPropertiesMap, dynamicScalingEnabled, vmType, template, hypervisorType, accountId, offering, isIso, rootDiskOfferingId, volumesSize, volume, snapshot); + kmsManager.checkKmsKeyForVolumeEncryption(owner, rootDiskKmsKeyId, zone.getId()); + if (dataDiskInfoList != null) { + for (VmDiskInfo diskInfo : dataDiskInfoList) { + kmsManager.checkKmsKeyForVolumeEncryption(owner, diskInfo.getKmsKeyId(), zone.getId()); + } + } + + UserVm vm = getCheckedUserVmResource(zone, hostName, displayName, owner, diskOfferingId, diskSize, dataDiskInfoList, networkList, securityGroupIdList, group, httpmethod, userData, userDataId, userDataDetails, sshKeyPairs, caller, requestedIps, defaultIps, isDisplayVm, keyboard, affinityGroupIdList, customParameters, customId, dhcpOptionMap, datadiskTemplateToDiskOfferringMap, userVmOVFPropertiesMap, dynamicScalingEnabled, vmType, template, hypervisorType, accountId, offering, isIso, rootDiskOfferingId, rootDiskKmsKeyId, volumesSize, volume, snapshot); _securityGroupMgr.addInstanceToGroups(vm, securityGroupIdList); @@ -4435,7 +4449,7 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir Map> dhcpOptionMap, Map datadiskTemplateToDiskOfferringMap, Map userVmOVFPropertiesMap, boolean dynamicScalingEnabled, String vmType, VMTemplateVO template, HypervisorType hypervisorType, long accountId, ServiceOfferingVO offering, boolean isIso, - Long rootDiskOfferingId, long volumesSize, Volume volume, Snapshot snapshot) throws ResourceAllocationException { + Long rootDiskOfferingId, Long rootDiskKmsKeyId, long volumesSize, Volume volume, Snapshot snapshot) throws ResourceAllocationException { if (!VirtualMachineManager.ResourceCountRunningVMsonly.value()) { List resourceLimitHostTags = resourceLimitService.getResourceLimitHostTags(offering, template); try (CheckedReservation vmReservation = new CheckedReservation(owner, ResourceType.user_vm, resourceLimitHostTags, 1l, reservationDao, resourceLimitService); @@ -4444,7 +4458,7 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir CheckedReservation gpuReservation = offering.getGpuCount() != null && offering.getGpuCount() > 0 ? new CheckedReservation(owner, ResourceType.gpu, resourceLimitHostTags, Long.valueOf(offering.getGpuCount()), reservationDao, resourceLimitService) : null; ) { - return getUncheckedUserVmResource(zone, hostName, displayName, owner, diskOfferingId, diskSize, dataDiskInfoList, networkList, securityGroupIdList, group, httpmethod, userData, userDataId, userDataDetails, sshKeyPairs, caller, requestedIps, defaultIps, isDisplayVm, keyboard, affinityGroupIdList, customParameters, customId, dhcpOptionMap, datadiskTemplateToDiskOfferringMap, userVmOVFPropertiesMap, dynamicScalingEnabled, vmType, template, hypervisorType, accountId, offering, isIso, rootDiskOfferingId, volumesSize, volume, snapshot); + return getUncheckedUserVmResource(zone, hostName, displayName, owner, diskOfferingId, diskSize, dataDiskInfoList, networkList, securityGroupIdList, group, httpmethod, userData, userDataId, userDataDetails, sshKeyPairs, caller, requestedIps, defaultIps, isDisplayVm, keyboard, affinityGroupIdList, customParameters, customId, dhcpOptionMap, datadiskTemplateToDiskOfferringMap, userVmOVFPropertiesMap, dynamicScalingEnabled, vmType, template, hypervisorType, accountId, offering, isIso, rootDiskOfferingId, rootDiskKmsKeyId, volumesSize, volume, snapshot); } catch (ResourceAllocationException | CloudRuntimeException e) { throw e; } catch (Exception e) { @@ -4453,7 +4467,7 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir } } else { - return getUncheckedUserVmResource(zone, hostName, displayName, owner, diskOfferingId, diskSize, dataDiskInfoList, networkList, securityGroupIdList, group, httpmethod, userData, userDataId, userDataDetails, sshKeyPairs, caller, requestedIps, defaultIps, isDisplayVm, keyboard, affinityGroupIdList, customParameters, customId, dhcpOptionMap, datadiskTemplateToDiskOfferringMap, userVmOVFPropertiesMap, dynamicScalingEnabled, vmType, template, hypervisorType, accountId, offering, isIso, rootDiskOfferingId, volumesSize, volume, snapshot); + return getUncheckedUserVmResource(zone, hostName, displayName, owner, diskOfferingId, diskSize, dataDiskInfoList, networkList, securityGroupIdList, group, httpmethod, userData, userDataId, userDataDetails, sshKeyPairs, caller, requestedIps, defaultIps, isDisplayVm, keyboard, affinityGroupIdList, customParameters, customId, dhcpOptionMap, datadiskTemplateToDiskOfferringMap, userVmOVFPropertiesMap, dynamicScalingEnabled, vmType, template, hypervisorType, accountId, offering, isIso, rootDiskOfferingId, rootDiskKmsKeyId, volumesSize, volume, snapshot); } } @@ -4501,9 +4515,10 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir Map> dhcpOptionMap, Map datadiskTemplateToDiskOfferringMap, Map userVmOVFPropertiesMap, boolean dynamicScalingEnabled, String vmType, VMTemplateVO template, HypervisorType hypervisorType, long accountId, ServiceOfferingVO offering, boolean isIso, - Long rootDiskOfferingId, long volumesSize, Volume volume, Snapshot snapshot) throws ResourceAllocationException { + Long rootDiskOfferingId, Long rootDiskKmsKeyId, long volumesSize, Volume volume, Snapshot snapshot) throws ResourceAllocationException { List checkedReservations = new ArrayList<>(); + try { reserveStorageResourcesForVm(checkedReservations, owner, diskOfferingId, diskSize, dataDiskInfoList, rootDiskOfferingId, offering, volumesSize); @@ -4791,7 +4806,7 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir UserVmVO vm = commitUserVm(zone, template, hostName, displayName, owner, diskOfferingId, diskSize, userData, userDataId, userDataDetails, caller, isDisplayVm, keyboard, accountId, userId, offering, isIso, sshPublicKeys, networkNicMap, id, instanceName, uuidName, hypervisorType, customParameters, dhcpOptionMap, - datadiskTemplateToDiskOfferringMap, userVmOVFPropertiesMap, dynamicScalingEnabled, vmType, rootDiskOfferingId, keypairnames, dataDiskInfoList, volume, snapshot); + datadiskTemplateToDiskOfferringMap, userVmOVFPropertiesMap, dynamicScalingEnabled, vmType, rootDiskOfferingId, rootDiskKmsKeyId, keypairnames, dataDiskInfoList, volume, snapshot); assignInstanceToGroup(group, id); return vm; @@ -4986,7 +5001,7 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir final long accountId, final long userId, final ServiceOffering offering, final boolean isIso, final Long guestOsId, final String sshPublicKeys, final LinkedHashMap> networkNicMap, final long id, final String instanceName, final String uuidName, final HypervisorType hypervisorType, final Map customParameters, final Map> extraDhcpOptionMap, final Map dataDiskTemplateToDiskOfferingMap, - final Map userVmOVFPropertiesMap, final VirtualMachine.PowerState powerState, final boolean dynamicScalingEnabled, String vmType, final Long rootDiskOfferingId, String sshkeypairs, + final Map userVmOVFPropertiesMap, final VirtualMachine.PowerState powerState, final boolean dynamicScalingEnabled, String vmType, final Long rootDiskOfferingId, final Long rootDiskKmsKeyId, String sshkeypairs, List dataDiskInfoList, Volume volume, Snapshot snapshot) throws InsufficientCapacityException { long selectedGuestOsId = guestOsId != null ? guestOsId : template.getGuestOSId(); UserVmVO vm = new UserVmVO(id, instanceName, displayName, template.getId(), hypervisorType, selectedGuestOsId, offering.isOfferHA(), @@ -5108,7 +5123,7 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir orchestrateVirtualMachineCreate(vm, guestOSCategory, computeTags, rootDiskTags, plan, rootDiskSize, template, hostName, displayName, owner, diskOfferingId, diskSize, offering, isIso,networkNicMap, hypervisorType, extraDhcpOptionMap, dataDiskTemplateToDiskOfferingMap, - rootDiskOfferingId, dataDiskInfoList, volume, snapshot); + rootDiskOfferingId, rootDiskKmsKeyId, dataDiskInfoList, volume, snapshot); } CallContext.current().setEventDetails("Vm Id: " + vm.getUuid()); @@ -5141,16 +5156,16 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir ServiceOffering offering, boolean isIso, LinkedHashMap> networkNicMap, HypervisorType hypervisorType, Map> extraDhcpOptionMap, Map dataDiskTemplateToDiskOfferingMap, - Long rootDiskOfferingId, List dataDiskInfoList, Volume volume, Snapshot snapshot) throws InsufficientCapacityException{ + Long rootDiskOfferingId, Long rootDiskKmsKeyId, List dataDiskInfoList, Volume volume, Snapshot snapshot) throws InsufficientCapacityException{ try { if (isIso) { _orchSrvc.createVirtualMachineFromScratch(vm.getUuid(), Long.toString(owner.getAccountId()), vm.getIsoId().toString(), hostName, displayName, hypervisorType.name(), guestOSCategory.getName(), offering.getCpu(), offering.getSpeed(), offering.getRamSize(), diskSize, computeTags, rootDiskTags, - networkNicMap, plan, extraDhcpOptionMap, rootDiskOfferingId, dataDiskInfoList, volume, snapshot); + networkNicMap, plan, extraDhcpOptionMap, rootDiskOfferingId, rootDiskKmsKeyId, dataDiskInfoList, volume, snapshot); } else { _orchSrvc.createVirtualMachine(vm.getUuid(), Long.toString(owner.getAccountId()), Long.toString(template.getId()), hostName, displayName, hypervisorType.name(), offering.getCpu(), offering.getSpeed(), offering.getRamSize(), diskSize, computeTags, rootDiskTags, networkNicMap, plan, rootDiskSize, extraDhcpOptionMap, - dataDiskTemplateToDiskOfferingMap, diskOfferingId, rootDiskOfferingId, dataDiskInfoList, volume, snapshot); + dataDiskTemplateToDiskOfferingMap, diskOfferingId, rootDiskOfferingId, rootDiskKmsKeyId, dataDiskInfoList, volume, snapshot); } if (logger.isDebugEnabled()) { @@ -5272,15 +5287,15 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir final long accountId, final long userId, final ServiceOfferingVO offering, final boolean isIso, final String sshPublicKeys, final LinkedHashMap> networkNicMap, final long id, final String instanceName, final String uuidName, final HypervisorType hypervisorType, final Map customParameters, final Map> extraDhcpOptionMap, final Map dataDiskTemplateToDiskOfferingMap, - Map userVmOVFPropertiesMap, final boolean dynamicScalingEnabled, String vmType, final Long rootDiskOfferingId, String sshkeypairs, + Map userVmOVFPropertiesMap, final boolean dynamicScalingEnabled, String vmType, final Long rootDiskOfferingId, final Long rootDiskKmsKeyId, String sshkeypairs, List dataDiskInfoList, Volume volume, Snapshot snapshot) throws InsufficientCapacityException { Long guestOsId = getGuestOsIdIfNeeded(template); return commitUserVm(false, zone, null, null, template, hostName, displayName, owner, diskOfferingId, diskSize, userData, userDataId, userDataDetails, isDisplayVm, keyboard, - accountId, userId, offering, isIso, guestOsId, sshPublicKeys, networkNicMap, id, instanceName, uuidName, - hypervisorType, customParameters, extraDhcpOptionMap, dataDiskTemplateToDiskOfferingMap, - userVmOVFPropertiesMap, null, dynamicScalingEnabled, vmType, rootDiskOfferingId, sshkeypairs, - dataDiskInfoList, volume, snapshot); + accountId, userId, offering, isIso, guestOsId, sshPublicKeys, networkNicMap, + id, instanceName, uuidName, hypervisorType, customParameters, + extraDhcpOptionMap, dataDiskTemplateToDiskOfferingMap, + userVmOVFPropertiesMap, null, dynamicScalingEnabled, vmType, rootDiskOfferingId, rootDiskKmsKeyId, sshkeypairs, dataDiskInfoList, volume, snapshot); } protected Long getGuestOsIdIfNeeded(VirtualMachineTemplate template) { @@ -6729,7 +6744,7 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir vm = createBasicSecurityGroupVirtualMachine(zone, serviceOffering, template, getSecurityGroupIdList(cmd, zone, template, owner), owner, name, displayName, diskOfferingId, size , dataDiskInfoList, group , hypervisor, cmd.getHttpMethod(), userData, userDataId, userDataDetails, sshKeyPairNames, ipToNetworkMap, addrs, displayVm , keyboard , cmd.getAffinityGroupIdList(), cmd.getDetails(), cmd.getCustomId(), cmd.getDhcpOptionsMap(), - dataDiskTemplateToDiskOfferingMap, userVmOVFProperties, dynamicScalingEnabled, overrideDiskOfferingId, volume, snapshot); + dataDiskTemplateToDiskOfferingMap, userVmOVFProperties, dynamicScalingEnabled, overrideDiskOfferingId, cmd.getRootDiskKmsKeyId(), volume, snapshot); } } else { if (_networkModel.checkSecurityGroupSupportForNetwork(owner, zone, networkIds, @@ -6737,7 +6752,7 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir vm = createAdvancedSecurityGroupVirtualMachine(zone, serviceOffering, template, networkIds, getSecurityGroupIdList(cmd, zone, template, owner), owner, name, displayName, diskOfferingId, size, dataDiskInfoList, group, hypervisor, cmd.getHttpMethod(), userData, userDataId, userDataDetails, sshKeyPairNames, ipToNetworkMap, addrs, displayVm, keyboard, cmd.getAffinityGroupIdList(), cmd.getDetails(), cmd.getCustomId(), cmd.getDhcpOptionsMap(), - dataDiskTemplateToDiskOfferingMap, userVmOVFProperties, dynamicScalingEnabled, overrideDiskOfferingId, instanceType, volume, snapshot); + dataDiskTemplateToDiskOfferingMap, userVmOVFProperties, dynamicScalingEnabled, overrideDiskOfferingId, cmd.getRootDiskKmsKeyId(), instanceType, volume, snapshot); } else { if (cmd.getSecurityGroupIdList() != null && !cmd.getSecurityGroupIdList().isEmpty()) { @@ -6745,7 +6760,7 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir } vm = createAdvancedVirtualMachine(zone, serviceOffering, template, networkIds, owner, name, displayName, diskOfferingId, size, dataDiskInfoList, group, hypervisor, cmd.getHttpMethod(), userData, userDataId, userDataDetails, sshKeyPairNames, ipToNetworkMap, addrs, displayVm, keyboard, cmd.getAffinityGroupIdList(), cmd.getDetails(), - cmd.getCustomId(), cmd.getDhcpOptionsMap(), dataDiskTemplateToDiskOfferingMap, userVmOVFProperties, dynamicScalingEnabled, instanceType, overrideDiskOfferingId, volume, snapshot); + cmd.getCustomId(), cmd.getDhcpOptionsMap(), dataDiskTemplateToDiskOfferingMap, userVmOVFProperties, dynamicScalingEnabled, instanceType, overrideDiskOfferingId, cmd.getRootDiskKmsKeyId(), volume, snapshot); if (cmd instanceof DeployVnfApplianceCmd) { vnfTemplateManager.createIsolatedNetworkRulesForVnfAppliance(zone, template, owner, vm, (DeployVnfApplianceCmd) cmd); } @@ -9794,7 +9809,7 @@ public class UserVmManagerImpl extends ManagerBase implements UserVmManager, Vir null, null, userData, null, null, isDisplayVm, keyboard, accountId, userId, serviceOffering, template.getFormat().equals(ImageFormat.ISO), guestOsId, sshPublicKeys, networkNicMap, id, instanceName, uuidName, hypervisorType, customParameters, - null, null, null, powerState, dynamicScalingEnabled, null, serviceOffering.getDiskOfferingId(), null, null, null, null); + null, null, null, powerState, dynamicScalingEnabled, null, serviceOffering.getDiskOfferingId(), null, null, null, null, null); }); } diff --git a/server/src/main/java/com/cloud/vm/snapshot/VMSnapshotManagerImpl.java b/server/src/main/java/com/cloud/vm/snapshot/VMSnapshotManagerImpl.java index 7572ab4a78b..819eef0fc26 100644 --- a/server/src/main/java/com/cloud/vm/snapshot/VMSnapshotManagerImpl.java +++ b/server/src/main/java/com/cloud/vm/snapshot/VMSnapshotManagerImpl.java @@ -396,7 +396,9 @@ public class VMSnapshotManagerImpl extends MutualExclusiveIdsManagerBase impleme } // disallow KVM snapshots for VMs if root volume is encrypted (Qemu crash) - if (rootVolume.getPassphraseId() != null && userVmVo.getState() == VirtualMachine.State.Running && Boolean.TRUE.equals(snapshotMemory)) { + if ((rootVolume.getPassphraseId() != null || rootVolume.getKmsKeyId() != null) && + userVmVo.getState() == VirtualMachine.State.Running && Boolean.TRUE.equals(snapshotMemory) + ) { throw new UnsupportedOperationException("Cannot create Instance memory Snapshots on KVM from encrypted root volumes"); } diff --git a/server/src/main/java/org/apache/cloudstack/kms/KMSManagerImpl.java b/server/src/main/java/org/apache/cloudstack/kms/KMSManagerImpl.java new file mode 100644 index 00000000000..cfb16c647fe --- /dev/null +++ b/server/src/main/java/org/apache/cloudstack/kms/KMSManagerImpl.java @@ -0,0 +1,1834 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms; + +import com.cloud.api.ApiDBUtils; +import com.cloud.api.ApiResponseHelper; +import com.cloud.dc.DataCenter; +import com.cloud.dc.DataCenterVO; +import com.cloud.dc.dao.DataCenterDao; +import com.cloud.domain.DomainVO; +import com.cloud.domain.dao.DomainDao; +import com.cloud.user.AccountVO; +import com.cloud.user.dao.AccountDao; +import com.cloud.event.ActionEvent; +import com.cloud.event.ActionEventUtils; +import com.cloud.event.EventTypes; +import com.cloud.event.EventVO; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.PermissionDeniedException; +import com.cloud.projects.Project.ListProjectResourcesCriteria; +import com.cloud.storage.Volume; +import com.cloud.storage.VolumeVO; +import com.cloud.storage.dao.VolumeDao; +import com.cloud.user.Account; +import com.cloud.user.AccountManager; +import com.cloud.utils.Pair; +import com.cloud.utils.Ternary; +import com.cloud.utils.component.ManagerBase; +import com.cloud.utils.component.PluggableService; +import com.cloud.utils.crypt.DBEncryptionUtil; +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 org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.command.admin.kms.MigrateVolumesToKMSCmd; +import org.apache.cloudstack.api.command.user.kms.CreateKMSKeyCmd; +import org.apache.cloudstack.api.command.user.kms.DeleteKMSKeyCmd; +import org.apache.cloudstack.api.command.user.kms.ListKMSKeysCmd; +import org.apache.cloudstack.api.command.user.kms.RotateKMSKeyCmd; +import org.apache.cloudstack.api.command.user.kms.UpdateKMSKeyCmd; +import org.apache.cloudstack.api.command.user.kms.hsm.CreateHSMProfileCmd; +import org.apache.cloudstack.api.command.user.kms.hsm.DeleteHSMProfileCmd; +import org.apache.cloudstack.api.command.user.kms.hsm.ListHSMProfilesCmd; +import org.apache.cloudstack.api.command.user.kms.hsm.UpdateHSMProfileCmd; +import org.apache.cloudstack.api.response.HSMProfileResponse; +import org.apache.cloudstack.api.response.KMSKeyResponse; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.framework.kms.KMSProvider; +import org.apache.cloudstack.framework.kms.KeyPurpose; +import org.apache.cloudstack.framework.kms.WrappedKey; +import org.apache.cloudstack.kms.dao.HSMProfileDao; +import org.apache.cloudstack.kms.dao.HSMProfileDetailsDao; +import org.apache.cloudstack.kms.dao.KMSKekVersionDao; +import org.apache.cloudstack.kms.dao.KMSKeyDao; +import org.apache.cloudstack.kms.dao.KMSWrappedKeyDao; +import org.apache.cloudstack.managed.context.ManagedContextRunnable; +import org.apache.cloudstack.secret.PassphraseVO; +import org.apache.cloudstack.secret.dao.PassphraseDao; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import javax.inject.Inject; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +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.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; + +public class KMSManagerImpl extends ManagerBase implements KMSManager, PluggableService { + private static final Logger logger = LogManager.getLogger(KMSManagerImpl.class); + private static final Map kmsProviderMap = new HashMap<>(); + private static final String DATABASE_PROVIDER_NAME = "database"; + private ExecutorService kmsOperationExecutor; + + @Inject + private KMSWrappedKeyDao kmsWrappedKeyDao; + + @Inject + private KMSKeyDao kmsKeyDao; + + @Inject + private KMSKekVersionDao kmsKekVersionDao; + + @Inject + private HSMProfileDao hsmProfileDao; + + @Inject + private HSMProfileDetailsDao hsmProfileDetailsDao; + + @Inject + private AccountManager accountManager; + + @Inject + private DataCenterDao dataCenterDao; + + @Inject + private VolumeDao volumeDao; + @Inject + private AccountDao accountDao; + @Inject + private DomainDao domainDao; + + @Inject + private PassphraseDao passphraseDao; + private List kmsProviders; + private ScheduledExecutorService rewrapExecutor; + + + @Override + public List listKMSProviders() { + return kmsProviders; + } + + @Override + public KMSProvider getKMSProvider(String name) { + if (StringUtils.isEmpty(name)) { + name = DATABASE_PROVIDER_NAME; + } + + String providerName = name.toLowerCase(); + if (!kmsProviderMap.containsKey(providerName)) { + throw new CloudRuntimeException(String.format("KMS provider '%s' not found", providerName)); + } + + KMSProvider provider = kmsProviderMap.get(providerName); + if (provider == null) { + throw new CloudRuntimeException(String.format("KMS provider '%s' returned is null", providerName)); + } + + return provider; + } + + @Override + public boolean hasPermission(Long callerAccountId, KMSKey key) { + if (callerAccountId == null || key == null) { + return false; + } + if (!key.isEnabled()) { + throw new InvalidParameterValueException("KMS key is not enabled: " + key); + } + return callerAccountId == key.getAccountId(); + } + + @Override + public void checkKmsKeyForVolumeEncryption(Account owner, Long kmsKeyId, Long zoneId) { + if (kmsKeyId == null) { + return; + } + KMSKeyVO key = kmsKeyDao.findById(kmsKeyId); + if (key == null) { + throw new InvalidParameterValueException("KMS key not found: " + kmsKeyId); + } + if (key.getAccountId() != owner.getId()) { + throw new PermissionDeniedException("KMS key does not belong to the volume owner account"); + } + if (key.getZoneId() != null && zoneId != null && !key.getZoneId().equals(zoneId)) { + throw new InvalidParameterValueException( + "KMS key belongs to zone " + key.getZoneId() + + " but the target resource is in zone " + zoneId); + } + if (!key.isEnabled()) { + throw new InvalidParameterValueException( + "KMS key is not enabled and cannot be used for volume encryption: " + key.getUuid()); + } + if (key.getPurpose() != KeyPurpose.VOLUME_ENCRYPTION) { + throw new InvalidParameterValueException( + "KMS key purpose must be volume encryption; key has purpose: " + key.getPurpose().getName()); + } + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_KMS_KEY_UNWRAP, eventDescription = "unwrapping key") + public byte[] unwrapKey(Long wrappedKeyId) throws KMSException { + KMSWrappedKeyVO wrappedVO = kmsWrappedKeyDao.findById(wrappedKeyId); + if (wrappedVO == null) { + throw KMSException.kekNotFound("Wrapped key not found: " + wrappedKeyId); + } + + KMSKeyVO kmsKey = kmsKeyDao.findById(wrappedVO.getKmsKeyId()); + if (kmsKey == null) { + throw KMSException.kekNotFound("KMS key not found for wrapped key: " + wrappedKeyId); + } + + Exception lastException = null; + + if (wrappedVO.getKekVersionId() != null) { + KMSKekVersionVO version = kmsKekVersionDao.findById(wrappedVO.getKekVersionId()); + if (version != null && version.getStatus() != KMSKekVersionVO.Status.Archived) { + HSMProfileVO hsmProfile = hsmProfileDao.findById(version.getHsmProfileId()); + if (!hsmProfile.isEnabled()) { + throw KMSException.providerNotInitialized("HSM profile is not enabled for wrapped key: " + version); + } + try { + byte[] dek = getUnwrappedKey(wrappedVO, kmsKey, version, hsmProfile); + + CallContext.current().setEventResourceId(kmsKey.getId()); + CallContext.current().setEventResourceType(ApiCommandResourceType.KmsKey); + CallContext.current().setEventDetails(String.format( + "Unwrapped %s key (wrapped key uuid: %s) using KMS key: %s (uuid: %s) with KEK version %d", + kmsKey.getPurpose().getName(), wrappedVO.getUuid(), kmsKey.getName(), kmsKey.getUuid(), version.getVersionNumber())); + + logger.debug("Successfully unwrapped key {} with KEK version {}", wrappedKeyId, version.getVersionNumber()); + return dek; + } catch (Exception e) { + lastException = e; + logger.warn("Failed to unwrap with version {}: {}", version.getVersionNumber(), e.getMessage(), e); + } + } + } + + // Fallback: try all available versions for decryption + List versions = kmsKekVersionDao.getVersionsForDecryption(kmsKey.getId()); + for (KMSKekVersionVO version : versions) { + HSMProfileVO hsmProfile = hsmProfileDao.findById(version.getHsmProfileId()); + if (!hsmProfile.isEnabled()) { + throw KMSException.providerNotInitialized("HSM profile is not enabled for wrapped key: " + version); + } + try { + byte[] dek = getUnwrappedKey(wrappedVO, kmsKey, version, hsmProfile); + + CallContext.current().setEventResourceId(kmsKey.getId()); + CallContext.current().setEventResourceType(ApiCommandResourceType.KmsKey); + CallContext.current().setEventDetails(String.format( + "Unwrapped %s key (wrapped key uuid: %s) using KMS key: %s (uuid: %s) with KEK version %d (fallback)", + kmsKey.getPurpose().getName(), wrappedVO.getUuid(), kmsKey.getName(), kmsKey.getUuid(), version.getVersionNumber())); + + logger.info("Successfully unwrapped key {} with KEK version {} (fallback)", + wrappedKeyId, version.getVersionNumber()); + return dek; + } catch (Exception e) { + lastException = e; + logger.warn("Failed to unwrap with version {}: {}", version.getVersionNumber(), e.getMessage(), e); + } + } + + throw KMSException.wrapUnwrapFailed("Failed to unwrap key: no available KEK version for wrapped key " + wrappedKeyId, lastException); + } + + private byte[] getUnwrappedKey(KMSWrappedKeyVO wrappedVO, KMSKeyVO kmsKey, + KMSKekVersionVO version, HSMProfileVO hsmProfile) throws Exception { + KMSProvider provider = getKMSProvider(hsmProfile.getProtocol()); + + WrappedKey wrapped = new WrappedKey(wrappedVO.getUuid(), version.getKekLabel(), kmsKey.getPurpose(), + kmsKey.getAlgorithm(), wrappedVO.getWrappedBlob(), + hsmProfile.getProtocol(), wrappedVO.getCreated(), kmsKey.getZoneId()); + return retryOperation(() -> provider.unwrapKey(wrapped, version.getHsmProfileId())); + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_KMS_KEY_WRAP, eventDescription = "generating key with specified KEK") + public WrappedKey generateVolumeKeyWithKek(KMSKey kmsKey, Long callerAccountId) throws KMSException { + if (kmsKey == null) { + throw KMSException.kekNotFound("KMS key not found"); + } + + if (!kmsKey.isEnabled()) { + throw KMSException.invalidParameter("KMS key is not enabled: " + kmsKey); + } + + if (kmsKey.getPurpose() != KeyPurpose.VOLUME_ENCRYPTION) { + throw KMSException.invalidParameter("KMS key purpose is not VOLUME_ENCRYPTION: " + kmsKey); + } + + KMSKekVersionVO activeVersion = getActiveKekVersion(kmsKey.getId()); + + HSMProfileVO hsmProfile = hsmProfileDao.findById(activeVersion.getHsmProfileId()); + if (hsmProfile == null) { + throw KMSException.invalidParameter("HSM profile not found: " + activeVersion.getHsmProfileId()); + } + if (!hsmProfile.isEnabled()) { + throw KMSException.invalidParameter("HSM profile is not enabled: " + hsmProfile.getName()); + } + KMSProvider provider = getKMSProvider(hsmProfile.getProtocol()); + + int dekSize = KMSDekSizeBits.value(); + WrappedKey wrappedKey; + try { + wrappedKey = retryOperation(() -> provider.generateAndWrapDek(KeyPurpose.VOLUME_ENCRYPTION, + activeVersion.getKekLabel(), dekSize, + activeVersion.getHsmProfileId())); + KMSWrappedKeyVO wrappedKeyVO = new KMSWrappedKeyVO(kmsKey.getId(), activeVersion.getId(), + kmsKey.getZoneId(), wrappedKey.getWrappedKeyMaterial()); + wrappedKeyVO = kmsWrappedKeyDao.persist(wrappedKeyVO); + + // Volume creation code looks up by UUID and sets volume.kmsWrappedKeyId + wrappedKey = new WrappedKey( + wrappedKeyVO.getUuid(), + wrappedKey.getKekId(), + wrappedKey.getPurpose(), + wrappedKey.getAlgorithm(), + wrappedKey.getWrappedKeyMaterial(), + wrappedKey.getProviderName(), + wrappedKey.getCreated(), + wrappedKey.getZoneId()); + } catch (Exception e) { + throw handleKmsException(e); + } + + CallContext.current().setEventResourceId(kmsKey.getId()); + CallContext.current().setEventResourceType(ApiCommandResourceType.KmsKey); + CallContext.current().setEventDetails(String.format( + "Generated %s key (wrapped key uuid: %s) using KMS key: %s (uuid: %s) with KEK version %d", + kmsKey.getPurpose().getName(), wrappedKey.getUuid(), kmsKey.getName(), kmsKey.getUuid(), activeVersion.getVersionNumber())); + + logger.debug("Generated {} key using KMS key {} with KEK version {}, wrapped key UUID: {}", + kmsKey.getPurpose().getName(), kmsKey, activeVersion.getVersionNumber(), wrappedKey.getUuid()); + return wrappedKey; + } + + private KMSKekVersionVO getActiveKekVersion(Long kmsKeyId) throws KMSException { + KMSKekVersionVO activeVersion = kmsKekVersionDao.getActiveVersion(kmsKeyId); + if (activeVersion == null) { + throw KMSException.kekNotFound("No active KEK version found for KMS key ID: " + kmsKeyId); + } + return activeVersion; + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_KMS_KEY_CREATE, eventDescription = "creating user KMS key") + public KMSKeyResponse createKMSKey(CreateKMSKeyCmd cmd) throws KMSException { + if (cmd.getDomainId() != null && StringUtils.isBlank(cmd.getAccountName())) { + throw new InvalidParameterValueException("Account name must be provided with domain ID"); + } + + Account caller = CallContext.current().getCallingAccount(); + Account targetAccount = accountManager.finalizeOwner(caller, cmd.getAccountName(), cmd.getDomainId(), + cmd.getProjectId()); + + KeyPurpose keyPurpose = parseKeyPurpose("volume"); + + int bits = cmd.getKeyBits(); + if (bits != 128 && bits != 192 && bits != 256) { + throw new InvalidParameterValueException("Key bits must be 128, 192, or 256"); + } + + HSMProfileVO profile = getHSMProfile(cmd.getHsmProfileId()); + checkHSMProfileAccess(caller, profile, false); + validateProfileScopeForOwner(profile, targetAccount.getId(), targetAccount.getDomainId()); + if (!profile.isEnabled()) { + throw new InvalidParameterValueException("HSM profile is not enabled: " + profile.getName()); + } + + KMSKey kmsKey = createUserKMSKey( + targetAccount.getId(), + targetAccount.getDomainId(), + cmd.getZoneId(), + cmd.getName(), + cmd.getDescription(), + keyPurpose, + bits, + cmd.getHsmProfileId()); + + CallContext.current().setEventResourceId(kmsKey.getId()); + CallContext.current().setEventResourceType(ApiCommandResourceType.KmsKey); + CallContext.current().setEventDetails(String.format("Created KMS key: %s (uuid: %s)", kmsKey.getName(), kmsKey.getUuid())); + + return createKMSKeyResponse(kmsKey); + } + + KMSKeyResponse createKMSKeyResponse(KMSKey kmsKey) { + KMSKeyResponse response = new KMSKeyResponse(); + response.setId(kmsKey.getUuid()); + response.setName(kmsKey.getName()); + response.setDescription(kmsKey.getDescription()); + response.setAlgorithm(kmsKey.getAlgorithm()); + response.setKeyBits(kmsKey.getKeyBits()); + response.setEnabled(kmsKey.isEnabled()); + response.setCreated(kmsKey.getCreated()); + + KMSKekVersionVO activeVersion = kmsKekVersionDao.getActiveVersion(kmsKey.getId()); + if (activeVersion != null) { + response.setVersion(activeVersion.getVersionNumber()); + } + + HSMProfileVO hsmProfile = hsmProfileDao.findById(kmsKey.getHsmProfileId()); + if (hsmProfile != null) { + response.setHsmProfileId(hsmProfile.getUuid()); + response.setHsmProfileName(hsmProfile.getName()); + } + + ApiResponseHelper.populateOwner(response, kmsKey); + + DataCenter zone = ApiDBUtils.findZoneById(kmsKey.getZoneId()); + if (zone != null) { + response.setZoneId(zone.getUuid()); + response.setZoneName(zone.getName()); + } + + Account caller = CallContext.current().getCallingAccount(); + if (caller != null && (caller.getType() == Account.Type.ADMIN + || caller.getType() == Account.Type.RESOURCE_DOMAIN_ADMIN)) { + response.setKekLabel(kmsKey.getKekLabel()); + } + + response.setObjectName("kmskey"); + return response; + } + + KMSKey createUserKMSKey(Long accountId, Long domainId, Long zoneId, + String name, String description, KeyPurpose purpose, + Integer keyBits, long hsmProfileId) throws KMSException { + HSMProfileVO profile = hsmProfileDao.findById(hsmProfileId); + if (profile == null) { + throw KMSException.invalidParameter("HSM Profile not found"); + } + if (kmsKeyDao.findByNameAndAccountId(name, accountId) != null) { + throw new InvalidParameterValueException("A KMS key with name " + name + " already exists in this account"); + } + + KMSKeyVO kmsKey = new KMSKeyVO(name, description, "", purpose, + accountId, domainId, zoneId, "AES/GCM/NoPadding", keyBits); + + KMSProvider provider = getKMSProvider(profile.getProtocol()); + String kekLabel = purpose.generateKekLabel(domainId, accountId, kmsKey.getUuid(), 1); + + String providerKekLabel; + Long finalProfileId = hsmProfileId; + try { + providerKekLabel = retryOperation(() -> provider.createKek(purpose, kekLabel, keyBits, finalProfileId)); + } catch (Exception e) { + throw handleKmsException(e); + } + + kmsKey.setKekLabel(providerKekLabel); + kmsKey.setHsmProfileId(finalProfileId); + kmsKey = kmsKeyDao.persist(kmsKey); + + KMSKekVersionVO initialVersion = new KMSKekVersionVO(kmsKey.getId(), 1, providerKekLabel); + initialVersion.setHsmProfileId(finalProfileId); + initialVersion = kmsKekVersionDao.persist(initialVersion); + + logger.info("Created KMS key ({}) with initial KEK version {} for account {} in zone {} (profile: {})", + kmsKey, initialVersion.getVersionNumber(), accountId, zoneId, finalProfileId); + return kmsKey; + } + + @Override + public ListResponse listKMSKeys(ListKMSKeysCmd cmd) { + Account caller = CallContext.current().getCallingAccount(); + + List permittedAccounts = new ArrayList<>(); + Ternary domainIdRecursiveListProject = new Ternary<>( + cmd.getDomainId(), cmd.isRecursive(), null); + accountManager.buildACLSearchParameters(caller, cmd.getId(), cmd.getAccountName(), + cmd.getProjectId(), permittedAccounts, domainIdRecursiveListProject, + cmd.listAll(), false); + Long domainId = domainIdRecursiveListProject.first(); + Boolean isRecursive = domainIdRecursiveListProject.second(); + ListProjectResourcesCriteria listProjectResourcesCriteria = domainIdRecursiveListProject.third(); + + SearchBuilder sb = getSearchBuilderForKMSKeys(cmd, domainId, isRecursive, permittedAccounts, + listProjectResourcesCriteria); + SearchCriteria sc = getSearchCriteriaForKMSKeys(sb, cmd, domainId, isRecursive, permittedAccounts, + listProjectResourcesCriteria); + + Filter searchFilter = new Filter(KMSKeyVO.class, "id", true, cmd.getStartIndex(), cmd.getPageSizeVal()); + Pair, Integer> result = kmsKeyDao.searchAndCount(sc, searchFilter); + List keys = result.first(); + Integer count = result.second(); + + List responses = new ArrayList<>(); + for (KMSKey key : keys) { + responses.add(createKMSKeyResponse(key)); + } + + ListResponse listResponse = new ListResponse<>(); + listResponse.setResponses(responses, count); + return listResponse; + } + + SearchBuilder getSearchBuilderForKMSKeys(ListKMSKeysCmd cmd, Long domainId, Boolean isRecursive, List permittedAccounts, + ListProjectResourcesCriteria listProjectResourcesCriteria) { + SearchBuilder sb = kmsKeyDao.createSearchBuilder(); + accountManager.buildACLSearchBuilder(sb, domainId, isRecursive, permittedAccounts, + listProjectResourcesCriteria); + + sb.and("id", sb.entity().getId(), SearchCriteria.Op.EQ); + sb.and("zoneId", sb.entity().getZoneId(), SearchCriteria.Op.EQ); + sb.and("purpose", sb.entity().getPurpose(), SearchCriteria.Op.EQ); + sb.and("enabled", sb.entity().isEnabled(), SearchCriteria.Op.EQ); + sb.and("hsmProfileId", sb.entity().getHsmProfileId(), SearchCriteria.Op.EQ); + if (cmd.getKeyword() != null) { + sb.and().op("name", sb.entity().getName(), SearchCriteria.Op.LIKE); + sb.or("description", sb.entity().getDescription(), SearchCriteria.Op.LIKE); + sb.cp(); + } + sb.done(); + return sb; + } + + SearchCriteria getSearchCriteriaForKMSKeys(SearchBuilder searchBuilder, ListKMSKeysCmd cmd, + Long domainId, Boolean isRecursive, List permittedAccounts, + ListProjectResourcesCriteria listProjectResourcesCriteria) { + SearchCriteria sc = searchBuilder.create(); + accountManager.buildACLSearchCriteria(sc, domainId, isRecursive, permittedAccounts, + listProjectResourcesCriteria); + KeyPurpose keyPurpose = parseKeyPurpose("volume"); + sc.setParametersIfNotNull("id", cmd.getId()); + sc.setParametersIfNotNull("zoneId", cmd.getZoneId()); + sc.setParametersIfNotNull("purpose", keyPurpose); + sc.setParametersIfNotNull("enabled", cmd.getEnabled()); + sc.setParametersIfNotNull("hsmProfileId", cmd.getHsmProfileId()); + if (cmd.getKeyword() != null) { + sc.setParameters("name", "%" + cmd.getKeyword() + "%"); + sc.setParameters("description", "%" + cmd.getKeyword() + "%"); + } + return sc; + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_KMS_KEY_UPDATE, eventDescription = "updating KMS Key") + public KMSKeyResponse updateKMSKey(UpdateKMSKeyCmd cmd) throws KMSException { + Account caller = CallContext.current().getCallingAccount(); + KMSKeyVO key = findKMSKeyAndCheckAccess(cmd.getId(), caller); + KMSKey updatedKey = updateUserKMSKey(key, cmd.getName(), cmd.getDescription(), cmd.getEnabled()); + String details = String.format("Updated KMS key: %s (uuid: %s, enabled: %s)", updatedKey.getName(), updatedKey.getUuid(), updatedKey.isEnabled()); + CallContext.current().setEventDetails(details); + ActionEventUtils.onCompletedActionEvent(CallContext.current().getCallingUserId(), + updatedKey.getAccountId(), EventVO.LEVEL_INFO, EventTypes.EVENT_KMS_KEY_UPDATE, true, + details, updatedKey.getId(), ApiCommandResourceType.Volume.toString(), CallContext.current().getStartEventId()); + return createKMSKeyResponse(updatedKey); + } + + KMSKey updateUserKMSKey(KMSKeyVO key, String name, String description, Boolean enabled) { + boolean updated = false; + if (name != null && !name.equals(key.getName())) { + key.setName(name); + updated = true; + } + if (description != null && !description.equals(key.getDescription())) { + key.setDescription(description); + updated = true; + } + if (enabled != null && enabled != key.isEnabled()) { + key.setEnabled(enabled); + updated = true; + } + + if (updated) { + kmsKeyDao.update(key.getId(), key); + logger.info("Updated KMS key {}", key); + } + + return key; + } + + @Override + public boolean deleteKMSWrappedKey(Volume vol) throws KMSException { + if (vol.getKmsWrappedKeyId() == null) { + return true; + } + + KMSWrappedKeyVO wrappedKey = kmsWrappedKeyDao.findById(vol.getKmsWrappedKeyId()); + if (wrappedKey != null) { + List volumes = volumeDao.findByKmsWrappedKeyId(vol.getKmsWrappedKeyId()); + if (CollectionUtils.isNotEmpty(volumes)) { + for (VolumeVO volume : volumes) { + if (volume.getId() != vol.getId()) { + logger.warn("KMS Wrapped Key {} is still in use by volume {}, cannot delete", + wrappedKey, volume); + return false; + } + } + } + logger.info("Deleted KMS Wrapped Key {} for volume {}", wrappedKey, vol); + return kmsWrappedKeyDao.remove(wrappedKey.getId()); + } + return true; + } + + @Override + public SuccessResponse deleteKMSKey(DeleteKMSKeyCmd cmd) throws KMSException { + Account caller = CallContext.current().getCallingAccount(); + + KMSKeyVO key = findKMSKeyAndCheckAccess(cmd.getId(), caller); + CallContext.current().setEventResourceId(key.getId()); + CallContext.current().setEventResourceType(ApiCommandResourceType.KmsKey); + CallContext.current().setEventDetails(String.format("Deleted KMS key: %s (uuid: %s)", key.getName(), key.getUuid())); + deleteUserKMSKey(key); + return new SuccessResponse(); + } + + void deleteUserKMSKey(KMSKeyVO key) throws KMSException { + long wrappedKeyCount = kmsWrappedKeyDao.countByKmsKeyId(key.getId()); + if (wrappedKeyCount > 0) { + throw new InvalidParameterValueException("Cannot delete KMS key: " + key + ". " + wrappedKeyCount + + " wrapped key(s) still reference this key"); + } + + if (volumeDao.existsWithKmsKey(key.getId())) { + throw new InvalidParameterValueException("Cannot delete KMS key: " + key + ". " + + "There are Volumes which still reference this key"); + } + + List kekVersions = kmsKekVersionDao.listByKmsKeyId(key.getId()); + for (KMSKekVersionVO kekVersion : kekVersions) { + try { + HSMProfileVO hsmProfile = hsmProfileDao.findById(kekVersion.getHsmProfileId()); + if (hsmProfile != null) { + KMSProvider provider = getKMSProvider(hsmProfile.getProtocol()); + provider.deleteKek(kekVersion.getKekLabel()); + logger.info("Deleted KEK {} (v{}) from provider {}", + kekVersion.getKekLabel(), kekVersion.getVersionNumber(), provider.getProviderName()); + } + } catch (Exception e) { + logger.warn("Failed to delete KEK {} (v{}) from provider during KMS key deletion: {}", + kekVersion.getKekLabel(), kekVersion.getVersionNumber(), e.getMessage()); + ActionEventUtils.onCompletedActionEvent(CallContext.current().getCallingUserId(), + key.getAccountId(), EventVO.LEVEL_WARN, EventTypes.EVENT_KMS_KEY_DELETE, true, + String.format("Failed to delete KEK %s from provider during KMS key deletion", kekVersion.getKekLabel()), + key.getId(), ApiCommandResourceType.KmsKey.toString(), CallContext.current().getStartEventId()); + } + } + + kmsKeyDao.remove(key.getId()); + logger.info("Deleted KMS key {}", key); + } + + @Override + public String rotateKMSKey(RotateKMSKeyCmd cmd) throws KMSException { + Account caller = CallContext.current().getCallingAccount(); + Integer keyBits = cmd.getKeyBits(); + Long hsmProfileId = cmd.getHsmProfileId(); + + KMSKeyVO kmsKey = findKMSKeyAndCheckAccess(cmd.getId(), caller); + + if (!kmsKey.isEnabled()) { + throw new InvalidParameterValueException("KMS key is not enabled: " + kmsKey); + } + + HSMProfileVO profile = null; + if (hsmProfileId != null) { + profile = hsmProfileDao.findById(hsmProfileId); + if (profile == null) { + throw new InvalidParameterValueException("Target HSM Profile not found: " + hsmProfileId); + } + checkHSMProfileAccess(caller, profile, false); + validateProfileScopeForOwner(profile, kmsKey.getAccountId(), kmsKey.getDomainId()); + if (!profile.isEnabled()) { + throw new InvalidParameterValueException("HSM profile is not enabled: " + profile.getName()); + } + } + + int newKeyBits = keyBits != null ? keyBits : kmsKey.getKeyBits(); + KMSKekVersionVO currentActive = getActiveKekVersion(kmsKey.getId()); + + rotateKek( + kmsKey, + currentActive.getKekLabel(), + null, // auto-generate new label + newKeyBits, + profile); + + KMSKekVersionVO newVersion = getActiveKekVersion(kmsKey.getId()); + CallContext.current().setEventDetails(String.format("Rotated KMS key: %s (uuid: %s) from version %d to %d", kmsKey.getName(), kmsKey.getUuid(), currentActive.getVersionNumber(), newVersion.getVersionNumber())); + + logger.info("KMS key rotation initiated: {} -> new KEK version {} (UUID: {}). " + + "Background job will gradually rewrap {} wrapped key(s)", + kmsKey, newVersion.getVersionNumber(), newVersion.getUuid(), + kmsWrappedKeyDao.countByKmsKeyId(kmsKey.getId())); + + return newVersion.getUuid(); + } + + String rotateKek(KMSKeyVO kmsKey, String oldKekLabel, String newKekLabel, int keyBits, + HSMProfileVO newHSMProfile) throws KMSException { + if (StringUtils.isEmpty(oldKekLabel)) { + throw KMSException.invalidParameter("oldKekLabel must be specified"); + } + + if (newHSMProfile == null) { + newHSMProfile = hsmProfileDao.findById(kmsKey.getHsmProfileId()); + } + + KMSProvider provider = getKMSProvider(newHSMProfile.getProtocol()); + + try { + logger.info("Starting KEK rotation from {} to {} for kms key {}", oldKekLabel, newKekLabel, kmsKey); + + if (StringUtils.isEmpty(newKekLabel)) { + List existingVersions = kmsKekVersionDao.listByKmsKeyId(kmsKey.getId()); + int nextVersion = existingVersions.stream().mapToInt(KMSKekVersionVO::getVersionNumber).max().orElse(0) + + 1; + newKekLabel = kmsKey.getPurpose().generateKekLabel(kmsKey.getDomainId(), kmsKey.getAccountId(), + kmsKey.getUuid(), nextVersion); + } + final KMSKekVersionVO newVersionEntity = new KMSKekVersionVO(kmsKey.getId(), newKekLabel); + + String finalNewKekLabel = newKekLabel; + Long newProfileId = newHSMProfile.getId(); + final HSMProfileVO finalHSMProfile = newHSMProfile; + String newKekId = retryOperation( + () -> provider.createKek(kmsKey.getPurpose(), finalNewKekLabel, keyBits, newProfileId)); + + try { + KMSKekVersionVO newVersion = Transaction + .execute((TransactionCallbackWithException) status -> { + newVersionEntity.setKmsKeyId(kmsKey.getId()); + newVersionEntity.setHsmProfileId(newProfileId); + newVersionEntity.setKekLabel(finalNewKekLabel); + KMSKekVersionVO version = createKekVersion(newVersionEntity); + + if (!newProfileId.equals(kmsKey.getHsmProfileId())) { + kmsKey.setHsmProfileId(newProfileId); + logger.info("Updated KMS key {} to use HSM profile {}", kmsKey, finalHSMProfile); + } + kmsKey.setKekLabel(finalNewKekLabel); + kmsKeyDao.update(kmsKey.getId(), kmsKey); + return version; + }); + + logger.info("KEK rotation: KMS key {} now has {} versions (active: v{}, previous: v{})", + kmsKey, newVersion.getVersionNumber(), newVersion.getVersionNumber(), + newVersion.getVersionNumber() - 1); + + return newKekId; + } catch (KMSException e) { + logger.error( + "Database update failed during KEK rotation for kmsKey {}. Attempting to delete orphaned KEK " + + "{} from provider {}", + kmsKey, newKekId, provider.getProviderName()); + try { + provider.deleteKek(newKekId); + } catch (KMSException ex) { + logger.error("Failed to delete orphaned KEK {} from provider {} after DB failure: {}", + newKekId, provider.getProviderName(), ex.getMessage()); + ActionEventUtils.onCompletedActionEvent(CallContext.current().getCallingUserId(), + kmsKey.getAccountId(), EventVO.LEVEL_WARN, EventTypes.EVENT_KMS_KEY_ROTATE, true, + String.format("Failed to delete orphaned KEK %s from provider after DB rollback failure", newKekId), + kmsKey.getId(), ApiCommandResourceType.KmsKey.toString(), CallContext.current().getStartEventId()); + } + throw e; + } + + } catch (Exception e) { + logger.error("KEK rotation failed for kmsKey {}: {}", kmsKey, e.getMessage()); + throw handleKmsException(e); + } + } + + private KMSKekVersionVO createKekVersion(KMSKekVersionVO newVersion) throws KMSException { + List existingVersions = kmsKekVersionDao.listByKmsKeyId(newVersion.getKmsKeyId()); + int nextVersion = existingVersions.stream() + .mapToInt(KMSKekVersionVO::getVersionNumber) + .max() + .orElse(0) + 1; + + KMSKekVersionVO currentActive = kmsKekVersionDao.getActiveVersion(newVersion.getKmsKeyId()); + if (currentActive != null) { + currentActive.setStatus(KMSKekVersionVO.Status.Previous); + kmsKekVersionDao.update(currentActive.getId(), currentActive); + } + + newVersion.setVersionNumber(nextVersion); + newVersion.setStatus(KMSKekVersionVO.Status.Active); + newVersion = kmsKekVersionDao.persist(newVersion); + logger.info("Created KEK version {} for KMS key {}", nextVersion, newVersion.getKmsKeyId()); + return newVersion; + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_VOLUME_MIGRATE_TO_KMS, eventDescription = "Migrating Volumes to KMS") + public int migrateVolumesToKMS(MigrateVolumesToKMSCmd cmd) throws KMSException { + Account caller = CallContext.current().getCallingAccount(); + String accountName = cmd.getAccountName(); + Long domainId = cmd.getDomainId(); + Long kmsKeyId = cmd.getKmsKeyId(); + List volumeIds = cmd.getVolumeIds(); + + if (domainId != null && StringUtils.isBlank(accountName)) { + throw new InvalidParameterValueException("accountName must be specified when domainId is provided"); + } + + if (CollectionUtils.isEmpty(volumeIds)) { + throw new InvalidParameterValueException("volumeIds must be specified"); + } + + if (kmsKeyId == null) { + throw new InvalidParameterValueException("kmsKeyId must be specified"); + } + + KMSKeyVO kmsKey = findKMSKeyAndCheckAccess(kmsKeyId, caller); + + if (!kmsKey.isEnabled()) { + throw new InvalidParameterValueException("KMS key is not enabled: " + kmsKey.getUuid()); + } + + if (kmsKey.getPurpose() != KeyPurpose.VOLUME_ENCRYPTION) { + throw new InvalidParameterValueException("KMS key purpose must be VOLUME_ENCRYPTION"); + } + + KMSProvider provider; + if (kmsKey.getHsmProfileId() != null) { + HSMProfileVO profile = getHSMProfile(kmsKey.getHsmProfileId()); + if (!profile.isEnabled()) { + throw new InvalidParameterValueException("HSM profile is not enabled: " + profile.getName()); + } + provider = getKMSProvider(profile.getProtocol()); + } else { + provider = getKMSProvider("database"); + } + + KMSKekVersionVO activeVersion = getActiveKekVersion(kmsKey.getId()); + + Long accountId = null; + if (accountName != null) { + accountId = accountManager.finalizeAccountId(accountName, domainId, null, true); + } + + int successCount = 0; + int failureCount = 0; + logger.info("Starting migration of volumes to KMS (zone: {}, account: {}, domain: {})", + kmsKey.getZoneId(), accountId, domainId); + + List volumes; + volumes = volumeDao.listByIds(volumeIds); + accountManager.checkAccess(caller, null, true, volumes.toArray(new Volume[0])); + + List mismatchedVolumeUuids = new ArrayList<>(); + for (VolumeVO volume : volumes) { + if (volume.getAccountId() != kmsKey.getAccountId()) { + mismatchedVolumeUuids.add(volume.getUuid()); + } + } + if (!mismatchedVolumeUuids.isEmpty()) { + throw new InvalidParameterValueException( + "The following volumes do not belong to the same account as the KMS key: " + + String.join(", ", mismatchedVolumeUuids)); + } + + for (VolumeVO volume : volumes) { + try { + if (migrateVolumeToKmsKey(provider, volume, kmsKey, activeVersion)) { + successCount++; + logger.debug("Migrated volume's encryption {} to KMS {}", volume, kmsKey); + } + } catch (Exception e) { + failureCount++; + logger.warn("Failed to migrate volume {}: {}", volume.getId(), e.getMessage()); + } + } + + String details = String.format("Migrated %d volumes to KMS key: %s (uuid: %s). Success: %d, Failures: %d", + successCount + failureCount, kmsKey.getName(), kmsKey.getUuid(), successCount, failureCount); + logger.info(details); + CallContext.current().setEventDetails(details); + return successCount; + } + + private boolean migrateVolumeToKmsKey(KMSProvider provider, VolumeVO volume, KMSKey kmsKey, + KMSKekVersionVO activeVersion) { + if (volume.getAccountId() != kmsKey.getAccountId()) { + throw new InvalidParameterValueException( + "Volume " + volume.getUuid() + " does not belong to the same account as KMS key " + kmsKey.getUuid()); + } + PassphraseVO passphrase = passphraseDao.findById(volume.getPassphraseId()); + if (passphrase == null) { + logger.warn( + "Skipping migration of volume from to the KMS key {} because passphrase id: {} not found for " + + "volume {}", + kmsKey, volume.getPassphraseId(), volume); + return false; + } + + // PassphraseVO.getPassphrase() returns Base64-encoded bytes matching KVM/QEMU + // format + byte[] passphraseBytes = passphrase.getPassphrase(); + try { + WrappedKey wrappedKey = provider.wrapKey( + passphraseBytes, + KeyPurpose.VOLUME_ENCRYPTION, + activeVersion.getKekLabel(), + activeVersion.getHsmProfileId()); + + KMSWrappedKeyVO wrappedKeyVO = new KMSWrappedKeyVO( + kmsKey.getId(), + activeVersion.getId(), + volume.getDataCenterId(), + wrappedKey.getWrappedKeyMaterial()); + wrappedKeyVO = kmsWrappedKeyDao.persist(wrappedKeyVO); + + volume.setKmsWrappedKeyId(wrappedKeyVO.getId()); + volume.setKmsKeyId(kmsKey.getId()); + volume.setPassphraseId(null); + volumeDao.update(volume.getId(), volume); + + ActionEventUtils.onCompletedActionEvent(CallContext.current().getCallingUserId(), + kmsKey.getAccountId(), EventVO.LEVEL_INFO, EventTypes.EVENT_VOLUME_MIGRATE_TO_KMS, true, + String.format("Successfully migrated volume encryption key to KMS key %s (v%d)", kmsKey.getUuid(), activeVersion.getVersionNumber()), + volume.getId(), ApiCommandResourceType.Volume.toString(), CallContext.current().getStartEventId()); + return true; + } finally { + if (passphraseBytes != null) { + Arrays.fill(passphraseBytes, (byte) 0); + } + } + } + + @Override + public boolean deleteKMSKeysByAccountId(Long accountId) { + if (accountId == null) { + logger.warn("Cannot delete KMS keys: account ID is null"); + return false; + } + + try { + List accountKeys = kmsKeyDao.listByAccount(accountId, null, null); + + if (accountKeys == null || accountKeys.isEmpty()) { + logger.debug("No KMS keys found for account {}", accountId); + return true; + } + + logger.info("Deleting {} KMS key(s) for account {}", accountKeys.size(), accountId); + + boolean allDeleted = true; + for (KMSKeyVO key : accountKeys) { + try { + List kekVersions = kmsKekVersionDao.listByKmsKeyId(key.getId()); + if (kekVersions != null && !kekVersions.isEmpty()) { + logger.debug("Deleting {} KEK version(s) from provider for KMS key {}", + kekVersions.size(), key.getUuid()); + for (KMSKekVersionVO kekVersion : kekVersions) { + HSMProfileVO hsmProfile = hsmProfileDao.findById(kekVersion.getHsmProfileId()); + try { + KMSProvider provider = getKMSProvider(hsmProfile.getProtocol()); + provider.deleteKek(kekVersion.getKekLabel()); + logger.debug("Deleted KEK {} (v{}) from provider", + kekVersion.getKekLabel(), kekVersion.getVersionNumber()); + } catch (Exception e) { + logger.warn("Failed to delete KEK {} from provider: {}", + kekVersion.getKekLabel(), e.getMessage()); + ActionEventUtils.onCompletedActionEvent(CallContext.current().getCallingUserId(), + key.getAccountId(), EventVO.LEVEL_WARN, EventTypes.EVENT_KMS_KEY_DELETE, true, + String.format("Failed to delete KEK %s from provider during account KMS cleanup", kekVersion.getKekLabel()), + key.getId(), ApiCommandResourceType.KmsKey.toString(), CallContext.current().getStartEventId()); + } + } + } + + // CASCADE deletes KEK versions and wrapped keys + boolean deleted = kmsKeyDao.remove(key.getId()); + if (deleted) { + logger.debug("Deleted KMS key {} as part of account {} cleanup", key.getUuid(), accountId); + } else { + logger.warn("Failed to delete KMS key {} as part of account {} cleanup", + key.getUuid(), accountId); + allDeleted = false; + } + } catch (Exception e) { + logger.error("Error deleting KMS key {} for account {}: {}", + key.getUuid(), accountId, e.getMessage(), e); + allDeleted = false; + } + } + + if (allDeleted) { + logger.info("Successfully deleted all KMS keys for account {}", accountId); + } else { + logger.warn("Some KMS keys for account {} could not be deleted", accountId); + } + + return allDeleted; + } catch (Exception e) { + logger.error("Error during KMS key cleanup for account {}: {}", accountId, e.getMessage(), e); + return false; + } + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_HSM_PROFILE_CREATE, eventDescription = "Adding HSM profile") + public HSMProfile addHSMProfile(CreateHSMProfileCmd cmd) throws KMSException { + Account caller = CallContext.current().getCallingAccount(); + + String protocol = cmd.getProtocol(); + if (StringUtils.isEmpty(protocol)) { + throw new InvalidParameterValueException("Protocol cannot be empty"); + } + + KMSProvider provider; + try { + provider = getKMSProvider(protocol); + } catch (CloudRuntimeException e) { + throw new InvalidParameterValueException("No provider found for protocol: " + protocol); + } + + Map details = cmd.getDetails() != null ? cmd.getDetails() : new HashMap<>(); + provider.validateProfileConfig(details); + + boolean isPublic = cmd.getIsPublic(); + if (isPublic && !accountManager.isRootAdmin(caller.getId())) { + throw new PermissionDeniedException("Only root admins can create system HSM profiles"); + } + + Long accountId; + Long domainId; + if (StringUtils.isNotBlank(cmd.getAccountName()) || cmd.getProjectId() != null) { + // Account- or project-scoped profile + Account targetAccount = accountManager.finalizeOwner(caller, cmd.getAccountName(), cmd.getDomainId(), + cmd.getProjectId()); + accountId = targetAccount.getId(); + domainId = targetAccount.getDomainId(); + } else if (cmd.getDomainId() != null) { + // Domain-scoped profile: account-less, usable by all accounts directly in the domain. + if (domainDao.findById(cmd.getDomainId()) == null) { + throw new InvalidParameterValueException("Domain not found: " + cmd.getDomainId()); + } + accountId = null; + domainId = cmd.getDomainId(); + } else { + // Fallback: owned by the caller's account. + Account targetAccount = accountManager.finalizeOwner(caller, null, null, null); + accountId = targetAccount.getId(); + domainId = targetAccount.getDomainId(); + } + + HSMProfileVO profile = new HSMProfileVO( + cmd.getName(), + protocol, + accountId, + domainId, + cmd.getZoneId(), + cmd.getVendorName()); + profile.setIsPublic(isPublic); + profile = hsmProfileDao.persist(profile); + + if (cmd.getDetails() != null) { + for (Map.Entry entry : cmd.getDetails().entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + + if (isSensitiveKey(key)) { + value = DBEncryptionUtil.encrypt(value); + } + + hsmProfileDetailsDao.persist(profile.getId(), key, value); + } + } + + CallContext.current().setEventResourceId(profile.getId()); + CallContext.current().setEventResourceType(ApiCommandResourceType.HsmProfile); + CallContext.current().setEventDetails(String.format("Created HSM profile: %s (uuid: %s)", profile.getName(), profile.getUuid())); + + return profile; + } + + @Override + public ListResponse listHSMProfiles(ListHSMProfilesCmd cmd) { + Account caller = CallContext.current().getCallingAccount(); + if (caller == null) { + return new ListResponse<>(); + } + + List permittedAccounts = new ArrayList<>(); + Ternary domainIdRecursiveListProject = new Ternary<>( + cmd.getDomainId(), cmd.isRecursive(), null); + accountManager.buildACLSearchParameters(caller, cmd.getId(), cmd.getAccountName(), + cmd.getProjectId(), permittedAccounts, domainIdRecursiveListProject, + cmd.listAll(), false); + Long domainId = domainIdRecursiveListProject.first(); + Boolean isRecursive = domainIdRecursiveListProject.second(); + ListProjectResourcesCriteria listProjectResourcesCriteria = domainIdRecursiveListProject.third(); + + SearchBuilder sb = getSearchBuilderForHSMProfiles(caller, domainId, isRecursive, + permittedAccounts, listProjectResourcesCriteria, cmd.listAll()); + SearchCriteria sc = getSearchCriteriaForHSMProfiles(sb, cmd, caller, domainId, isRecursive, + permittedAccounts, listProjectResourcesCriteria); + + Filter searchFilter = new Filter(HSMProfileVO.class, "id", true, cmd.getStartIndex(), cmd.getPageSizeVal()); + Pair, Integer> result = hsmProfileDao.searchAndCount(sc, searchFilter); + List profiles = result.first(); + Integer totalCount = result.second(); + + List responses = new ArrayList<>(); + + boolean isRootAdmin = accountManager.isRootAdmin(caller.getId()); + for (HSMProfileVO profile : profiles) { + // When isPublic=true, non-admin users explicitly requested public profiles, so + // don't mark as limited + // When listall=true, also don't mark as limited since user requested all + // profiles + // If the profile is owned by the user, they should see full details even if it + // is a system profile + boolean limited = profile.getIsPublic() && !isRootAdmin && profile.getAccountId() != caller.getId(); + responses.add(createHSMProfileResponse(profile, limited)); + } + + ListResponse listResponse = new ListResponse<>(); + listResponse.setResponses(responses, totalCount); + return listResponse; + } + + SearchBuilder getSearchBuilderForHSMProfiles(Account caller, Long domainId, Boolean isRecursive, + List permittedAccounts, + ListProjectResourcesCriteria listProjectResourcesCriteria, + boolean listAll) { + SearchBuilder sb = hsmProfileDao.createSearchBuilder(); + + sb.and("id", sb.entity().getId(), SearchCriteria.Op.EQ); + sb.and("zoneId", sb.entity().getZoneId(), SearchCriteria.Op.EQ); + sb.and("protocol", sb.entity().getProtocol(), SearchCriteria.Op.EQ); + sb.and("enabled", sb.entity().isEnabled(), SearchCriteria.Op.EQ); + sb.and("isPublic", sb.entity().getIsPublic(), SearchCriteria.Op.EQ); + sb.and("name", sb.entity().getName(), SearchCriteria.Op.LIKE); + + if (listProjectResourcesCriteria == ListProjectResourcesCriteria.SkipProjectResources) { + sb.and().op("accountIdNIN", sb.entity().getAccountId(), SearchCriteria.Op.NIN); + sb.or("accountIdNULL", sb.entity().getAccountId(), SearchCriteria.Op.NULL); + sb.cp(); + } + + boolean isRootAdmin = accountManager.isRootAdmin(caller.getId()); + + if (!isRootAdmin) { + if (listAll) { + // (isPublic = true OR accountId/domainId IN (...) OR (account_id IS NULL AND domain_id = ?)) + sb.and().op("isPublicACL", sb.entity().getIsPublic(), SearchCriteria.Op.EQ); + if (!permittedAccounts.isEmpty()) { + sb.or("accountIdIN", sb.entity().getAccountId(), SearchCriteria.Op.IN); + } else if (domainId != null) { + if (isRecursive) { + sb.or("domainIdIN", sb.entity().getDomainId(), SearchCriteria.Op.IN); + } else { + sb.or("domainIdEQ", sb.entity().getDomainId(), SearchCriteria.Op.EQ); + } + } + sb.or().op("sharedAccountNull", sb.entity().getAccountId(), SearchCriteria.Op.NULL); + sb.and("sharedDomainId", sb.entity().getDomainId(), SearchCriteria.Op.EQ); + sb.cp(); + sb.cp(); + } else { + // No listAll: profiles the user/domain owns, OR domain-shared for caller's domain + if (!permittedAccounts.isEmpty()) { + sb.and().op("accountIdIN", sb.entity().getAccountId(), SearchCriteria.Op.IN); + sb.or().op("sharedAccountNull", sb.entity().getAccountId(), SearchCriteria.Op.NULL); + sb.and("sharedDomainId", sb.entity().getDomainId(), SearchCriteria.Op.EQ); + sb.cp(); + sb.cp(); + } else if (domainId != null) { + if (isRecursive) { + sb.and().op("domainIdIN", sb.entity().getDomainId(), SearchCriteria.Op.IN); + } else { + sb.and().op("domainIdEQ", sb.entity().getDomainId(), SearchCriteria.Op.EQ); + } + sb.or().op("sharedAccountNull", sb.entity().getAccountId(), SearchCriteria.Op.NULL); + sb.and("sharedDomainId", sb.entity().getDomainId(), SearchCriteria.Op.EQ); + sb.cp(); + sb.cp(); + } + } + } else { + if (!permittedAccounts.isEmpty()) { + sb.and("accountIdIN", sb.entity().getAccountId(), SearchCriteria.Op.IN); + } else if (domainId != null) { + if (isRecursive) { + sb.and("domainIdIN", sb.entity().getDomainId(), SearchCriteria.Op.IN); + } else { + sb.and("domainIdEQ", sb.entity().getDomainId(), SearchCriteria.Op.EQ); + } + } + } + + sb.done(); + return sb; + } + + SearchCriteria getSearchCriteriaForHSMProfiles(SearchBuilder searchBuilder, + ListHSMProfilesCmd cmd, Account caller, Long domainId, Boolean isRecursive, List permittedAccounts, + ListProjectResourcesCriteria listProjectResourcesCriteria) { + SearchCriteria sc = searchBuilder.create(); + sc.setParametersIfNotNull("id", cmd.getId()); + sc.setParametersIfNotNull("zoneId", cmd.getZoneId()); + sc.setParametersIfNotNull("protocol", cmd.getProtocol()); + sc.setParametersIfNotNull("enabled", cmd.getEnabled()); + sc.setParametersIfNotNull("isPublic", cmd.getIsPublic()); + if (cmd.getKeyword() != null) { + sc.setParameters("name", "%" + cmd.getKeyword() + "%"); + } + + if (listProjectResourcesCriteria == ListProjectResourcesCriteria.SkipProjectResources) { + SearchCriteria projectAcctSC = accountDao.createSearchCriteria(); + projectAcctSC.addAnd("type", SearchCriteria.Op.EQ, Account.Type.PROJECT); + if (domainId != null) { + if (isRecursive) { + List domainIds = domainDao.getDomainAndChildrenIds(domainId); + projectAcctSC.addAnd("domainId", SearchCriteria.Op.IN, domainIds.toArray()); + } else { + projectAcctSC.addAnd("domainId", SearchCriteria.Op.EQ, domainId); + } + } + List projectAccounts = accountDao.search(projectAcctSC, null); + if (CollectionUtils.isNotEmpty(projectAccounts)) { + List projectAccountIds = projectAccounts.stream().map(AccountVO::getId).collect(Collectors.toList()); + sc.setParameters("accountIdNIN", projectAccountIds.toArray()); + } else { + // No project accounts exist; use a dummy ID so the NIN condition has no effect + sc.setParameters("accountIdNIN", -1L); + } + } + + boolean isRootAdmin = accountManager.isRootAdmin(caller.getId()); + + if (!isRootAdmin) { + if (cmd.listAll()) { + sc.setParameters("isPublicACL", true); + } + + // Domain-shared profiles (account_id IS NULL AND domain_id = caller's domain) are visible + // to all accounts directly in the caller's domain, alongside public and owned profiles. + sc.setParameters("sharedDomainId", caller.getDomainId()); + + if (!permittedAccounts.isEmpty()) { + sc.setParameters("accountIdIN", permittedAccounts.toArray()); + } else if (domainId != null) { + if (isRecursive) { + List domainIds = domainDao.getDomainAndChildrenIds(domainId); + sc.setParameters("domainIdIN", domainIds.toArray()); + } else { + sc.setParameters("domainIdEQ", domainId); + } + } + } else { + if (!permittedAccounts.isEmpty()) { + sc.setParameters("accountIdIN", permittedAccounts.toArray()); + } else if (domainId != null) { + if (isRecursive) { + List domainIds = domainDao.getDomainAndChildrenIds(domainId); + sc.setParameters("domainIdIN", domainIds.toArray()); + } else { + sc.setParameters("domainIdEQ", domainId); + } + } + } + return sc; + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_HSM_PROFILE_DELETE, eventDescription = "Deleting HSM profile") + public boolean deleteHSMProfile(DeleteHSMProfileCmd cmd) throws KMSException { + HSMProfileVO profile = getHSMProfile(cmd.getId()); + Account caller = CallContext.current().getCallingAccount(); + checkHSMProfileAccess(caller, profile, true); + + if (profile.getProtocol().equals(DATABASE_PROVIDER_NAME) + && profile.getAccountId() == Account.ACCOUNT_ID_SYSTEM) { + throw new InvalidParameterValueException("Default database HSM profile cannot be deleted"); + } + + long keyCount = kmsKeyDao.countByHsmProfileId(profile.getId()); + if (keyCount > 0) { + throw new InvalidParameterValueException( + String.format("Cannot delete HSM profile '%s': it is referenced by %d KMS key(s). " + + "Please delete or reassign those keys first.", profile.getName(), keyCount)); + } + + // Check if any KEK versions reference this HSM profile + List kekVersions = kmsKekVersionDao.listByHsmProfileId(profile.getId()); + if (!kekVersions.isEmpty()) { + // Check if any wrapped keys are using these KEK versions + long wrappedKeyCount = 0; + for (KMSKekVersionVO kekVersion : kekVersions) { + wrappedKeyCount += kmsWrappedKeyDao.countByKekVersionId(kekVersion.getId()); + } + if (wrappedKeyCount > 0) { + throw new InvalidParameterValueException( + String.format("Cannot delete HSM profile '%s': it is referenced by %d wrapped key(s) " + + "through KEK versions. Please wait for key rotation to complete or delete those" + + " volumes first.", + profile.getName(), wrappedKeyCount)); + } + } + + getKMSProvider(profile.getProtocol()).invalidateProfileCache(profile.getId()); + hsmProfileDetailsDao.deleteDetails(profile.getId()); + + CallContext.current().setEventResourceId(profile.getId()); + CallContext.current().setEventResourceType(ApiCommandResourceType.HsmProfile); + CallContext.current().setEventDetails(String.format("Deleted HSM profile: %s (uuid: %s)", profile.getName(), profile.getUuid())); + + return hsmProfileDao.remove(profile.getId()); + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_HSM_PROFILE_UPDATE, eventDescription = "Updating HSM profile") + public HSMProfile updateHSMProfile(UpdateHSMProfileCmd cmd) throws KMSException { + HSMProfileVO profile = getHSMProfile(cmd.getId()); + Account caller = CallContext.current().getCallingAccount(); + checkHSMProfileAccess(caller, profile, true); + + if (cmd.getName() != null) { + profile.setName(cmd.getName()); + } + if (cmd.getEnabled() != null) { + profile.setEnabled(cmd.getEnabled()); + } + + hsmProfileDao.update(profile.getId(), profile); + + CallContext.current().setEventResourceId(profile.getId()); + CallContext.current().setEventResourceType(ApiCommandResourceType.HsmProfile); + CallContext.current().setEventDetails(String.format("Updated HSM profile: %s (uuid: %s)", profile.getName(), profile.getUuid())); + + return profile; + } + + @Override + public HSMProfileResponse createHSMProfileResponse(HSMProfile profile) { + return createHSMProfileResponse(profile, false); + } + + private HSMProfileResponse createHSMProfileResponse(HSMProfile profile, boolean limited) { + HSMProfileResponse response = new HSMProfileResponse(); + response.setId(profile.getUuid()); + response.setName(profile.getName()); + response.setVendorName(profile.getVendorName()); + response.setIsPublic(profile.getIsPublic()); + + if (profile.getZoneId() != null) { + DataCenterVO zone = dataCenterDao.findById(profile.getZoneId()); + if (zone != null) { + response.setZoneId(zone.getUuid()); + response.setZoneName(zone.getName()); + } + } + + response.setProtocol(profile.getProtocol()); + response.setEnabled(profile.isEnabled()); + response.setCreated(profile.getCreated()); + + if (profile.getAccountId() == -1) { + // Domain-scoped (account-less) profile: populateOwner would NPE on findAccountById(-1). + DomainVO domain = profile.getDomainId() == -1 ? null : domainDao.findById(profile.getDomainId()); + if (domain != null) { + response.setDomainId(domain.getUuid()); + response.setDomainName(domain.getName()); + response.setDomainPath(ApiResponseHelper.getPrettyDomainPath(domain.getPath())); + } + } else { + ApiResponseHelper.populateOwner(response, profile); + } + + if (!limited) { + List details = hsmProfileDetailsDao.listByProfileId(profile.getId()); + Map detailsMap = new HashMap<>(); + for (HSMProfileDetailsVO detail : details) { + detailsMap.put(detail.getName(), detail.getValue()); + } + response.setDetails(detailsMap); + } + response.setObjectName("hsmprofile"); + return response; + } + + boolean isSensitiveKey(String key) { + return KMSProvider.isSensitiveKey(key); + } + + /** + * Find an HSM profile by ID, throwing InvalidParameterValueException if not + * found. + */ + private HSMProfileVO getHSMProfile(Long profileId) { + HSMProfileVO profile = hsmProfileDao.findById(profileId); + if (profile == null) { + throw new InvalidParameterValueException("HSM Profile not found: " + profileId); + } + return profile; + } + + /** + * Validate caller's access to an HSM profile. + * For system profiles: read/use access is open to all; modify access requires + * root admin. + * For owned profiles: delegates to ACL checkAccess. + */ + void checkHSMProfileAccess(Account caller, HSMProfileVO profile, boolean requireModifyAccess) { + if (profile.getIsPublic()) { + if (requireModifyAccess && !accountManager.isRootAdmin(caller.getId())) { + throw new PermissionDeniedException("Only root admins can modify system HSM profiles"); + } + } else if (profile.getAccountId() == -1 && profile.getDomainId() != -1) { + // Domain-scoped (account-less) profile: usable by accounts directly in the domain, + // modifiable only by root admins. Cannot route through accountManager.checkAccess + // because findById(-1) on the null owner would deny everyone. + boolean isRootAdmin = accountManager.isRootAdmin(caller.getId()); + if (requireModifyAccess) { + if (!isRootAdmin) { + throw new PermissionDeniedException("Only root admins can modify domain-scoped HSM profiles"); + } + } else if (!isRootAdmin && caller.getDomainId() != profile.getDomainId()) { + throw new PermissionDeniedException("Access denied to HSM profile"); + } + } else { + accountManager.checkAccess(caller, null, requireModifyAccess, profile); + } + } + + /** + * Validate that an HSM profile's scope is compatible with a prospective KMS key owner. + * This is a consistency rule (not a caller-permission check): it applies to all callers, + * including root admins, so a key cannot be bound to a profile outside its scope. + * - public profile: usable by any owner. + * - domain-scoped (account-less) profile: the owner must be directly in the profile's domain. + * - account-owned profile: the owner must be that account. + */ + void validateProfileScopeForOwner(HSMProfileVO profile, long ownerAccountId, long ownerDomainId) { + if (profile.getIsPublic()) { + return; + } + if (profile.getAccountId() == -1 && profile.getDomainId() != -1) { + if (ownerDomainId != profile.getDomainId()) { + throw new InvalidParameterValueException(String.format( + "HSM profile '%s' is domain-scoped and can only be used by accounts in its domain", + profile.getName())); + } + } else if (profile.getAccountId() != -1) { + if (ownerAccountId != profile.getAccountId()) { + throw new InvalidParameterValueException(String.format( + "HSM profile '%s' is owned by another account and cannot be used for this key's owner", + profile.getName())); + } + } + } + + /** + * Parse and validate a key purpose string. Returns null if the input is null. + */ + KeyPurpose parseKeyPurpose(String purpose) { + if (purpose == null) { + return null; + } + try { + return KeyPurpose.fromString(purpose); + } catch (IllegalArgumentException e) { + throw new InvalidParameterValueException( + "Invalid purpose: " + purpose + ". Valid values: volume, tls"); + } + } + + T retryOperation(KmsOperation operation) throws Exception { + int maxRetries = getRetryCount(); + int retryDelay = getRetryDelayMs(); + int timeoutSec = getOperationTimeoutSec(); + + Exception lastException = null; + + for (int attempt = 0; attempt <= maxRetries; attempt++) { + Future future = kmsOperationExecutor.submit(operation::execute); + try { + return future.get(timeoutSec, TimeUnit.SECONDS); + } catch (TimeoutException e) { + future.cancel(true); + // Note: if the underlying provider makes a native (JNI/JNA) call, the daemon + // thread may remain blocked until the native call returns even after cancel — + // this is a known JVM limitation. The caller is unblocked regardless. + lastException = KMSException.transientError( + "KMS operation timed out after " + timeoutSec + "s", e); + logger.warn("KMS operation timed out (attempt {}/{}), timeout={}s", + attempt + 1, maxRetries + 1, timeoutSec); + } catch (ExecutionException e) { + future.cancel(true); + Throwable cause = e.getCause(); + lastException = (cause instanceof Exception) ? (Exception) cause : e; + + if (lastException instanceof KMSException && !((KMSException) lastException).isRetryable()) { + throw lastException; + } + + logger.warn("KMS operation failed (attempt {}/{}): {}", + attempt + 1, maxRetries + 1, lastException.getMessage()); + } catch (InterruptedException e) { + future.cancel(true); + Thread.currentThread().interrupt(); + throw new CloudRuntimeException("Interrupted while waiting for KMS operation", e); + } + + if (attempt < maxRetries) { + try { + Thread.sleep((long) retryDelay * (attempt + 1)); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new CloudRuntimeException("Interrupted during KMS retry delay", ie); + } + } else { + logger.error("KMS operation failed after {} attempt(s)", maxRetries + 1); + } + } + + if (lastException != null) { + throw lastException; + } + + throw new CloudRuntimeException("KMS operation failed with no exception details"); + } + + protected int getOperationTimeoutSec() { + return KMSOperationTimeoutSec.value(); + } + + protected int getRetryCount() { + return KMSRetryCount.value(); + } + + protected int getRetryDelayMs() { + return KMSRetryDelayMs.value(); + } + + private KMSException handleKmsException(Exception e) { + if (e instanceof KMSException) { + return (KMSException) e; + } + return KMSException.transientError("KMS operation failed: " + e.getMessage(), e); + } + + /** + * Find a KMS key by ID and verify the caller has access to it. + * Throws {@link InvalidParameterValueException} if the key does not exist + * and {@link PermissionDeniedException} if the caller lacks access. + * + * @return the resolved {@link KMSKeyVO} + */ + KMSKeyVO findKMSKeyAndCheckAccess(Long keyId, Account caller) { + KMSKeyVO key = kmsKeyDao.findById(keyId); + if (key == null) { + throw new InvalidParameterValueException("KMS key not found: " + keyId); + } + accountManager.checkAccess(caller, null, true, key); + return key; + } + + public void setKmsProviders(List kmsProviders) { + this.kmsProviders = kmsProviders; + initializeKmsProviderMap(); + } + + private void initializeKmsProviderMap() { + if (kmsProviders == null) { + return; + } + kmsProviderMap.clear(); + for (KMSProvider provider : kmsProviders) { + if (provider != null) { + kmsProviderMap.put(provider.getProviderName().toLowerCase(), provider); + logger.info("Registered KMS provider: {}", provider.getProviderName()); + } + } + } + + @Override + public boolean start() { + super.start(); + initializeKmsProviderMap(); + kmsOperationExecutor = new ThreadPoolExecutor( + KMSOperationPoolCoreSize.value(), + KMSOperationPoolMaxSize.value(), + 60L, TimeUnit.SECONDS, new SynchronousQueue<>(), r -> { + Thread t = new Thread(r, "kms-operation"); + t.setDaemon(true); + return t; + }); + + for (KMSProvider provider : kmsProviderMap.values()) { + if (provider != null) { + try { + boolean healthy = provider.healthCheck(); + if (healthy) { + logger.info("KMS provider {} health check passed", provider.getProviderName()); + } else { + logger.warn("KMS provider {} health check failed", provider.getProviderName()); + } + } catch (Exception e) { + logger.warn("KMS provider {} health check error: {}", provider.getProviderName(), e.getMessage()); + } + } + } + + scheduleRewrapWorker(); + + return true; + } + + private void scheduleRewrapWorker() { + long intervalMs = KMSRewrapIntervalMs.value(); + if (intervalMs <= 0) { + return; + } + + rewrapExecutor = Executors.newScheduledThreadPool(1, r -> { + Thread t = new Thread(r, "KMSRewrapWorker"); + t.setDaemon(true); + return t; + }); + + rewrapExecutor.scheduleAtFixedRate(new ManagedContextRunnable() { + @Override + protected void runInContext() { + try { + processRewrapBatch(); + } catch (final Exception e) { + logger.error("Error while running KMS rewrap worker", e); + } + } + }, 10000L, intervalMs, TimeUnit.MILLISECONDS); + + logger.info("KMS rewrap worker scheduled with interval: {} ms", intervalMs); + } + + /** + * Finds KEK versions marked as Previous and gradually rewraps wrapped keys + * using the active version. + */ + private void processRewrapBatch() { + GlobalLock lock = GlobalLock.getInternLock("kms.rewrap.worker"); + try { + if (lock.lock(5)) { + try { + List previousVersions = kmsKekVersionDao + .findByStatus(KMSKekVersionVO.Status.Previous); + + if (previousVersions.isEmpty()) { + logger.trace("No KEK versions pending rewrap"); + return; + } + + logger.debug("Found {} KEK version(s) with status Previous - processing rewrap batches", + previousVersions.size()); + + int batchSize = KMSRewrapBatchSize.value(); + + for (KMSKekVersionVO oldVersion : previousVersions) { + try { + processVersionRewrap(oldVersion, batchSize); + } catch (Exception e) { + logger.error("Error processing rewrap for KEK version {}: {}", oldVersion, e.getMessage(), + e); + } + } + } finally { + lock.unlock(); + } + } else { + logger.trace("KMS rewrap worker: could not acquire cluster lock, skipping batch"); + } + } catch (Exception e) { + logger.error("Error in rewrap worker: {}", e.getMessage(), e); + } finally { + lock.releaseRef(); + } + } + + private void processVersionRewrap(KMSKekVersionVO oldVersion, int batchSize) throws KMSException { + KMSKeyVO kmsKey = kmsKeyDao.findById(oldVersion.getKmsKeyId()); + if (kmsKey == null) { + logger.warn("KMS key not found for KEK version {}, skipping", oldVersion); + return; + } + + KMSKekVersionVO activeVersion = kmsKekVersionDao.getActiveVersion(oldVersion.getKmsKeyId()); + if (activeVersion == null) { + logger.warn("No active KEK version found for KMS key {}, skipping", kmsKey); + return; + } + + List keysToRewrap = kmsWrappedKeyDao.listByKekVersionId(oldVersion.getId(), batchSize); + + if (keysToRewrap.isEmpty()) { + logger.info("All wrapped keys rewrapped for KEK version {} (v{}) - archiving and deleting from provider", + oldVersion.getUuid(), oldVersion.getVersionNumber()); + + oldVersion.setStatus(KMSKekVersionVO.Status.Archived); + kmsKekVersionDao.update(oldVersion.getId(), oldVersion); + + // Delete the old KEK from the HSM since no wrapped keys reference it anymore + try { + HSMProfileVO oldProfile = hsmProfileDao.findById(oldVersion.getHsmProfileId()); + if (oldProfile != null) { + KMSProvider provider = getKMSProvider(oldProfile.getProtocol()); + provider.deleteKek(oldVersion.getKekLabel()); + logger.info("Deleted archived KEK {} (v{}) from provider {}", + oldVersion.getKekLabel(), oldVersion.getVersionNumber(), provider.getProviderName()); + + ActionEventUtils.onCompletedActionEvent(CallContext.current().getCallingUserId(), + kmsKey.getAccountId(), EventVO.LEVEL_INFO, EventTypes.EVENT_KMS_KEY_DELETE, true, + String.format("Deleted archived KEK %s from provider after all wrapped keys were rewrapped", oldVersion.getKekLabel()), + kmsKey.getId(), ApiCommandResourceType.KmsKey.toString(), CallContext.current().getStartEventId()); + } + } catch (Exception e) { + logger.warn("Failed to delete archived KEK {} (v{}) from provider: {}", + oldVersion.getKekLabel(), oldVersion.getVersionNumber(), e.getMessage()); + ActionEventUtils.onCompletedActionEvent(CallContext.current().getCallingUserId(), + kmsKey.getAccountId(), EventVO.LEVEL_WARN, EventTypes.EVENT_KMS_KEY_DELETE, true, + String.format("Failed to delete archived KEK %s from provider: %s", oldVersion.getKekLabel(), e.getMessage()), + kmsKey.getId(), ApiCommandResourceType.KmsKey.toString(), CallContext.current().getStartEventId()); + } + + return; + } + + HSMProfileVO hsmProfile = hsmProfileDao.findById(activeVersion.getHsmProfileId()); + KMSProvider provider = getKMSProvider(hsmProfile.getProtocol()); + + int successCount = 0; + int failureCount = 0; + + for (KMSWrappedKeyVO wrappedKeyVO : keysToRewrap) { + try { + rewrapSingleKey(wrappedKeyVO, kmsKey, activeVersion, provider); + successCount++; + } catch (Exception e) { + failureCount++; + logger.warn("Failed to rewrap key {} for KMS key {}: {}", + wrappedKeyVO.getId(), kmsKey, e.getMessage()); + // Continue with next key - will retry in next run + } + } + + logger.info("Rewrapped batch for KMS key {} (KEK v{} -> v{}): {} success, {} failures", + kmsKey, oldVersion.getVersionNumber(), activeVersion.getVersionNumber(), + successCount, failureCount); + } + + void rewrapSingleKey(KMSWrappedKeyVO wrappedKeyVO, KMSKeyVO kmsKey, + KMSKekVersionVO newVersion, KMSProvider provider) { + byte[] dek = null; + try { + dek = unwrapKey(wrappedKeyVO.getId()); + + WrappedKey newWrapped = provider.wrapKey( + dek, + kmsKey.getPurpose(), + newVersion.getKekLabel(), + newVersion.getHsmProfileId()); + + wrappedKeyVO.setKekVersionId(newVersion.getId()); + wrappedKeyVO.setWrappedBlob(newWrapped.getWrappedKeyMaterial()); + kmsWrappedKeyDao.update(wrappedKeyVO.getId(), wrappedKeyVO); + + ActionEventUtils.onCompletedActionEvent(CallContext.current().getCallingUserId(), kmsKey.getAccountId(), + EventVO.LEVEL_INFO, EventTypes.EVENT_KMS_KEY_WRAP, true, + String.format("Rewrapped %s key (wrapped key uuid: %s) with new KEK version %d", kmsKey.getPurpose().getName(), wrappedKeyVO.getUuid(), newVersion.getVersionNumber()), + kmsKey.getId(), ApiCommandResourceType.KmsKey.toString(), CallContext.current().getStartEventId()); + } finally { + if (dek != null) { + Arrays.fill(dek, (byte) 0); + } + } + } + + @Override + public boolean stop() { + if (rewrapExecutor != null) { + rewrapExecutor.shutdownNow(); + rewrapExecutor = null; + } + kmsOperationExecutor.shutdownNow(); + return super.stop(); + } + + @Override + public String getConfigComponentName() { + return KMSManager.class.getSimpleName(); + } + + @Override + public ConfigKey[] getConfigKeys() { + return new ConfigKey[]{ + KMSDekSizeBits, + KMSRetryCount, + KMSRetryDelayMs, + KMSOperationTimeoutSec, + KMSRewrapBatchSize, + KMSRewrapIntervalMs, + KMSOperationPoolCoreSize, + KMSOperationPoolMaxSize, + }; + } + + @Override + public List> getCommands() { + List> cmdList = new ArrayList<>(); + cmdList.add(ListKMSKeysCmd.class); + cmdList.add(CreateKMSKeyCmd.class); + cmdList.add(UpdateKMSKeyCmd.class); + cmdList.add(DeleteKMSKeyCmd.class); + cmdList.add(RotateKMSKeyCmd.class); + cmdList.add(MigrateVolumesToKMSCmd.class); + cmdList.add(CreateHSMProfileCmd.class); + cmdList.add(ListHSMProfilesCmd.class); + cmdList.add(UpdateHSMProfileCmd.class); + cmdList.add(DeleteHSMProfileCmd.class); + + return cmdList; + } + + @FunctionalInterface + interface KmsOperation { + T execute() throws Exception; + } +} diff --git a/server/src/main/java/org/apache/cloudstack/schedule/BaseScheduleWorker.java b/server/src/main/java/org/apache/cloudstack/schedule/BaseScheduleWorker.java new file mode 100644 index 00000000000..29b0d683c87 --- /dev/null +++ b/server/src/main/java/org/apache/cloudstack/schedule/BaseScheduleWorker.java @@ -0,0 +1,417 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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.schedule; + +import com.cloud.api.ApiGsonHelper; +import com.cloud.event.ActionEventUtils; +import com.cloud.event.EventTypes; +import com.cloud.user.User; +import com.cloud.utils.DateUtil; +import com.cloud.utils.component.ComponentContext; +import com.cloud.utils.component.ManagerBase; +import com.cloud.utils.db.GlobalLock; +import com.google.common.primitives.Longs; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.jobs.AsyncJobDispatcher; +import org.apache.cloudstack.framework.jobs.AsyncJobManager; +import org.apache.cloudstack.framework.jobs.impl.AsyncJobVO; +import org.apache.cloudstack.managed.context.ManagedContextTimerTask; +import org.apache.cloudstack.schedule.dao.ResourceScheduleDao; +import org.apache.cloudstack.schedule.dao.ResourceScheduledJobDao; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang.time.DateUtils; +import org.springframework.scheduling.support.CronExpression; + +import javax.inject.Inject; +import javax.persistence.EntityExistsException; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Timer; +import java.util.TimerTask; + +/** + * Base class for per-resource-type schedule workers. + * Each subclass owns a dedicated {@link Timer} and {@link GlobalLock} keyed by + * its resource type, so VM scheduling and AutoScale scheduling (for example) run + * independently and cannot block each other. + */ +public abstract class BaseScheduleWorker extends ManagerBase { + + public static final ConfigKey ScheduledJobExpireInterval = new ConfigKey<>( + ConfigKey.CATEGORY_ADVANCED, Integer.class, + "scheduler.jobs.expire.interval", "30", + "Scheduled job expiry interval in days (applies to all resource-type schedulers)", true); + + @Inject + protected ResourceScheduleDao resourceScheduleDao; + + @Inject + protected ResourceScheduledJobDao resourceScheduledJobDao; + + @Inject + protected AsyncJobManager asyncJobManager; + + protected AsyncJobDispatcher asyncJobDispatcher; + private Timer schedulerTimer; + protected Date currentTimestamp; + + public AsyncJobDispatcher getAsyncJobDispatcher() { + return asyncJobDispatcher; + } + + public void setAsyncJobDispatcher(final AsyncJobDispatcher dispatcher) { + asyncJobDispatcher = dispatcher; + } + + /** + * The API resource type this worker handles (e.g. {@code ApiCommandResourceType.VirtualMachine}). + */ + public abstract ApiCommandResourceType getApiResourceType(); + + /** + * Convenience method returning {@code getApiResourceType().name()} for use in DAO queries, locks, and logging. + */ + protected final String getResourceTypeName() { + return getApiResourceType().name(); + } + + /** + * Execute the action described by {@code job} against the owning resource. + * + * @return the async-job id, or {@code null} if the job was skipped. + */ + protected abstract Long processJob(ResourceScheduledJobVO job); + + /** + * Get a human-readable description of the schedule, for use in the UI and audit logs. + */ + public String getDescription(ResourceSchedule.Action parsedAction, CronExpression cronExpression, Map details) { + return String.format("%s - %s", parsedAction.name(), DateUtil.getHumanReadableSchedule(cronExpression)); + } + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + @Override + public boolean start() { + currentTimestamp = DateUtils.addMinutes(new Date(), 1); + scheduleNextJobs(currentTimestamp); + + final TimerTask pollTask = new ManagedContextTimerTask() { + @Override + protected void runInContext() { + try { + poll(new Date()); + } catch (final Throwable t) { + logger.warn("Uncaught throwable in {} scheduler", getResourceTypeName(), t); + } + } + }; + + schedulerTimer = new Timer(getResourceTypeName() + "SchedulerPollTask"); + schedulerTimer.scheduleAtFixedRate(pollTask, 5000L, 60 * 1000L); + return true; + } + + @Override + public boolean stop() { + if (schedulerTimer != null) { + schedulerTimer.cancel(); + } + return true; + } + + // ------------------------------------------------------------------------- + // Poll loop (identical structure for every resource type) + // ------------------------------------------------------------------------- + + public void poll(Date timestamp) { + currentTimestamp = DateUtils.round(timestamp, Calendar.MINUTE); + String displayTime = DateUtil.displayDateInTimezone(DateUtil.GMT_TIMEZONE, currentTimestamp); + logger.debug("{} scheduler poll at {}", getResourceTypeName(), displayTime); + + GlobalLock scanLock = GlobalLock.getInternLock("resourceScheduler.poll." + getResourceTypeName()); + try { + if (scanLock.lock(30)) { + try { + scheduleNextJobs(currentTimestamp); + } finally { + scanLock.unlock(); + } + } + } finally { + scanLock.releaseRef(); + } + + scanLock = GlobalLock.getInternLock("resourceScheduler.poll." + getResourceTypeName()); + try { + if (scanLock.lock(30)) { + try { + startJobs(); + } finally { + scanLock.unlock(); + } + } + } finally { + scanLock.releaseRef(); + } + + try { + cleanupScheduledJobs(); + } catch (Exception e) { + logger.warn("Error cleaning up scheduled jobs for {}", getResourceTypeName(), e); + } + } + + // ------------------------------------------------------------------------- + // Scheduling helpers + // ------------------------------------------------------------------------- + + private void scheduleNextJobs(Date timestamp) { + for (ResourceScheduleVO schedule : resourceScheduleDao.listAllActiveSchedules(getApiResourceType())) { + try { + scheduleNextJob(schedule, timestamp); + } catch (Exception e) { + logger.warn("Error scheduling next job for schedule {}", schedule, e); + } + } + } + + public Date scheduleNextJob(ResourceScheduleVO schedule, Date timestamp) { + if (!schedule.getEnabled()) { + logger.debug("Schedule {} is disabled. Skipping.", schedule); + return null; + } + + CronExpression cron = DateUtil.parseSchedule(schedule.getSchedule()); + Date startDate = schedule.getStartDate(); + Date endDate = schedule.getEndDate(); + + if (!isResourceValid(schedule.getResourceId())) { + logger.info("Resource id={} is no longer valid. Disabling schedule {}.", schedule.getResourceId(), schedule); + disableSchedule(schedule, "owning resource is no longer valid"); + return null; + } + + ZonedDateTime now = (timestamp != null) + ? ZonedDateTime.ofInstant(timestamp.toInstant(), schedule.getTimeZoneId()) + : ZonedDateTime.now(schedule.getTimeZoneId()); + ZonedDateTime zonedStart = ZonedDateTime.ofInstant(startDate.toInstant(), schedule.getTimeZoneId()); + ZonedDateTime zonedEnd = (endDate != null) + ? ZonedDateTime.ofInstant(endDate.toInstant(), schedule.getTimeZoneId()) + : null; + + if (zonedEnd != null && now.isAfter(zonedEnd)) { + logger.info("End time has passed. Disabling schedule {}.", schedule); + disableSchedule(schedule, String.format("end date %s has passed", zonedEnd)); + return null; + } + + ZonedDateTime ts = zonedStart.isAfter(now) ? cron.next(zonedStart) : cron.next(now); + if (ts == null) { + logger.info("No next schedule time found. Disabling schedule {}.", schedule); + disableSchedule(schedule, "schedule has no further occurrences"); + return null; + } + + if (zonedEnd != null && ts.isAfter(zonedEnd)) { + logger.info("Next schedule time {} is after end time {}. No more jobs to schedule for {}.", ts, zonedEnd, schedule); + return null; + } + + Date scheduledDateTime = Date.from(ts.toInstant()); + ResourceScheduledJobVO existingJob = resourceScheduledJobDao.findByScheduleAndTimestamp(schedule.getId(), scheduledDateTime); + if (existingJob != null) { + logger.trace("Job already scheduled for {} at {}", schedule, scheduledDateTime); + return scheduledDateTime; + } + + ResourceScheduledJobVO job = new ResourceScheduledJobVO( + getApiResourceType(), schedule.getResourceId(), schedule.getId(), + schedule.getActionName(), scheduledDateTime); + try { + resourceScheduledJobDao.persist(job); + long accountId = getEntityOwnerId(schedule.getResourceId()); + ActionEventUtils.onScheduledActionEvent( + User.UID_SYSTEM, accountId, + parseAction(schedule.getActionName()).getEventType(), + String.format("Scheduled action %s as part of Resource Schedule (uuid=%s description='%s' schedule='%s') for %s (id=%d) at %s", + schedule.getActionName(), schedule.getUuid(), schedule.getDescription(), + schedule.getSchedule(), getResourceTypeName(), schedule.getResourceId(), scheduledDateTime), + schedule.getResourceId(), getResourceTypeName(), true, 0); + } catch (EntityExistsException e) { + logger.debug("Job already scheduled (concurrent insert)."); + } + return scheduledDateTime; + } + + public void updateScheduledJob(ResourceScheduleVO schedule) { + removeScheduledJobs(Longs.asList(schedule.getId())); + scheduleNextJob(schedule, new Date()); + } + + public void removeScheduledJobs(List scheduleIds) { + if (CollectionUtils.isEmpty(scheduleIds)) { + return; + } + int removed = resourceScheduledJobDao.expungeJobsForSchedules(scheduleIds, new Date()); + logger.debug("Removed {} scheduled jobs for schedules {}", removed, scheduleIds); + } + + // ------------------------------------------------------------------------- + // Job execution + // ------------------------------------------------------------------------- + + private void startJobs() { + String displayTime = DateUtil.displayDateInTimezone(DateUtil.GMT_TIMEZONE, currentTimestamp); + List jobs = resourceScheduledJobDao.listJobsToStart(getApiResourceType(), currentTimestamp); + logger.debug("Got {} scheduled jobs for {} at {}", jobs.size(), getResourceTypeName(), displayTime); + + Map toExecute = new HashMap<>(); + Map> toSkip = new HashMap<>(); + + for (ResourceScheduledJobVO job : jobs) { + long resourceId = job.getResourceId(); + if (toExecute.get(resourceId) == null) { + toExecute.put(resourceId, job); + } else { + toSkip.computeIfAbsent(resourceId, k -> new ArrayList<>()).add(job); + } + } + + executeJobs(toExecute); + logSkippedJobs(toExecute, toSkip); + } + + public void executeJobs(Map jobsToExecute) { + for (Map.Entry entry : jobsToExecute.entrySet()) { + ResourceScheduledJobVO job = entry.getValue(); + ResourceScheduledJobVO locked = null; + try { + locked = resourceScheduledJobDao.acquireInLockTable(job.getId()); + Long jobId = processJob(job); + if (jobId != null) { + locked.setAsyncJobId(jobId); + resourceScheduledJobDao.update(job.getId(), locked); + } + } catch (Exception e) { + logger.warn("Failed executing scheduled job {}", job, e); + } finally { + if (locked != null) { + resourceScheduledJobDao.releaseFromLockTable(job.getId()); + } + } + } + } + + private void logSkippedJobs(Map executed, + Map> skipped) { + for (Map.Entry> entry : skipped.entrySet()) { + long resourceId = entry.getKey(); + ResourceScheduledJobVO running = executed.get(resourceId); + for (ResourceScheduledJobVO s : entry.getValue()) { + logger.info("Skipping job {} for resource {} — conflict with {}", s, resourceId, running); + } + } + } + + private void cleanupScheduledJobs() { + Date deleteBeforeDate = DateUtils.addDays(currentTimestamp, + -1 * ScheduledJobExpireInterval.value()); + int removed = resourceScheduledJobDao.expungeJobsBefore(getApiResourceType(), deleteBeforeDate); + logger.info("Cleaned up {} scheduled job entries for {}", removed, getResourceTypeName()); + } + + // ------------------------------------------------------------------------- + // Subclass helpers + // ------------------------------------------------------------------------- + + private void disableSchedule(ResourceScheduleVO schedule, String reason) { + schedule.setEnabled(false); + resourceScheduleDao.persist(schedule); + long accountId = getEntityOwnerId(schedule.getResourceId()); + ActionEventUtils.onScheduledActionEvent( + User.UID_SYSTEM, accountId, EventTypes.EVENT_SCHEDULE_UPDATE, + String.format("Auto-disabled resource schedule (uuid=%s description='%s') for %s (id=%d): %s", + schedule.getUuid(), schedule.getDescription(), + getResourceTypeName(), schedule.getResourceId(), reason), + schedule.getResourceId(), getResourceTypeName(), true, 0); + } + + public abstract boolean isResourceValid(long resourceId); + + public abstract long getEntityOwnerId(long resourceId); + + /** + * Parses an action string into the resource-type-specific typed action constant. Throws InvalidParameterValueException for unknown values. + */ + public abstract ResourceSchedule.Action parseAction(String action); + + /** + * Validates action-specific detail parameters. Throws InvalidParameterValueException on failure. + */ + public abstract void validateDetails(ResourceSchedule.Action action, Map details); + + /** + * Submits an async job for the given command class. + * + * @param cmdClass the command to dispatch + * @param accountId account submitting the job + * @param resourceId primary resource id (written to AsyncJobVO.instanceId) + * @param eventId the start-event id for correlation + * @param extra additional parameters (e.g. "forced" -> "true") + * @return the async job id + */ + public long submitAsyncJob( + Class cmdClass, long accountId, long resourceId, long eventId, + Map extra + ) { + Map params = new HashMap<>(extra); + params.put(ApiConstants.ID, String.valueOf(resourceId)); + params.put("ctxUserId", "1"); + params.put("ctxAccountId", String.valueOf(accountId)); + params.put(ApiConstants.CTX_START_EVENT_ID, String.valueOf(eventId)); + + T cmd; + try { + cmd = cmdClass.getDeclaredConstructor().newInstance(); + } catch (Exception e) { + throw new RuntimeException("Failed to instantiate " + cmdClass.getName(), e); + } + ComponentContext.inject(cmd); + + AsyncJobVO job = new AsyncJobVO("", User.UID_SYSTEM, accountId, + cmdClass.getName(), + ApiGsonHelper.getBuilder().create().toJson(params), + resourceId, + cmd.getApiResourceType() != null ? cmd.getApiResourceType().toString() : null, + null); + job.setDispatcher(asyncJobDispatcher.getName()); + return asyncJobManager.submitAsyncJob(job); + } +} diff --git a/server/src/main/java/org/apache/cloudstack/schedule/ResourceScheduleManagerImpl.java b/server/src/main/java/org/apache/cloudstack/schedule/ResourceScheduleManagerImpl.java new file mode 100644 index 00000000000..db08088feca --- /dev/null +++ b/server/src/main/java/org/apache/cloudstack/schedule/ResourceScheduleManagerImpl.java @@ -0,0 +1,450 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.schedule; + +import com.cloud.api.query.MutualExclusiveIdsManagerBase; +import com.cloud.event.ActionEvent; +import com.cloud.event.EventTypes; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.user.AccountManager; +import com.cloud.utils.DateUtil; +import com.cloud.utils.Pair; +import com.cloud.utils.component.PluggableService; +import com.cloud.utils.db.EntityManager; +import com.cloud.utils.db.Transaction; +import com.cloud.utils.db.TransactionCallback; +import com.cloud.utils.exception.CloudRuntimeException; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; +import org.apache.cloudstack.api.command.user.schedule.CreateResourceScheduleCmd; +import org.apache.cloudstack.api.command.user.schedule.DeleteResourceScheduleCmd; +import org.apache.cloudstack.api.command.user.schedule.ListResourceScheduleCmd; +import org.apache.cloudstack.api.command.user.schedule.UpdateResourceScheduleCmd; +import org.apache.cloudstack.api.command.user.vm.CreateVMScheduleCmd; +import org.apache.cloudstack.api.command.user.vm.DeleteVMScheduleCmd; +import org.apache.cloudstack.api.command.user.vm.ListVMScheduleCmd; +import org.apache.cloudstack.api.command.user.vm.UpdateVMScheduleCmd; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.ResourceScheduleResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.config.Configurable; +import org.apache.cloudstack.schedule.dao.ResourceScheduleDao; +import org.apache.cloudstack.schedule.dao.ResourceScheduleDetailsDao; +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.lang.time.DateUtils; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.scheduling.support.CronExpression; + +import javax.inject.Inject; +import javax.naming.ConfigurationException; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TimeZone; +import java.util.stream.Collectors; + +public class ResourceScheduleManagerImpl extends MutualExclusiveIdsManagerBase implements ResourceScheduleManager, PluggableService, Configurable { + + @Inject + private ResourceScheduleDao resourceScheduleDao; + + @Inject + private ResourceScheduleDetailsDao resourceScheduleDetailsDao; + + @Inject + private AccountManager accountManager; + + @Inject + private EntityManager entityManager; + + @Inject + private List workerList; + + private Map workerMap; + + @Override + public boolean configure(String name, Map params) throws ConfigurationException { + workerMap = new HashMap<>(); + if (workerList != null) { + for (BaseScheduleWorker worker : workerList) { + workerMap.put(worker.getApiResourceType(), worker); + } + } + return super.configure(name, params); + } + + private BaseScheduleWorker getWorker(ApiCommandResourceType resourceType) { + BaseScheduleWorker worker = workerMap.get(resourceType); + if (worker == null) { + throw new InvalidParameterValueException("Scheduling is not supported for resource type: " + resourceType); + } + return worker; + } + + @Override + public List> getCommands() { + final List> cmdList = new ArrayList<>(); + cmdList.add(CreateVMScheduleCmd.class); + cmdList.add(ListVMScheduleCmd.class); + cmdList.add(UpdateVMScheduleCmd.class); + cmdList.add(DeleteVMScheduleCmd.class); + cmdList.add(CreateResourceScheduleCmd.class); + cmdList.add(ListResourceScheduleCmd.class); + cmdList.add(UpdateResourceScheduleCmd.class); + cmdList.add(DeleteResourceScheduleCmd.class); + return cmdList; + } + + @Override + public String getConfigComponentName() { + return ResourceScheduleManager.class.getSimpleName(); + } + + @Override + public ConfigKey[] getConfigKeys() { + return new ConfigKey[]{ + BaseScheduleWorker.ScheduledJobExpireInterval + }; + } + + // Helper to resolve UUID string to internal ID + private long resolveResourceId(String resourceIdStr, Class entityClass) { + if (entityClass == null) { + throw new CloudRuntimeException("Entity class is required to resolve resource ID"); + } + Object obj = entityManager.findByUuid(entityClass, resourceIdStr); + if (obj == null) { + try { + long id = Long.parseLong(resourceIdStr); + obj = entityManager.findById(entityClass, id); + if (obj == null) { + throw new InvalidParameterValueException("Unable to find resource by id " + resourceIdStr); + } + } catch (NumberFormatException e) { + throw new InvalidParameterValueException("Unable to find resource by id " + resourceIdStr); + } + } + return ((InternalIdentity) obj).getId(); + } + + private String getResourceUuid(long internalId, Class entityClass) { + if (entityClass != null) { + Object obj = entityManager.findById(entityClass, internalId); + if (obj instanceof Identity) { + return ((Identity) obj).getUuid(); + } + } + return String.valueOf(internalId); + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_SCHEDULE_CREATE, eventDescription = "Creating Resource Schedule", create = true) + public ResourceScheduleResponse createSchedule(ApiCommandResourceType resourceType, String resourceUuid, String description, + String schedule, String timeZoneStr, String action, + Date cmdStartDate, Date cmdEndDate, boolean enabled, + Map details) { + BaseScheduleWorker worker = getWorker(resourceType); + + long internalResourceId = resolveResourceId(resourceUuid, worker.getApiResourceType().getAssociatedClass()); + + if (!worker.isResourceValid(internalResourceId)) { + throw new InvalidParameterValueException("Invalid or non-existent resource: " + resourceUuid); + } + + long ownerId = worker.getEntityOwnerId(internalResourceId); + accountManager.checkAccess(CallContext.current().getCallingAccount(), null, false, accountManager.getAccount(ownerId)); + + ResourceSchedule.Action parsedAction = worker.parseAction(action); + + worker.validateDetails(parsedAction, details); + + TimeZone timeZone = TimeZone.getTimeZone(timeZoneStr); + String timeZoneId = timeZone.getID(); + Date startDate = DateUtils.addMinutes(new Date(), 1); + if (cmdStartDate != null) { + startDate = Date.from(DateUtil.getZoneDateTime(cmdStartDate, timeZone.toZoneId()).toInstant()); + } + Date endDate = null; + if (cmdEndDate != null) { + endDate = Date.from(DateUtil.getZoneDateTime(cmdEndDate, timeZone.toZoneId()).toInstant()); + } + + CronExpression cronExpression = DateUtil.parseSchedule(schedule); + validateStartDateEndDate(startDate, endDate, timeZone); + + if (StringUtils.isBlank(description)) { + description = worker.getDescription(parsedAction, cronExpression, details); + } + + logger.warn("Using timezone [{}] for running the schedule for resource [{}], as an equivalent of [{}].", + timeZoneId, resourceUuid, timeZoneStr); + + String finalDescription = description; + String finalAction = parsedAction.name(); + Date finalStartDate = startDate; + Date finalEndDate = endDate; + + return Transaction.execute((TransactionCallback) status -> { + ResourceScheduleVO scheduleVO = resourceScheduleDao.persist(new ResourceScheduleVO( + resourceType, internalResourceId, + finalDescription, cronExpression.toString(), timeZoneId, + finalAction, finalStartDate, finalEndDate, enabled)); + + if (MapUtils.isNotEmpty(details)) { + List detailVOs = new ArrayList<>(); + for (Map.Entry entry : details.entrySet()) { + detailVOs.add(new ResourceScheduleDetailVO(scheduleVO.getId(), entry.getKey(), entry.getValue(), true)); + } + resourceScheduleDetailsDao.saveDetails(detailVOs); + } + + worker.scheduleNextJob(scheduleVO, new Date()); + + CallContext.current().setEventResourceId(internalResourceId); + CallContext.current().setEventResourceType(worker.getApiResourceType()); + CallContext.current().setEventDetails(String.format("Created resource schedule %s", scheduleVO)); + return createResponse(scheduleVO, details); + }); + } + + ResourceScheduleResponse createResponse(ResourceSchedule schedule, Map details) { + if (details == null || details.isEmpty()) { + details = resourceScheduleDetailsDao.listDetailsKeyPairs(schedule.getId(), true); + } + + BaseScheduleWorker worker = getWorker(schedule.getResourceType()); + + ResourceScheduleResponse response = new ResourceScheduleResponse(); + response.setObjectName("resourceschedule"); + response.setId(schedule.getUuid()); + response.setResourceType(schedule.getResourceType()); + + String uuid = getResourceUuid(schedule.getResourceId(), worker.getApiResourceType().getAssociatedClass()); + response.setResourceId(uuid); + + response.setDescription(schedule.getDescription()); + response.setSchedule(schedule.getSchedule()); + response.setTimeZone(schedule.getTimeZone()); + response.setAction(worker.parseAction(schedule.getActionName())); + response.setEnabled(schedule.getEnabled()); + response.setStartDate(schedule.getStartDate()); + response.setEndDate(schedule.getEndDate()); + response.setDetails(details); + response.setCreated(schedule.getCreated()); + return response; + } + + @Override + public ListResponse listSchedule(Long id, List ids, ApiCommandResourceType resourceType, + String resourceUuid, String action, Boolean enabled, + Long startIndex, Long pageSize) { + Long internalResourceId = null; + BaseScheduleWorker worker = getWorker(resourceType); + if (StringUtils.isBlank(resourceUuid)) { + throw new InvalidParameterValueException("Resource ID must be specified"); + } else { + internalResourceId = resolveResourceId(resourceUuid, worker.getApiResourceType().getAssociatedClass()); + long ownerId = worker.getEntityOwnerId(internalResourceId); + accountManager.checkAccess(CallContext.current().getCallingAccount(), null, false, accountManager.getAccount(ownerId)); + } + + List scheduleIds = getIdsListFromCmd(id, ids); + if (action != null) { + action = worker.parseAction(action).name(); + } + + Pair, Integer> result = resourceScheduleDao.searchAndCount( + scheduleIds, resourceType, internalResourceId, action, enabled, startIndex, pageSize); + + ListResponse response = new ListResponse<>(); + List responsesList = new ArrayList<>(); + for (ResourceScheduleVO schedule : result.first()) { + responsesList.add(createResponse(schedule, null)); + } + response.setResponses(responsesList, result.second()); + return response; + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_SCHEDULE_UPDATE, eventDescription = "Updating Resource Schedule") + public ResourceScheduleResponse updateSchedule(Long id, String description, String schedule, + String timeZoneStr, Date cmdStartDate, Date cmdEndDate, + Boolean enabled, Map details) { + ResourceScheduleVO scheduleVO = resourceScheduleDao.findById(id); + + if (scheduleVO == null) { + throw new CloudRuntimeException("Resource schedule doesn't exist"); + } + + BaseScheduleWorker worker = getWorker(scheduleVO.getResourceType()); + long ownerId = worker.getEntityOwnerId(scheduleVO.getResourceId()); + accountManager.checkAccess(CallContext.current().getCallingAccount(), null, false, accountManager.getAccount(ownerId)); + + ResourceSchedule.Action parsedAction = worker.parseAction(scheduleVO.getActionName()); + if (MapUtils.isNotEmpty(details)) { + worker.validateDetails(parsedAction, details); + } + + CronExpression cronExpression = Objects.requireNonNullElse( + DateUtil.parseSchedule(schedule), + DateUtil.parseSchedule(scheduleVO.getSchedule()) + ); + + if (description == null && scheduleVO.getDescription() == null) { + Map effectiveDetails = MapUtils.isNotEmpty(details) + ? details + : resourceScheduleDetailsDao.listDetailsKeyPairs(id, true); + description = worker.getDescription(parsedAction, cronExpression, effectiveDetails); + } + + final String originalTimeZone = scheduleVO.getTimeZone(); + final Date originalStartDate = scheduleVO.getStartDate(); + final Date originalEndDate = scheduleVO.getEndDate(); + + TimeZone timeZone; + String timeZoneId; + if (timeZoneStr != null) { + timeZone = TimeZone.getTimeZone(timeZoneStr); + timeZoneId = timeZone.getID(); + if (!timeZoneId.equals(timeZoneStr)) { + logger.warn("Using timezone [{}] for running the schedule [{}] for resource {}, as an equivalent of [{}].", + timeZoneId, scheduleVO.getSchedule(), scheduleVO.getResourceId(), timeZoneStr); + } + scheduleVO.setTimeZone(timeZoneId); + } else { + timeZoneId = scheduleVO.getTimeZone(); + timeZone = TimeZone.getTimeZone(timeZoneId); + } + + Date startDate = scheduleVO.getStartDate().before(DateUtils.addMinutes(new Date(), 1)) ? DateUtils.addMinutes(new Date(), 1) : scheduleVO.getStartDate(); + Date endDate = scheduleVO.getEndDate(); + if (cmdEndDate != null) { + endDate = Date.from(DateUtil.getZoneDateTime(cmdEndDate, timeZone.toZoneId()).toInstant()); + } + + if (cmdStartDate != null) { + startDate = Date.from(DateUtil.getZoneDateTime(cmdStartDate, timeZone.toZoneId()).toInstant()); + } + + boolean enabling = Boolean.TRUE.equals(enabled) && !scheduleVO.getEnabled(); + if (enabling || (ObjectUtils.anyNotNull(cmdStartDate, cmdEndDate, timeZoneStr) && + (!Objects.equals(originalTimeZone, timeZoneId) || + !Objects.equals(originalStartDate, startDate) || + !Objects.equals(originalEndDate, endDate)))) { + validateStartDateEndDate(Objects.requireNonNullElse(startDate, DateUtils.addMinutes(new Date(), 1)), + endDate, timeZone); + } + + if (enabled != null) { + scheduleVO.setEnabled(enabled); + } + if (description != null) { + scheduleVO.setDescription(description); + } + if (cmdEndDate != null) { + scheduleVO.setEndDate(endDate); + } + if (cmdStartDate != null) { + scheduleVO.setStartDate(startDate); + } + scheduleVO.setSchedule(cronExpression.toString()); + + return Transaction.execute((TransactionCallback) status -> { + resourceScheduleDao.update(id, scheduleVO); + + if (MapUtils.isNotEmpty(details)) { + List detailVOs = new ArrayList<>(); + for (Map.Entry entry : details.entrySet()) { + detailVOs.add(new ResourceScheduleDetailVO(id, entry.getKey(), entry.getValue(), true)); + } + resourceScheduleDetailsDao.saveDetails(detailVOs); + } + + worker.updateScheduledJob(scheduleVO); + + CallContext.current().setEventResourceId(scheduleVO.getResourceId()); + CallContext.current().setEventResourceType(worker.getApiResourceType()); + + CallContext.current().setEventDetails(String.format("Updated resource schedule %s", scheduleVO)); + + // Re-load details if they weren't fully replaced + Map currentDetails = resourceScheduleDetailsDao.listDetailsKeyPairs(id, true); + return createResponse(scheduleVO, currentDetails); + }); + } + + void validateStartDateEndDate(Date startDate, Date endDate, TimeZone tz) { + ZonedDateTime now = ZonedDateTime.now(tz.toZoneId()); + ZonedDateTime zonedStartDate = ZonedDateTime.ofInstant(startDate.toInstant(), tz.toZoneId()); + + if (zonedStartDate.isBefore(now)) { + throw new InvalidParameterValueException(String.format("Invalid value for start date. Start date [%s] can't be before current time [%s].", zonedStartDate, now)); + } + + if (endDate != null) { + ZonedDateTime zonedEndDate = ZonedDateTime.ofInstant(endDate.toInstant(), tz.toZoneId()); + if (zonedEndDate.isBefore(now)) { + throw new InvalidParameterValueException(String.format("Invalid value for end date. End date [%s] can't be before current time [%s].", zonedEndDate, now)); + } + if (zonedEndDate.isBefore(zonedStartDate)) { + throw new InvalidParameterValueException(String.format("Invalid value for end date. End date [%s] can't be before start date [%s].", zonedEndDate, zonedStartDate)); + } + } + } + + @Override + public void removeSchedulesForResource(ApiCommandResourceType resourceType, long resourceId) { + List schedules = resourceScheduleDao.search( + resourceScheduleDao.getSearchCriteriaForResource(resourceType, resourceId), null); + List ids = new ArrayList<>(); + for (ResourceScheduleVO schedule : schedules) { + ids.add(schedule.getId()); + } + + getWorker(resourceType).removeScheduledJobs(ids); + resourceScheduleDao.removeAllSchedulesForResource(resourceType, resourceId); + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_SCHEDULE_DELETE, eventDescription = "Deleting Resource Schedule") + public Long removeSchedule(ApiCommandResourceType resourceType, String resourceUuid, Long id, List idsList) { + BaseScheduleWorker worker = getWorker(resourceType); + long internalResourceId = resolveResourceId(resourceUuid, worker.getApiResourceType().getAssociatedClass()); + + long ownerId = worker.getEntityOwnerId(internalResourceId); + accountManager.checkAccess(CallContext.current().getCallingAccount(), null, false, accountManager.getAccount(ownerId)); + + List ids = getIdsListFromCmd(id, idsList); + Pair, Integer> result = resourceScheduleDao.searchAndCount(ids, resourceType, internalResourceId, null, null, null, null); + List schedulesToRemove = result.first(); + List scheduleIdsToRemove = schedulesToRemove.stream().map(ResourceScheduleVO::getId).collect(Collectors.toList()); + return Transaction.execute((TransactionCallback) status -> { + worker.removeScheduledJobs(scheduleIdsToRemove); + + CallContext.current().setEventResourceId(internalResourceId); + CallContext.current().setEventResourceType(worker.getApiResourceType()); + return resourceScheduleDao.removeSchedulesForResourceAndIds(resourceType, internalResourceId, scheduleIdsToRemove); + }); + } +} diff --git a/server/src/main/java/org/apache/cloudstack/schedule/autoscale/AutoScaleScheduleWorker.java b/server/src/main/java/org/apache/cloudstack/schedule/autoscale/AutoScaleScheduleWorker.java new file mode 100644 index 00000000000..cd7bc1e485f --- /dev/null +++ b/server/src/main/java/org/apache/cloudstack/schedule/autoscale/AutoScaleScheduleWorker.java @@ -0,0 +1,146 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.schedule.autoscale; + +import com.cloud.event.ActionEventUtils; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.network.as.AutoScaleManager; +import com.cloud.network.as.AutoScaleVmGroup; +import com.cloud.network.as.AutoScaleVmGroupVO; +import com.cloud.network.as.dao.AutoScaleVmGroupDao; +import com.cloud.user.User; +import com.cloud.utils.DateUtil; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.command.user.autoscale.UpdateAutoScaleVmGroupCmd; +import org.apache.cloudstack.schedule.BaseScheduleWorker; +import org.apache.cloudstack.schedule.ResourceSchedule; +import org.apache.cloudstack.schedule.ResourceScheduledJobVO; +import org.apache.cloudstack.schedule.dao.ResourceScheduleDetailsDao; +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.lang3.EnumUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.scheduling.support.CronExpression; + +import javax.inject.Inject; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import static org.apache.cloudstack.api.ApiConstants.MAX_MEMBERS; +import static org.apache.cloudstack.api.ApiConstants.MIN_MEMBERS; + +public class AutoScaleScheduleWorker extends BaseScheduleWorker { + + @Inject + private AutoScaleManager autoScaleManager; + + @Inject + private AutoScaleVmGroupDao autoScaleVmGroupDao; + + @Inject + private ResourceScheduleDetailsDao resourceScheduleDetailsDao; + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.AutoScaleVmGroup; + } + + @Override + public String getDescription(ResourceSchedule.Action parsedAction, CronExpression cronExpression, Map details) { + return String.format("%s Min (%s) Max (%s) - %s", parsedAction.name(), details.get(MIN_MEMBERS), details.get(MAX_MEMBERS), DateUtil.getHumanReadableSchedule(cronExpression)); + } + + @Override + public boolean isResourceValid(long resourceId) { + AutoScaleVmGroupVO group = autoScaleVmGroupDao.findById(resourceId); + return group != null && !AutoScaleVmGroup.State.REVOKE.equals(group.getState()); + } + + @Override + public long getEntityOwnerId(long resourceId) { + AutoScaleVmGroupVO group = autoScaleVmGroupDao.findById(resourceId); + return group != null ? group.getAccountId() : User.UID_SYSTEM; + } + + @Override + public AutoScaleScheduleAction parseAction(String actionName) { + AutoScaleScheduleAction action = EnumUtils.getEnumIgnoreCase(AutoScaleScheduleAction.class, actionName); + if (action == null) { + throw new InvalidParameterValueException(String.format( + "Invalid action for AutoScaleVmGroup schedule: %s. Supported actions: %s", + actionName, Arrays.toString(AutoScaleScheduleAction.values()))); + } + return action; + } + + @Override + public void validateDetails(ResourceSchedule.Action action, Map details) { + if (!(action instanceof AutoScaleScheduleAction)) { + throw new InvalidParameterValueException("Invalid action type for AutoScaleVmGroup schedule"); + } + if (MapUtils.isEmpty(details)) { + throw new InvalidParameterValueException("Details are required for AutoScaleVmGroup schedule"); + } + if (!details.keySet().stream().allMatch(key -> MIN_MEMBERS.equalsIgnoreCase(key) || MAX_MEMBERS.equalsIgnoreCase(key))) { + throw new InvalidParameterValueException("Only minmembers and maxmembers are supported for AutoScaleVmGroup schedule details"); + } + + String minMembersRaw = details.get(MIN_MEMBERS); + String maxMembersRaw = details.get(MAX_MEMBERS); + if (StringUtils.isBlank(minMembersRaw) || StringUtils.isBlank(maxMembersRaw)) { + throw new InvalidParameterValueException("Both minmembers and maxmembers are required for AutoScaleVmGroup schedule"); + } + + int minMembers; + int maxMembers; + try { + minMembers = Integer.parseInt(minMembersRaw); + maxMembers = Integer.parseInt(maxMembersRaw); + } catch (NumberFormatException e) { + throw new InvalidParameterValueException("minmembers and maxmembers must be valid integers"); + } + + autoScaleManager.validateMinMaxMembers(minMembers, maxMembers); + } + + @Override + protected Long processJob(ResourceScheduledJobVO job) { + AutoScaleVmGroupVO group = autoScaleVmGroupDao.findById(job.getResourceId()); + if (group == null || AutoScaleVmGroup.State.REVOKE.equals(group.getState())) { + logger.warn("AutoScaleVmGroup id={} not found/invalid; skipping scheduled job {}", job.getResourceId(), job); + return null; + } + + AutoScaleScheduleAction action = parseAction(job.getActionName()); + Map details = resourceScheduleDetailsDao.listDetailsKeyPairs(job.getScheduleId(), true); + validateDetails(action, details); + + String minMembers = details.get(MIN_MEMBERS); + String maxMembers = details.get(MAX_MEMBERS); + long eventId = ActionEventUtils.onCompletedActionEvent( + User.UID_SYSTEM, group.getAccountId(), null, + action.getEventType(), true, + String.format("Executing action %s (min=%s max=%s) for AutoScaleVmGroup: %s (min=%s max=%s)", + action, minMembers, maxMembers, group.getUuid(), group.getMinMembers(), group.getMaxMembers()), + group.getId(), ApiCommandResourceType.AutoScaleVmGroup.toString(), 0); + + Map params = new HashMap<>(); + params.put(MIN_MEMBERS, minMembers); + params.put(MAX_MEMBERS, maxMembers); + return submitAsyncJob(UpdateAutoScaleVmGroupCmd.class, group.getAccountId(), group.getId(), eventId, params); + } +} diff --git a/server/src/main/java/org/apache/cloudstack/schedule/vm/VMScheduleWorker.java b/server/src/main/java/org/apache/cloudstack/schedule/vm/VMScheduleWorker.java new file mode 100644 index 00000000000..9684c5bfcb5 --- /dev/null +++ b/server/src/main/java/org/apache/cloudstack/schedule/vm/VMScheduleWorker.java @@ -0,0 +1,145 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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.schedule.vm; + +import com.cloud.event.ActionEventUtils; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.network.as.AutoScaleVmGroupVmMapVO; +import com.cloud.network.as.dao.AutoScaleVmGroupVmMapDao; +import com.cloud.user.User; +import com.cloud.uservm.UserVm; +import com.cloud.vm.UserVmManager; +import com.cloud.vm.VirtualMachine; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.command.user.vm.RebootVMCmd; +import org.apache.cloudstack.api.command.user.vm.StartVMCmd; +import org.apache.cloudstack.api.command.user.vm.StopVMCmd; +import org.apache.cloudstack.schedule.BaseScheduleWorker; +import org.apache.cloudstack.schedule.ResourceSchedule; +import org.apache.cloudstack.schedule.ResourceScheduledJobVO; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.EnumUtils; + +import javax.inject.Inject; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class VMScheduleWorker extends BaseScheduleWorker { + + @Inject + private UserVmManager userVmManager; + + @Inject + private AutoScaleVmGroupVmMapDao autoScaleVmGroupVmMapDao; + + @Override + public ApiCommandResourceType getApiResourceType() { + return ApiCommandResourceType.VirtualMachine; + } + + @Override + public boolean isResourceValid(long resourceId) { + UserVm userVm = userVmManager.getUserVm(resourceId); + if (userVm != null) { + List autoScalingGroups = autoScaleVmGroupVmMapDao.listByVm(resourceId); + return CollectionUtils.isEmpty(autoScalingGroups); + } + return false; + } + + @Override + public long getEntityOwnerId(long resourceId) { + VirtualMachine vm = userVmManager.getUserVm(resourceId); + return vm != null ? vm.getAccountId() : User.UID_SYSTEM; + } + + @Override + public VMScheduleAction parseAction(String actionName) { + VMScheduleAction action = EnumUtils.getEnumIgnoreCase(VMScheduleAction.class, actionName); + if (action == null) { + throw new InvalidParameterValueException(String.format( + "Invalid action for VirtualMachine schedule: %s. Supported actions: %s", + actionName, Arrays.toString(VMScheduleAction.values()))); + } + return action; + } + + @Override + public void validateDetails(ResourceSchedule.Action action, Map details) {} + + @Override + protected Long processJob(ResourceScheduledJobVO job) { + VirtualMachine vm = userVmManager.getUserVm(job.getResourceId()); + if (vm == null) { + logger.warn("VM id={} not found; skipping scheduled job {}", job.getResourceId(), job); + return null; + } + + if (!Arrays.asList(VirtualMachine.State.Running, VirtualMachine.State.Stopped).contains(vm.getState())) { + logger.info("Skipping action ({}) for [vm: {}, job: {}] — VM is in state: {}", + job.getActionName(), vm, job, vm.getState()); + return null; + } + + VMScheduleAction action = parseAction(job.getActionName()); + final long eventId = ActionEventUtils.onCompletedActionEvent( + User.UID_SYSTEM, vm.getAccountId(), null, + action.getEventType(), true, + String.format("Executing action (%s) for VM: %s", action, vm), + vm.getId(), ApiCommandResourceType.VirtualMachine.toString(), 0); + + if (vm.getState() == VirtualMachine.State.Running) { + switch (action) { + case STOP: + return submitStopVMJob(vm, false, eventId); + case FORCE_STOP: + return submitStopVMJob(vm, true, eventId); + case REBOOT: + return submitRebootVMJob(vm, false, eventId); + case FORCE_REBOOT: + return submitRebootVMJob(vm, true, eventId); + default: + break; + } + } else if (vm.getState() == VirtualMachine.State.Stopped && action == VMScheduleAction.START) { + return submitStartVMJob(vm, eventId); + } + + logger.warn("Skipping action ({}) for [vm: {}, job: {}] — VM is in state: {}", + action, vm, job, vm.getState()); + return null; + } + + private long submitStartVMJob(VirtualMachine vm, long eventId) { + return submitAsyncJob(StartVMCmd.class, vm.getAccountId(), vm.getId(), eventId, Collections.emptyMap()); + } + + private long submitStopVMJob(VirtualMachine vm, boolean forced, long eventId) { + return submitAsyncJob(StopVMCmd.class, vm.getAccountId(), vm.getId(), eventId, + Map.of(ApiConstants.FORCED, String.valueOf(forced))); + } + + private long submitRebootVMJob(VirtualMachine vm, boolean forced, long eventId) { + return submitAsyncJob(RebootVMCmd.class, vm.getAccountId(), vm.getId(), eventId, + Map.of(ApiConstants.FORCED, String.valueOf(forced))); + } +} diff --git a/server/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImpl.java b/server/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImpl.java index 846eab599fd..36905fb40ec 100644 --- a/server/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImpl.java +++ b/server/src/main/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImpl.java @@ -749,8 +749,8 @@ public class UnmanagedVMsManagerImpl implements UnmanagedVMsManager { } private Pair importExternalDisk(UnmanagedInstanceTO.Disk disk, VirtualMachine vm, DeployDestination dest, DiskOffering diskOffering, - Volume.Type type, VirtualMachineTemplate template,Long deviceId, String remoteUrl, String username, String password, - String tmpPath, DiskProfile diskProfile) { + Volume.Type type, VirtualMachineTemplate template,Long deviceId, String remoteUrl, String username, String password, + String tmpPath, DiskProfile diskProfile) { final String path = StringUtils.isEmpty(disk.getDatastorePath()) ? disk.getImagePath() : disk.getDatastorePath(); String chainInfo = disk.getChainInfo(); if (StringUtils.isEmpty(chainInfo)) { @@ -818,8 +818,8 @@ public class UnmanagedVMsManagerImpl implements UnmanagedVMsManager { } private Pair importKVMSharedDisk(VirtualMachine vm, DiskOffering diskOffering, - Volume.Type type, VirtualMachineTemplate template, - Long deviceId, Long poolId, String diskPath, DiskProfile diskProfile) { + Volume.Type type, VirtualMachineTemplate template, + Long deviceId, Long poolId, String diskPath, DiskProfile diskProfile) { StoragePool storagePool = primaryDataStoreDao.findById(poolId); DiskProfile profile = volumeManager.updateImportedVolume(type, diskOffering, vm, template, deviceId, @@ -1644,11 +1644,11 @@ public class UnmanagedVMsManagerImpl implements UnmanagedVMsManager { } protected UserVm importUnmanagedInstanceFromVmwareToKvm(DataCenter zone, Cluster destinationCluster, VMTemplateVO template, - String sourceVMName, String displayName, String hostName, - Account caller, Account owner, long userId, - ServiceOfferingVO serviceOffering, Map dataDiskOfferingMap, - Map nicNetworkMap, Map nicIpAddressMap, - Map details, ImportVmCmd cmd, boolean forced) throws ResourceAllocationException { + String sourceVMName, String displayName, String hostName, + Account caller, Account owner, long userId, + ServiceOfferingVO serviceOffering, Map dataDiskOfferingMap, + Map nicNetworkMap, Map nicIpAddressMap, + Map details, ImportVmCmd cmd, boolean forced) throws ResourceAllocationException { Long existingVcenterId = cmd.getExistingVcenterId(); String vcenter = cmd.getVcenter(); String datacenterName = cmd.getDatacenterName(); @@ -1835,7 +1835,7 @@ public class UnmanagedVMsManagerImpl implements UnmanagedVMsManager { } if (!volumeApiService.doesStoragePoolSupportDiskOffering(selectedStoragePool, rootDiskOffering)) { throw new InvalidParameterValueException(String.format("The root disk offering '%s' is not supported by the selected conversion storage pool '%s'. " + - "When using VDDK, all selected disk offerings must be compatible with the conversion storage pool, as it will become the primary storage for the imported volumes.", + "When using VDDK, all selected disk offerings must be compatible with the conversion storage pool, as it will become the primary storage for the imported volumes.", rootDiskOffering.getName(), selectedStoragePool.getName())); } } @@ -1848,7 +1848,7 @@ public class UnmanagedVMsManagerImpl implements UnmanagedVMsManager { } if (!volumeApiService.doesStoragePoolSupportDiskOffering(selectedStoragePool, diskOffering)) { throw new InvalidParameterValueException(String.format("The data disk offering '%s' is not supported by the selected conversion storage pool '%s'. " + - "When using VDDK, all selected disk offerings must be compatible with the conversion storage pool, as it will become the primary storage for the imported volumes.", + "When using VDDK, all selected disk offerings must be compatible with the conversion storage pool, as it will become the primary storage for the imported volumes.", diskOffering.getName(), selectedStoragePool.getName())); } } @@ -2078,9 +2078,9 @@ public class UnmanagedVMsManagerImpl implements UnmanagedVMsManager { String err = useVddk ? String.format("Could not find any suitable %s host in cluster %s with '%s' configured to perform the VDDK-based instance conversion", - destinationCluster.getHypervisorType(), destinationCluster, Host.HOST_VDDK_SUPPORT) + destinationCluster.getHypervisorType(), destinationCluster, Host.HOST_VDDK_SUPPORT) : String.format("Could not find any suitable %s host in cluster %s to perform the instance conversion", - destinationCluster.getHypervisorType(), destinationCluster); + destinationCluster.getHypervisorType(), destinationCluster); logger.error(err); throw new CloudRuntimeException(err); } @@ -2708,10 +2708,10 @@ public class UnmanagedVMsManagerImpl implements UnmanagedVMsManager { } private UserVm importExternalKvmVirtualMachine(final UnmanagedInstanceTO unmanagedInstance, final String instanceName, final DataCenter zone, - final VirtualMachineTemplate template, final String displayName, final String hostName, final Account caller, final Account owner, final Long userId, - final ServiceOfferingVO serviceOffering, final Map dataDiskOfferingMap, - final Map nicNetworkMap, final Map callerNicIpAddressMap, - final String remoteUrl, String username, String password, String tmpPath, final Map details) throws ResourceAllocationException { + final VirtualMachineTemplate template, final String displayName, final String hostName, final Account caller, final Account owner, final Long userId, + final ServiceOfferingVO serviceOffering, final Map dataDiskOfferingMap, + final Map nicNetworkMap, final Map callerNicIpAddressMap, + final String remoteUrl, String username, String password, String tmpPath, final Map details) throws ResourceAllocationException { UserVm userVm = null; Map allDetails = new HashMap<>(details); @@ -2744,28 +2744,28 @@ public class UnmanagedVMsManagerImpl implements UnmanagedVMsManager { } VirtualMachine.PowerState powerState = VirtualMachine.PowerState.PowerOff; - try { - userVm = userVmManager.importVM(zone, null, template, null, displayName, owner, - null, caller, true, null, owner.getAccountId(), userId, - serviceOffering, null, null, hostName, - Hypervisor.HypervisorType.KVM, allDetails, powerState, null); - } catch (InsufficientCapacityException ice) { - logger.error(String.format("Failed to import vm name: %s", instanceName), ice); - throw new ServerApiException(ApiErrorCode.INSUFFICIENT_CAPACITY_ERROR, ice.getMessage()); - } - if (userVm == null) { - throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, String.format("Failed to import vm name: %s", instanceName)); - } - String rootVolumeName = String.format("ROOT-%s", userVm.getId()); - DiskProfile diskProfile = volumeManager.allocateRawVolume(Volume.Type.ROOT, rootVolumeName, diskOffering, null, null, null, userVm, template, owner, null, false); + try { + userVm = userVmManager.importVM(zone, null, template, null, displayName, owner, + null, caller, true, null, owner.getAccountId(), userId, + serviceOffering, null, null, hostName, + Hypervisor.HypervisorType.KVM, allDetails, powerState, null); + } catch (InsufficientCapacityException ice) { + logger.error(String.format("Failed to import vm name: %s", instanceName), ice); + throw new ServerApiException(ApiErrorCode.INSUFFICIENT_CAPACITY_ERROR, ice.getMessage()); + } + if (userVm == null) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, String.format("Failed to import vm name: %s", instanceName)); + } + String rootVolumeName = String.format("ROOT-%s", userVm.getId()); + DiskProfile diskProfile = volumeManager.allocateRawVolume(Volume.Type.ROOT, rootVolumeName, diskOffering, null, null, null, userVm, template, owner, null, null, false); - DiskProfile[] dataDiskProfiles = new DiskProfile[dataDisks.size()]; - int diskSeq = 0; - for (UnmanagedInstanceTO.Disk disk : dataDisks) { - DiskOffering offering = diskOfferingDao.findById(dataDiskOfferingMap.get(disk.getDiskId())); - DiskProfile dataDiskProfile = volumeManager.allocateRawVolume(Volume.Type.DATADISK, String.format("DATA-%d-%s", userVm.getId(), disk.getDiskId()), offering, null, null, null, userVm, template, owner, null, false); - dataDiskProfiles[diskSeq++] = dataDiskProfile; - } + DiskProfile[] dataDiskProfiles = new DiskProfile[dataDisks.size()]; + int diskSeq = 0; + for (UnmanagedInstanceTO.Disk disk : dataDisks) { + DiskOffering offering = diskOfferingDao.findById(dataDiskOfferingMap.get(disk.getDiskId())); + DiskProfile dataDiskProfile = volumeManager.allocateRawVolume(Volume.Type.DATADISK, String.format("DATA-%d-%s", userVm.getId(), disk.getDiskId()), offering, null, null, null, userVm, template, owner, null, null, false); + dataDiskProfiles[diskSeq++] = dataDiskProfile; + } final VirtualMachineProfile profile = new VirtualMachineProfileImpl(userVm, template, serviceOffering, owner, null); ServiceOfferingVO dummyOffering = serviceOfferingDao.findById(userVm.getId(), serviceOffering.getId()); @@ -2917,7 +2917,7 @@ public class UnmanagedVMsManagerImpl implements UnmanagedVMsManager { reservations.add(volumeReservation); String rootVolumeName = String.format("ROOT-%s", userVm.getId()); - DiskProfile diskProfile = volumeManager.allocateRawVolume(Volume.Type.ROOT, rootVolumeName, diskOffering, null, null, null, userVm, template, owner, null, false); + DiskProfile diskProfile = volumeManager.allocateRawVolume(Volume.Type.ROOT, rootVolumeName, diskOffering, null, null, null, userVm, template, owner, null, null, false); final VirtualMachineProfile profile = new VirtualMachineProfileImpl(userVm, template, serviceOffering, owner, null); ServiceOfferingVO dummyOffering = serviceOfferingDao.findById(userVm.getId(), serviceOffering.getId()); diff --git a/server/src/main/java/org/apache/cloudstack/vm/schedule/VMScheduleManagerImpl.java b/server/src/main/java/org/apache/cloudstack/vm/schedule/VMScheduleManagerImpl.java deleted file mode 100644 index 866fca3f96e..00000000000 --- a/server/src/main/java/org/apache/cloudstack/vm/schedule/VMScheduleManagerImpl.java +++ /dev/null @@ -1,326 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT 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.vm.schedule; - -import java.time.ZonedDateTime; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.Objects; -import java.util.TimeZone; - -import javax.inject.Inject; - -import org.apache.cloudstack.api.ApiCommandResourceType; -import org.apache.cloudstack.api.command.user.vm.CreateVMScheduleCmd; -import org.apache.cloudstack.api.command.user.vm.DeleteVMScheduleCmd; -import org.apache.cloudstack.api.command.user.vm.ListVMScheduleCmd; -import org.apache.cloudstack.api.command.user.vm.UpdateVMScheduleCmd; -import org.apache.cloudstack.api.response.ListResponse; -import org.apache.cloudstack.api.response.VMScheduleResponse; -import org.apache.cloudstack.context.CallContext; -import org.apache.cloudstack.vm.schedule.dao.VMScheduleDao; -import org.apache.commons.lang.time.DateUtils; -import org.apache.commons.lang3.ObjectUtils; -import org.apache.commons.lang3.StringUtils; -import org.springframework.scheduling.support.CronExpression; - -import com.cloud.api.query.MutualExclusiveIdsManagerBase; -import com.cloud.event.ActionEvent; -import com.cloud.event.EventTypes; -import com.cloud.exception.InvalidParameterValueException; -import com.cloud.user.AccountManager; -import com.cloud.utils.DateUtil; -import com.cloud.utils.Pair; -import com.cloud.utils.component.PluggableService; -import com.cloud.utils.db.SearchCriteria; -import com.cloud.utils.db.Transaction; -import com.cloud.utils.db.TransactionCallback; -import com.cloud.utils.exception.CloudRuntimeException; -import com.cloud.vm.UserVmManager; -import com.cloud.vm.VirtualMachine; - -public class VMScheduleManagerImpl extends MutualExclusiveIdsManagerBase implements VMScheduleManager, PluggableService { - - @Inject - private VMScheduleDao vmScheduleDao; - @Inject - private UserVmManager userVmManager; - @Inject - private VMScheduler vmScheduler; - @Inject - private AccountManager accountManager; - - @Override - public List> getCommands() { - final List> cmdList = new ArrayList<>(); - cmdList.add(CreateVMScheduleCmd.class); - cmdList.add(ListVMScheduleCmd.class); - cmdList.add(UpdateVMScheduleCmd.class); - cmdList.add(DeleteVMScheduleCmd.class); - return cmdList; - } - - @Override - @ActionEvent(eventType = EventTypes.EVENT_VM_SCHEDULE_CREATE, eventDescription = "Creating VM Schedule", create = true) - public VMScheduleResponse createSchedule(CreateVMScheduleCmd cmd) { - VirtualMachine vm = userVmManager.getUserVm(cmd.getVmId()); - accountManager.checkAccess(CallContext.current().getCallingAccount(), null, false, vm); - if (vm == null) { - throw new InvalidParameterValueException(String.format("Invalid value for vmId: %s", cmd.getVmId())); - } - - VMSchedule.Action action = null; - if (cmd.getAction() != null) { - try { - action = VMSchedule.Action.valueOf(cmd.getAction().toUpperCase()); - } catch (IllegalArgumentException exception) { - throw new InvalidParameterValueException(String.format("Invalid value for action: %s", cmd.getAction())); - } - } - - Date cmdStartDate = cmd.getStartDate(); - Date cmdEndDate = cmd.getEndDate(); - String cmdTimeZone = cmd.getTimeZone(); - TimeZone timeZone = TimeZone.getTimeZone(cmdTimeZone); - String timeZoneId = timeZone.getID(); - Date startDate = DateUtils.addMinutes(new Date(), 1); - if (cmdStartDate != null) { - startDate = Date.from(DateUtil.getZoneDateTime(cmdStartDate, timeZone.toZoneId()).toInstant()); - } - Date endDate = null; - if (cmdEndDate != null) { - endDate = Date.from(DateUtil.getZoneDateTime(cmdEndDate, timeZone.toZoneId()).toInstant()); - } - - CronExpression cronExpression = DateUtil.parseSchedule(cmd.getSchedule()); - - validateStartDateEndDate(startDate, endDate, timeZone); - - String description = null; - if (StringUtils.isBlank(cmd.getDescription())) { - description = String.format("%s - %s", action, DateUtil.getHumanReadableSchedule(cronExpression)); - } else description = cmd.getDescription(); - - logger.warn("Using timezone [{}] for running the schedule for VM [{}], as an equivalent of [{}].", timeZoneId, vm, cmdTimeZone); - - String finalDescription = description; - VMSchedule.Action finalAction = action; - Date finalStartDate = startDate; - Date finalEndDate = endDate; - - return Transaction.execute((TransactionCallback) status -> { - VMScheduleVO vmSchedule = vmScheduleDao.persist(new VMScheduleVO(cmd.getVmId(), finalDescription, cronExpression.toString(), timeZoneId, finalAction, finalStartDate, finalEndDate, cmd.getEnabled())); - vmScheduler.scheduleNextJob(vmSchedule, new Date()); - CallContext.current().setEventResourceId(vm.getId()); - CallContext.current().setEventResourceType(ApiCommandResourceType.VirtualMachine); - return createResponse(vmSchedule); - }); - } - - @Override - public VMScheduleResponse createResponse(VMSchedule vmSchedule) { - VirtualMachine vm = userVmManager.getUserVm(vmSchedule.getVmId()); - VMScheduleResponse response = new VMScheduleResponse(); - - response.setObjectName(VMSchedule.class.getSimpleName().toLowerCase()); - response.setId(vmSchedule.getUuid()); - response.setVmId(vm.getUuid()); - response.setDescription(vmSchedule.getDescription()); - response.setSchedule(vmSchedule.getSchedule()); - response.setTimeZone(vmSchedule.getTimeZone()); - response.setAction(vmSchedule.getAction()); - response.setEnabled(vmSchedule.getEnabled()); - response.setStartDate(vmSchedule.getStartDate()); - response.setEndDate(vmSchedule.getEndDate()); - response.setCreated(vmSchedule.getCreated()); - return response; - } - - @Override - public ListResponse listSchedule(ListVMScheduleCmd cmd) { - Long id = cmd.getId(); - Boolean enabled = cmd.getEnabled(); - Long vmId = cmd.getVmId(); - - VirtualMachine vm = userVmManager.getUserVm(vmId); - accountManager.checkAccess(CallContext.current().getCallingAccount(), null, false, vm); - - VMSchedule.Action action = null; - if (cmd.getAction() != null) { - try { - action = VMSchedule.Action.valueOf(cmd.getAction()); - } catch (IllegalArgumentException exception) { - throw new InvalidParameterValueException("Invalid value for action: " + cmd.getAction()); - } - } - - Pair, Integer> result = vmScheduleDao.searchAndCount(id, vmId, action, enabled, cmd.getStartIndex(), cmd.getPageSizeVal()); - - ListResponse response = new ListResponse<>(); - List responsesList = new ArrayList<>(); - for (VMSchedule vmSchedule : result.first()) { - responsesList.add(createResponse(vmSchedule)); - } - response.setResponses(responsesList, result.second()); - return response; - } - - @Override - @ActionEvent(eventType = EventTypes.EVENT_VM_SCHEDULE_UPDATE, eventDescription = "Updating VM Schedule") - public VMScheduleResponse updateSchedule(UpdateVMScheduleCmd cmd) { - Long id = cmd.getId(); - VMScheduleVO vmSchedule = vmScheduleDao.findById(id); - - if (vmSchedule == null) { - throw new CloudRuntimeException("VM schedule doesn't exist"); - } - - VirtualMachine vm = userVmManager.getUserVm(vmSchedule.getVmId()); - accountManager.checkAccess(CallContext.current().getCallingAccount(), null, false, vm); - - CronExpression cronExpression = Objects.requireNonNullElse( - DateUtil.parseSchedule(cmd.getSchedule()), - DateUtil.parseSchedule(vmSchedule.getSchedule()) - ); - - String description = cmd.getDescription(); - if (description == null && vmSchedule.getDescription() == null) { - description = String.format("%s - %s", vmSchedule.getAction(), DateUtil.getHumanReadableSchedule(cronExpression)); - } - String cmdTimeZone = cmd.getTimeZone(); - Date cmdStartDate = cmd.getStartDate(); - Date cmdEndDate = cmd.getEndDate(); - Boolean enabled = cmd.getEnabled(); - final String originalTimeZone = vmSchedule.getTimeZone(); - final Date originalStartDate = vmSchedule.getStartDate(); - final Date originalEndDate = vmSchedule.getEndDate(); - - TimeZone timeZone; - String timeZoneId; - if (cmdTimeZone != null) { - timeZone = TimeZone.getTimeZone(cmdTimeZone); - timeZoneId = timeZone.getID(); - if (!timeZoneId.equals(cmdTimeZone)) { - logger.warn("Using timezone [{}] for running the schedule [{}] for VM {}, as an equivalent of [{}].", - timeZoneId, vmSchedule.getSchedule(), userVmManager.getUserVm(vmSchedule.getVmId()), cmdTimeZone); - } - vmSchedule.setTimeZone(timeZoneId); - } else { - timeZoneId = vmSchedule.getTimeZone(); - timeZone = TimeZone.getTimeZone(timeZoneId); - } - - Date startDate = vmSchedule.getStartDate().before(DateUtils.addMinutes(new Date(), 1)) ? DateUtils.addMinutes(new Date(), 1) : vmSchedule.getStartDate(); - Date endDate = vmSchedule.getEndDate(); - if (cmdEndDate != null) { - endDate = Date.from(DateUtil.getZoneDateTime(cmdEndDate, timeZone.toZoneId()).toInstant()); - } - - if (cmdStartDate != null) { - startDate = Date.from(DateUtil.getZoneDateTime(cmdStartDate, timeZone.toZoneId()).toInstant()); - } - - if (ObjectUtils.anyNotNull(cmdStartDate, cmdEndDate, cmdTimeZone) && - (!Objects.equals(originalTimeZone, timeZoneId) || - !Objects.equals(originalStartDate, startDate) || - !Objects.equals(originalEndDate, endDate))) { - validateStartDateEndDate(Objects.requireNonNullElse(startDate, DateUtils.addMinutes(new Date(), 1)), - endDate, timeZone); - } - - if (enabled != null) { - vmSchedule.setEnabled(enabled); - } - if (description != null) { - vmSchedule.setDescription(description); - } - if (cmdEndDate != null) { - vmSchedule.setEndDate(endDate); - } - if (cmdStartDate != null) { - vmSchedule.setStartDate(startDate); - } - vmSchedule.setSchedule(cronExpression.toString()); - - return Transaction.execute((TransactionCallback) status -> { - vmScheduleDao.update(cmd.getId(), vmSchedule); - vmScheduler.updateScheduledJob(vmSchedule); - CallContext.current().setEventResourceId(vm.getId()); - CallContext.current().setEventResourceType(ApiCommandResourceType.VirtualMachine); - return createResponse(vmSchedule); - }); - } - - void validateStartDateEndDate(Date startDate, Date endDate, TimeZone tz) { - ZonedDateTime now = ZonedDateTime.now(tz.toZoneId()); - ZonedDateTime zonedStartDate = ZonedDateTime.ofInstant(startDate.toInstant(), tz.toZoneId()); - - if (zonedStartDate.isBefore(now)) { - throw new InvalidParameterValueException(String.format("Invalid value for start date. Start date [%s] can't be before current time [%s].", zonedStartDate, now)); - } - - if (endDate != null) { - ZonedDateTime zonedEndDate = ZonedDateTime.ofInstant(endDate.toInstant(), tz.toZoneId()); - if (zonedEndDate.isBefore(now)) { - throw new InvalidParameterValueException(String.format("Invalid value for end date. End date [%s] can't be before current time [%s].", zonedEndDate, now)); - } - if (zonedEndDate.isBefore(zonedStartDate)) { - throw new InvalidParameterValueException(String.format("Invalid value for end date. End date [%s] can't be before start date [%s].", zonedEndDate, zonedStartDate)); - } - } - } - - @Override - @ActionEvent(eventType = EventTypes.EVENT_VM_SCHEDULE_DELETE, eventDescription = "Deleting VM Schedule for VM") - public long removeScheduleByVmId(long vmId, boolean expunge) { - SearchCriteria sc = vmScheduleDao.getSearchCriteriaForVMId(vmId); - List vmSchedules = vmScheduleDao.search(sc, null); - List ids = new ArrayList<>(); - for (final VMScheduleVO vmSchedule : vmSchedules) { - ids.add(vmSchedule.getId()); - } - vmScheduler.removeScheduledJobs(ids); - if (expunge) { - return vmScheduleDao.expunge(sc); - } - CallContext.current().setEventResourceId(vmId); - CallContext.current().setEventResourceType(ApiCommandResourceType.VirtualMachine); - return vmScheduleDao.remove(sc); - } - - @Override - @ActionEvent(eventType = EventTypes.EVENT_VM_SCHEDULE_DELETE, eventDescription = "Deleting VM Schedule") - public Long removeSchedule(DeleteVMScheduleCmd cmd) { - VirtualMachine vm = userVmManager.getUserVm(cmd.getVmId()); - accountManager.checkAccess(CallContext.current().getCallingAccount(), null, false, vm); - - List ids = getIdsListFromCmd(cmd.getId(), cmd.getIds()); - - if (ids.isEmpty()) { - throw new InvalidParameterValueException("Either id or ids parameter must be specified"); - } - return Transaction.execute((TransactionCallback) status -> { - vmScheduler.removeScheduledJobs(ids); - CallContext.current().setEventResourceId(vm.getId()); - CallContext.current().setEventResourceType(ApiCommandResourceType.VirtualMachine); - return vmScheduleDao.removeSchedulesForVmIdAndIds(vm.getId(), ids); - }); - } -} diff --git a/server/src/main/java/org/apache/cloudstack/vm/schedule/VMScheduler.java b/server/src/main/java/org/apache/cloudstack/vm/schedule/VMScheduler.java deleted file mode 100644 index fa96a1c97b9..00000000000 --- a/server/src/main/java/org/apache/cloudstack/vm/schedule/VMScheduler.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT 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.vm.schedule; - -import com.cloud.utils.component.Manager; -import com.cloud.utils.concurrency.Scheduler; -import org.apache.cloudstack.framework.config.ConfigKey; - -import java.util.Date; -import java.util.List; - -public interface VMScheduler extends Manager, Scheduler { - ConfigKey VMScheduledJobExpireInterval = new ConfigKey<>(ConfigKey.CATEGORY_ADVANCED, Integer.class, "vmscheduler.jobs.expire.interval", "30", "VM Scheduler expire interval in days", true); - - void removeScheduledJobs(List vmScheduleIds); - - void updateScheduledJob(VMScheduleVO vmSchedule); - - Date scheduleNextJob(VMScheduleVO vmSchedule, Date timestamp); -} diff --git a/server/src/main/java/org/apache/cloudstack/vm/schedule/VMSchedulerImpl.java b/server/src/main/java/org/apache/cloudstack/vm/schedule/VMSchedulerImpl.java deleted file mode 100644 index 56d794fa5c2..00000000000 --- a/server/src/main/java/org/apache/cloudstack/vm/schedule/VMSchedulerImpl.java +++ /dev/null @@ -1,420 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT 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.vm.schedule; - -import com.cloud.api.ApiGsonHelper; -import com.cloud.event.ActionEventUtils; -import com.cloud.event.EventTypes; -import com.cloud.user.User; -import com.cloud.utils.DateUtil; -import com.cloud.utils.component.ComponentContext; -import com.cloud.utils.component.ManagerBase; -import com.cloud.utils.db.GlobalLock; -import com.cloud.vm.UserVmManager; -import com.cloud.vm.VirtualMachine; -import com.google.common.primitives.Longs; -import org.apache.cloudstack.api.ApiCommandResourceType; -import org.apache.cloudstack.api.ApiConstants; -import org.apache.cloudstack.api.command.user.vm.RebootVMCmd; -import org.apache.cloudstack.api.command.user.vm.StartVMCmd; -import org.apache.cloudstack.api.command.user.vm.StopVMCmd; -import org.apache.cloudstack.framework.config.ConfigKey; -import org.apache.cloudstack.framework.config.Configurable; -import org.apache.cloudstack.framework.jobs.AsyncJobDispatcher; -import org.apache.cloudstack.framework.jobs.AsyncJobManager; -import org.apache.cloudstack.framework.jobs.impl.AsyncJobVO; -import org.apache.cloudstack.managed.context.ManagedContextTimerTask; -import org.apache.cloudstack.vm.schedule.dao.VMScheduleDao; -import org.apache.cloudstack.vm.schedule.dao.VMScheduledJobDao; -import org.apache.commons.lang.time.DateUtils; -import org.springframework.scheduling.support.CronExpression; - -import javax.inject.Inject; -import javax.persistence.EntityExistsException; -import java.time.ZonedDateTime; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Calendar; -import java.util.Date; -import java.util.EnumMap; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Timer; -import java.util.TimerTask; - -public class VMSchedulerImpl extends ManagerBase implements VMScheduler, Configurable { - @Inject - private VMScheduledJobDao vmScheduledJobDao; - @Inject - private VMScheduleDao vmScheduleDao; - @Inject - private UserVmManager userVmManager; - @Inject - private AsyncJobManager asyncJobManager; - private AsyncJobDispatcher asyncJobDispatcher; - private Timer vmSchedulerTimer; - private Date currentTimestamp; - - private EnumMap actionEventMap = new EnumMap<>(VMSchedule.Action.class); - - public AsyncJobDispatcher getAsyncJobDispatcher() { - return asyncJobDispatcher; - } - - public void setAsyncJobDispatcher(final AsyncJobDispatcher dispatcher) { - asyncJobDispatcher = dispatcher; - } - - @Override - public ConfigKey[] getConfigKeys() { - return new ConfigKey[]{VMScheduledJobExpireInterval}; - } - - @Override - public String getConfigComponentName() { - return VMScheduler.class.getSimpleName(); - } - - @Override - public void removeScheduledJobs(List vmScheduleIds) { - if (vmScheduleIds == null || vmScheduleIds.isEmpty()) { - logger.debug("Removed 0 scheduled jobs"); - return; - } - Date now = new Date(); - int rowsRemoved = vmScheduledJobDao.expungeJobsForSchedules(vmScheduleIds, now); - logger.debug(String.format("Removed %s VM scheduled jobs", rowsRemoved)); - } - - @Override - public void updateScheduledJob(VMScheduleVO vmSchedule) { - removeScheduledJobs(Longs.asList(vmSchedule.getId())); - scheduleNextJob(vmSchedule, new Date()); - } - - @Override - public Date scheduleNextJob(VMScheduleVO vmSchedule, Date timestamp) { - if (!vmSchedule.getEnabled()) { - logger.debug("VM Schedule {} for VM {} with id {} is disabled. Not scheduling next job.", - vmSchedule::toString, () -> userVmManager.getUserVm(vmSchedule.getVmId()), vmSchedule::getVmId); - return null; - } - - CronExpression cron = DateUtil.parseSchedule(vmSchedule.getSchedule()); - Date startDate = vmSchedule.getStartDate(); - Date endDate = vmSchedule.getEndDate(); - VirtualMachine vm = userVmManager.getUserVm(vmSchedule.getVmId()); - - if (vm == null) { - logger.info("VM id={} is removed. Disabling VM schedule {}.", vmSchedule.getVmId(), vmSchedule); - vmSchedule.setEnabled(false); - vmScheduleDao.persist(vmSchedule); - return null; - } - - ZonedDateTime now; - if (timestamp != null) { - now = ZonedDateTime.ofInstant(timestamp.toInstant(), vmSchedule.getTimeZoneId()); - } else { - now = ZonedDateTime.now(vmSchedule.getTimeZoneId()); - } - ZonedDateTime zonedStartDate = ZonedDateTime.ofInstant(startDate.toInstant(), vmSchedule.getTimeZoneId()); - ZonedDateTime zonedEndDate = null; - if (endDate != null) { - zonedEndDate = ZonedDateTime.ofInstant(endDate.toInstant(), vmSchedule.getTimeZoneId()); - } - if (zonedEndDate != null && now.isAfter(zonedEndDate)) { - logger.info("End time is less than current time. Disabling VM schedule {} for VM {}.", vmSchedule, vm); - vmSchedule.setEnabled(false); - vmScheduleDao.persist(vmSchedule); - return null; - } - - ZonedDateTime ts = null; - if (zonedStartDate.isAfter(now)) { - ts = cron.next(zonedStartDate); - } else { - ts = cron.next(now); - } - - if (ts == null) { - logger.info("No next schedule found. Disabling VM schedule {} for VM {}.", vmSchedule, vm); - vmSchedule.setEnabled(false); - vmScheduleDao.persist(vmSchedule); - return null; - } - - Date scheduledDateTime = Date.from(ts.toInstant()); - VMScheduledJobVO scheduledJob = vmScheduledJobDao.findByScheduleAndTimestamp(vmSchedule.getId(), scheduledDateTime); - if (scheduledJob != null) { - logger.trace("Job is already scheduled for schedule {} at {}", vmSchedule, scheduledDateTime); - return scheduledDateTime; - } - - scheduledJob = new VMScheduledJobVO(vmSchedule.getVmId(), vmSchedule.getId(), vmSchedule.getAction(), scheduledDateTime); - try { - vmScheduledJobDao.persist(scheduledJob); - ActionEventUtils.onScheduledActionEvent(User.UID_SYSTEM, vm.getAccountId(), actionEventMap.get(vmSchedule.getAction()), - String.format("Scheduled action (%s) [vm: %s, schedule: %s] at %s", vmSchedule.getAction(), vm, vmSchedule, scheduledDateTime), - vm.getId(), ApiCommandResourceType.VirtualMachine.toString(), true, 0); - } catch (EntityExistsException exception) { - logger.debug("Job is already scheduled."); - } - return scheduledDateTime; - } - - @Override - public boolean start() { - actionEventMap.put(VMSchedule.Action.START, EventTypes.EVENT_VM_SCHEDULE_START); - actionEventMap.put(VMSchedule.Action.STOP, EventTypes.EVENT_VM_SCHEDULE_STOP); - actionEventMap.put(VMSchedule.Action.REBOOT, EventTypes.EVENT_VM_SCHEDULE_REBOOT); - actionEventMap.put(VMSchedule.Action.FORCE_STOP, EventTypes.EVENT_VM_SCHEDULE_FORCE_STOP); - actionEventMap.put(VMSchedule.Action.FORCE_REBOOT, EventTypes.EVENT_VM_SCHEDULE_FORCE_REBOOT); - - // Adding 1 minute to currentTimestamp to ensure that - // jobs which were to be run at current time, doesn't cause issues - currentTimestamp = DateUtils.addMinutes(new Date(), 1); - scheduleNextJobs(currentTimestamp); - - final TimerTask schedulerPollTask = new ManagedContextTimerTask() { - @Override - protected void runInContext() { - try { - poll(new Date()); - } catch (final Throwable t) { - logger.warn("Catch throwable in VM scheduler ", t); - } - } - }; - - vmSchedulerTimer = new Timer("VMSchedulerPollTask"); - vmSchedulerTimer.scheduleAtFixedRate(schedulerPollTask, 5000L, 60 * 1000L); - return true; - } - - @Override - public void poll(Date timestamp) { - currentTimestamp = DateUtils.round(timestamp, Calendar.MINUTE); - String displayTime = DateUtil.displayDateInTimezone(DateUtil.GMT_TIMEZONE, currentTimestamp); - logger.debug(String.format("VM scheduler.poll is being called at %s", displayTime)); - - GlobalLock scanLock = GlobalLock.getInternLock("vmScheduler.poll"); - try { - if (scanLock.lock(30)) { - try { - scheduleNextJobs(currentTimestamp); - } finally { - scanLock.unlock(); - } - } - } finally { - scanLock.releaseRef(); - } - - scanLock = GlobalLock.getInternLock("vmScheduler.poll"); - try { - if (scanLock.lock(30)) { - try { - startJobs(); // Create async job and update scheduled job - } finally { - scanLock.unlock(); - } - } - } finally { - scanLock.releaseRef(); - } - - try { - cleanupVMScheduledJobs(); - } catch (Exception e) { - logger.warn("Error in cleaning up vm scheduled jobs", e); - } - } - - private void scheduleNextJobs(Date timestamp) { - for (final VMScheduleVO schedule : vmScheduleDao.listAllActiveSchedules()) { - try { - scheduleNextJob(schedule, timestamp); - } catch (Exception e) { - logger.warn("Error in scheduling next job for schedule {}", schedule, e); - } - } - } - - /** - * Delete scheduled jobs before vm.scheduler.expire.interval days - */ - private void cleanupVMScheduledJobs() { - Date deleteBeforeDate = DateUtils.addDays(currentTimestamp, -1 * VMScheduledJobExpireInterval.value()); - int rowsRemoved = vmScheduledJobDao.expungeJobsBefore(deleteBeforeDate); - logger.info(String.format("Cleaned up %d VM scheduled job entries", rowsRemoved)); - } - - void executeJobs(Map jobsToExecute) { - String displayTime = DateUtil.displayDateInTimezone(DateUtil.GMT_TIMEZONE, currentTimestamp); - - for (Map.Entry entry : jobsToExecute.entrySet()) { - VMScheduledJob vmScheduledJob = entry.getValue(); - VirtualMachine vm = userVmManager.getUserVm(vmScheduledJob.getVmId()); - - VMScheduledJobVO tmpVMScheduleJob = null; - try { - if (logger.isDebugEnabled()) { - final Date scheduledTimestamp = vmScheduledJob.getScheduledTime(); - displayTime = DateUtil.displayDateInTimezone(DateUtil.GMT_TIMEZONE, scheduledTimestamp); - logger.debug("Executing {} for VM {} for scheduled job: {} at {}", - vmScheduledJob.getAction(), vm, vmScheduledJob, displayTime); - } - - tmpVMScheduleJob = vmScheduledJobDao.acquireInLockTable(vmScheduledJob.getId()); - Long jobId = processJob(vmScheduledJob, vm); - if (jobId != null) { - tmpVMScheduleJob.setAsyncJobId(jobId); - vmScheduledJobDao.update(vmScheduledJob.getId(), tmpVMScheduleJob); - } - } catch (final Exception e) { - logger.warn("Executing scheduled job {} failed due to {}", vmScheduledJob, e); - } finally { - if (tmpVMScheduleJob != null) { - vmScheduledJobDao.releaseFromLockTable(vmScheduledJob.getId()); - } - } - } - } - - Long processJob(VMScheduledJob vmScheduledJob, VirtualMachine vm) { - if (!Arrays.asList(VirtualMachine.State.Running, VirtualMachine.State.Stopped).contains(vm.getState())) { - logger.info("Skipping action ({}) for [vm: {}, scheduled job: {}] because VM is invalid state: {}", - vmScheduledJob.getAction(), vm, vmScheduledJob, vm.getState()); - return null; - } - - final Long eventId = ActionEventUtils.onCompletedActionEvent(User.UID_SYSTEM, vm.getAccountId(), null, - actionEventMap.get(vmScheduledJob.getAction()), true, - String.format("Executing action (%s) for VM: %s", vmScheduledJob.getAction(), vm), - vm.getId(), ApiCommandResourceType.VirtualMachine.toString(), 0); - - if (vm.getState() == VirtualMachine.State.Running) { - switch (vmScheduledJob.getAction()) { - case STOP: - return executeStopVMJob(vm, false, eventId); - case FORCE_STOP: - return executeStopVMJob(vm, true, eventId); - case REBOOT: - return executeRebootVMJob(vm, false, eventId); - case FORCE_REBOOT: - return executeRebootVMJob(vm, true, eventId); - } - } else if (vm.getState() == VirtualMachine.State.Stopped && vmScheduledJob.getAction() == VMSchedule.Action.START) { - return executeStartVMJob(vm, eventId); - } - - logger.warn("Skipping action ({}) for [vm: {}, scheduled job: {}] because VM is in state: {}", - vmScheduledJob.getAction(), vm, vmScheduledJob, vm.getState()); - return null; - } - - private void skipJobs(Map jobsToExecute, Map> jobsNotToExecute) { - for (Map.Entry> entry : jobsNotToExecute.entrySet()) { - Long vmId = entry.getKey(); - List skippedVmScheduledJobVOS = entry.getValue(); - VirtualMachine vm = userVmManager.getUserVm(vmId); - for (final VMScheduledJob skippedVmScheduledJobVO : skippedVmScheduledJobVOS) { - VMScheduledJob scheduledJob = jobsToExecute.get(vmId); - logger.info("Skipping scheduled job {} for vm {} because of conflict with another scheduled job {}", - skippedVmScheduledJobVO, vm, scheduledJob); - } - } - } - - /** - * Create async jobs for VM scheduled jobs - */ - private void startJobs() { - String displayTime = DateUtil.displayDateInTimezone(DateUtil.GMT_TIMEZONE, currentTimestamp); - - final List vmScheduledJobs = vmScheduledJobDao.listJobsToStart(currentTimestamp); - logger.debug(String.format("Got %d scheduled jobs to be executed at %s", vmScheduledJobs.size(), displayTime)); - - Map jobsToExecute = new HashMap<>(); - Map> jobsNotToExecute = new HashMap<>(); - for (final VMScheduledJobVO vmScheduledJobVO : vmScheduledJobs) { - long vmId = vmScheduledJobVO.getVmId(); - if (jobsToExecute.get(vmId) == null) { - jobsToExecute.put(vmId, vmScheduledJobVO); - } else { - jobsNotToExecute.computeIfAbsent(vmId, k -> new ArrayList<>()).add(vmScheduledJobVO); - } - } - - executeJobs(jobsToExecute); - skipJobs(jobsToExecute, jobsNotToExecute); - } - - long executeStartVMJob(VirtualMachine vm, long eventId) { - final Map params = new HashMap<>(); - params.put(ApiConstants.ID, String.valueOf(vm.getId())); - params.put("ctxUserId", "1"); - params.put("ctxAccountId", String.valueOf(vm.getAccountId())); - params.put(ApiConstants.CTX_START_EVENT_ID, String.valueOf(eventId)); - - final StartVMCmd cmd = new StartVMCmd(); - ComponentContext.inject(cmd); - - AsyncJobVO job = new AsyncJobVO("", User.UID_SYSTEM, vm.getAccountId(), StartVMCmd.class.getName(), ApiGsonHelper.getBuilder().create().toJson(params), vm.getId(), cmd.getApiResourceType() != null ? cmd.getApiResourceType().toString() : null, null); - job.setDispatcher(asyncJobDispatcher.getName()); - - return asyncJobManager.submitAsyncJob(job); - } - - long executeStopVMJob(VirtualMachine vm, boolean isForced, long eventId) { - final Map params = new HashMap<>(); - params.put(ApiConstants.ID, String.valueOf(vm.getId())); - params.put("ctxUserId", "1"); - params.put("ctxAccountId", String.valueOf(vm.getAccountId())); - params.put(ApiConstants.CTX_START_EVENT_ID, String.valueOf(eventId)); - params.put(ApiConstants.FORCED, String.valueOf(isForced)); - - final StopVMCmd cmd = new StopVMCmd(); - ComponentContext.inject(cmd); - - AsyncJobVO job = new AsyncJobVO("", User.UID_SYSTEM, vm.getAccountId(), StopVMCmd.class.getName(), ApiGsonHelper.getBuilder().create().toJson(params), vm.getId(), cmd.getApiResourceType() != null ? cmd.getApiResourceType().toString() : null, null); - job.setDispatcher(asyncJobDispatcher.getName()); - - return asyncJobManager.submitAsyncJob(job); - } - - long executeRebootVMJob(VirtualMachine vm, boolean isForced, long eventId) { - final Map params = new HashMap<>(); - params.put(ApiConstants.ID, String.valueOf(vm.getId())); - params.put("ctxUserId", "1"); - params.put("ctxAccountId", String.valueOf(vm.getAccountId())); - params.put(ApiConstants.CTX_START_EVENT_ID, String.valueOf(eventId)); - params.put(ApiConstants.FORCED, String.valueOf(isForced)); - - final RebootVMCmd cmd = new RebootVMCmd(); - ComponentContext.inject(cmd); - - AsyncJobVO job = new AsyncJobVO("", User.UID_SYSTEM, vm.getAccountId(), RebootVMCmd.class.getName(), ApiGsonHelper.getBuilder().create().toJson(params), vm.getId(), cmd.getApiResourceType() != null ? cmd.getApiResourceType().toString() : null, null); - job.setDispatcher(asyncJobDispatcher.getName()); - - return asyncJobManager.submitAsyncJob(job); - } -} diff --git a/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml b/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml index 83298c59b61..eef304226af 100644 --- a/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml +++ b/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml @@ -332,6 +332,11 @@ + + + + + @@ -376,8 +381,11 @@ - - + + + + + diff --git a/server/src/test/java/com/cloud/api/query/dao/UserVmJoinDaoImplTest.java b/server/src/test/java/com/cloud/api/query/dao/UserVmJoinDaoImplTest.java index e4146fd2265..f657a8bbf04 100755 --- a/server/src/test/java/com/cloud/api/query/dao/UserVmJoinDaoImplTest.java +++ b/server/src/test/java/com/cloud/api/query/dao/UserVmJoinDaoImplTest.java @@ -16,10 +16,12 @@ // under the License. package com.cloud.api.query.dao; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.MockitoAnnotations.openMocks; import java.util.Arrays; +import java.util.Collections; import java.util.EnumSet; import com.cloud.storage.dao.VMTemplateDao; @@ -49,9 +51,11 @@ import com.cloud.user.AccountManager; import com.cloud.user.UserStatisticsVO; import com.cloud.user.dao.UserDao; import com.cloud.user.dao.UserStatisticsDao; +import com.cloud.host.dao.HostDetailsDao; import com.cloud.utils.db.SearchBuilder; import com.cloud.utils.db.SearchCriteria; import com.cloud.vm.dao.VMInstanceDetailsDao; +import com.cloud.vm.dao.VmIsoMapDao; @RunWith(MockitoJUnitRunner.class) public class UserVmJoinDaoImplTest extends GenericDaoBaseWithTagInformationBaseTest { @@ -83,6 +87,12 @@ public class UserVmJoinDaoImplTest extends GenericDaoBaseWithTagInformationBaseT @Mock private VMTemplateDao vmTemplateDao; + @Mock + private VmIsoMapDao vmIsoMapDao; + + @Mock + private HostDetailsDao hostDetailsDao; + @Mock ExtensionHelper extensionHelper; @@ -103,6 +113,7 @@ public class UserVmJoinDaoImplTest extends GenericDaoBaseWithTagInformationBaseT @Before public void setup() { closeable = openMocks(this); + Mockito.lenient().when(vmIsoMapDao.listByVmId(anyLong())).thenReturn(Collections.emptyList()); prepareSetup(); } @@ -166,4 +177,39 @@ public class UserVmJoinDaoImplTest extends GenericDaoBaseWithTagInformationBaseT Assert.assertEquals(2, response.getVnfNics().size()); Assert.assertEquals(3, response.getVnfDetails().size()); } + + @Test + public void advertisedCdromCapReturnsDefaultWhenHostIdNull() { + Assert.assertEquals(com.cloud.template.TemplateManager.DEFAULT_CDROM_MAX_PER_VM, + _userVmJoinDaoImpl.advertisedCdromCap(null)); + } + + @Test + public void advertisedCdromCapReturnsParsedValue() { + com.cloud.host.DetailVO detail = Mockito.mock(com.cloud.host.DetailVO.class); + Mockito.when(detail.getValue()).thenReturn("2"); + Mockito.when(hostDetailsDao.findDetail(7L, com.cloud.host.Host.HOST_CDROM_MAX_COUNT)).thenReturn(detail); + Assert.assertEquals(2, _userVmJoinDaoImpl.advertisedCdromCap(7L)); + } + + @Test + public void advertisedCdromCapFallsBackOnInvalidValue() { + com.cloud.host.DetailVO detail = Mockito.mock(com.cloud.host.DetailVO.class); + Mockito.when(detail.getValue()).thenReturn("xyz"); + Mockito.when(hostDetailsDao.findDetail(7L, com.cloud.host.Host.HOST_CDROM_MAX_COUNT)).thenReturn(detail); + Assert.assertEquals(com.cloud.template.TemplateManager.DEFAULT_CDROM_MAX_PER_VM, + _userVmJoinDaoImpl.advertisedCdromCap(7L)); + } + + @Test + public void effectiveCdromMaxCountClampsToHypervisorCap() { + UserVmJoinVO userVm = Mockito.mock(UserVmJoinVO.class); + Mockito.when(userVm.getHostId()).thenReturn(7L); + Mockito.when(userVm.getClusterId()).thenReturn(5L); + com.cloud.host.DetailVO detail = Mockito.mock(com.cloud.host.DetailVO.class); + Mockito.when(detail.getValue()).thenReturn("2"); + Mockito.when(hostDetailsDao.findDetail(7L, com.cloud.host.Host.HOST_CDROM_MAX_COUNT)).thenReturn(detail); + // Configured cap defaults to 1 (no cluster override mocked); host advertises 2; clamps to 1. + Assert.assertEquals(1, _userVmJoinDaoImpl.effectiveCdromMaxCount(userVm)); + } } diff --git a/server/src/test/java/com/cloud/configuration/ConfigurationManagerImplTest.java b/server/src/test/java/com/cloud/configuration/ConfigurationManagerImplTest.java index dd45324db41..9a0b150780e 100644 --- a/server/src/test/java/com/cloud/configuration/ConfigurationManagerImplTest.java +++ b/server/src/test/java/com/cloud/configuration/ConfigurationManagerImplTest.java @@ -77,6 +77,7 @@ import org.apache.cloudstack.storage.datastore.db.PrimaryDataStoreDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolDetailsDao; import org.apache.cloudstack.storage.datastore.db.StoragePoolVO; import org.apache.cloudstack.vm.UnmanagedVMsManager; +import org.apache.cloudstack.kms.KMSManager; import org.junit.Assert; import org.junit.Before; import org.junit.Test; @@ -540,6 +541,33 @@ public class ConfigurationManagerImplTest { Assert.assertNull(msg); } + @Test + public void testValidateConfig_KMSDekSizeBits_Failure() { + ConfigurationVO cfg = mock(ConfigurationVO.class); + when(cfg.getScopes()).thenReturn(List.of(ConfigKey.Scope.Global)); + ConfigKey configKey = KMSManager.KMSDekSizeBits; + Mockito.doReturn(cfg).when(configDao).findByName(Mockito.anyString()); + Mockito.doReturn(configKey).when(configurationManagerImplSpy._configDepot).get(configKey.key()); + + String result = configurationManagerImplSpy.validateConfigurationValue(configKey.key(), "512", configKey.getScopes().get(0)); + + Assert.assertNotNull(result); + } + + @Test + public void testValidateConfig_KMSDekSizeBits_Success() { + ConfigurationVO cfg = mock(ConfigurationVO.class); + when(cfg.getScopes()).thenReturn(List.of(ConfigKey.Scope.Global)); + ConfigKey configKey = KMSManager.KMSDekSizeBits; + Mockito.doReturn(cfg).when(configDao).findByName(Mockito.anyString()); + Mockito.doReturn(configKey).when(configurationManagerImplSpy._configDepot).get(configKey.key()); + + for (String validVal : List.of("128", "192", "256")) { + String msg = configurationManagerImplSpy.validateConfigurationValue(configKey.key(), validVal, configKey.getScopes().get(0)); + Assert.assertNull(msg); + } + } + @Test public void validateDomainTestInvalidIdThrowException() { Mockito.doReturn(null).when(domainDaoMock).findById(invalidId); diff --git a/server/src/test/java/com/cloud/network/as/AutoScaleManagerImplTest.java b/server/src/test/java/com/cloud/network/as/AutoScaleManagerImplTest.java index 215a0e784bc..1660e193838 100644 --- a/server/src/test/java/com/cloud/network/as/AutoScaleManagerImplTest.java +++ b/server/src/test/java/com/cloud/network/as/AutoScaleManagerImplTest.java @@ -47,6 +47,7 @@ import org.apache.cloudstack.affinity.AffinityGroupVO; import org.apache.cloudstack.affinity.dao.AffinityGroupDao; import org.apache.cloudstack.annotation.AnnotationService; import org.apache.cloudstack.annotation.dao.AnnotationDao; +import org.apache.cloudstack.api.ApiCommandResourceType; import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.BaseCmd; import org.apache.cloudstack.api.command.admin.autoscale.CreateCounterCmd; @@ -62,6 +63,7 @@ import org.apache.cloudstack.api.command.user.vm.DeployVMCmd; import org.apache.cloudstack.config.ApiServiceConfiguration; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.schedule.ResourceScheduleManager; import org.apache.cloudstack.userdata.UserDataManager; import org.junit.After; import org.junit.Assert; @@ -268,6 +270,8 @@ public class AutoScaleManagerImplTest { VirtualMachineManager virtualMachineManager; @Mock GuestOSDao guestOSDao; + @Mock + ResourceScheduleManager resourceScheduleManager; @Mock NetworkOrchestrationService networkOrchestrationService; @@ -1036,7 +1040,6 @@ public class AutoScaleManagerImplTest { when(asVmGroupMock.getInterval()).thenReturn(interval); when(asVmGroupMock.getMaxMembers()).thenReturn(maxMembers); when(asVmGroupMock.getMinMembers()).thenReturn(minMembers); - when(asVmGroupMock.getState()).thenReturn(AutoScaleVmGroup.State.DISABLED); when(asVmGroupMock.getProfileId()).thenReturn(vmProfileId); when(asVmGroupMock.getLoadBalancerId()).thenReturn(loadBalancerId); @@ -1086,7 +1089,6 @@ public class AutoScaleManagerImplTest { when(autoScaleVmGroupDao.findById(vmGroupId)).thenReturn(asVmGroupMock); when(asVmGroupMock.getInterval()).thenReturn(interval); - when(asVmGroupMock.getState()).thenReturn(AutoScaleVmGroup.State.ENABLED); AutoScaleVmGroup vmGroup = autoScaleManagerImplSpy.updateAutoScaleVmGroup(cmd); } @@ -1213,6 +1215,7 @@ public class AutoScaleManagerImplTest { Mockito.verify(autoScaleManagerImplSpy).configureAutoScaleVmGroup(vmGroupId, AutoScaleVmGroup.State.ENABLED); Mockito.verify(annotationDao).removeByEntityType(AnnotationService.EntityType.AUTOSCALE_VM_GROUP.name(), vmGroupUuid); Mockito.verify(autoScaleManagerImplSpy).cancelMonitorTask(vmGroupId); + Mockito.verify(resourceScheduleManager).removeSchedulesForResource(ApiCommandResourceType.AutoScaleVmGroup, vmGroupId); } @Test @@ -1271,7 +1274,7 @@ public class AutoScaleManagerImplTest { when(zoneMock.getNetworkType()).thenReturn(DataCenter.NetworkType.Basic); when(userVmService.createBasicSecurityGroupVirtualMachine(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), eq(userData), eq(userDataId), eq(userDataDetails.toString()), any(), any(), any(), eq(true), any(), any(), any(), - any(), any(), any(), any(), eq(true), any(), any(), any())).thenReturn(userVmMock); + any(), any(), any(), any(), eq(true), any(), any(), any(), any())).thenReturn(userVmMock); UserVm result = autoScaleManagerImplSpy.createNewVM(asVmGroupMock); @@ -1282,7 +1285,7 @@ public class AutoScaleManagerImplTest { Mockito.verify(userVmService).createBasicSecurityGroupVirtualMachine(any(), any(), any(), any(), any(), matches(vmHostNamePattern), matches(vmHostNamePattern), any(), any(), any(), any(), any(), any(), eq(userData), eq(userDataId), eq(userDataDetails.toString()), any(), any(), any(), eq(true), any(), any(), any(), - any(), any(), any(), any(), eq(true), any(), any(), any()); + any(), any(), any(), any(), eq(true), any(), any(), any(), any()); Mockito.verify(asVmGroupMock).setNextVmSeq(nextVmSeq + 1); } @@ -1318,7 +1321,7 @@ public class AutoScaleManagerImplTest { when(zoneMock.getNetworkType()).thenReturn(DataCenter.NetworkType.Advanced); when(userVmService.createAdvancedSecurityGroupVirtualMachine(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), eq(userData), eq(userDataId), eq(userDataDetails.toString()), any(), any(), any(), any(), any(), any(), - any(), any(), any(), any(), any(), eq(true), any(), any(), any(), any())).thenReturn(userVmMock); + any(), any(), any(), any(), any(), eq(true), any(), any(), any(), any(), any())).thenReturn(userVmMock); when(networkModel.checkSecurityGroupSupportForNetwork(account, zoneMock, List.of(networkId), Collections.emptyList())).thenReturn(true); @@ -1331,7 +1334,7 @@ public class AutoScaleManagerImplTest { Mockito.verify(userVmService).createAdvancedSecurityGroupVirtualMachine(any(), any(), any(), any(), any(), any(), matches(vmHostNamePattern), matches(vmHostNamePattern), any(), any(), any(), any(), any(), any(), eq(userData), eq(userDataId), eq(userDataDetails.toString()), any(), any(), any(), any(), any(), any(), - any(), any(), any(), any(), any(), eq(true), any(), any(), any(), any()); + any(), any(), any(), any(), any(), eq(true), any(), any(), any(), any(), any()); Mockito.verify(asVmGroupMock).setNextVmSeq(nextVmSeq + 2); } @@ -1367,7 +1370,7 @@ public class AutoScaleManagerImplTest { when(zoneMock.getNetworkType()).thenReturn(DataCenter.NetworkType.Advanced); when(userVmService.createAdvancedVirtualMachine(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), eq(userData), eq(userDataId), eq(userDataDetails.toString()), any(), any(), any(), eq(true), any(), any(), any(), - any(), any(), any(), any(), eq(true), any(), any(), any(), any())).thenReturn(userVmMock); + any(), any(), any(), any(), eq(true), any(), any(), any(), any(), any())).thenReturn(userVmMock); when(networkModel.checkSecurityGroupSupportForNetwork(account, zoneMock, List.of(networkId), Collections.emptyList())).thenReturn(false); @@ -1380,7 +1383,7 @@ public class AutoScaleManagerImplTest { Mockito.verify(userVmService).createAdvancedVirtualMachine(any(), any(), any(), any(), any(), matches(vmHostNamePattern), matches(vmHostNamePattern), any(), any(), any(), any(), any(), any(), eq(userData), eq(userDataId), eq(userDataDetails.toString()), any(), any(), any(), eq(true), any(), any(), any(), - any(), any(), any(), any(), eq(true), any(), any(), any(), any()); + any(), any(), any(), any(), eq(true), any(), any(), any(), any(), any()); Mockito.verify(asVmGroupMock).setNextVmSeq(nextVmSeq + 3); } @@ -2557,4 +2560,19 @@ public class AutoScaleManagerImplTest { Assert.assertTrue(result.first().matches(vmHostNamePattern)); Assert.assertEquals(result.first(), result.second()); } + + @Test(expected = InvalidParameterValueException.class) + public void testValidateMinMaxMembersInvalidMin() { + autoScaleManagerImplSpy.validateMinMaxMembers(-1, 5); + } + + @Test(expected = InvalidParameterValueException.class) + public void testValidateMinMaxMembersInvalidMax() { + autoScaleManagerImplSpy.validateMinMaxMembers(1, -1); + } + + @Test(expected = InvalidParameterValueException.class) + public void testValidateMinMaxMembersInvalidRange() { + autoScaleManagerImplSpy.validateMinMaxMembers(5, 1); + } } diff --git a/server/src/test/java/com/cloud/template/HypervisorTemplateAdapterTest.java b/server/src/test/java/com/cloud/template/HypervisorTemplateAdapterTest.java index e2a97be469f..4cd48e686b0 100644 --- a/server/src/test/java/com/cloud/template/HypervisorTemplateAdapterTest.java +++ b/server/src/test/java/com/cloud/template/HypervisorTemplateAdapterTest.java @@ -32,10 +32,8 @@ import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.concurrent.ExecutionException; import org.apache.cloudstack.engine.subsystem.api.storage.DataStore; @@ -339,7 +337,7 @@ public class HypervisorTemplateAdapterTest { Mockito.when(templateProfileMock.getZoneIdList()).thenReturn(zoneIds); Mockito.doReturn(null).when(_adapter).getImageStoresThrowsExceptionIfNotFound(Mockito.any(Long.class), Mockito.any(TemplateProfile.class)); Mockito.doReturn(null).when(_templateMgr).verifyHeuristicRulesForZone(Mockito.any(VMTemplateVO.class), Mockito.anyLong()); - Mockito.doNothing().when(_adapter).standardImageStoreAllocation(Mockito.isNull(), Mockito.any(VMTemplateVO.class)); + Mockito.doNothing().when(_adapter).standardImageStoreAllocation(Mockito.isNull(), Mockito.any(VMTemplateVO.class), Mockito.anyLong()); _adapter.createTemplateWithinZones(templateProfileMock, vmTemplateVOMock); @@ -355,11 +353,11 @@ public class HypervisorTemplateAdapterTest { Mockito.when(templateProfileMock.getZoneIdList()).thenReturn(zoneIds); Mockito.doReturn(null).when(_adapter).getImageStoresThrowsExceptionIfNotFound(Mockito.any(Long.class), Mockito.any(TemplateProfile.class)); Mockito.doReturn(null).when(_templateMgr).verifyHeuristicRulesForZone(Mockito.any(VMTemplateVO.class), Mockito.anyLong()); - Mockito.doNothing().when(_adapter).standardImageStoreAllocation(Mockito.isNull(), Mockito.any(VMTemplateVO.class)); + Mockito.doNothing().when(_adapter).standardImageStoreAllocation(Mockito.isNull(), Mockito.any(VMTemplateVO.class), Mockito.anyLong()); _adapter.createTemplateWithinZones(templateProfileMock, vmTemplateVOMock); - Mockito.verify(_adapter, Mockito.times(1)).standardImageStoreAllocation(Mockito.isNull(), Mockito.any(VMTemplateVO.class)); + Mockito.verify(_adapter, Mockito.times(1)).standardImageStoreAllocation(Mockito.isNull(), Mockito.any(VMTemplateVO.class), Mockito.anyLong()); } @Test @@ -371,11 +369,11 @@ public class HypervisorTemplateAdapterTest { Mockito.when(templateProfileMock.getZoneIdList()).thenReturn(zoneIds); Mockito.doReturn(dataStoreMock).when(_templateMgr).verifyHeuristicRulesForZone(Mockito.any(VMTemplateVO.class), Mockito.anyLong()); - Mockito.doNothing().when(_adapter).validateSecondaryStorageAndCreateTemplate(Mockito.any(List.class), Mockito.any(VMTemplateVO.class), Mockito.isNull()); + Mockito.doNothing().when(_adapter).validateSecondaryStorageAndCreateTemplate(Mockito.any(List.class), Mockito.any(VMTemplateVO.class), Mockito.any(Map.class), Mockito.anyInt()); _adapter.createTemplateWithinZones(templateProfileMock, vmTemplateVOMock); - Mockito.verify(_adapter, Mockito.times(1)).validateSecondaryStorageAndCreateTemplate(Mockito.any(List.class), Mockito.any(VMTemplateVO.class), Mockito.isNull()); + Mockito.verify(_adapter, Mockito.times(1)).validateSecondaryStorageAndCreateTemplate(Mockito.any(List.class), Mockito.any(VMTemplateVO.class), Mockito.any(Map.class), Mockito.anyInt()); } @Test(expected = CloudRuntimeException.class) @@ -411,11 +409,8 @@ public class HypervisorTemplateAdapterTest { @Test public void isZoneAndImageStoreAvailableTestZoneIdIsNullShouldReturnFalse() { DataStore dataStoreMock = Mockito.mock(DataStore.class); - Long zoneId = null; - Set zoneSet = null; - boolean isTemplatePrivate = false; - boolean result = _adapter.isZoneAndImageStoreAvailable(dataStoreMock, zoneId, zoneSet, isTemplatePrivate); + boolean result = _adapter.isZoneAndImageStoreAvailable(dataStoreMock, null, new HashMap<>(), 0); Mockito.verify(loggerMock, Mockito.times(1)).warn(String.format("Zone ID is null, cannot allocate ISO/template in image store [%s].", dataStoreMock)); Assert.assertFalse(result); @@ -425,13 +420,10 @@ public class HypervisorTemplateAdapterTest { public void isZoneAndImageStoreAvailableTestZoneIsNullShouldReturnFalse() { DataStore dataStoreMock = Mockito.mock(DataStore.class); Long zoneId = 1L; - Set zoneSet = null; - boolean isTemplatePrivate = false; - DataCenterVO dataCenterVOMock = null; - Mockito.when(_dcDao.findById(Mockito.anyLong())).thenReturn(dataCenterVOMock); + Mockito.when(_dcDao.findById(Mockito.anyLong())).thenReturn(null); - boolean result = _adapter.isZoneAndImageStoreAvailable(dataStoreMock, zoneId, zoneSet, isTemplatePrivate); + boolean result = _adapter.isZoneAndImageStoreAvailable(dataStoreMock, zoneId, new HashMap<>(), 0); Mockito.verify(loggerMock, Mockito.times(1)).warn("Unable to find zone by id [{}], so skip downloading template to its image store [{}].", zoneId, dataStoreMock); @@ -442,14 +434,12 @@ public class HypervisorTemplateAdapterTest { public void isZoneAndImageStoreAvailableTestZoneIsDisabledShouldReturnFalse() { DataStore dataStoreMock = Mockito.mock(DataStore.class); Long zoneId = 1L; - Set zoneSet = null; - boolean isTemplatePrivate = false; DataCenterVO dataCenterVOMock = Mockito.mock(DataCenterVO.class); Mockito.when(_dcDao.findById(Mockito.anyLong())).thenReturn(dataCenterVOMock); Mockito.when(dataCenterVOMock.getAllocationState()).thenReturn(Grouping.AllocationState.Disabled); - boolean result = _adapter.isZoneAndImageStoreAvailable(dataStoreMock, zoneId, zoneSet, isTemplatePrivate); + boolean result = _adapter.isZoneAndImageStoreAvailable(dataStoreMock, zoneId, new HashMap<>(), 0); Mockito.verify(loggerMock, Mockito.times(1)).info("Zone [{}] is disabled. Skip downloading template to its image store [{}].", dataCenterVOMock, dataStoreMock); Assert.assertFalse(result); @@ -459,15 +449,13 @@ public class HypervisorTemplateAdapterTest { public void isZoneAndImageStoreAvailableTestImageStoreDoesNotHaveEnoughCapacityShouldReturnFalse() { DataStore dataStoreMock = Mockito.mock(DataStore.class); Long zoneId = 1L; - Set zoneSet = null; - boolean isTemplatePrivate = false; DataCenterVO dataCenterVOMock = Mockito.mock(DataCenterVO.class); Mockito.when(_dcDao.findById(Mockito.anyLong())).thenReturn(dataCenterVOMock); Mockito.when(dataCenterVOMock.getAllocationState()).thenReturn(Grouping.AllocationState.Enabled); Mockito.when(statsCollectorMock.imageStoreHasEnoughCapacity(any(DataStore.class))).thenReturn(false); - boolean result = _adapter.isZoneAndImageStoreAvailable(dataStoreMock, zoneId, zoneSet, isTemplatePrivate); + boolean result = _adapter.isZoneAndImageStoreAvailable(dataStoreMock, zoneId, new HashMap<>(), 0); Mockito.verify(loggerMock, times(1)).info("Image store doesn't have enough capacity. Skip downloading template to this image store [{}].", dataStoreMock); @@ -475,60 +463,72 @@ public class HypervisorTemplateAdapterTest { } @Test - public void isZoneAndImageStoreAvailableTestImageStoreHasEnoughCapacityAndZoneSetIsNullShouldReturnTrue() { + public void isZoneAndImageStoreAvailableTestReplicaLimitZeroShouldCopyToAllStores() { DataStore dataStoreMock = Mockito.mock(DataStore.class); Long zoneId = 1L; - Set zoneSet = null; - boolean isTemplatePrivate = false; DataCenterVO dataCenterVOMock = Mockito.mock(DataCenterVO.class); + Map zoneCopyCount = new HashMap<>(); + zoneCopyCount.put(zoneId, 999); Mockito.when(_dcDao.findById(Mockito.anyLong())).thenReturn(dataCenterVOMock); Mockito.when(dataCenterVOMock.getAllocationState()).thenReturn(Grouping.AllocationState.Enabled); Mockito.when(statsCollectorMock.imageStoreHasEnoughCapacity(any(DataStore.class))).thenReturn(true); - boolean result = _adapter.isZoneAndImageStoreAvailable(dataStoreMock, zoneId, zoneSet, isTemplatePrivate); + boolean result = _adapter.isZoneAndImageStoreAvailable(dataStoreMock, zoneId, zoneCopyCount, 0); - Mockito.verify(loggerMock, times(1)).info(String.format("Zone set is null; therefore, the ISO/template should be allocated in every secondary storage " + - "of zone [%s].", dataCenterVOMock)); Assert.assertTrue(result); + Assert.assertEquals(1000, (int) zoneCopyCount.get(zoneId)); } @Test - public void isZoneAndImageStoreAvailableTestTemplateIsPrivateAndItIsAlreadyAllocatedToTheSameZoneShouldReturnFalse() { + public void isZoneAndImageStoreAvailableTestReplicaLimitReachedShouldReturnFalse() { DataStore dataStoreMock = Mockito.mock(DataStore.class); Long zoneId = 1L; - Set zoneSet = Set.of(1L); - boolean isTemplatePrivate = true; DataCenterVO dataCenterVOMock = Mockito.mock(DataCenterVO.class); + Map zoneCopyCount = new HashMap<>(); + zoneCopyCount.put(zoneId, 1); Mockito.when(_dcDao.findById(Mockito.anyLong())).thenReturn(dataCenterVOMock); Mockito.when(dataCenterVOMock.getAllocationState()).thenReturn(Grouping.AllocationState.Enabled); Mockito.when(statsCollectorMock.imageStoreHasEnoughCapacity(any(DataStore.class))).thenReturn(true); - boolean result = _adapter.isZoneAndImageStoreAvailable(dataStoreMock, zoneId, zoneSet, isTemplatePrivate); + boolean result = _adapter.isZoneAndImageStoreAvailable(dataStoreMock, zoneId, zoneCopyCount, 1); - Mockito.verify(loggerMock, times(1)).info(String.format("The template is private and it is already allocated in a secondary storage in zone [%s]; " + - "therefore, image store [%s] will be skipped.", dataCenterVOMock, dataStoreMock)); + Mockito.verify(loggerMock, times(1)).info("Copy limit of {} reached for zone [{}]; skipping image store [{}].", 1, dataCenterVOMock, dataStoreMock); Assert.assertFalse(result); } @Test - public void isZoneAndImageStoreAvailableTestTemplateIsPrivateAndItIsNotAlreadyAllocatedToTheSameZoneShouldReturnTrue() { + public void isZoneAndImageStoreAvailableTestReplicaLimitNotYetReachedShouldReturnTrueAndIncrementCount() { DataStore dataStoreMock = Mockito.mock(DataStore.class); Long zoneId = 1L; - Set zoneSet = new HashSet<>(); - boolean isTemplatePrivate = true; DataCenterVO dataCenterVOMock = Mockito.mock(DataCenterVO.class); + Map zoneCopyCount = new HashMap<>(); Mockito.when(_dcDao.findById(Mockito.anyLong())).thenReturn(dataCenterVOMock); Mockito.when(dataCenterVOMock.getAllocationState()).thenReturn(Grouping.AllocationState.Enabled); Mockito.when(statsCollectorMock.imageStoreHasEnoughCapacity(any(DataStore.class))).thenReturn(true); - boolean result = _adapter.isZoneAndImageStoreAvailable(dataStoreMock, zoneId, zoneSet, isTemplatePrivate); + boolean result = _adapter.isZoneAndImageStoreAvailable(dataStoreMock, zoneId, zoneCopyCount, 2); - Mockito.verify(loggerMock, times(1)).info(String.format("Private template will be allocated in image store [%s] in zone [%s].", - dataStoreMock, dataCenterVOMock)); Assert.assertTrue(result); + Assert.assertEquals(1, (int) zoneCopyCount.get(zoneId)); + } + + @Test + public void isZoneAndImageStoreAvailableTestReplicaLimitOfTwoShouldCopyToExactlyTwoStores() { + Long zoneId = 1L; + DataCenterVO dataCenterVOMock = Mockito.mock(DataCenterVO.class); + Map zoneCopyCount = new HashMap<>(); + + Mockito.when(_dcDao.findById(Mockito.anyLong())).thenReturn(dataCenterVOMock); + Mockito.when(dataCenterVOMock.getAllocationState()).thenReturn(Grouping.AllocationState.Enabled); + Mockito.when(statsCollectorMock.imageStoreHasEnoughCapacity(any(DataStore.class))).thenReturn(true); + + Assert.assertTrue(_adapter.isZoneAndImageStoreAvailable(Mockito.mock(DataStore.class), zoneId, zoneCopyCount, 2)); + Assert.assertTrue(_adapter.isZoneAndImageStoreAvailable(Mockito.mock(DataStore.class), zoneId, zoneCopyCount, 2)); + Assert.assertFalse(_adapter.isZoneAndImageStoreAvailable(Mockito.mock(DataStore.class), zoneId, zoneCopyCount, 2)); + Assert.assertEquals(2, (int) zoneCopyCount.get(zoneId)); } @Test diff --git a/server/src/test/java/com/cloud/template/TemplateManagerImplTest.java b/server/src/test/java/com/cloud/template/TemplateManagerImplTest.java index 6288180a9f4..47099c371dc 100755 --- a/server/src/test/java/com/cloud/template/TemplateManagerImplTest.java +++ b/server/src/test/java/com/cloud/template/TemplateManagerImplTest.java @@ -22,6 +22,7 @@ package com.cloud.template; import com.cloud.agent.AgentManager; import com.cloud.api.query.dao.SnapshotJoinDao; import com.cloud.api.query.dao.UserVmJoinDao; +import com.cloud.api.query.vo.UserVmJoinVO; import com.cloud.dc.dao.DataCenterDao; import com.cloud.deployasis.dao.TemplateDeployAsIsDetailsDao; import com.cloud.domain.dao.DomainDao; @@ -29,7 +30,11 @@ import com.cloud.event.dao.UsageEventDao; import com.cloud.exception.InvalidParameterValueException; import com.cloud.exception.ResourceAllocationException; import com.cloud.host.Status; +import com.cloud.host.DetailVO; +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.Hypervisor; import com.cloud.hypervisor.HypervisorGuruManager; import com.cloud.projects.ProjectManager; @@ -66,9 +71,15 @@ import com.cloud.user.UserVO; import com.cloud.user.dao.AccountDao; import com.cloud.utils.concurrency.NamedThreadFactory; import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.uservm.UserVm; +import com.cloud.vm.UserVmVO; import com.cloud.vm.VMInstanceVO; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.VirtualMachine.State; +import com.cloud.vm.VmIsoMapVO; import com.cloud.vm.dao.UserVmDao; import com.cloud.vm.dao.VMInstanceDao; +import com.cloud.vm.dao.VmIsoMapDao; import junit.framework.TestCase; @@ -133,6 +144,7 @@ import org.springframework.core.type.filter.TypeFilter; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.UUID; import java.util.concurrent.BlockingQueue; @@ -220,6 +232,21 @@ public class TemplateManagerImplTest extends TestCase { @Mock HeuristicRuleHelper heuristicRuleHelperMock; + @Mock + UserVmDao _userVmDao; + + @Mock + VmIsoMapDao _vmIsoMapDao; + + @Mock + HostDao _hostDao; + + @Mock + HostDetailsDao _hostDetailsDao; + + @Mock + UserVmJoinDao _userVmJoinDao; + public class CustomThreadPoolExecutor extends ThreadPoolExecutor { AtomicInteger ai = new AtomicInteger(0); public CustomThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, @@ -750,6 +777,222 @@ public class TemplateManagerImplTest extends TestCase { Mockito.verify(heuristicRuleHelperMock, Mockito.times(1)).getImageStoreIfThereIsHeuristicRule(1L, HeuristicType.TEMPLATE, vmTemplateVOMock); } + @Test + public void highestCdromMapEntryReturnsNullWhenMapIsEmpty() { + Mockito.when(_vmIsoMapDao.listByVmId(1L)).thenReturn(new ArrayList<>()); + Assert.assertNull(templateManager.highestCdromMapEntry(1L)); + } + + @Test + public void highestCdromMapEntryReturnsEntryWithMaxDeviceSeq() { + VmIsoMapVO low = new VmIsoMapVO(1L, 100L, 4); + VmIsoMapVO high = new VmIsoMapVO(1L, 200L, 5); + Mockito.when(_vmIsoMapDao.listByVmId(1L)).thenReturn(Arrays.asList(low, high)); + VmIsoMapVO result = templateManager.highestCdromMapEntry(1L); + Assert.assertNotNull(result); + Assert.assertEquals(5, result.getDeviceSeq()); + } + + @Test + public void attachISOToVMAttachWritesToIsoIdWhenPrimarySlotEmpty() { + UserVmVO vm = Mockito.mock(UserVmVO.class); + VMTemplateVO iso = Mockito.mock(VMTemplateVO.class); + Mockito.when(_userVmDao.findById(1L)).thenReturn(vm); + Mockito.when(vmTemplateDao.findById(42L)).thenReturn(iso); + Mockito.when(iso.getId()).thenReturn(42L); + Mockito.when(vm.getIsoId()).thenReturn(null); + + boolean result = templateManager.attachISOToVM(1L, 1L, 42L, true, false, false); + + Assert.assertTrue(result); + Mockito.verify(vm).setIsoId(42L); + Mockito.verify(_userVmDao).update(eq(1L), eq(vm)); + Mockito.verify(_vmIsoMapDao, Mockito.never()).persist(any(VmIsoMapVO.class)); + } + + @Test + public void resolveIsoIdForDetachReturnsPrimaryWhenOnlyPrimaryIsAttached() { + Long resolved = templateManager.resolveIsoIdForDetach(99L, new ArrayList<>(), null); + Assert.assertEquals(Long.valueOf(99L), resolved); + } + + @Test + public void resolveIsoIdForDetachReturnsMapEntryWhenOnlyMapHasOne() { + VmIsoMapVO row = new VmIsoMapVO(1L, 100L, 4); + Long resolved = templateManager.resolveIsoIdForDetach(null, Arrays.asList(row), null); + Assert.assertEquals(Long.valueOf(100L), resolved); + } + + @Test(expected = InvalidParameterValueException.class) + public void resolveIsoIdForDetachThrowsWhenMultipleAttachedAndNoIdGiven() { + VmIsoMapVO row = new VmIsoMapVO(1L, 100L, 4); + templateManager.resolveIsoIdForDetach(99L, Arrays.asList(row), null); + } + + @Test(expected = InvalidParameterValueException.class) + public void resolveIsoIdForDetachThrowsWhenNothingAttached() { + templateManager.resolveIsoIdForDetach(null, new ArrayList<>(), null); + } + + @Test(expected = InvalidParameterValueException.class) + public void resolveIsoIdForDetachThrowsWhenIdNotAttached() { + templateManager.resolveIsoIdForDetach(99L, new ArrayList<>(), 42L); + } + + @Test + public void isIsoAlreadyAttachedReturnsTrueWhenPrimaryMatches() { + Assert.assertTrue(templateManager.isIsoAlreadyAttached(1L, 42L, 42L)); + } + + @Test + public void isIsoAlreadyAttachedReturnsTrueWhenInMap() { + Mockito.when(_vmIsoMapDao.findByVmIdIsoId(1L, 42L)).thenReturn(new VmIsoMapVO(1L, 42L, 4)); + Assert.assertTrue(templateManager.isIsoAlreadyAttached(1L, 99L, 42L)); + } + + @Test + public void isIsoAlreadyAttachedReturnsFalseWhenNotAttached() { + Mockito.when(_vmIsoMapDao.findByVmIdIsoId(1L, 42L)).thenReturn(null); + Assert.assertFalse(templateManager.isIsoAlreadyAttached(1L, null, 42L)); + } + + @Test + public void attachISOToVMAttachWritesToVmIsoMapWhenPrimarySlotOccupied() { + UserVmVO vm = Mockito.mock(UserVmVO.class); + VMTemplateVO iso = Mockito.mock(VMTemplateVO.class); + Mockito.when(_userVmDao.findById(1L)).thenReturn(vm); + Mockito.when(vmTemplateDao.findById(42L)).thenReturn(iso); + Mockito.when(iso.getId()).thenReturn(42L); + Mockito.when(vm.getIsoId()).thenReturn(99L); + Mockito.when(_vmIsoMapDao.listByVmId(1L)).thenReturn(new ArrayList<>()); + + boolean result = templateManager.attachISOToVM(1L, 1L, 42L, true, false, false); + + Assert.assertTrue(result); + Mockito.verify(_vmIsoMapDao).persist(Mockito.argThat(row -> + row.getVmId() == 1L && row.getIsoId() == 42L + && row.getDeviceSeq() == TemplateManager.CDROM_PRIMARY_DEVICE_SEQ + 1)); + Mockito.verify(vm, Mockito.never()).setIsoId(anyLong()); + } + + @Test(expected = InvalidParameterValueException.class) + public void enforceCdromAttachLimitsThrowsWhenIsoAlreadyAttachedAtPrimary() { + UserVm vm = Mockito.mock(UserVm.class); + Mockito.when(vm.getIsoId()).thenReturn(42L); + templateManager.enforceCdromAttachLimits(1L, vm, 42L); + } + + @Test(expected = InvalidParameterValueException.class) + public void enforceCdromAttachLimitsThrowsWhenIsoAlreadyAttachedInMap() { + UserVm vm = Mockito.mock(UserVm.class); + Mockito.when(vm.getIsoId()).thenReturn(99L); + Mockito.when(_vmIsoMapDao.findByVmIdIsoId(1L, 42L)).thenReturn(new VmIsoMapVO(1L, 42L, 4)); + templateManager.enforceCdromAttachLimits(1L, vm, 42L); + } + + @Test + public void advertisedCdromCapReturnsDefaultWhenHostIdNull() { + Assert.assertEquals(TemplateManager.DEFAULT_CDROM_MAX_PER_VM, templateManager.advertisedCdromCap(null)); + } + + @Test + public void advertisedCdromCapReturnsDefaultWhenDetailMissing() { + Mockito.when(_hostDetailsDao.findDetail(7L, Host.HOST_CDROM_MAX_COUNT)).thenReturn(null); + Assert.assertEquals(TemplateManager.DEFAULT_CDROM_MAX_PER_VM, templateManager.advertisedCdromCap(7L)); + } + + @Test + public void advertisedCdromCapReturnsParsedValue() { + DetailVO detail = Mockito.mock(DetailVO.class); + Mockito.when(detail.getValue()).thenReturn("3"); + Mockito.when(_hostDetailsDao.findDetail(7L, Host.HOST_CDROM_MAX_COUNT)).thenReturn(detail); + Assert.assertEquals(3, templateManager.advertisedCdromCap(7L)); + } + + @Test + public void advertisedCdromCapFallsBackOnInvalidValue() { + DetailVO detail = Mockito.mock(DetailVO.class); + Mockito.when(detail.getValue()).thenReturn("not-a-number"); + Mockito.when(_hostDetailsDao.findDetail(7L, Host.HOST_CDROM_MAX_COUNT)).thenReturn(detail); + Assert.assertEquals(TemplateManager.DEFAULT_CDROM_MAX_PER_VM, templateManager.advertisedCdromCap(7L)); + } + + @Test + public void hostIdForVmReturnsCurrentHost() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + Mockito.when(vm.getHostId()).thenReturn(42L); + Assert.assertEquals(Long.valueOf(42L), templateManager.hostIdForVm(vm)); + } + + @Test + public void hostIdForVmFallsBackToLastHost() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + Mockito.when(vm.getHostId()).thenReturn(null); + Mockito.when(vm.getLastHostId()).thenReturn(99L); + Assert.assertEquals(Long.valueOf(99L), templateManager.hostIdForVm(vm)); + } + + @Test + public void hostIdForVmReturnsNullWhenNoHost() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + Mockito.when(vm.getHostId()).thenReturn(null); + Mockito.when(vm.getLastHostId()).thenReturn(null); + Assert.assertNull(templateManager.hostIdForVm(vm)); + } + + @Test + public void effectiveMaxCdromsReturnsConfiguredCapWhenWithinHypervisorCap() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + DetailVO detail = Mockito.mock(DetailVO.class); + Mockito.when(detail.getValue()).thenReturn("2"); + HostVO host = Mockito.mock(HostVO.class); + Mockito.when(host.getClusterId()).thenReturn(5L); + Mockito.when(_hostDao.findById(7L)).thenReturn(host); + Mockito.when(_hostDetailsDao.findDetail(7L, Host.HOST_CDROM_MAX_COUNT)).thenReturn(detail); + // Configured cap defaults to 1 (no cluster override mocked); hypervisor cap is 2; 1 <= 2 → no throw, returns 1. + Assert.assertEquals(1, templateManager.effectiveMaxCdroms(vm, 7L)); + } + + @Test + public void templateIsDeleteableReturnsTrueWhenNoVmsUseIso() { + Mockito.when(_userVmJoinDao.listActiveByIsoId(42L)).thenReturn(new ArrayList<>()); + Mockito.when(_vmIsoMapDao.listByIsoId(42L)).thenReturn(new ArrayList<>()); + Assert.assertTrue(templateManager.templateIsDeleteable(42L)); + } + + @Test + public void templateIsDeleteableReturnsFalseWhenPrimarySlotInUse() { + Mockito.when(_userVmJoinDao.listActiveByIsoId(42L)) + .thenReturn(java.util.Collections.singletonList(Mockito.mock(UserVmJoinVO.class))); + Assert.assertFalse(templateManager.templateIsDeleteable(42L)); + // Should not even need to consult vm_iso_map once primary slot in use. + Mockito.verify(_vmIsoMapDao, Mockito.never()).listByIsoId(anyLong()); + } + + @Test + public void templateIsDeleteableReturnsFalseWhenAttachedViaVmIsoMapToActiveVm() { + Mockito.when(_userVmJoinDao.listActiveByIsoId(42L)).thenReturn(new ArrayList<>()); + Mockito.when(_vmIsoMapDao.listByIsoId(42L)) + .thenReturn(java.util.Collections.singletonList(new VmIsoMapVO(1L, 42L, 4))); + UserVmVO vm = Mockito.mock(UserVmVO.class); + Mockito.when(vm.getState()).thenReturn(State.Running); + Mockito.when(vm.getUuid()).thenReturn("uuid-1"); + Mockito.when(_userVmDao.findById(1L)).thenReturn(vm); + Assert.assertFalse(templateManager.templateIsDeleteable(42L)); + } + + @Test + public void templateIsDeleteableIgnoresVmIsoMapForDestroyedVm() { + Mockito.when(_userVmJoinDao.listActiveByIsoId(42L)).thenReturn(new ArrayList<>()); + Mockito.when(_vmIsoMapDao.listByIsoId(42L)) + .thenReturn(java.util.Collections.singletonList(new VmIsoMapVO(1L, 42L, 4))); + UserVmVO vm = Mockito.mock(UserVmVO.class); + Mockito.when(vm.getState()).thenReturn(State.Expunging); + Mockito.when(_userVmDao.findById(1L)).thenReturn(vm); + Assert.assertTrue(templateManager.templateIsDeleteable(42L)); + } + + @Configuration @ComponentScan(basePackageClasses = {TemplateManagerImpl.class}, includeFilters = {@ComponentScan.Filter(value = TestConfiguration.Library.class, type = FilterType.CUSTOM)}, diff --git a/server/src/test/java/com/cloud/user/AccountManagentImplTestBase.java b/server/src/test/java/com/cloud/user/AccountManagentImplTestBase.java index 8c790b78da0..93ed3c87829 100644 --- a/server/src/test/java/com/cloud/user/AccountManagentImplTestBase.java +++ b/server/src/test/java/com/cloud/user/AccountManagentImplTestBase.java @@ -66,6 +66,7 @@ import org.apache.cloudstack.engine.orchestration.service.NetworkOrchestrationSe import org.apache.cloudstack.engine.service.api.OrchestrationService; import org.apache.cloudstack.framework.config.dao.ConfigurationDao; import org.apache.cloudstack.framework.messagebus.MessageBus; +import org.apache.cloudstack.kms.KMSManager; import org.apache.cloudstack.network.RoutedIpv4Manager; import org.apache.cloudstack.network.dao.NetworkPermissionDao; import org.apache.cloudstack.region.gslb.GlobalLoadBalancerRuleDao; @@ -212,6 +213,8 @@ public class AccountManagentImplTestBase { AccountService _accountService; @Mock RoutedIpv4Manager routedIpv4Manager; + @Mock + KMSManager kmsManager; @Before public void setup() { diff --git a/server/src/test/java/com/cloud/user/AccountManagerImplTest.java b/server/src/test/java/com/cloud/user/AccountManagerImplTest.java index c476895ea50..61cdde697dd 100644 --- a/server/src/test/java/com/cloud/user/AccountManagerImplTest.java +++ b/server/src/test/java/com/cloud/user/AccountManagerImplTest.java @@ -245,6 +245,7 @@ public class AccountManagerImplTest extends AccountManagentImplTestBase { Mockito.when(_sshKeyPairDao.remove(Mockito.anyLong())).thenReturn(true); Mockito.when(userDataDao.removeByAccountId(Mockito.anyLong())).thenReturn(222); Mockito.when(sslCertDao.removeByAccountId(Mockito.anyLong())).thenReturn(333); + Mockito.when(kmsManager.deleteKMSKeysByAccountId(Mockito.anyLong())).thenReturn(true); Mockito.doNothing().when(accountManagerImpl).deleteWebhooksForAccount(Mockito.anyLong()); Mockito.doNothing().when(accountManagerImpl).verifyCallerPrivilegeForUserOrAccountOperations((Account) any()); diff --git a/server/src/test/java/com/cloud/vm/UserVmManagerImplTest.java b/server/src/test/java/com/cloud/vm/UserVmManagerImplTest.java index 75779cc4c96..f55124f0ad6 100644 --- a/server/src/test/java/com/cloud/vm/UserVmManagerImplTest.java +++ b/server/src/test/java/com/cloud/vm/UserVmManagerImplTest.java @@ -1201,14 +1201,14 @@ public class UserVmManagerImplTest { when(_dcMock.getNetworkType()).thenReturn(DataCenter.NetworkType.Basic); Mockito.doReturn(userVmVoMock).when(userVmManagerImpl).createBasicSecurityGroupVirtualMachine(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), nullable(Boolean.class), any(), any(), any(), - any(), any(), any(), any(), eq(true), any(), any(), any()); + any(), any(), any(), any(), eq(true), any(), any(), any(), any()); UserVm result = userVmManagerImpl.createVirtualMachine(deployVMCmd); assertEquals(userVmVoMock, result); Mockito.verify(vnfTemplateManager).validateVnfApplianceNics(templateMock, null, Collections.emptyMap()); Mockito.verify(userVmManagerImpl).createBasicSecurityGroupVirtualMachine(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), nullable(Boolean.class), any(), any(), any(), - any(), any(), any(), any(), eq(true), any(), any(), any()); + any(), any(), any(), any(), eq(true), any(), any(), any(), any()); } private List mockVolumesForIsAnyVmVolumeUsingLocalStorageTest(int localVolumes, int nonLocalVolumes) { @@ -1461,7 +1461,7 @@ public class UserVmManagerImplTest { doThrow(cre).when(userVmManagerImpl).createBasicSecurityGroupVirtualMachine(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), nullable(Boolean.class), any(), any(), any(), - any(), any(), any(), any(), eq(true), any(), any(), any()); + any(), any(), any(), any(), eq(true), any(), any(), any(), any()); CloudRuntimeException creThrown = assertThrows(CloudRuntimeException.class, () -> userVmManagerImpl.createVirtualMachine(deployVMCmd)); ArrayList proxyIdList = creThrown.getIdProxyList(); @@ -3363,7 +3363,7 @@ public class UserVmManagerImplTest { Mockito.doReturn(userVmVoMock).when(userVmManagerImpl).createAdvancedVirtualMachine(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), nullable(Boolean.class), any(), any(), any(), - any(), any(), any(), any(), eq(true), any(), any(), any(), any()); + any(), any(), any(), any(), eq(true), any(), any(), any(), any(), any()); UserVm result = userVmManagerImpl.allocateVMFromBackup(cmd); @@ -3371,7 +3371,7 @@ public class UserVmManagerImplTest { Mockito.verify(backupDao).findById(backupId); Mockito.verify(userVmManagerImpl).createAdvancedVirtualMachine(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), nullable(Boolean.class), any(), any(), any(), - any(), any(), any(), any(), eq(true), any(), any(), any(), any()); + any(), any(), any(), any(), eq(true), any(), any(), any(), any(), any()); } @Test @@ -3422,14 +3422,14 @@ public class UserVmManagerImplTest { Mockito.doReturn(userVmVoMock).when(userVmManagerImpl).createAdvancedVirtualMachine(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), eq(false), any(), any(), any(), - any(), any(), any(), any(), eq(false), any(), any(), any(), any()); + any(), any(), any(), any(), eq(false), any(), any(), any(), any(), any()); UserVm result = userVmManagerImpl.allocateVMFromBackup(cmd); assertNotNull(result); Mockito.verify(userVmManagerImpl).createAdvancedVirtualMachine(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), eq(false), any(), any(), any(), - any(), any(), any(), any(), eq(false), any(), any(), any(), any()); + any(), any(), any(), any(), eq(false), any(), any(), any(), any(), any()); } @Test @@ -3539,7 +3539,7 @@ public class UserVmManagerImplTest { Mockito.doReturn(userVmVoMock).when(userVmManagerImpl).createAdvancedVirtualMachine(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), nullable(Boolean.class), any(), any(), any(), - any(), any(), any(), any(), eq(true), any(), any(), any(), any()); + any(), any(), any(), any(), eq(true), any(), any(), any(), any(), any()); UserVm result = userVmManagerImpl.allocateVMFromBackup(cmd); @@ -3547,7 +3547,7 @@ public class UserVmManagerImplTest { Mockito.verify(backupDao).findById(backupId); Mockito.verify(userVmManagerImpl).createAdvancedVirtualMachine(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), nullable(Boolean.class), any(), any(), any(), - any(), any(), any(), any(), eq(true), any(), any(), any(), any()); + any(), any(), any(), any(), eq(true), any(), any(), any(), any(), any()); } @Test @@ -3601,14 +3601,14 @@ public class UserVmManagerImplTest { Mockito.doReturn(userVmVoMock).when(userVmManagerImpl).createAdvancedVirtualMachine(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), eq(false), any(), any(), any(), - any(), any(), any(), any(), eq(false), any(), any(), any(), any()); + any(), any(), any(), any(), eq(false), any(), any(), any(), any(), any()); UserVm result = userVmManagerImpl.allocateVMFromBackup(cmd); assertNotNull(result); Mockito.verify(userVmManagerImpl).createAdvancedVirtualMachine(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), eq(false), any(), any(), any(), - any(), any(), any(), any(), eq(false), any(), any(), any(), any()); + any(), any(), any(), any(), eq(false), any(), any(), any(), any(), any()); } @Test @@ -3929,7 +3929,7 @@ public class UserVmManagerImplTest { when(_dcMock.getNetworkType()).thenReturn(DataCenter.NetworkType.Basic); Mockito.doReturn(userVmVoMock).when(userVmManagerImpl).createBasicSecurityGroupVirtualMachine(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), nullable(Boolean.class), any(), any(), any(), - any(), any(), any(), any(), eq(true), any(), any(), any()); + any(), any(), any(), any(), eq(true), any(), any(), any(), any()); userVmManagerImpl.createVirtualMachine(deployVMCmd); @@ -3960,7 +3960,7 @@ public class UserVmManagerImplTest { when(_dcMock.getNetworkType()).thenReturn(DataCenter.NetworkType.Basic); Mockito.doReturn(userVmVoMock).when(userVmManagerImpl).createBasicSecurityGroupVirtualMachine(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), nullable(Boolean.class), any(), any(), any(), - any(), any(), any(), any(), eq(true), any(), any(), any()); + any(), any(), any(), any(), eq(true), any(), any(), any(), any()); userVmManagerImpl.createVirtualMachine(deployVMCmd); } @@ -3989,7 +3989,7 @@ public class UserVmManagerImplTest { when(_dcMock.getNetworkType()).thenReturn(DataCenter.NetworkType.Basic); Mockito.doReturn(userVmVoMock).when(userVmManagerImpl).createBasicSecurityGroupVirtualMachine(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), nullable(Boolean.class), any(), any(), any(), - any(), any(), any(), any(), eq(true), any(), any(), any()); + any(), any(), any(), any(), eq(true), any(), any(), any(), any()); // Must NOT throw "Deployment of virtual machine is supported only for Zone-wide storage pools" userVmManagerImpl.createVirtualMachine(deployVMCmd); @@ -4019,7 +4019,7 @@ public class UserVmManagerImplTest { when(_dcMock.getNetworkType()).thenReturn(DataCenter.NetworkType.Basic); Mockito.doReturn(userVmVoMock).when(userVmManagerImpl).createBasicSecurityGroupVirtualMachine(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), nullable(Boolean.class), any(), any(), any(), - any(), any(), any(), any(), eq(true), any(), any(), any()); + any(), any(), any(), any(), eq(true), any(), any(), any(), any()); // Must NOT throw "Deployment of virtual machine is supported only for Zone-wide storage pools" userVmManagerImpl.createVirtualMachine(deployVMCmd); @@ -4104,7 +4104,7 @@ public class UserVmManagerImplTest { when(createdVm.getId()).thenReturn(2L); Mockito.doReturn(createdVm).when(userVmManagerImpl).createAdvancedVirtualMachine(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), nullable(Boolean.class), any(), any(), any(), - any(), any(), any(), any(), eq(true), any(), any(), any(), any()); + any(), any(), any(), any(), eq(true), any(), any(), any(), any(), any()); Map existingDetails = new HashMap<>(); existingDetails.put("existingKey", "existingValue"); @@ -4172,7 +4172,7 @@ public class UserVmManagerImplTest { when(createdVm.getId()).thenReturn(2L); Mockito.doReturn(createdVm).when(userVmManagerImpl).createAdvancedVirtualMachine(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), nullable(Boolean.class), any(), any(), any(), - any(), any(), any(), any(), eq(true), any(), any(), any(), any()); + any(), any(), any(), any(), eq(true), any(), any(), any(), any(), any()); UserVm result = userVmManagerImpl.allocateVMFromBackup(cmd); diff --git a/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplAccessTest.java b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplAccessTest.java new file mode 100644 index 00000000000..324f84579ab --- /dev/null +++ b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplAccessTest.java @@ -0,0 +1,281 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms; + +import com.cloud.domain.dao.DomainDao; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.PermissionDeniedException; +import com.cloud.user.Account; +import com.cloud.user.AccountManager; +import com.cloud.user.dao.AccountDao; +import org.apache.cloudstack.framework.kms.KeyPurpose; +import org.apache.cloudstack.kms.dao.HSMProfileDao; +import org.apache.cloudstack.kms.dao.KMSKeyDao; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests covering access and permission helpers in KMSManagerImpl. + */ +@RunWith(MockitoJUnitRunner.class) +public class KMSManagerImplAccessTest { + + @Spy + @InjectMocks + private KMSManagerImpl kmsManager; + + @Mock + private KMSKeyDao kmsKeyDao; + + @Mock + private HSMProfileDao hsmProfileDao; + + @Mock + private AccountDao accountDao; + + @Mock + private DomainDao domainDao; + + @Mock + private AccountManager accountManager; + + @Test + public void testHasPermission_ReturnsFalseWhenCallerAccountIdIsNull() { + assertFalse(kmsManager.hasPermission(null, mock(KMSKey.class))); + } + + @Test + public void testHasPermission_ReturnsFalseWhenKeyIsNull() { + assertFalse(kmsManager.hasPermission(1L, null)); + } + + @Test(expected = InvalidParameterValueException.class) + public void testHasPermission_ThrowsWhenKeyIsDisabled() { + KMSKey key = mock(KMSKey.class); + when(key.isEnabled()).thenReturn(false); + kmsManager.hasPermission(1L, key); + } + + @Test + public void testHasPermission_ReturnsFalseWhenCallerAccountNotFound() { + KMSKey key = mock(KMSKey.class); + when(key.isEnabled()).thenReturn(true); + + assertFalse(kmsManager.hasPermission(1L, key)); + } + + @Test + public void testHasPermission_ReturnsFalseWhenPermissionDenied() { + KMSKey key = mock(KMSKey.class); + when(key.isEnabled()).thenReturn(true); + when(key.getAccountId()).thenReturn(10L); + + assertFalse(kmsManager.hasPermission(1L, key)); + } + + @Test + public void testHasPermission_ReturnsTrueWhenAccessGranted() { + KMSKey key = mock(KMSKey.class); + when(key.isEnabled()).thenReturn(true); + when(key.getAccountId()).thenReturn(1L); + assertTrue(kmsManager.hasPermission(1L, key)); + } + + @Test(expected = InvalidParameterValueException.class) + public void testFindKMSKeyAndCheckAccess_ThrowsWhenKeyNotFound() { + when(kmsKeyDao.findById(99L)).thenReturn(null); + kmsManager.findKMSKeyAndCheckAccess(99L, mock(Account.class)); + } + + @Test(expected = PermissionDeniedException.class) + public void testFindKMSKeyAndCheckAccess_ThrowsWhenPermissionDenied() { + KMSKeyVO key = mock(KMSKeyVO.class); + Account caller = mock(Account.class); + when(kmsKeyDao.findById(1L)).thenReturn(key); + doThrow(new PermissionDeniedException("denied")) + .when(accountManager).checkAccess(caller, null, true, key); + + kmsManager.findKMSKeyAndCheckAccess(1L, caller); + } + + @Test + public void testFindKMSKeyAndCheckAccess_ReturnsKeyOnSuccess() { + KMSKeyVO key = mock(KMSKeyVO.class); + Account caller = mock(Account.class); + when(kmsKeyDao.findById(1L)).thenReturn(key); + + KMSKeyVO result = kmsManager.findKMSKeyAndCheckAccess(1L, caller); + + assertSame(key, result); + } + + @Test + public void testCheckKmsKeyForVolumeEncryption_NoOpWhenKeyIdIsNull() { + kmsManager.checkKmsKeyForVolumeEncryption(mock(Account.class), null, 1L); + } + + @Test(expected = InvalidParameterValueException.class) + public void testCheckKmsKeyForVolumeEncryption_ThrowsWhenKeyNotFound() { + when(kmsKeyDao.findById(1L)).thenReturn(null); + kmsManager.checkKmsKeyForVolumeEncryption(mock(Account.class), 1L, null); + } + + @Test(expected = PermissionDeniedException.class) + public void testCheckKmsKeyForVolumeEncryption_ThrowsWhenPermissionDenied() { + KMSKeyVO key = mock(KMSKeyVO.class); + when(key.getAccountId()).thenReturn(2L); + + Account owner = mock(Account.class); + when(kmsKeyDao.findById(1L)).thenReturn(key); + + kmsManager.checkKmsKeyForVolumeEncryption(owner, 1L, null); + } + + @Test(expected = InvalidParameterValueException.class) + public void testCheckKmsKeyForVolumeEncryption_ThrowsOnZoneMismatch() { + KMSKeyVO key = mock(KMSKeyVO.class); + when(key.getZoneId()).thenReturn(2L); + when(kmsKeyDao.findById(1L)).thenReturn(key); + + kmsManager.checkKmsKeyForVolumeEncryption(mock(Account.class), 1L, 3L); + } + + @Test(expected = InvalidParameterValueException.class) + public void testCheckKmsKeyForVolumeEncryption_ThrowsWhenKeyDisabled() { + KMSKeyVO key = mock(KMSKeyVO.class); + when(key.getZoneId()).thenReturn(null); + when(key.isEnabled()).thenReturn(false); + when(kmsKeyDao.findById(1L)).thenReturn(key); + + kmsManager.checkKmsKeyForVolumeEncryption(mock(Account.class), 1L, null); + } + + @Test(expected = InvalidParameterValueException.class) + public void testCheckKmsKeyForVolumeEncryption_ThrowsWhenWrongPurpose() { + KMSKeyVO key = mock(KMSKeyVO.class); + when(key.getZoneId()).thenReturn(null); + when(key.isEnabled()).thenReturn(true); + when(key.getPurpose()).thenReturn(KeyPurpose.TLS_CERT); + when(kmsKeyDao.findById(1L)).thenReturn(key); + + kmsManager.checkKmsKeyForVolumeEncryption(mock(Account.class), 1L, null); + } + + @Test + public void testCheckKmsKeyForVolumeEncryption_PassesForMatchingZone() { + KMSKeyVO key = mock(KMSKeyVO.class); + when(key.getZoneId()).thenReturn(1L); + when(key.isEnabled()).thenReturn(true); + when(key.getPurpose()).thenReturn(KeyPurpose.VOLUME_ENCRYPTION); + when(kmsKeyDao.findById(1L)).thenReturn(key); + + kmsManager.checkKmsKeyForVolumeEncryption(mock(Account.class), 1L, 1L); + } + + @Test + public void testCheckKmsKeyForVolumeEncryption_PassesWhenKeyHasNoZoneRestriction() { + KMSKeyVO key = mock(KMSKeyVO.class); + when(key.getZoneId()).thenReturn(null); + when(key.isEnabled()).thenReturn(true); + when(key.getPurpose()).thenReturn(KeyPurpose.VOLUME_ENCRYPTION); + when(kmsKeyDao.findById(1L)).thenReturn(key); + + kmsManager.checkKmsKeyForVolumeEncryption(mock(Account.class), 1L, 5L); + } + + @Test(expected = PermissionDeniedException.class) + public void testCheckHSMProfileAccess_DeniesNonRootModifyOfSystemProfile() { + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getIsPublic()).thenReturn(true); + + Account caller = mock(Account.class); + when(caller.getId()).thenReturn(1L); + when(accountManager.isRootAdmin(1L)).thenReturn(false); + + kmsManager.checkHSMProfileAccess(caller, profile, true); + } + + @Test + public void testCheckHSMProfileAccess_AllowsRootModifyOfSystemProfile() { + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getIsPublic()).thenReturn(true); + + Account caller = mock(Account.class); + when(caller.getId()).thenReturn(1L); + when(accountManager.isRootAdmin(1L)).thenReturn(true); + + kmsManager.checkHSMProfileAccess(caller, profile, true); + } + + @Test + public void testCheckHSMProfileAccess_AllowsReadAccessToSystemProfileForAllUsers() { + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getIsPublic()).thenReturn(true); + + kmsManager.checkHSMProfileAccess(mock(Account.class), profile, false); + } + + @Test + public void testCheckHSMProfileAccess_DelegatesToAclForOwnedProfile() { + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getIsPublic()).thenReturn(false); + + kmsManager.checkHSMProfileAccess(mock(Account.class), profile, true); + } + + @Test(expected = PermissionDeniedException.class) + public void testCheckHSMProfileAccess_ThrowsWhenAclDeniesOwnedProfile() { + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getIsPublic()).thenReturn(false); + + Account caller = mock(Account.class); + doThrow(new PermissionDeniedException("denied")) + .when(accountManager).checkAccess(caller, null, true, profile); + + kmsManager.checkHSMProfileAccess(caller, profile, true); + } + + @Test + public void testParseKeyPurpose_ReturnsNullForNullInput() { + assertNull(kmsManager.parseKeyPurpose(null)); + } + + @Test + public void testParseKeyPurpose_ReturnsVolumeEncryptionForValidName() { + KeyPurpose result = kmsManager.parseKeyPurpose("volume"); + assertNotNull(result); + } + + @Test(expected = InvalidParameterValueException.class) + public void testParseKeyPurpose_ThrowsForUnknownPurpose() { + kmsManager.parseKeyPurpose("not-a-valid-purpose"); + } +} diff --git a/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplHSMTest.java b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplHSMTest.java new file mode 100644 index 00000000000..81f29825b1b --- /dev/null +++ b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplHSMTest.java @@ -0,0 +1,442 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms; + +import com.cloud.api.ApiResponseHelper; +import com.cloud.dc.dao.DataCenterDao; +import com.cloud.domain.DomainVO; +import com.cloud.domain.dao.DomainDao; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.PermissionDeniedException; +import com.cloud.user.Account; +import com.cloud.user.AccountManager; +import com.cloud.user.dao.AccountDao; +import org.apache.cloudstack.api.command.user.kms.hsm.DeleteHSMProfileCmd; +import org.apache.cloudstack.api.response.HSMProfileResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.kms.KMSProvider; +import org.apache.cloudstack.kms.dao.HSMProfileDao; +import org.apache.cloudstack.kms.dao.HSMProfileDetailsDao; +import org.apache.cloudstack.kms.dao.KMSKekVersionDao; +import org.apache.cloudstack.kms.dao.KMSKeyDao; +import org.apache.cloudstack.kms.dao.KMSWrappedKeyDao; +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 java.util.Arrays; +import java.util.Collections; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for HSM-related business logic in KMSManagerImpl + * Tests sensitive key detection, profile resolution hierarchy, and provider matching + */ +@RunWith(MockitoJUnitRunner.class) +public class KMSManagerImplHSMTest { + + private final Long testAccountId = 100L; + + @Spy + @InjectMocks + private KMSManagerImpl kmsManager; + + @Mock + private HSMProfileDao hsmProfileDao; + + @Mock + private HSMProfileDetailsDao hsmProfileDetailsDao; + + @Mock + private AccountManager accountManager; + + @Mock + private DataCenterDao dataCenterDao; + + @Mock + private DomainDao domainDao; + + @Mock + private AccountDao accountDao; + + @Mock + private KMSKeyDao kmsKeyDao; + + @Mock + private KMSKekVersionDao kmsKekVersionDao; + + @Mock + private KMSWrappedKeyDao kmsWrappedKeyDao; + + /** + * Test: isSensitiveKey correctly identifies "pin" as sensitive + */ + @Test + public void testIsSensitiveKey_DetectsPin() { + boolean result = kmsManager.isSensitiveKey("pin"); + assertTrue("'pin' should be detected as sensitive", result); + } + + /** + * Test: isSensitiveKey correctly identifies "password" as sensitive + */ + @Test + public void testIsSensitiveKey_DetectsPassword() { + boolean result = kmsManager.isSensitiveKey("password"); + assertTrue("'password' should be detected as sensitive", result); + } + + /** + * Test: isSensitiveKey correctly identifies keys containing "secret" as sensitive + */ + @Test + public void testIsSensitiveKey_DetectsSecret() { + boolean result = kmsManager.isSensitiveKey("api_secret"); + assertTrue("'api_secret' should be detected as sensitive", result); + } + + /** + * Test: isSensitiveKey correctly identifies "private_key" as sensitive + */ + @Test + public void testIsSensitiveKey_DetectsPrivateKey() { + boolean result = kmsManager.isSensitiveKey("private_key"); + assertTrue("'private_key' should be detected as sensitive", result); + } + + /** + * Test: isSensitiveKey correctly identifies non-sensitive keys + */ + @Test + public void testIsSensitiveKey_DoesNotDetectNonSensitive() { + boolean result = kmsManager.isSensitiveKey("library_path"); + assertFalse("'library_path' should not be detected as sensitive", result); + } + + /** + * Test: isSensitiveKey is case-insensitive + */ + @Test + public void testIsSensitiveKey_CaseInsensitive() { + boolean resultUpper = kmsManager.isSensitiveKey("PIN"); + boolean resultMixed = kmsManager.isSensitiveKey("Password"); + + assertTrue("'PIN' (uppercase) should be detected as sensitive", resultUpper); + assertTrue("'Password' (mixed case) should be detected as sensitive", resultMixed); + } + + /** + * Test: createHSMProfileResponse populates details correctly + */ + @Test + public void testCreateHSMProfileResponse_PopulatesDetails() { + Long profileId = 10L; + + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getId()).thenReturn(profileId); + when(profile.getUuid()).thenReturn("profile-uuid"); + when(profile.getName()).thenReturn("test-profile"); + when(profile.getProtocol()).thenReturn("PKCS11"); + when(profile.getVendorName()).thenReturn("TestVendor"); + when(profile.isEnabled()).thenReturn(true); + when(profile.getCreated()).thenReturn(new java.util.Date()); + + HSMProfileDetailsVO detail1 = mock(HSMProfileDetailsVO.class); + when(detail1.getName()).thenReturn("library_path"); + when(detail1.getValue()).thenReturn("/path/to/lib.so"); + + HSMProfileDetailsVO detail2 = mock(HSMProfileDetailsVO.class); + when(detail2.getName()).thenReturn("pin"); + when(detail2.getValue()).thenReturn("ENC(encrypted_value)"); + + when(hsmProfileDetailsDao.listByProfileId(profileId)).thenReturn(Arrays.asList(detail1, detail2)); + + try (MockedStatic mockedApiResponseHelper = Mockito.mockStatic(ApiResponseHelper.class)) { + HSMProfileResponse response = kmsManager.createHSMProfileResponse(profile); + + assertNotNull("Response should not be null", response); + verify(hsmProfileDetailsDao).listByProfileId(profileId); + } + } + + /** + * Test: the seeded default (system-owned) database profile cannot be deleted. + */ + @Test(expected = InvalidParameterValueException.class) + public void testDeleteHSMProfile_RejectsSystemOwnedDatabaseProfile() { + Long profileId = 10L; + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getProtocol()).thenReturn("database"); + when(profile.getAccountId()).thenReturn(Account.ACCOUNT_ID_SYSTEM); + when(profile.getIsPublic()).thenReturn(true); + when(hsmProfileDao.findById(profileId)).thenReturn(profile); + + Account caller = mock(Account.class); + when(caller.getId()).thenReturn(2L); + when(accountManager.isRootAdmin(2L)).thenReturn(true); + + DeleteHSMProfileCmd cmd = mock(DeleteHSMProfileCmd.class); + when(cmd.getId()).thenReturn(profileId); + + try (MockedStatic mockedCallContext = Mockito.mockStatic(CallContext.class)) { + CallContext callContext = mock(CallContext.class); + mockedCallContext.when(CallContext::current).thenReturn(callContext); + when(callContext.getCallingAccount()).thenReturn(caller); + + kmsManager.deleteHSMProfile(cmd); + } + } + + /** + * Test: an admin-created (non-system) database profile with no keys can be deleted. + */ + @Test + public void testDeleteHSMProfile_AllowsAdminOwnedDatabaseProfile() { + Long profileId = 10L; + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getId()).thenReturn(profileId); + when(profile.getProtocol()).thenReturn("database"); + when(profile.getAccountId()).thenReturn(200L); + when(profile.getIsPublic()).thenReturn(false); + when(hsmProfileDao.findById(profileId)).thenReturn(profile); + + when(kmsKeyDao.countByHsmProfileId(profileId)).thenReturn(0L); + when(kmsKekVersionDao.listByHsmProfileId(profileId)).thenReturn(Collections.emptyList()); + + KMSProvider kmsProvider = mock(KMSProvider.class); + doReturn(kmsProvider).when(kmsManager).getKMSProvider("database"); + when(hsmProfileDao.remove(profileId)).thenReturn(true); + + Account caller = mock(Account.class); + + DeleteHSMProfileCmd cmd = mock(DeleteHSMProfileCmd.class); + when(cmd.getId()).thenReturn(profileId); + + try (MockedStatic mockedCallContext = Mockito.mockStatic(CallContext.class)) { + CallContext callContext = mock(CallContext.class); + mockedCallContext.when(CallContext::current).thenReturn(callContext); + when(callContext.getCallingAccount()).thenReturn(caller); + + boolean result = kmsManager.deleteHSMProfile(cmd); + + assertTrue("Admin-owned database profile should be deletable", result); + verify(kmsProvider).invalidateProfileCache(profileId); + verify(hsmProfileDao).remove(profileId); + } + } + + private HSMProfileVO domainScopedProfile(long domainId) { + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getIsPublic()).thenReturn(false); + when(profile.getAccountId()).thenReturn(-1L); + when(profile.getDomainId()).thenReturn(domainId); + return profile; + } + + /** + * Test: a non-admin caller in the same domain may use a domain-scoped profile. + */ + @Test + public void testCheckHSMProfileAccess_DomainScoped_AllowsUseBySameDomain() { + HSMProfileVO profile = domainScopedProfile(5L); + + Account caller = mock(Account.class); + when(caller.getId()).thenReturn(2L); + when(caller.getDomainId()).thenReturn(5L); + when(accountManager.isRootAdmin(2L)).thenReturn(false); + + kmsManager.checkHSMProfileAccess(caller, profile, false); + + verify(accountManager, Mockito.never()).checkAccess(Mockito.any(Account.class), Mockito.any(), Mockito.anyBoolean(), Mockito.any()); + } + + /** + * Test: a non-admin caller in a different domain is denied use of a domain-scoped profile. + */ + @Test(expected = PermissionDeniedException.class) + public void testCheckHSMProfileAccess_DomainScoped_DeniesUseByOtherDomain() { + HSMProfileVO profile = domainScopedProfile(5L); + + Account caller = mock(Account.class); + when(caller.getId()).thenReturn(2L); + when(caller.getDomainId()).thenReturn(7L); + when(accountManager.isRootAdmin(2L)).thenReturn(false); + + kmsManager.checkHSMProfileAccess(caller, profile, false); + } + + /** + * Test: a root admin may use a domain-scoped profile from any domain. + */ + @Test + public void testCheckHSMProfileAccess_DomainScoped_AllowsRootAdminFromAnyDomain() { + HSMProfileVO profile = domainScopedProfile(5L); + + Account caller = mock(Account.class); + when(caller.getId()).thenReturn(2L); + when(accountManager.isRootAdmin(2L)).thenReturn(true); + + kmsManager.checkHSMProfileAccess(caller, profile, false); + } + + /** + * Test: a non-admin caller in the same domain may NOT modify a domain-scoped profile. + */ + @Test(expected = PermissionDeniedException.class) + public void testCheckHSMProfileAccess_DomainScoped_DeniesModifyByNonAdmin() { + HSMProfileVO profile = domainScopedProfile(5L); + + Account caller = mock(Account.class); + when(caller.getId()).thenReturn(2L); + when(accountManager.isRootAdmin(2L)).thenReturn(false); + + kmsManager.checkHSMProfileAccess(caller, profile, true); + } + + /** + * Test: a root admin may modify a domain-scoped profile. + */ + @Test + public void testCheckHSMProfileAccess_DomainScoped_AllowsModifyByRootAdmin() { + HSMProfileVO profile = domainScopedProfile(5L); + + Account caller = mock(Account.class); + when(caller.getId()).thenReturn(2L); + when(accountManager.isRootAdmin(2L)).thenReturn(true); + + kmsManager.checkHSMProfileAccess(caller, profile, true); + } + + /** + * Test: createHSMProfileResponse for a domain-scoped (account-less) profile populates the + * domain fields directly and does not route through populateOwner (which would NPE on a null owner). + */ + @Test + public void testCreateHSMProfileResponse_DomainScoped_PopulatesDomainFields() { + Long profileId = 11L; + Long domainId = 5L; + + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getId()).thenReturn(profileId); + when(profile.getUuid()).thenReturn("profile-uuid"); + when(profile.getName()).thenReturn("domain-profile"); + when(profile.getProtocol()).thenReturn("PKCS11"); + when(profile.getVendorName()).thenReturn("TestVendor"); + when(profile.isEnabled()).thenReturn(true); + when(profile.getCreated()).thenReturn(new java.util.Date()); + when(profile.getAccountId()).thenReturn(-1L); + when(profile.getDomainId()).thenReturn(domainId); + + DomainVO domain = mock(DomainVO.class); + when(domain.getUuid()).thenReturn("domain-uuid"); + when(domain.getName()).thenReturn("D"); + when(domain.getPath()).thenReturn("/D/"); + when(domainDao.findById(domainId)).thenReturn(domain); + + when(hsmProfileDetailsDao.listByProfileId(profileId)).thenReturn(Collections.emptyList()); + + try (MockedStatic mockedApiResponseHelper = Mockito.mockStatic(ApiResponseHelper.class)) { + mockedApiResponseHelper.when(() -> ApiResponseHelper.getPrettyDomainPath("/D/")).thenReturn("ROOT/D"); + + HSMProfileResponse response = kmsManager.createHSMProfileResponse(profile); + + assertNotNull("Response should not be null", response); + assertEquals("domain-uuid", response.getDomainId()); + assertEquals("D", response.getDomainName()); + assertEquals("Domain path should use the pretty (ROOT/...) form", "ROOT/D", response.getDomainPath()); + // populateOwner would NPE on a null owner; the account-less branch must avoid it. + mockedApiResponseHelper.verify(() -> ApiResponseHelper.populateOwner(Mockito.any(), Mockito.any()), Mockito.never()); + } + } + + /** + * Test: validateProfileScopeForOwner allows any owner for a public profile. + */ + @Test + public void testValidateProfileScopeForOwner_PublicAllowsAnyOwner() { + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getIsPublic()).thenReturn(true); + + kmsManager.validateProfileScopeForOwner(profile, 200L, 9L); + } + + /** + * Test: a domain-scoped profile permits a key owner in the same domain. + */ + @Test + public void testValidateProfileScopeForOwner_DomainScopedAllowsSameDomain() { + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getIsPublic()).thenReturn(false); + when(profile.getAccountId()).thenReturn(-1L); + when(profile.getDomainId()).thenReturn(5L); + + kmsManager.validateProfileScopeForOwner(profile, 200L, 5L); + } + + /** + * Test: a domain-scoped profile rejects a key owner in a different domain. + */ + @Test(expected = InvalidParameterValueException.class) + public void testValidateProfileScopeForOwner_DomainScopedRejectsOtherDomain() { + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getIsPublic()).thenReturn(false); + when(profile.getAccountId()).thenReturn(-1L); + when(profile.getDomainId()).thenReturn(5L); + when(profile.getName()).thenReturn("domain-profile"); + + kmsManager.validateProfileScopeForOwner(profile, 200L, 7L); + } + + /** + * Test: an account-owned profile permits a key owned by that account. + */ + @Test + public void testValidateProfileScopeForOwner_AccountOwnedAllowsSameAccount() { + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getIsPublic()).thenReturn(false); + when(profile.getAccountId()).thenReturn(200L); + + kmsManager.validateProfileScopeForOwner(profile, 200L, 5L); + } + + /** + * Test: an account-owned profile rejects a key owned by a different account. + */ + @Test(expected = InvalidParameterValueException.class) + public void testValidateProfileScopeForOwner_AccountOwnedRejectsOtherAccount() { + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getIsPublic()).thenReturn(false); + when(profile.getAccountId()).thenReturn(200L); + when(profile.getName()).thenReturn("account-profile"); + + kmsManager.validateProfileScopeForOwner(profile, 201L, 5L); + } +} diff --git a/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyCreationTest.java b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyCreationTest.java new file mode 100644 index 00000000000..54e9e4d815b --- /dev/null +++ b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyCreationTest.java @@ -0,0 +1,216 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms; + +import com.cloud.domain.dao.DomainDao; +import com.cloud.event.ActionEventUtils; +import com.cloud.user.dao.AccountDao; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.framework.kms.KMSProvider; +import org.apache.cloudstack.framework.kms.KeyPurpose; +import org.apache.cloudstack.kms.dao.HSMProfileDao; +import org.apache.cloudstack.kms.dao.KMSKekVersionDao; +import org.apache.cloudstack.kms.dao.KMSKeyDao; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +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 java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for KMS key creation logic in KMSManagerImpl + * Tests key creation with explicit and auto-resolved HSM profiles + */ +@RunWith(MockitoJUnitRunner.class) +public class KMSManagerImplKeyCreationTest { + + private final Long testAccountId = 100L; + private final Long testDomainId = 1L; + private final Long testZoneId = 1L; + private final String testProviderName = "pkcs11"; + + @Spy + @InjectMocks + private KMSManagerImpl kmsManager; + + @Mock + private KMSKeyDao kmsKeyDao; + + @Mock + private KMSKekVersionDao kmsKekVersionDao; + + @Mock + private HSMProfileDao hsmProfileDao; + + @Mock + private AccountDao accountDao; + + @Mock + private DomainDao domainDao; + + @Mock + private KMSProvider kmsProvider; + + private ExecutorService executor; + + @Before + public void setUp() { + executor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "kms-test"); + t.setDaemon(true); + return t; + }); + ReflectionTestUtils.setField(kmsManager, "kmsOperationExecutor", executor); + doReturn(5).when(kmsManager).getOperationTimeoutSec(); + doReturn(0).when(kmsManager).getRetryCount(); + doReturn(0).when(kmsManager).getRetryDelayMs(); + } + + @After + public void tearDown() { + if (executor != null) { + executor.shutdownNow(); + } + } + + /** + * Test: createUserKMSKey uses explicit HSM profile when provided + */ + @Test + public void testCreateUserKMSKey_WithExplicitProfile() throws Exception { + // Setup: Explicit profile name provided + String hsmProfileName = "user-hsm-profile"; + Long hsmProfileId = 10L; + + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getProtocol()).thenReturn(testProviderName); + when(hsmProfileDao.findById(hsmProfileId)).thenReturn(profile); + + // Mock provider KEK creation + when(kmsProvider.createKek(any(KeyPurpose.class), anyString(), anyInt(), eq(hsmProfileId))) + .thenReturn("test-kek-label"); + + // Mock DAO persist operations + KMSKeyVO mockKey = mock(KMSKeyVO.class); + when(mockKey.getId()).thenReturn(1L); + when(kmsKeyDao.persist(any(KMSKeyVO.class))).thenReturn(mockKey); + + KMSKekVersionVO mockVersion = mock(KMSKekVersionVO.class); + when(kmsKekVersionDao.persist(any(KMSKekVersionVO.class))).thenReturn(mockVersion); + + doReturn(kmsProvider).when(kmsManager).getKMSProvider(testProviderName); + + try (MockedStatic actionEventUtils = Mockito.mockStatic(ActionEventUtils.class)) { + actionEventUtils.when(() -> ActionEventUtils.onCompletedActionEvent( + Mockito.anyLong(), Mockito.anyLong(), Mockito.anyString(), + Mockito.anyString(), Mockito.anyString(), Mockito.anyLong(), + Mockito.anyString(), Mockito.anyInt())).thenReturn(2L); + KMSKey result = kmsManager.createUserKMSKey(testAccountId, testDomainId, + testZoneId, "test-key", "Test key", KeyPurpose.VOLUME_ENCRYPTION, 256, hsmProfileId); + + // Verify explicit profile was used + assertNotNull(result); + verify(hsmProfileDao).findById(hsmProfileId); + verify(kmsProvider).createKek(any(KeyPurpose.class), anyString(), eq(256), eq(hsmProfileId)); + + // Verify KMSKeyVO was created with correct profile ID + ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(KMSKeyVO.class); + verify(kmsKeyDao).persist(keyCaptor.capture()); + KMSKeyVO createdKey = keyCaptor.getValue(); + assertEquals(hsmProfileId, createdKey.getHsmProfileId()); + } + } + + /** + * Test: createUserKMSKey throws exception when explicit profile not found + */ + @Test(expected = KMSException.class) + public void testCreateUserKMSKey_ThrowsExceptionWhenProfileNotFound() throws KMSException { + // Setup: Profile name provided but doesn't exist + String invalidProfileName = "non-existent-profile"; + long hsmProfileId = 1L; + when(hsmProfileDao.findById(hsmProfileId)).thenReturn(null); + + kmsManager.createUserKMSKey(testAccountId, testDomainId, testZoneId, + "test-key", "Test key", KeyPurpose.VOLUME_ENCRYPTION, 256, hsmProfileId); + } + + /** + * Test: createUserKMSKey creates KEK version with correct profile ID + */ + @Test + public void testCreateUserKMSKey_CreatesKekVersionWithProfileId() throws Exception { + // Setup + Long hsmProfileId = 40L; + + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getProtocol()).thenReturn(testProviderName); + when(hsmProfileDao.findById(hsmProfileId)).thenReturn(profile); + + when(kmsProvider.createKek(any(KeyPurpose.class), anyString(), anyInt(), eq(hsmProfileId))) + .thenReturn("test-kek-label"); + + KMSKeyVO mockKey = mock(KMSKeyVO.class); + when(mockKey.getId()).thenReturn(1L); + when(kmsKeyDao.persist(any(KMSKeyVO.class))).thenReturn(mockKey); + + KMSKekVersionVO mockVersion = mock(KMSKekVersionVO.class); + when(kmsKekVersionDao.persist(any(KMSKekVersionVO.class))).thenReturn(mockVersion); + + doReturn(kmsProvider).when(kmsManager).getKMSProvider(testProviderName); + + try (MockedStatic actionEventUtils = Mockito.mockStatic(ActionEventUtils.class)) { + actionEventUtils.when(() -> ActionEventUtils.onCompletedActionEvent( + Mockito.anyLong(), Mockito.anyLong(), Mockito.anyString(), + Mockito.anyString(), Mockito.anyString(), Mockito.anyLong(), + Mockito.anyString(), Mockito.anyInt())).thenReturn(2L); + + kmsManager.createUserKMSKey(testAccountId, testDomainId, testZoneId, + "test-key", "Test key", KeyPurpose.VOLUME_ENCRYPTION, 256, hsmProfileId); + + // Verify KEK version was created with correct profile ID + ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(KMSKekVersionVO.class); + verify(kmsKekVersionDao).persist(versionCaptor.capture()); + KMSKekVersionVO createdVersion = versionCaptor.getValue(); + assertEquals(hsmProfileId, createdVersion.getHsmProfileId()); + assertEquals(Integer.valueOf(1), createdVersion.getVersionNumber()); + assertEquals("test-kek-label", createdVersion.getKekLabel()); + } + } +} diff --git a/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyLifecycleTest.java b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyLifecycleTest.java new file mode 100644 index 00000000000..1d4c2947a88 --- /dev/null +++ b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyLifecycleTest.java @@ -0,0 +1,457 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms; + +import com.cloud.event.ActionEventUtils; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.storage.dao.VolumeDao; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.framework.kms.KMSProvider; +import org.apache.cloudstack.framework.kms.KeyPurpose; +import org.apache.cloudstack.framework.kms.WrappedKey; +import org.apache.cloudstack.kms.dao.HSMProfileDao; +import org.apache.cloudstack.kms.dao.KMSKekVersionDao; +import org.apache.cloudstack.kms.dao.KMSKeyDao; +import org.apache.cloudstack.kms.dao.KMSWrappedKeyDao; +import org.junit.After; +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 java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +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.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests covering key lifecycle operations in KMSManagerImpl. + */ +@RunWith(MockitoJUnitRunner.class) +public class KMSManagerImplKeyLifecycleTest { + + @Spy + @InjectMocks + private KMSManagerImpl kmsManager; + + @Mock + private KMSKeyDao kmsKeyDao; + + @Mock + private KMSKekVersionDao kmsKekVersionDao; + + @Mock + private KMSWrappedKeyDao kmsWrappedKeyDao; + + @Mock + private HSMProfileDao hsmProfileDao; + + @Mock + private VolumeDao volumeDao; + + @Mock + private KMSProvider kmsProvider; + + private ExecutorService executor; + + @Before + public void setUp() { + executor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "kms-test"); + t.setDaemon(true); + return t; + }); + ReflectionTestUtils.setField(kmsManager, "kmsOperationExecutor", executor); + doReturn(5).when(kmsManager).getOperationTimeoutSec(); + doReturn(0).when(kmsManager).getRetryCount(); + doReturn(0).when(kmsManager).getRetryDelayMs(); + } + + @After + public void tearDown() { + if (executor != null) { + executor.shutdownNow(); + } + } + + @Test(expected = KMSException.class) + public void testUnwrapKey_ThrowsWhenWrappedKeyNotFound() throws KMSException { + when(kmsWrappedKeyDao.findById(1L)).thenReturn(null); + kmsManager.unwrapKey(1L); + } + + @Test(expected = KMSException.class) + public void testUnwrapKey_ThrowsWhenKmsKeyNotFound() throws KMSException { + KMSWrappedKeyVO wrappedVO = mock(KMSWrappedKeyVO.class); + when(wrappedVO.getKmsKeyId()).thenReturn(10L); + when(kmsWrappedKeyDao.findById(1L)).thenReturn(wrappedVO); + when(kmsKeyDao.findById(10L)).thenReturn(null); + + kmsManager.unwrapKey(1L); + } + + @Test + public void testUnwrapKey_SucceedsWithHintVersion() { + Long wrappedKeyId = 1L; + Long kmsKeyId = 10L; + Long versionId = 5L; + + KMSWrappedKeyVO wrappedVO = mock(KMSWrappedKeyVO.class); + when(wrappedVO.getKmsKeyId()).thenReturn(kmsKeyId); + when(wrappedVO.getKekVersionId()).thenReturn(versionId); + when(wrappedVO.getWrappedBlob()).thenReturn(new byte[]{0, 1}); + when(kmsWrappedKeyDao.findById(wrappedKeyId)).thenReturn(wrappedVO); + + KMSKeyVO kmsKey = mock(KMSKeyVO.class); + when(kmsKey.getPurpose()).thenReturn(KeyPurpose.VOLUME_ENCRYPTION); + when(kmsKey.getAlgorithm()).thenReturn("AES/GCM/NoPadding"); + when(kmsKeyDao.findById(kmsKeyId)).thenReturn(kmsKey); + + KMSKekVersionVO version = mock(KMSKekVersionVO.class); + when(version.getStatus()).thenReturn(KMSKekVersionVO.Status.Active); + when(version.getHsmProfileId()).thenReturn(20L); + when(version.getKekLabel()).thenReturn("kek-label"); + when(kmsKekVersionDao.findById(versionId)).thenReturn(version); + + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getProtocol()).thenReturn("database"); + when(profile.isEnabled()).thenReturn(true); + when(hsmProfileDao.findById(20L)).thenReturn(profile); + + when(kmsProvider.unwrapKey(any(WrappedKey.class), anyLong())).thenReturn(new byte[]{1, 2, 3}); + doReturn(kmsProvider).when(kmsManager).getKMSProvider("database"); + + byte[] result = kmsManager.unwrapKey(wrappedKeyId); + + assertNotNull(result); + verify(kmsKekVersionDao, never()).getVersionsForDecryption(kmsKeyId); + } + + @Test(expected = KMSException.class) + public void testUnwrapKey_FallsBackToAllVersionsWhenNoHint() { + KMSWrappedKeyVO wrappedVO = mock(KMSWrappedKeyVO.class); + when(wrappedVO.getKmsKeyId()).thenReturn(10L); + when(wrappedVO.getKekVersionId()).thenReturn(null); + when(kmsWrappedKeyDao.findById(1L)).thenReturn(wrappedVO); + when(kmsKeyDao.findById(10L)).thenReturn(mock(KMSKeyVO.class)); + + kmsManager.unwrapKey(1L); + } + + @Test(expected = KMSException.class) + public void testUnwrapKey_ThrowsWhenAllVersionsFail() { + KMSWrappedKeyVO wrappedVO = mock(KMSWrappedKeyVO.class); + when(wrappedVO.getKmsKeyId()).thenReturn(10L); + when(wrappedVO.getKekVersionId()).thenReturn(null); + when(kmsWrappedKeyDao.findById(1L)).thenReturn(wrappedVO); + when(kmsKeyDao.findById(10L)).thenReturn(mock(KMSKeyVO.class)); + + kmsManager.unwrapKey(1L); + } + + @Test(expected = KMSException.class) + public void testGenerateVolumeKeyWithKek_ThrowsWhenKeyNull() throws KMSException { + kmsManager.generateVolumeKeyWithKek(null, 1L); + } + + @Test(expected = KMSException.class) + public void testGenerateVolumeKeyWithKek_ThrowsWhenKeyDisabled() throws KMSException { + KMSKey key = mock(KMSKey.class); + when(key.isEnabled()).thenReturn(false); + kmsManager.generateVolumeKeyWithKek(key, 1L); + } + + @Test(expected = KMSException.class) + public void testGenerateVolumeKeyWithKek_ThrowsWhenWrongPurpose() throws KMSException { + KMSKey key = mock(KMSKey.class); + when(key.isEnabled()).thenReturn(true); + when(key.getPurpose()).thenReturn(KeyPurpose.TLS_CERT); + kmsManager.generateVolumeKeyWithKek(key, 1L); + } + + @Test(expected = KMSException.class) + public void testGenerateVolumeKeyWithKek_ThrowsWhenNoActiveKekVersion() throws KMSException { + KMSKey key = mock(KMSKey.class); + when(key.isEnabled()).thenReturn(true); + when(key.getPurpose()).thenReturn(KeyPurpose.VOLUME_ENCRYPTION); + when(key.getId()).thenReturn(1L); + when(kmsKekVersionDao.getActiveVersion(1L)).thenReturn(null); + + kmsManager.generateVolumeKeyWithKek(key, 1L); + } + + @Test(expected = KMSException.class) + public void testGenerateVolumeKeyWithKek_ThrowsWhenHsmProfileDisabled() throws KMSException { + KMSKey key = mock(KMSKey.class); + when(key.isEnabled()).thenReturn(true); + when(key.getPurpose()).thenReturn(KeyPurpose.VOLUME_ENCRYPTION); + when(key.getId()).thenReturn(1L); + + KMSKekVersionVO activeVersion = mock(KMSKekVersionVO.class); + when(activeVersion.getHsmProfileId()).thenReturn(10L); + when(kmsKekVersionDao.getActiveVersion(1L)).thenReturn(activeVersion); + + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.isEnabled()).thenReturn(false); + when(hsmProfileDao.findById(10L)).thenReturn(profile); + + kmsManager.generateVolumeKeyWithKek(key, 1L); + } + + @Test + public void testGenerateVolumeKeyWithKek_HappyPath() { + KMSKey key = mock(KMSKey.class); + when(key.isEnabled()).thenReturn(true); + when(key.getPurpose()).thenReturn(KeyPurpose.VOLUME_ENCRYPTION); + when(key.getId()).thenReturn(1L); + + KMSKekVersionVO activeVersion = mock(KMSKekVersionVO.class); + when(activeVersion.getHsmProfileId()).thenReturn(10L); + when(activeVersion.getKekLabel()).thenReturn("kek-label"); + when(kmsKekVersionDao.getActiveVersion(1L)).thenReturn(activeVersion); + + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.isEnabled()).thenReturn(true); + when(profile.getProtocol()).thenReturn("database"); + when(hsmProfileDao.findById(10L)).thenReturn(profile); + + WrappedKey wrappedKeyResult = mock(WrappedKey.class); + when(wrappedKeyResult.getWrappedKeyMaterial()).thenReturn(new byte[]{1, 2, 3}); + when(wrappedKeyResult.getKekId()).thenReturn("kek-label"); + when(wrappedKeyResult.getPurpose()).thenReturn(KeyPurpose.VOLUME_ENCRYPTION); + when(wrappedKeyResult.getAlgorithm()).thenReturn("AES/GCM/NoPadding"); + when(wrappedKeyResult.getProviderName()).thenReturn("database"); + when(kmsProvider.generateAndWrapDek(any(KeyPurpose.class), anyString(), anyInt(), anyLong())) + .thenReturn(wrappedKeyResult); + doReturn(kmsProvider).when(kmsManager).getKMSProvider("database"); + + KMSWrappedKeyVO persisted = mock(KMSWrappedKeyVO.class); + when(persisted.getUuid()).thenReturn("wrapped-uuid"); + when(kmsWrappedKeyDao.persist(any(KMSWrappedKeyVO.class))).thenReturn(persisted); + + WrappedKey result = kmsManager.generateVolumeKeyWithKek(key, 1L); + + assertNotNull(result); + verify(kmsProvider).generateAndWrapDek(any(KeyPurpose.class), anyString(), anyInt(), anyLong()); + verify(kmsWrappedKeyDao).persist(any(KMSWrappedKeyVO.class)); + } + + @Test + public void testUpdateUserKMSKey_UpdatesName() { + KMSKeyVO key = mock(KMSKeyVO.class); + when(key.getId()).thenReturn(1L); + when(key.getName()).thenReturn("old-name"); + + kmsManager.updateUserKMSKey(key, "new-name", null, null); + + verify(key).setName("new-name"); + verify(kmsKeyDao).update(1L, key); + } + + @Test + public void testUpdateUserKMSKey_NoUpdateWhenNothingChanges() { + KMSKeyVO key = mock(KMSKeyVO.class); + when(key.getName()).thenReturn("same-name"); + + kmsManager.updateUserKMSKey(key, "same-name", null, null); + + verify(kmsKeyDao, never()).update(anyLong(), any(KMSKeyVO.class)); + } + + @Test + public void testUpdateUserKMSKey_UpdatesDescription() { + KMSKeyVO key = mock(KMSKeyVO.class); + when(key.getId()).thenReturn(1L); + when(key.getDescription()).thenReturn("old"); + + kmsManager.updateUserKMSKey(key, null, "new-desc", null); + + verify(key).setDescription("new-desc"); + verify(kmsKeyDao).update(1L, key); + } + + @Test + public void testUpdateUserKMSKey_TogglesEnabled() { + KMSKeyVO key = mock(KMSKeyVO.class); + when(key.getId()).thenReturn(1L); + when(key.isEnabled()).thenReturn(true); + + kmsManager.updateUserKMSKey(key, null, null, false); + + verify(key).setEnabled(false); + verify(kmsKeyDao).update(1L, key); + } + + @Test(expected = InvalidParameterValueException.class) + public void testDeleteUserKMSKey_ThrowsWhenWrappedKeysExist() throws KMSException { + KMSKeyVO key = mock(KMSKeyVO.class); + when(key.getId()).thenReturn(1L); + when(kmsWrappedKeyDao.countByKmsKeyId(1L)).thenReturn(3L); + + kmsManager.deleteUserKMSKey(key); + } + + @Test(expected = InvalidParameterValueException.class) + public void testDeleteUserKMSKey_ThrowsWhenVolumesExist() throws KMSException { + KMSKeyVO key = mock(KMSKeyVO.class); + when(key.getId()).thenReturn(1L); + when(kmsWrappedKeyDao.countByKmsKeyId(1L)).thenReturn(0L); + when(volumeDao.existsWithKmsKey(1L)).thenReturn(true); + + kmsManager.deleteUserKMSKey(key); + } + + @Test + public void testDeleteUserKMSKey_DeletesKekFromProviderAndRemovesKey() throws KMSException { + Long keyId = 1L; + KMSKeyVO key = mock(KMSKeyVO.class); + when(key.getId()).thenReturn(keyId); + when(kmsWrappedKeyDao.countByKmsKeyId(keyId)).thenReturn(0L); + when(volumeDao.existsWithKmsKey(keyId)).thenReturn(false); + + KMSKekVersionVO version = mock(KMSKekVersionVO.class); + when(version.getHsmProfileId()).thenReturn(10L); + when(version.getKekLabel()).thenReturn("kek-label"); + when(kmsKekVersionDao.listByKmsKeyId(keyId)).thenReturn(List.of(version)); + + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getProtocol()).thenReturn("database"); + when(hsmProfileDao.findById(10L)).thenReturn(profile); + doReturn(kmsProvider).when(kmsManager).getKMSProvider("database"); + + try (MockedStatic actionEventUtils = Mockito.mockStatic(ActionEventUtils.class)) { + kmsManager.deleteUserKMSKey(key); + + verify(kmsProvider).deleteKek("kek-label"); + verify(kmsKeyDao).remove(keyId); + } + } + + @Test + public void testDeleteUserKMSKey_ContinuesWhenKekDeletionFails() throws KMSException { + Long keyId = 1L; + KMSKeyVO key = mock(KMSKeyVO.class); + when(key.getId()).thenReturn(keyId); + when(kmsWrappedKeyDao.countByKmsKeyId(keyId)).thenReturn(0L); + when(volumeDao.existsWithKmsKey(keyId)).thenReturn(false); + + KMSKekVersionVO version = mock(KMSKekVersionVO.class); + when(version.getHsmProfileId()).thenReturn(10L); + when(version.getKekLabel()).thenReturn("kek-label"); + when(kmsKekVersionDao.listByKmsKeyId(keyId)).thenReturn(List.of(version)); + + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getProtocol()).thenReturn("database"); + when(hsmProfileDao.findById(10L)).thenReturn(profile); + doReturn(kmsProvider).when(kmsManager).getKMSProvider("database"); + doThrow(KMSException.kekOperationFailed("provider error")).when(kmsProvider).deleteKek(anyString()); + + try (MockedStatic actionEventUtils = Mockito.mockStatic(ActionEventUtils.class)) { + kmsManager.deleteUserKMSKey(key); + + verify(kmsKeyDao).remove(keyId); + } + } + + @Test + public void testDeleteKMSKeysByAccountId_ReturnsFalseWhenAccountIdIsNull() { + assertFalse(kmsManager.deleteKMSKeysByAccountId(null)); + } + + @Test + public void testDeleteKMSKeysByAccountId_ReturnsTrueWhenNoKeys() { + when(kmsKeyDao.listByAccount(1L, null, null)).thenReturn(List.of()); + + assertTrue(kmsManager.deleteKMSKeysByAccountId(1L)); + } + + @Test + public void testDeleteKMSKeysByAccountId_DeletesAllKeysAndKeks() { + Long accountId = 1L; + + KMSKeyVO key = mock(KMSKeyVO.class); + when(key.getId()).thenReturn(10L); + when(kmsKeyDao.listByAccount(accountId, null, null)).thenReturn(List.of(key)); + when(kmsKeyDao.remove(10L)).thenReturn(true); + + KMSKekVersionVO version = mock(KMSKekVersionVO.class); + when(version.getHsmProfileId()).thenReturn(20L); + when(version.getKekLabel()).thenReturn("kek-label"); + when(kmsKekVersionDao.listByKmsKeyId(10L)).thenReturn(List.of(version)); + + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getProtocol()).thenReturn("database"); + when(hsmProfileDao.findById(20L)).thenReturn(profile); + doReturn(kmsProvider).when(kmsManager).getKMSProvider("database"); + + try (MockedStatic actionEventUtils = Mockito.mockStatic(ActionEventUtils.class)) { + boolean result = kmsManager.deleteKMSKeysByAccountId(accountId); + + assertTrue(result); + verify(kmsProvider).deleteKek("kek-label"); + verify(kmsKeyDao).remove(10L); + } + } + + @Test + public void testDeleteKMSKeysByAccountId_ToleratesKekProviderFailure() { + Long accountId = 1L; + + KMSKeyVO key = mock(KMSKeyVO.class); + when(key.getId()).thenReturn(10L); + when(kmsKeyDao.listByAccount(accountId, null, null)).thenReturn(List.of(key)); + when(kmsKeyDao.remove(10L)).thenReturn(true); + + KMSKekVersionVO version = mock(KMSKekVersionVO.class); + when(version.getHsmProfileId()).thenReturn(20L); + when(version.getKekLabel()).thenReturn("kek-label"); + when(kmsKekVersionDao.listByKmsKeyId(10L)).thenReturn(List.of(version)); + + HSMProfileVO profile = mock(HSMProfileVO.class); + when(profile.getProtocol()).thenReturn("database"); + when(hsmProfileDao.findById(20L)).thenReturn(profile); + doReturn(kmsProvider).when(kmsManager).getKMSProvider("database"); + doThrow(new RuntimeException("provider unavailable")).when(kmsProvider).deleteKek(anyString()); + + try (MockedStatic actionEventUtils = Mockito.mockStatic(ActionEventUtils.class)) { + boolean result = kmsManager.deleteKMSKeysByAccountId(accountId); + + assertTrue(result); + verify(kmsKeyDao).remove(10L); + } + } +} diff --git a/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyRotationTest.java b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyRotationTest.java new file mode 100644 index 00000000000..ed0df98e395 --- /dev/null +++ b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplKeyRotationTest.java @@ -0,0 +1,387 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms; + +import com.cloud.event.ActionEventUtils; +import org.apache.cloudstack.framework.kms.KMSException; +import org.apache.cloudstack.framework.kms.KMSProvider; +import org.apache.cloudstack.framework.kms.KeyPurpose; +import org.apache.cloudstack.framework.kms.WrappedKey; +import org.apache.cloudstack.kms.dao.HSMProfileDao; +import org.apache.cloudstack.kms.dao.KMSKekVersionDao; +import org.apache.cloudstack.kms.dao.KMSKeyDao; +import org.apache.cloudstack.kms.dao.KMSWrappedKeyDao; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +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 java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for KMS key rotation logic in KMSManagerImpl + * Tests key rotation within same HSM and cross-HSM migration + */ +@RunWith(MockitoJUnitRunner.class) +public class KMSManagerImplKeyRotationTest { + + @Spy + @InjectMocks + private KMSManagerImpl kmsManager; + + @Mock + private KMSKeyDao kmsKeyDao; + + @Mock + private KMSKekVersionDao kmsKekVersionDao; + + @Mock + private KMSWrappedKeyDao kmsWrappedKeyDao; + + @Mock + private HSMProfileDao hsmProfileDao; + + @Mock + private KMSProvider kmsProvider; + + private final Long testZoneId = 1L; + private final String testProviderName = "pkcs11"; + + private ExecutorService executor; + + @Before + public void setUp() { + executor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "kms-test"); + t.setDaemon(true); + return t; + }); + ReflectionTestUtils.setField(kmsManager, "kmsOperationExecutor", executor); + doReturn(5).when(kmsManager).getOperationTimeoutSec(); + doReturn(0).when(kmsManager).getRetryCount(); + doReturn(0).when(kmsManager).getRetryDelayMs(); + } + + @After + public void tearDown() { + if (executor != null) { + executor.shutdownNow(); + } + } + + /** + * Test: rotateKek creates new KEK version in same HSM + */ + @Test + public void testRotateKek_SameHSM() throws Exception { + // Setup: Rotating within same HSM + Long oldProfileId = 10L; + Long kmsKeyId = 1L; + String oldKekLabel = "old-kek-label"; + String newKekLabel = "new-kek-label"; + + KMSKeyVO kmsKey = mock(KMSKeyVO.class); + when(kmsKey.getId()).thenReturn(kmsKeyId); + when(kmsKey.getHsmProfileId()).thenReturn(oldProfileId); + when(kmsKey.getPurpose()).thenReturn(KeyPurpose.VOLUME_ENCRYPTION); + + HSMProfileVO hsmProfile = mock(HSMProfileVO.class); + when(hsmProfile.getId()).thenReturn(oldProfileId); + when(hsmProfile.getProtocol()).thenReturn(testProviderName); + when(hsmProfileDao.findById(oldProfileId)).thenReturn(hsmProfile); + + // Old version should be marked as Previous + KMSKekVersionVO oldVersion = mock(KMSKekVersionVO.class); + when(oldVersion.getVersionNumber()).thenReturn(1); + when(oldVersion.getId()).thenReturn(10L); + when(kmsKekVersionDao.getActiveVersion(kmsKeyId)).thenReturn(oldVersion); + when(kmsKekVersionDao.listByKmsKeyId(kmsKeyId)).thenReturn(List.of(oldVersion)); + + // Provider creates new KEK + when(kmsProvider.createKek(any(KeyPurpose.class), eq(newKekLabel), anyInt(), eq(oldProfileId))) + .thenReturn("new-kek-id"); + + KMSKekVersionVO newVersion = mock(KMSKekVersionVO.class); + when(newVersion.getVersionNumber()).thenReturn(2); + when(kmsKekVersionDao.persist(any(KMSKekVersionVO.class))).thenReturn(newVersion); + + doReturn(kmsProvider).when(kmsManager).getKMSProvider(testProviderName); + + String result = kmsManager.rotateKek(kmsKey, oldKekLabel, newKekLabel, 256, null); + + // Verify new KEK was created in same HSM + assertNotNull(result); + verify(kmsProvider).createKek(any(KeyPurpose.class), eq(newKekLabel), eq(256), eq(oldProfileId)); + + // Verify old version marked as Previous + verify(oldVersion).setStatus(KMSKekVersionVO.Status.Previous); + verify(kmsKekVersionDao).update(eq(10L), eq(oldVersion)); + + // Verify new version created + ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(KMSKekVersionVO.class); + verify(kmsKekVersionDao).persist(versionCaptor.capture()); + KMSKekVersionVO createdVersion = versionCaptor.getValue(); + assertEquals(Integer.valueOf(2), createdVersion.getVersionNumber()); + assertEquals(oldProfileId, createdVersion.getHsmProfileId()); + } + + /** + * Test: rotateKek migrates key to different HSM + */ + @Test + public void testRotateKek_CrossHSMMigration() throws Exception { + // Setup: Rotating to different HSM + Long oldProfileId = 10L; + Long newProfileId = 20L; + Long kmsKeyId = 1L; + String oldKekLabel = "old-kek-label"; + String newKekLabel = "new-kek-label"; + + KMSKeyVO kmsKey = mock(KMSKeyVO.class); + when(kmsKey.getId()).thenReturn(kmsKeyId); + when(kmsKey.getHsmProfileId()).thenReturn(oldProfileId); + when(kmsKey.getPurpose()).thenReturn(KeyPurpose.VOLUME_ENCRYPTION); + + HSMProfileVO hsmProfile = mock(HSMProfileVO.class); + when(hsmProfile.getId()).thenReturn(newProfileId); + when(hsmProfile.getProtocol()).thenReturn(testProviderName); + + KMSKekVersionVO oldVersion = mock(KMSKekVersionVO.class); + when(oldVersion.getVersionNumber()).thenReturn(1); + when(oldVersion.getId()).thenReturn(10L); + when(kmsKekVersionDao.getActiveVersion(kmsKeyId)).thenReturn(oldVersion); + when(kmsKekVersionDao.listByKmsKeyId(kmsKeyId)).thenReturn(List.of(oldVersion)); + + // Provider creates new KEK in different HSM + when(kmsProvider.createKek(any(KeyPurpose.class), eq(newKekLabel), anyInt(), eq(newProfileId))) + .thenReturn("new-kek-id"); + + KMSKekVersionVO newVersion = mock(KMSKekVersionVO.class); + when(newVersion.getVersionNumber()).thenReturn(2); + when(kmsKekVersionDao.persist(any(KMSKekVersionVO.class))).thenReturn(newVersion); + + doReturn(kmsProvider).when(kmsManager).getKMSProvider(testProviderName); + + String result = kmsManager.rotateKek(kmsKey, oldKekLabel, newKekLabel, 256, hsmProfile); + + // Verify new KEK was created in new HSM + assertNotNull(result); + verify(kmsProvider).createKek(any(KeyPurpose.class), eq(newKekLabel), eq(256), eq(newProfileId)); + + // Verify new version created with new profile ID + ArgumentCaptor versionCaptor = ArgumentCaptor.forClass(KMSKekVersionVO.class); + verify(kmsKekVersionDao).persist(versionCaptor.capture()); + KMSKekVersionVO createdVersion = versionCaptor.getValue(); + assertEquals(newProfileId, createdVersion.getHsmProfileId()); + + // Verify KMS key updated with new profile ID + verify(kmsKey).setHsmProfileId(newProfileId); + verify(kmsKeyDao).update(kmsKeyId, kmsKey); + } + + /** + * Test: rewrapSingleKey unwraps with old KEK and wraps with new KEK + */ + @Test + public void testRewrapSingleKey_UnwrapAndRewrap() throws Exception { + // Setup + Long wrappedKeyId = 100L; + Long oldVersionId = 1L; + Long newVersionId = 2L; + Long oldProfileId = 10L; + Long newProfileId = 20L; + + KMSWrappedKeyVO wrappedKeyVO = mock(KMSWrappedKeyVO.class); + when(wrappedKeyVO.getId()).thenReturn(wrappedKeyId); + + KMSKeyVO kmsKey = mock(KMSKeyVO.class); + when(kmsKey.getPurpose()).thenReturn(KeyPurpose.VOLUME_ENCRYPTION); + + KMSKekVersionVO oldVersion = mock(KMSKekVersionVO.class); + + KMSKekVersionVO newVersion = mock(KMSKekVersionVO.class); + when(newVersion.getId()).thenReturn(newVersionId); + when(newVersion.getKekLabel()).thenReturn("new-kek-label"); + when(newVersion.getHsmProfileId()).thenReturn(newProfileId); + + // Mock unwrap and wrap operations + byte[] plainDek = "plain-dek-bytes".getBytes(); + doReturn(plainDek).when(kmsManager).unwrapKey(wrappedKeyId); + + WrappedKey newWrappedKey = mock(WrappedKey.class); + when(newWrappedKey.getWrappedKeyMaterial()).thenReturn("new-wrapped-blob".getBytes()); + when(kmsProvider.wrapKey(plainDek, KeyPurpose.VOLUME_ENCRYPTION, "new-kek-label", newProfileId)) + .thenReturn(newWrappedKey); + + try (MockedStatic actionEventUtils = Mockito.mockStatic(ActionEventUtils.class)) { + kmsManager.rewrapSingleKey(wrappedKeyVO, kmsKey, newVersion, kmsProvider); + + // Verify unwrap was called + verify(kmsManager).unwrapKey(wrappedKeyId); + + // Verify wrap was called with new profile + verify(kmsProvider).wrapKey(plainDek, KeyPurpose.VOLUME_ENCRYPTION, "new-kek-label", newProfileId); + + // Verify wrapped key was updated + verify(wrappedKeyVO).setKekVersionId(newVersionId); + verify(wrappedKeyVO).setWrappedBlob("new-wrapped-blob".getBytes()); + verify(kmsWrappedKeyDao).update(wrappedKeyId, wrappedKeyVO); + } + } + + /** + * Test: rotateKek generates new label when not provided + */ + @Test + public void testRotateKek_GeneratesLabel() throws Exception { + // Setup + Long oldProfileId = 10L; + Long kmsKeyId = 1L; + String oldKekLabel = "old-kek-label"; + + KMSKeyVO kmsKey = mock(KMSKeyVO.class); + when(kmsKey.getId()).thenReturn(kmsKeyId); + when(kmsKey.getHsmProfileId()).thenReturn(oldProfileId); + when(kmsKey.getPurpose()).thenReturn(KeyPurpose.VOLUME_ENCRYPTION); + when(kmsKey.getUuid()).thenReturn("test-kms-key-uuid"); + when(kmsKey.getDomainId()).thenReturn(1L); + when(kmsKey.getAccountId()).thenReturn(2L); + + HSMProfileVO hsmProfile = mock(HSMProfileVO.class); + when(hsmProfile.getId()).thenReturn(oldProfileId); + when(hsmProfile.getProtocol()).thenReturn(testProviderName); + when(hsmProfileDao.findById(oldProfileId)).thenReturn(hsmProfile); + + KMSKekVersionVO oldVersion = mock(KMSKekVersionVO.class); + when(oldVersion.getVersionNumber()).thenReturn(1); + when(oldVersion.getId()).thenReturn(10L); + when(kmsKekVersionDao.getActiveVersion(kmsKeyId)).thenReturn(oldVersion); + when(kmsKekVersionDao.listByKmsKeyId(kmsKeyId)).thenReturn(List.of(oldVersion)); + + // Provider creates new KEK - will accept any label + when(kmsProvider.createKek(any(KeyPurpose.class), anyString(), anyInt(), eq(oldProfileId))) + .thenReturn("new-kek-id"); + + KMSKekVersionVO newVersion = mock(KMSKekVersionVO.class); + when(newVersion.getVersionNumber()).thenReturn(2); + when(kmsKekVersionDao.persist(any(KMSKekVersionVO.class))).thenReturn(newVersion); + + doReturn(kmsProvider).when(kmsManager).getKMSProvider(testProviderName); + + kmsManager.rotateKek(kmsKey, oldKekLabel, null, 256, null); + + // Verify a label was generated and passed to createKek + ArgumentCaptor labelCaptor = ArgumentCaptor.forClass(String.class); + verify(kmsProvider).createKek(any(KeyPurpose.class), labelCaptor.capture(), eq(256), eq(oldProfileId)); + String generatedLabel = labelCaptor.getValue(); + assertNotNull("Label should be generated", generatedLabel); + } + + /** + * Test: rotateKek throws exception when old KEK not found (provider rejects the rotation) + */ + @Test(expected = KMSException.class) + public void testRotateKek_ThrowsExceptionWhenOldKekNotFound() throws KMSException { + Long oldProfileId = 10L; + Long kmsKeyId = 1L; + + KMSKeyVO kmsKey = mock(KMSKeyVO.class); + when(kmsKey.getHsmProfileId()).thenReturn(oldProfileId); + when(kmsKey.getPurpose()).thenReturn(KeyPurpose.VOLUME_ENCRYPTION); + + HSMProfileVO hsmProfile = mock(HSMProfileVO.class); + when(hsmProfile.getId()).thenReturn(oldProfileId); + when(hsmProfile.getProtocol()).thenReturn(testProviderName); + when(hsmProfileDao.findById(oldProfileId)).thenReturn(hsmProfile); + + // Provider throws because the old KEK label doesn't exist in the HSM + when(kmsProvider.createKek(any(KeyPurpose.class), eq("new-label"), eq(256), eq(oldProfileId))) + .thenThrow(KMSException.kekNotFound("Old KEK not found: non-existent-label")); + + doReturn(kmsProvider).when(kmsManager).getKMSProvider(testProviderName); + + kmsManager.rotateKek(kmsKey, "non-existent-label", "new-label", 256, null); + } + + /** + * Test: rotateKek uses current profile when target profile is null + */ + @Test + public void testRotateKek_UsesCurrentProfileWhenTargetNull() throws Exception { + // Setup + Long currentProfileId = 10L; + Long kmsKeyId = 1L; + String oldKekLabel = "old-kek-label"; + + KMSKeyVO kmsKey = mock(KMSKeyVO.class); + when(kmsKey.getId()).thenReturn(kmsKeyId); + when(kmsKey.getHsmProfileId()).thenReturn(currentProfileId); + when(kmsKey.getPurpose()).thenReturn(KeyPurpose.VOLUME_ENCRYPTION); + + HSMProfileVO hsmProfile = mock(HSMProfileVO.class); + when(hsmProfile.getId()).thenReturn(currentProfileId); + when(hsmProfile.getProtocol()).thenReturn(testProviderName); + when(hsmProfileDao.findById(currentProfileId)).thenReturn(hsmProfile); + + KMSKekVersionVO oldVersion = mock(KMSKekVersionVO.class); + when(oldVersion.getVersionNumber()).thenReturn(1); + when(oldVersion.getId()).thenReturn(10L); + when(kmsKekVersionDao.getActiveVersion(kmsKeyId)).thenReturn(oldVersion); + when(kmsKekVersionDao.listByKmsKeyId(kmsKeyId)).thenReturn(List.of(oldVersion)); + + when(kmsProvider.createKek(any(KeyPurpose.class), anyString(), anyInt(), eq(currentProfileId))) + .thenReturn("new-kek-id"); + + KMSKekVersionVO newVersion = mock(KMSKekVersionVO.class); + when(newVersion.getVersionNumber()).thenReturn(2); + when(kmsKekVersionDao.persist(any(KMSKekVersionVO.class))).thenReturn(newVersion); + + doReturn(kmsProvider).when(kmsManager).getKMSProvider(testProviderName); + + kmsManager.rotateKek(kmsKey, oldKekLabel, "new-label", 256, null); + + // Verify current profile was used (not a different one) + verify(kmsProvider).createKek(any(KeyPurpose.class), anyString(), eq(256), eq(currentProfileId)); + verify(kmsKeyDao).update(kmsKeyId, kmsKey); + + // Verify KMS key was not updated (same profile) + verify(kmsKey, never()).setHsmProfileId(currentProfileId); + } +} diff --git a/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplRetryTest.java b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplRetryTest.java new file mode 100644 index 00000000000..f221357bff9 --- /dev/null +++ b/server/src/test/java/org/apache/cloudstack/kms/KMSManagerImplRetryTest.java @@ -0,0 +1,185 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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.kms; + +import org.apache.cloudstack.framework.kms.KMSException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.doReturn; + +/** + * Unit tests for KMSManagerImpl's retryOperation() logic, covering + * timeout enforcement, retry-on-transient-failure, and non-retryable fast-fail. + *

+ * Config values (retry count, delay, timeout) are spied on so tests remain + * fast without needing a full management-server config context. + */ +@RunWith(MockitoJUnitRunner.class) +public class KMSManagerImplRetryTest { + + @Spy + @InjectMocks + private KMSManagerImpl kmsManager; + + private ExecutorService executor; + + @Before + public void setUp() { + executor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "kms-test"); + t.setDaemon(true); + return t; + }); + ReflectionTestUtils.setField(kmsManager, "kmsOperationExecutor", executor); + } + + @After + public void tearDown() { + if (executor != null) { + executor.shutdownNow(); + } + } + + /** + * Configure the spy to use a 1-second timeout, the given retry count, and no delay. + */ + private void useShortConfig(int retries) { + doReturn(1).when(kmsManager).getOperationTimeoutSec(); + doReturn(retries).when(kmsManager).getRetryCount(); + doReturn(0).when(kmsManager).getRetryDelayMs(); + } + + /** + * Normal path: operation completes immediately, result returned. + */ + @Test + public void testRetryOperation_succeedsImmediately() throws Exception { + useShortConfig(0); + + String result = kmsManager.retryOperation(() -> "ok"); + + assertEquals("ok", result); + } + + /** + * Timeout path: operation never finishes within the configured timeout. + * retryOperation() must unblock and throw a retryable KMSException. + */ + @Test + public void testRetryOperation_timesOutAndThrowsKMSException() { + useShortConfig(0); + + try { + kmsManager.retryOperation(() -> { + Thread.sleep(5_000); + return "should never reach here"; + }); + fail("Expected KMSException due to timeout"); + } catch (KMSException e) { + assertTrue("Exception should be retryable (transient timeout)", e.isRetryable()); + assertTrue("Message should mention timeout", e.getMessage().contains("timed out")); + } catch (Exception e) { + fail("Expected KMSException, got: " + e.getClass().getName() + ": " + e.getMessage()); + } + } + + /** + * Retry path: operation fails with a retryable KMSException on the first + * attempt and succeeds on the second. retryOperation() should return the + * successful result. + */ + @Test + public void testRetryOperation_retriesOnTransientFailureAndSucceeds() throws Exception { + useShortConfig(2); + AtomicInteger attempts = new AtomicInteger(0); + + String result = kmsManager.retryOperation(() -> { + if (attempts.getAndIncrement() == 0) { + throw KMSException.transientError("temporary HSM error", null); + } + return "recovered"; + }); + + assertEquals("recovered", result); + assertEquals("Should have taken exactly 2 attempts", 2, attempts.get()); + } + + /** + * Non-retryable path: a KMSException with isRetryable() == false must be + * re-thrown immediately without consuming any retry budget. + */ + @Test + public void testRetryOperation_nonRetryableExceptionFastFails() { + useShortConfig(3); + AtomicInteger attempts = new AtomicInteger(0); + + try { + kmsManager.retryOperation(() -> { + attempts.incrementAndGet(); + throw KMSException.invalidParameter("bad key label"); + }); + fail("Expected non-retryable KMSException"); + } catch (KMSException e) { + assertFalse("Exception should NOT be retryable", e.isRetryable()); + } catch (Exception e) { + fail("Expected KMSException, got: " + e.getClass().getName()); + } + + assertEquals("Non-retryable exception must not trigger retries", 1, attempts.get()); + } + + /** + * Retry exhaustion on timeout: all attempts time out; retryOperation() + * must eventually throw after exhausting the retry budget. + */ + @Test + public void testRetryOperation_exhaustsRetriesOnRepeatedTimeout() { + useShortConfig(2); // 3 total attempts (initial + 2 retries), each timing out after 1s + AtomicInteger attempts = new AtomicInteger(0); + + try { + kmsManager.retryOperation(() -> { + attempts.incrementAndGet(); + Thread.sleep(5_000); + return "never"; + }); + fail("Expected KMSException after retry exhaustion"); + } catch (KMSException e) { + assertTrue("Final exception should be retryable (timeout)", e.isRetryable()); + } catch (Exception e) { + fail("Expected KMSException, got: " + e.getClass().getName()); + } + + assertEquals("Should have attempted exactly 3 times (1 initial + 2 retries)", 3, attempts.get()); + } +} diff --git a/server/src/test/java/org/apache/cloudstack/schedule/ResourceScheduleManagerImplTest.java b/server/src/test/java/org/apache/cloudstack/schedule/ResourceScheduleManagerImplTest.java new file mode 100644 index 00000000000..c47cb59c9eb --- /dev/null +++ b/server/src/test/java/org/apache/cloudstack/schedule/ResourceScheduleManagerImplTest.java @@ -0,0 +1,427 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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.schedule; + +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.network.as.AutoScaleVmGroup; +import com.cloud.user.Account; +import com.cloud.user.AccountManager; +import com.cloud.user.User; +import com.cloud.utils.Pair; +import com.cloud.utils.db.EntityManager; +import com.cloud.utils.db.SearchCriteria; +import com.cloud.vm.UserVmManager; +import com.cloud.vm.VirtualMachine; +import org.apache.cloudstack.api.ApiCommandResourceType; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.ResourceScheduleResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.schedule.dao.ResourceScheduleDao; +import org.apache.cloudstack.schedule.dao.ResourceScheduleDetailsDao; +import org.apache.cloudstack.schedule.autoscale.AutoScaleScheduleAction; +import org.apache.cloudstack.schedule.autoscale.AutoScaleScheduleWorker; +import org.apache.cloudstack.schedule.vm.VMScheduleAction; +import org.apache.cloudstack.schedule.vm.VMScheduleWorker; +import org.apache.commons.lang.time.DateUtils; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.Spy; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; +import java.util.UUID; + +import static org.junit.Assert.assertNotNull; + +public class ResourceScheduleManagerImplTest { + + @Spy + @InjectMocks + ResourceScheduleManagerImpl resourceScheduleManager = new ResourceScheduleManagerImpl(); + + @Mock + ResourceScheduleDao resourceScheduleDao; + + @Mock + ResourceScheduleDetailsDao resourceScheduleDetailsDao; + + @Mock + VMScheduleWorker vmScheduleWorker; + + @Mock + AutoScaleScheduleWorker autoScaleScheduleWorker; + + @Mock + UserVmManager userVmManager; + + @Mock + AccountManager accountManager; + + @Mock + EntityManager entityManager; + + private AutoCloseable closeable; + + @Before + public void setUp() { + closeable = MockitoAnnotations.openMocks(this); + Account callingAccount = Mockito.mock(Account.class); + User callingUser = Mockito.mock(User.class); + CallContext.register(callingUser, callingAccount); + + Mockito.when(vmScheduleWorker.getApiResourceType()).thenReturn(ApiCommandResourceType.VirtualMachine); + Mockito.when(autoScaleScheduleWorker.getApiResourceType()).thenReturn(ApiCommandResourceType.AutoScaleVmGroup); + Map workerMap = new HashMap<>(); + workerMap.put(ApiCommandResourceType.VirtualMachine, vmScheduleWorker); + workerMap.put(ApiCommandResourceType.AutoScaleVmGroup, autoScaleScheduleWorker); + ReflectionTestUtils.setField(resourceScheduleManager, "workerMap", workerMap); + } + + @After + public void tearDown() throws Exception { + closeable.close(); + } + + private void validateResponse(ResourceScheduleResponse response, ResourceScheduleVO schedule, VirtualMachine vm) { + assertNotNull(response); + Assert.assertEquals(schedule.getUuid(), ReflectionTestUtils.getField(response, "id")); + Assert.assertEquals(schedule.getResourceType(), ReflectionTestUtils.getField(response, "resourceType")); + Assert.assertEquals(vm.getUuid(), ReflectionTestUtils.getField(response, "resourceId")); + Assert.assertEquals(schedule.getSchedule(), ReflectionTestUtils.getField(response, "schedule")); + Assert.assertEquals(schedule.getTimeZone(), ReflectionTestUtils.getField(response, "timeZone")); + ResourceSchedule.Action actionField = (ResourceSchedule.Action) ReflectionTestUtils.getField(response, "action"); + Assert.assertEquals(schedule.getActionName(), actionField == null ? null : actionField.name()); + Assert.assertEquals(schedule.getStartDate(), ReflectionTestUtils.getField(response, "startDate")); + Assert.assertEquals(schedule.getEndDate(), ReflectionTestUtils.getField(response, "endDate")); + } + + @Test + public void createSchedule() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + ResourceScheduleVO schedule = Mockito.mock(ResourceScheduleVO.class); + Account ownerAccount = Mockito.mock(Account.class); + + Mockito.when(vm.getUuid()).thenReturn(UUID.randomUUID().toString()); + Mockito.when(vm.getId()).thenReturn(1L); + Mockito.when(entityManager.findByUuid(VirtualMachine.class, "1")).thenReturn(null); + Mockito.when(entityManager.findById(VirtualMachine.class, 1L)).thenReturn(vm); + Mockito.when(vmScheduleWorker.isResourceValid(1L)).thenReturn(true); + Mockito.when(vmScheduleWorker.getEntityOwnerId(1L)).thenReturn(2L); + Mockito.when(vmScheduleWorker.parseAction("START")).thenReturn(VMScheduleAction.START); + Mockito.when(accountManager.getAccount(2L)).thenReturn(ownerAccount); + Mockito.when(resourceScheduleDao.persist(Mockito.any(ResourceScheduleVO.class))).thenReturn(schedule); + Mockito.when(schedule.getResourceType()).thenReturn(ApiCommandResourceType.VirtualMachine); + Mockito.when(schedule.getResourceId()).thenReturn(1L); + Mockito.when(schedule.getActionName()).thenReturn("START"); + Mockito.when(vmScheduleWorker.parseAction((String) Mockito.isNull())).thenReturn(null); + + ResourceScheduleResponse response = resourceScheduleManager.createSchedule( + ApiCommandResourceType.VirtualMachine, "1", null, + "0 0 * * *", "UTC", "START", + DateUtils.addDays(new Date(), 1), DateUtils.addDays(new Date(), 2), + true, null); + + Mockito.verify(resourceScheduleDao, Mockito.times(1)).persist(Mockito.any(ResourceScheduleVO.class)); + validateResponse(response, schedule, vm); + } + + @Test + public void createResponse() { + ResourceScheduleVO schedule = Mockito.mock(ResourceScheduleVO.class); + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + + Mockito.when(vm.getUuid()).thenReturn(UUID.randomUUID().toString()); + Mockito.when(schedule.getResourceId()).thenReturn(1L); + Mockito.when(schedule.getResourceType()).thenReturn(ApiCommandResourceType.VirtualMachine); + Mockito.when(schedule.getActionName()).thenReturn("START"); + Mockito.when(vmScheduleWorker.parseAction("START")).thenReturn(VMScheduleAction.START); + Mockito.when(entityManager.findById(VirtualMachine.class, 1L)).thenReturn(vm); + + ResourceScheduleResponse response = resourceScheduleManager.createResponse(schedule, null); + validateResponse(response, schedule, vm); + } + + @Test + public void listSchedule() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + ResourceScheduleVO schedule1 = Mockito.mock(ResourceScheduleVO.class); + ResourceScheduleVO schedule2 = Mockito.mock(ResourceScheduleVO.class); + List scheduleList = new ArrayList<>(); + scheduleList.add(schedule1); + scheduleList.add(schedule2); + + Mockito.when(vm.getUuid()).thenReturn(UUID.randomUUID().toString()); + Mockito.when(vm.getId()).thenReturn(1L); + Mockito.when(entityManager.findByUuid(VirtualMachine.class, "1")).thenReturn(null); + Mockito.when(entityManager.findById(VirtualMachine.class, 1L)).thenReturn(vm); + Mockito.when(vmScheduleWorker.getEntityOwnerId(1L)).thenReturn(2L); + Mockito.when(accountManager.getAccount(2L)).thenReturn(Mockito.mock(Account.class)); + + Mockito.when(resourceScheduleDao.searchAndCount( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any()) + ).thenReturn(new Pair<>(scheduleList, scheduleList.size())); + for (ResourceScheduleVO schedule : scheduleList) { + Mockito.when(schedule.getResourceId()).thenReturn(1L); + Mockito.when(schedule.getResourceType()).thenReturn(ApiCommandResourceType.VirtualMachine); + } + + ListResponse responseList = resourceScheduleManager.listSchedule( + null, null, ApiCommandResourceType.VirtualMachine, "1", null, null, 0L, 100L); + + Mockito.verify(resourceScheduleDao, Mockito.times(1)).searchAndCount( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any()); + assertNotNull(responseList); + Assert.assertEquals(2, (int) responseList.getCount()); + Assert.assertEquals(2, responseList.getResponses().size()); + + for (int i = 0; i < responseList.getResponses().size(); i++) { + validateResponse(responseList.getResponses().get(i), scheduleList.get(i), vm); + } + } + + @Test + public void updateSchedule() { + ResourceScheduleVO schedule = Mockito.mock(ResourceScheduleVO.class); + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + + Mockito.when(vm.getUuid()).thenReturn(UUID.randomUUID().toString()); + Mockito.when(resourceScheduleDao.findById(Mockito.anyLong())).thenReturn(schedule); + Mockito.when(resourceScheduleDao.update(Mockito.eq(1L), Mockito.any(ResourceScheduleVO.class))).thenReturn(true); + Mockito.when(schedule.getResourceId()).thenReturn(1L); + Mockito.when(schedule.getResourceType()).thenReturn(ApiCommandResourceType.VirtualMachine); + Mockito.when(schedule.getStartDate()).thenReturn(DateUtils.addDays(new Date(), 1)); + Mockito.when(schedule.getActionName()).thenReturn("START"); + Mockito.when(vmScheduleWorker.getEntityOwnerId(1L)).thenReturn(2L); + Mockito.when(vmScheduleWorker.parseAction("START")).thenReturn(VMScheduleAction.START); + Mockito.when(accountManager.getAccount(2L)).thenReturn(Mockito.mock(Account.class)); + Mockito.when(entityManager.findById(VirtualMachine.class, 1L)).thenReturn(vm); + + ResourceScheduleResponse response = resourceScheduleManager.updateSchedule( + 1L, null, "0 0 * * *", "UTC", + DateUtils.addDays(new Date(), 1), DateUtils.addDays(new Date(), 2), null, null); + Mockito.verify(resourceScheduleDao, Mockito.times(1)).update(Mockito.eq(1L), Mockito.any(ResourceScheduleVO.class)); + + validateResponse(response, schedule, vm); + } + + @Test(expected = InvalidParameterValueException.class) + public void updateScheduleEnableWithPastEndDateThrows() { + ResourceScheduleVO schedule = Mockito.mock(ResourceScheduleVO.class); + + Mockito.when(resourceScheduleDao.findById(1L)).thenReturn(schedule); + Mockito.when(schedule.getResourceId()).thenReturn(1L); + Mockito.when(schedule.getResourceType()).thenReturn(ApiCommandResourceType.VirtualMachine); + Mockito.when(schedule.getSchedule()).thenReturn("0 0 * * *"); + Mockito.when(schedule.getStartDate()).thenReturn(DateUtils.addDays(new Date(), 1)); + Mockito.when(schedule.getEndDate()).thenReturn(DateUtils.addDays(new Date(), -1)); + Mockito.when(schedule.getTimeZone()).thenReturn("UTC"); + Mockito.when(schedule.getActionName()).thenReturn("START"); + Mockito.when(vmScheduleWorker.getEntityOwnerId(1L)).thenReturn(2L); + Mockito.when(vmScheduleWorker.parseAction("START")).thenReturn(VMScheduleAction.START); + Mockito.when(accountManager.getAccount(2L)).thenReturn(Mockito.mock(Account.class)); + + resourceScheduleManager.updateSchedule(1L, null, null, null, null, null, true, null); + } + + @Test + public void updateScheduleEnableWithFutureEndDateSucceeds() { + ResourceScheduleVO schedule = Mockito.mock(ResourceScheduleVO.class); + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + + Mockito.when(vm.getUuid()).thenReturn(UUID.randomUUID().toString()); + Mockito.when(resourceScheduleDao.findById(1L)).thenReturn(schedule); + Mockito.when(resourceScheduleDao.update(Mockito.eq(1L), Mockito.any(ResourceScheduleVO.class))).thenReturn(true); + Mockito.when(schedule.getResourceId()).thenReturn(1L); + Mockito.when(schedule.getResourceType()).thenReturn(ApiCommandResourceType.VirtualMachine); + Mockito.when(schedule.getSchedule()).thenReturn("0 0 * * *"); + Mockito.when(schedule.getStartDate()).thenReturn(DateUtils.addDays(new Date(), 1)); + Mockito.when(schedule.getEndDate()).thenReturn(DateUtils.addDays(new Date(), 2)); + Mockito.when(schedule.getTimeZone()).thenReturn("UTC"); + Mockito.when(schedule.getActionName()).thenReturn("START"); + Mockito.when(vmScheduleWorker.getEntityOwnerId(1L)).thenReturn(2L); + Mockito.when(vmScheduleWorker.parseAction("START")).thenReturn(VMScheduleAction.START); + Mockito.when(accountManager.getAccount(2L)).thenReturn(Mockito.mock(Account.class)); + Mockito.when(entityManager.findById(VirtualMachine.class, 1L)).thenReturn(vm); + + resourceScheduleManager.updateSchedule(1L, null, null, null, null, null, true, null); + + Mockito.verify(resourceScheduleDao, Mockito.times(1)).update(Mockito.eq(1L), Mockito.any(ResourceScheduleVO.class)); + Mockito.verify(schedule).setEnabled(true); + } + + @Test + public void createScheduleAutoScale() { + AutoScaleVmGroup group = Mockito.mock(AutoScaleVmGroup.class); + ResourceScheduleVO schedule = Mockito.mock(ResourceScheduleVO.class); + Account ownerAccount = Mockito.mock(Account.class); + Map details = new HashMap<>(); + details.put("minmembers", "2"); + details.put("maxmembers", "5"); + + Mockito.when(group.getId()).thenReturn(21L); + Mockito.when(group.getUuid()).thenReturn(UUID.randomUUID().toString()); + Mockito.when(entityManager.findByUuid(AutoScaleVmGroup.class, "asg-uuid")).thenReturn(group); + Mockito.when(autoScaleScheduleWorker.isResourceValid(21L)).thenReturn(true); + Mockito.when(autoScaleScheduleWorker.getEntityOwnerId(21L)).thenReturn(2L); + Mockito.when(autoScaleScheduleWorker.parseAction("UPDATE")).thenReturn(AutoScaleScheduleAction.UPDATE); + Mockito.when(accountManager.getAccount(2L)).thenReturn(ownerAccount); + Mockito.when(resourceScheduleDao.persist(Mockito.any(ResourceScheduleVO.class))).thenReturn(schedule); + Mockito.when(schedule.getId()).thenReturn(99L); + Mockito.when(schedule.getResourceType()).thenReturn(ApiCommandResourceType.AutoScaleVmGroup); + Mockito.when(schedule.getResourceId()).thenReturn(21L); + Mockito.when(schedule.getActionName()).thenReturn("UPDATE"); + Mockito.when(autoScaleScheduleWorker.parseAction("UPDATE")).thenReturn(AutoScaleScheduleAction.UPDATE); + Mockito.when(entityManager.findById(AutoScaleVmGroup.class, 21L)).thenReturn(group); + + ResourceScheduleResponse response = resourceScheduleManager.createSchedule( + ApiCommandResourceType.AutoScaleVmGroup, "asg-uuid", null, + "0 0 * * *", "UTC", "UPDATE", + DateUtils.addDays(new Date(), 1), DateUtils.addDays(new Date(), 2), + true, details); + + Assert.assertEquals(ApiCommandResourceType.AutoScaleVmGroup, ReflectionTestUtils.getField(response, "resourceType")); + Assert.assertEquals("21", ReflectionTestUtils.getField(response, "resourceId")); + Assert.assertEquals("2", ((Map) ReflectionTestUtils.getField(response, "details")).get("minmembers")); + Assert.assertEquals("5", ((Map) ReflectionTestUtils.getField(response, "details")).get("maxmembers")); + Mockito.verify(autoScaleScheduleWorker, Mockito.times(1)).validateDetails(Mockito.eq(AutoScaleScheduleAction.UPDATE), Mockito.eq(details)); + } + + @Test + public void removeSchedulesForResource() { + ResourceScheduleVO schedule1 = Mockito.mock(ResourceScheduleVO.class); + ResourceScheduleVO schedule2 = Mockito.mock(ResourceScheduleVO.class); + List scheduleList = new ArrayList<>(); + scheduleList.add(schedule1); + scheduleList.add(schedule2); + SearchCriteria sc = Mockito.mock(SearchCriteria.class); + + Mockito.when(resourceScheduleDao.getSearchCriteriaForResource( + ApiCommandResourceType.VirtualMachine, 1L)).thenReturn(sc); + Mockito.when(resourceScheduleDao.search(sc, null)).thenReturn(scheduleList); + Mockito.when(schedule1.getId()).thenReturn(1L); + Mockito.when(schedule2.getId()).thenReturn(2L); + Mockito.when(resourceScheduleDao.removeAllSchedulesForResource(Mockito.any(), Mockito.anyLong())).thenReturn(2L); + + resourceScheduleManager.removeSchedulesForResource(ApiCommandResourceType.VirtualMachine, 1L); + + Mockito.verify(resourceScheduleDao, Mockito.times(1)).removeAllSchedulesForResource(Mockito.any(), Mockito.anyLong()); + Mockito.verify(vmScheduleWorker, Mockito.times(1)).removeScheduledJobs(Mockito.anyList()); + } + + @Test + public void removeSchedule() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + + Mockito.when(vm.getId()).thenReturn(1L); + Mockito.when(entityManager.findByUuid(VirtualMachine.class, "1")).thenReturn(null); + Mockito.when(entityManager.findById(VirtualMachine.class, 1L)).thenReturn(vm); + Mockito.when(vmScheduleWorker.getEntityOwnerId(1L)).thenReturn(2L); + Mockito.when(accountManager.getAccount(2L)).thenReturn(Mockito.mock(Account.class)); + Mockito.when(resourceScheduleDao.removeSchedulesForResourceAndIds( + Mockito.any(), Mockito.anyLong(), Mockito.anyList())).thenReturn(1L); + + ResourceScheduleVO schedule1 = Mockito.mock(ResourceScheduleVO.class); + ResourceScheduleVO schedule2 = Mockito.mock(ResourceScheduleVO.class); + List scheduleList = new ArrayList<>(); + scheduleList.add(schedule1); + scheduleList.add(schedule2); + + Mockito.when(resourceScheduleDao.searchAndCount( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any(), + Mockito.any(), Mockito.any(), Mockito.any()) + ).thenReturn(new Pair<>(scheduleList, scheduleList.size())); + + Long rowsRemoved = resourceScheduleManager.removeSchedule( + ApiCommandResourceType.VirtualMachine, "1", 10L, null); + + Mockito.verify(resourceScheduleDao, Mockito.times(1)).removeSchedulesForResourceAndIds( + Mockito.any(), Mockito.anyLong(), Mockito.anyList()); + Assert.assertEquals(1L, (long) rowsRemoved); + } + + @Test + public void validateStartDateEndDate() { + // Valid scenario 1 + // Start date is before end date + Date startDate = DateUtils.addDays(new Date(), 1); + Date endDate = DateUtils.addDays(new Date(), 2); + resourceScheduleManager.validateStartDateEndDate(startDate, endDate, TimeZone.getDefault()); + + // Valid Scenario 2 + // Start date is before current date and end date is null + endDate = null; + resourceScheduleManager.validateStartDateEndDate(startDate, endDate, TimeZone.getDefault()); + + // Invalid Scenario 2 + // Start date is before current date + startDate = DateUtils.addDays(new Date(), -1); + try { + resourceScheduleManager.validateStartDateEndDate(startDate, endDate, TimeZone.getDefault()); + Assert.fail("Should have thrown InvalidParameterValueException"); + } catch (InvalidParameterValueException e) { + Assert.assertTrue(e.getMessage().contains("Invalid value for start date. Start date") && + e.getMessage().contains("can't be before current time")); + } + + // Invalid Scenario 2 + // Start date is after end date + startDate = DateUtils.addDays(new Date(), 2); + endDate = DateUtils.addDays(new Date(), 1); + try { + resourceScheduleManager.validateStartDateEndDate(startDate, endDate, TimeZone.getDefault()); + Assert.fail("Should have thrown InvalidParameterValueException"); + } catch (InvalidParameterValueException e) { + Assert.assertTrue(e.getMessage().contains("Invalid value for end date. End date") && + e.getMessage().contains("can't be before start date")); + } + + // Invalid Scenario 3 + // End date is before current date + startDate = DateUtils.addDays(new Date(), 1); + endDate = DateUtils.addDays(new Date(), -1); + try { + resourceScheduleManager.validateStartDateEndDate(startDate, endDate, TimeZone.getDefault()); + Assert.fail("Should have thrown InvalidParameterValueException"); + } catch (InvalidParameterValueException e) { + Assert.assertTrue(e.getMessage().contains("Invalid value for end date. End date") && + e.getMessage().contains("can't be before current time")); + } + } + + @Test + public void getConfigKeys() { + ConfigKey[] configKeys = resourceScheduleManager.getConfigKeys(); + Assert.assertEquals(1, configKeys.length); + Assert.assertEquals(BaseScheduleWorker.ScheduledJobExpireInterval.key(), configKeys[0].key()); + } +} diff --git a/server/src/test/java/org/apache/cloudstack/schedule/autoscale/AutoScaleScheduleWorkerTest.java b/server/src/test/java/org/apache/cloudstack/schedule/autoscale/AutoScaleScheduleWorkerTest.java new file mode 100644 index 00000000000..83c77099981 --- /dev/null +++ b/server/src/test/java/org/apache/cloudstack/schedule/autoscale/AutoScaleScheduleWorkerTest.java @@ -0,0 +1,164 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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.schedule.autoscale; + +import com.cloud.event.ActionEventUtils; +import com.cloud.event.EventTypes; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.network.as.AutoScaleManager; +import com.cloud.network.as.AutoScaleVmGroup; +import com.cloud.network.as.AutoScaleVmGroupVO; +import com.cloud.network.as.dao.AutoScaleVmGroupDao; +import com.cloud.user.User; +import org.apache.cloudstack.api.command.user.autoscale.UpdateAutoScaleVmGroupCmd; +import org.apache.cloudstack.schedule.ResourceScheduleVO; +import org.apache.cloudstack.schedule.ResourceScheduledJobVO; +import org.apache.cloudstack.schedule.dao.ResourceScheduleDao; +import org.apache.cloudstack.schedule.dao.ResourceScheduleDetailsDao; +import org.apache.cloudstack.schedule.dao.ResourceScheduledJobDao; +import org.apache.cloudstack.framework.jobs.AsyncJobManager; +import org.apache.commons.lang.time.DateUtils; +import org.junit.After; +import org.junit.Assert; +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 java.time.ZoneId; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +@RunWith(MockitoJUnitRunner.class) +public class AutoScaleScheduleWorkerTest { + @Spy + @InjectMocks + private AutoScaleScheduleWorker worker = new AutoScaleScheduleWorker(); + + @Mock + private AutoScaleVmGroupDao autoScaleVmGroupDao; + @Mock + private ResourceScheduleDetailsDao resourceScheduleDetailsDao; + @Mock + private ResourceScheduleDao resourceScheduleDao; + @Mock + private ResourceScheduledJobDao resourceScheduledJobDao; + @Mock + private AsyncJobManager asyncJobManager; + @Mock + private AutoScaleManager autoScaleManager; + + private AutoCloseable closeable; + private MockedStatic actionEventUtilsMocked; + + @Before + public void setUp() { + closeable = MockitoAnnotations.openMocks(this); + actionEventUtilsMocked = Mockito.mockStatic(ActionEventUtils.class); + Mockito.when(ActionEventUtils.onCompletedActionEvent(Mockito.anyLong(), Mockito.anyLong(), Mockito.any(), + Mockito.anyString(), Mockito.anyBoolean(), Mockito.anyString(), + Mockito.anyLong(), Mockito.anyString(), Mockito.anyLong())).thenReturn(1L); + } + + @After + public void tearDown() throws Exception { + actionEventUtilsMocked.close(); + closeable.close(); + } + + @Test + public void testProcessJobWithValidDetailsSubmitsUpdateAutoScaleVmGroupCmd() { + ResourceScheduledJobVO job = Mockito.mock(ResourceScheduledJobVO.class); + AutoScaleVmGroupVO group = Mockito.mock(AutoScaleVmGroupVO.class); + Map details = new HashMap<>(); + details.put("minmembers", "2"); + details.put("maxmembers", "5"); + + Mockito.when(job.getResourceId()).thenReturn(1L); + Mockito.when(job.getScheduleId()).thenReturn(10L); + Mockito.when(job.getActionName()).thenReturn(AutoScaleScheduleAction.UPDATE.name()); + Mockito.when(autoScaleVmGroupDao.findById(1L)).thenReturn(group); + Mockito.when(group.getState()).thenReturn(AutoScaleVmGroup.State.ENABLED); + Mockito.when(group.getAccountId()).thenReturn(3L); + Mockito.when(group.getId()).thenReturn(1L); + Mockito.when(group.getUuid()).thenReturn("asg-uuid"); + Mockito.when(resourceScheduleDetailsDao.listDetailsKeyPairs(10L, true)).thenReturn(details); + Mockito.doReturn(11L).when(worker).submitAsyncJob( + Mockito.eq(UpdateAutoScaleVmGroupCmd.class), Mockito.eq(3L), Mockito.eq(1L), Mockito.eq(1L), Mockito.anyMap()); + + Long asyncJobId = worker.processJob(job); + + Assert.assertEquals(Long.valueOf(11L), asyncJobId); + Mockito.verify(worker).submitAsyncJob(Mockito.eq(UpdateAutoScaleVmGroupCmd.class), Mockito.eq(3L), Mockito.eq(1L), Mockito.eq(1L), + Mockito.argThat(map -> "2".equals(map.get("minmembers")) && "5".equals(map.get("maxmembers")))); + } + + @Test + public void testProcessJobWithMissingGroupSkipsExecution() { + ResourceScheduledJobVO job = Mockito.mock(ResourceScheduledJobVO.class); + Mockito.when(job.getResourceId()).thenReturn(1L); + Mockito.when(autoScaleVmGroupDao.findById(1L)).thenReturn(null); + + Long asyncJobId = worker.processJob(job); + Assert.assertNull(asyncJobId); + } + + @Test(expected = InvalidParameterValueException.class) + public void testValidateDetailsMissingRequiredKeys() { + Map details = new HashMap<>(); + details.put("minmembers", "1"); + worker.validateDetails(AutoScaleScheduleAction.UPDATE, details); + } + + @Test + public void testScheduleNextJobDisablesWithEventWhenEndDatePassed() { + ResourceScheduleVO schedule = Mockito.mock(ResourceScheduleVO.class); + AutoScaleVmGroupVO group = Mockito.mock(AutoScaleVmGroupVO.class); + Date pastDate = DateUtils.addDays(new Date(), -1); + + Mockito.when(schedule.getEnabled()).thenReturn(true); + Mockito.when(schedule.getResourceId()).thenReturn(1L); + Mockito.when(schedule.getSchedule()).thenReturn("0 0 * * *"); + Mockito.when(schedule.getStartDate()).thenReturn(pastDate); + Mockito.when(schedule.getEndDate()).thenReturn(pastDate); + Mockito.when(schedule.getTimeZoneId()).thenReturn(ZoneId.of("UTC")); + Mockito.when(schedule.getUuid()).thenReturn("sched-uuid"); + Mockito.when(schedule.getDescription()).thenReturn("test schedule"); + Mockito.when(autoScaleVmGroupDao.findById(1L)).thenReturn(group); + Mockito.when(group.getState()).thenReturn(AutoScaleVmGroup.State.ENABLED); + Mockito.when(group.getAccountId()).thenReturn(2L); + + worker.scheduleNextJob(schedule, new Date()); + + Mockito.verify(schedule).setEnabled(false); + Mockito.verify(resourceScheduleDao).persist(schedule); + actionEventUtilsMocked.verify(() -> ActionEventUtils.onScheduledActionEvent( + Mockito.eq(User.UID_SYSTEM), Mockito.eq(2L), + Mockito.eq(EventTypes.EVENT_SCHEDULE_UPDATE), + Mockito.contains("end date"), + Mockito.eq(1L), Mockito.anyString(), Mockito.eq(true), Mockito.eq(0L))); + } +} diff --git a/server/src/test/java/org/apache/cloudstack/schedule/vm/VMScheduleWorkerTest.java b/server/src/test/java/org/apache/cloudstack/schedule/vm/VMScheduleWorkerTest.java new file mode 100644 index 00000000000..7a88c2e0ecb --- /dev/null +++ b/server/src/test/java/org/apache/cloudstack/schedule/vm/VMScheduleWorkerTest.java @@ -0,0 +1,398 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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.schedule.vm; + +import com.cloud.event.ActionEventUtils; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.network.as.dao.AutoScaleVmGroupVmMapDao; +import com.cloud.uservm.UserVm; +import com.cloud.utils.component.ComponentContext; +import com.cloud.vm.UserVmManager; +import com.cloud.vm.VirtualMachine; +import org.apache.cloudstack.api.command.user.vm.RebootVMCmd; +import org.apache.cloudstack.api.command.user.vm.StartVMCmd; +import org.apache.cloudstack.api.command.user.vm.StopVMCmd; +import org.apache.cloudstack.framework.jobs.AsyncJobDispatcher; +import org.apache.cloudstack.framework.jobs.AsyncJobManager; +import org.apache.cloudstack.framework.jobs.impl.AsyncJobVO; +import org.apache.cloudstack.schedule.ResourceScheduleVO; +import org.apache.cloudstack.schedule.ResourceScheduledJobVO; +import org.apache.cloudstack.schedule.dao.ResourceScheduleDao; +import org.apache.cloudstack.schedule.dao.ResourceScheduledJobDao; +import org.apache.commons.lang.time.DateUtils; +import org.junit.After; +import org.junit.Assert; +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 javax.persistence.EntityExistsException; +import java.time.ZonedDateTime; +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.TimeZone; + +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class VMScheduleWorkerTest { + @Spy + @InjectMocks + private VMScheduleWorker vmScheduleWorker = new VMScheduleWorker(); + + @Mock + private UserVmManager userVmManager; + + @Mock + private AutoScaleVmGroupVmMapDao autoScaleVmGroupVmMapDao; + + @Mock + private ResourceScheduleDao resourceScheduleDao; + + @Mock + private ResourceScheduledJobDao resourceScheduledJobDao; + + @Mock + private AsyncJobManager asyncJobManager; + + @Mock + private AsyncJobDispatcher asyncJobDispatcher; + + private AutoCloseable closeable; + + private MockedStatic actionEventUtilsMocked; + + @Before + public void setUp() throws Exception { + closeable = MockitoAnnotations.openMocks(this); + actionEventUtilsMocked = Mockito.mockStatic(ActionEventUtils.class); + Mockito.when(ActionEventUtils.onScheduledActionEvent(Mockito.anyLong(), Mockito.anyLong(), Mockito.anyString(), + Mockito.anyString(), Mockito.anyLong(), Mockito.anyString(), Mockito.anyBoolean(), + Mockito.anyLong())) + .thenReturn(1L); + Mockito.when(ActionEventUtils.onCompletedActionEvent(Mockito.anyLong(), Mockito.anyLong(), Mockito.any(), + Mockito.anyString(), Mockito.anyBoolean(), + Mockito.anyString(), + Mockito.anyLong(), Mockito.anyString(), Mockito.anyLong())).thenReturn(1L); + } + + @After + public void tearDown() throws Exception { + actionEventUtilsMocked.close(); + closeable.close(); + } + + @Test + public void testProcessJobRunning() { + executeProcessJobWithVMStateAndActionNonSkipped(VirtualMachine.State.Running, VMScheduleAction.STOP); + executeProcessJobWithVMStateAndActionNonSkipped(VirtualMachine.State.Running, VMScheduleAction.FORCE_STOP); + executeProcessJobWithVMStateAndActionNonSkipped(VirtualMachine.State.Running, VMScheduleAction.REBOOT); + executeProcessJobWithVMStateAndActionNonSkipped(VirtualMachine.State.Running, VMScheduleAction.FORCE_REBOOT); + executeProcessJobWithVMStateAndActionNonSkipped(VirtualMachine.State.Stopped, VMScheduleAction.START); + } + + private void executeProcessJobWithVMStateAndActionNonSkipped(VirtualMachine.State state, VMScheduleAction action) { + ResourceScheduledJobVO job = Mockito.mock(ResourceScheduledJobVO.class); + UserVm vm = Mockito.mock(UserVm.class); + + Mockito.when(job.getResourceId()).thenReturn(1L); + Mockito.when(job.getActionName()).thenReturn(action.name()); + Mockito.when(vm.getState()).thenReturn(state); + Mockito.when(userVmManager.getUserVm(1L)).thenReturn(vm); + Mockito.doReturn(1L).when(vmScheduleWorker).submitAsyncJob( + Mockito.any(), Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong(), Mockito.any()); + + Long jobId = vmScheduleWorker.processJob(job); + + Assert.assertNotNull(jobId); + Assert.assertEquals(1L, (long) jobId); + } + + @Test + public void testProcessJobInvalidAction() { + ResourceScheduledJobVO job = Mockito.mock(ResourceScheduledJobVO.class); + UserVm vm = Mockito.mock(UserVm.class); + + Mockito.when(job.getResourceId()).thenReturn(1L); + Mockito.when(job.getActionName()).thenReturn(VMScheduleAction.START.name()); + Mockito.when(vm.getState()).thenReturn(VirtualMachine.State.Running); + Mockito.when(userVmManager.getUserVm(1L)).thenReturn(vm); + + Long jobId = vmScheduleWorker.processJob(job); + + Assert.assertNull(jobId); + } + + @Test + public void testProcessJobVMInInvalidState() { + ResourceScheduledJobVO job = Mockito.mock(ResourceScheduledJobVO.class); + UserVm vm = Mockito.mock(UserVm.class); + + Mockito.when(job.getResourceId()).thenReturn(1L); + Mockito.when(job.getActionName()).thenReturn(VMScheduleAction.START.name()); + Mockito.when(vm.getState()).thenReturn(VirtualMachine.State.Unknown); + Mockito.when(userVmManager.getUserVm(1L)).thenReturn(vm); + + Long jobId = vmScheduleWorker.processJob(job); + + Assert.assertNull(jobId); + } + + @Test + public void testScheduleNextJobScheduleScheduleExists() { + Date now = DateUtils.setSeconds(new Date(), 0); + Date startDate = DateUtils.addDays(now, 1); + Date expectedScheduledTime = DateUtils.round(DateUtils.addMinutes(startDate, 1), Calendar.MINUTE); + UserVm vm = Mockito.mock(UserVm.class); + + ResourceScheduleVO schedule = Mockito.mock(ResourceScheduleVO.class); + Mockito.when(schedule.getEnabled()).thenReturn(true); + Mockito.when(schedule.getSchedule()).thenReturn("* * * * *"); + Mockito.when(schedule.getTimeZoneId()).thenReturn(TimeZone.getTimeZone("UTC").toZoneId()); + Mockito.when(schedule.getStartDate()).thenReturn(startDate); + Mockito.when(schedule.getResourceId()).thenReturn(1L); + Mockito.when(schedule.getActionName()).thenReturn(VMScheduleAction.START.name()); + Mockito.when(userVmManager.getUserVm(Mockito.anyLong())).thenReturn(vm); + Mockito.when(resourceScheduledJobDao.persist(Mockito.any())).thenThrow(EntityExistsException.class); + Date actualScheduledTime = vmScheduleWorker.scheduleNextJob(schedule, new Date()); + + Assert.assertEquals(expectedScheduledTime, actualScheduledTime); + } + + @Test + public void testScheduleNextJobScheduleFutureSchedule() { + Date now = DateUtils.setSeconds(new Date(), 0); + Date startDate = DateUtils.addDays(now, 1); + Date expectedScheduledTime = DateUtils.round(DateUtils.addMinutes(startDate, 1), Calendar.MINUTE); + UserVm vm = Mockito.mock(UserVm.class); + + ResourceScheduleVO schedule = Mockito.mock(ResourceScheduleVO.class); + Mockito.when(schedule.getEnabled()).thenReturn(true); + Mockito.when(schedule.getSchedule()).thenReturn("* * * * *"); + Mockito.when(schedule.getTimeZoneId()).thenReturn(TimeZone.getTimeZone("UTC").toZoneId()); + Mockito.when(schedule.getStartDate()).thenReturn(startDate); + Mockito.when(schedule.getResourceId()).thenReturn(1L); + Mockito.when(schedule.getActionName()).thenReturn(VMScheduleAction.START.name()); + Mockito.when(userVmManager.getUserVm(Mockito.anyLong())).thenReturn(vm); + Date actualScheduledTime = vmScheduleWorker.scheduleNextJob(schedule, new Date()); + + Assert.assertEquals(expectedScheduledTime, actualScheduledTime); + } + + @Test + public void testScheduleNextJobScheduleFutureScheduleWithTimeZoneChecks() throws Exception { + String cron = "30 5 * * *"; + Date now = DateUtils.setSeconds(new Date(), 0); + Date startDate = DateUtils.addDays(now, 1); + UserVm vm = Mockito.mock(UserVm.class); + + ResourceScheduleVO schedule = Mockito.mock(ResourceScheduleVO.class); + Mockito.when(schedule.getEnabled()).thenReturn(true); + Mockito.when(schedule.getSchedule()).thenReturn(cron); + Mockito.when(schedule.getTimeZoneId()).thenReturn(TimeZone.getTimeZone("EST").toZoneId()); + Mockito.when(schedule.getStartDate()).thenReturn(startDate); + Mockito.when(schedule.getResourceId()).thenReturn(1L); + Mockito.when(schedule.getActionName()).thenReturn(VMScheduleAction.START.name()); + Mockito.when(userVmManager.getUserVm(Mockito.anyLong())).thenReturn(vm); + + ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(startDate.toInstant(), schedule.getTimeZoneId()); + zonedDateTime = zonedDateTime.withHour(5).withMinute(30).withSecond(0).withNano(0); + Date expectedScheduledTime = Date.from(zonedDateTime.toInstant()); + if (expectedScheduledTime.before(startDate)) { + expectedScheduledTime = Date.from(zonedDateTime.plusDays(1).toInstant()); + } + + Date actualScheduledTime = vmScheduleWorker.scheduleNextJob(schedule, new Date()); + Assert.assertEquals(expectedScheduledTime, actualScheduledTime); + } + + @Test + public void testScheduleNextJobScheduleCurrentSchedule() { + Date now = DateUtils.setSeconds(new Date(), 0); + Date expectedScheduledTime = DateUtils.round(DateUtils.addMinutes(now, 1), Calendar.MINUTE); + UserVm vm = Mockito.mock(UserVm.class); + + ResourceScheduleVO schedule = Mockito.mock(ResourceScheduleVO.class); + Mockito.when(schedule.getEnabled()).thenReturn(true); + Mockito.when(schedule.getSchedule()).thenReturn("* * * * *"); + Mockito.when(schedule.getTimeZoneId()).thenReturn(TimeZone.getTimeZone("UTC").toZoneId()); + Mockito.when(schedule.getStartDate()).thenReturn(DateUtils.addDays(now, -1)); + Mockito.when(schedule.getResourceId()).thenReturn(1L); + Mockito.when(schedule.getActionName()).thenReturn(VMScheduleAction.START.name()); + Mockito.when(userVmManager.getUserVm(Mockito.anyLong())).thenReturn(vm); + Date actualScheduledTime = vmScheduleWorker.scheduleNextJob(schedule, new Date()); + + Assert.assertEquals(expectedScheduledTime, actualScheduledTime); + } + + @Test + public void testScheduleNextJobScheduleCurrentScheduleWithTimeZoneChecks() throws Exception { + String cron = "30 5 * * *"; + Date now = DateUtils.setSeconds(new Date(), 0); + UserVm vm = Mockito.mock(UserVm.class); + + ResourceScheduleVO schedule = Mockito.mock(ResourceScheduleVO.class); + Mockito.when(schedule.getEnabled()).thenReturn(true); + Mockito.when(schedule.getSchedule()).thenReturn(cron); + Mockito.when(schedule.getTimeZoneId()).thenReturn(TimeZone.getTimeZone("EST").toZoneId()); + Mockito.when(schedule.getStartDate()).thenReturn(DateUtils.addDays(now, -1)); + Mockito.when(schedule.getResourceId()).thenReturn(1L); + Mockito.when(schedule.getActionName()).thenReturn(VMScheduleAction.START.name()); + Mockito.when(userVmManager.getUserVm(Mockito.anyLong())).thenReturn(vm); + + ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(now.toInstant(), schedule.getTimeZoneId()); + zonedDateTime = zonedDateTime.withHour(5).withMinute(30).withSecond(0).withNano(0); + Date expectedScheduledTime = Date.from(zonedDateTime.toInstant()); + if (expectedScheduledTime.before(now)) { + expectedScheduledTime = DateUtils.addDays(expectedScheduledTime, 1); + } + + Date actualScheduledTime = vmScheduleWorker.scheduleNextJob(schedule, new Date()); + Assert.assertEquals(expectedScheduledTime, actualScheduledTime); + } + + @Test + public void testScheduleNextJobScheduleExpired() { + Date now = DateUtils.setSeconds(new Date(), 0); + ResourceScheduleVO schedule = Mockito.mock(ResourceScheduleVO.class); + Mockito.when(schedule.getEnabled()).thenReturn(true); + Mockito.when(schedule.getSchedule()).thenReturn("* * * * *"); + Mockito.when(schedule.getTimeZoneId()).thenReturn(TimeZone.getTimeZone("UTC").toZoneId()); + Mockito.when(schedule.getStartDate()).thenReturn(DateUtils.addDays(now, -1)); + Mockito.when(schedule.getEndDate()).thenReturn(DateUtils.addMinutes(now, -5)); + Mockito.when(schedule.getResourceId()).thenReturn(1L); + Mockito.when(userVmManager.getUserVm(Mockito.anyLong())).thenReturn(Mockito.mock(UserVm.class)); + Date actualDate = vmScheduleWorker.scheduleNextJob(schedule, new Date()); + Assert.assertNull(actualDate); + } + + @Test + public void testScheduleNextJobNextOccurrenceAfterEndDate() { + Date now = DateUtils.setSeconds(new Date(), 0); + ResourceScheduleVO schedule = Mockito.mock(ResourceScheduleVO.class); + Mockito.when(schedule.getEnabled()).thenReturn(true); + Mockito.when(schedule.getSchedule()).thenReturn("* * * * *"); + Mockito.when(schedule.getTimeZoneId()).thenReturn(TimeZone.getTimeZone("UTC").toZoneId()); + // endDate is in the future (now < endDate), but the first occurrence (>= startDate) + // falls after endDate, so no further jobs can ever be scheduled. The schedule's + // declared lifetime hasn't ended yet though, so it must remain enabled until + // endDate actually passes (handled separately by the "end time has passed" branch). + Mockito.when(schedule.getStartDate()).thenReturn(DateUtils.addDays(now, 10)); + Mockito.when(schedule.getEndDate()).thenReturn(DateUtils.addDays(now, 5)); + Mockito.when(schedule.getResourceId()).thenReturn(1L); + Mockito.when(userVmManager.getUserVm(Mockito.anyLong())).thenReturn(Mockito.mock(UserVm.class)); + + Date actualDate = vmScheduleWorker.scheduleNextJob(schedule, new Date()); + + Assert.assertNull(actualDate); + Mockito.verify(schedule, Mockito.never()).setEnabled(Mockito.anyBoolean()); + Mockito.verify(resourceScheduleDao, Mockito.never()).persist(Mockito.any()); + } + + @Test + public void testScheduleNextJobScheduleDisabled() { + ResourceScheduleVO schedule = Mockito.mock(ResourceScheduleVO.class); + Mockito.when(schedule.getEnabled()).thenReturn(false); + Date actualDate = vmScheduleWorker.scheduleNextJob(schedule, new Date()); + Assert.assertNull(actualDate); + } + + @Test + public void testExecuteJobs() { + ResourceScheduledJobVO job1 = Mockito.mock(ResourceScheduledJobVO.class); + ResourceScheduledJobVO job2 = Mockito.mock(ResourceScheduledJobVO.class); + + Mockito.doReturn(null).when(vmScheduleWorker).processJob(job2); + + Mockito.when(resourceScheduledJobDao.acquireInLockTable(job1.getId())).thenReturn(job1); + Mockito.when(resourceScheduledJobDao.acquireInLockTable(job2.getId())).thenReturn(job2); + + Map jobs = new HashMap<>(); + jobs.put(1L, job1); + jobs.put(2L, job2); + + ReflectionTestUtils.setField(vmScheduleWorker, "currentTimestamp", new Date()); + + vmScheduleWorker.executeJobs(jobs); + + Mockito.verify(vmScheduleWorker, Mockito.times(2)).processJob(Mockito.any()); + Mockito.verify(resourceScheduledJobDao, Mockito.times(2)).acquireInLockTable(Mockito.anyLong()); + } + + @Test + public void testSubmitStopVMJob() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + Mockito.when(asyncJobManager.submitAsyncJob(Mockito.any(AsyncJobVO.class))).thenReturn(1L); + Mockito.when(asyncJobDispatcher.getName()).thenReturn("ApiAsyncJobDispatcher"); + try (MockedStatic ignored = Mockito.mockStatic(ComponentContext.class)) { + when(ComponentContext.inject(Mockito.any(StopVMCmd.class))).thenReturn(Mockito.mock(StopVMCmd.class)); + long jobId = vmScheduleWorker.submitAsyncJob(StopVMCmd.class, vm.getAccountId(), vm.getId(), 1L, + Map.of("forced", "false")); + Assert.assertEquals(1L, jobId); + } + } + + @Test + public void testSubmitRebootVMJob() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + Mockito.when(asyncJobManager.submitAsyncJob(Mockito.any(AsyncJobVO.class))).thenReturn(1L); + Mockito.when(asyncJobDispatcher.getName()).thenReturn("ApiAsyncJobDispatcher"); + try (MockedStatic ignored = Mockito.mockStatic(ComponentContext.class)) { + when(ComponentContext.inject(Mockito.any(RebootVMCmd.class))).thenReturn(Mockito.mock(RebootVMCmd.class)); + long jobId = vmScheduleWorker.submitAsyncJob(RebootVMCmd.class, vm.getAccountId(), vm.getId(), 1L, + Map.of("forced", "false")); + Assert.assertEquals(1L, jobId); + } + } + + @Test + public void testSubmitStartVMJob() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + Mockito.when(asyncJobManager.submitAsyncJob(Mockito.any(AsyncJobVO.class))).thenReturn(1L); + Mockito.when(asyncJobDispatcher.getName()).thenReturn("ApiAsyncJobDispatcher"); + try (MockedStatic ignored = Mockito.mockStatic(ComponentContext.class)) { + when(ComponentContext.inject(Mockito.any(StartVMCmd.class))).thenReturn(Mockito.mock(StartVMCmd.class)); + long jobId = vmScheduleWorker.submitAsyncJob(StartVMCmd.class, vm.getAccountId(), vm.getId(), 1L, + Map.of()); + Assert.assertEquals(1L, jobId); + } + } + + @Test + public void parseActionResolvesEnum() { + Assert.assertEquals(VMScheduleAction.START, vmScheduleWorker.parseAction("start")); + Assert.assertEquals(VMScheduleAction.STOP, vmScheduleWorker.parseAction("STOP")); + Assert.assertEquals(VMScheduleAction.FORCE_REBOOT, vmScheduleWorker.parseAction("Force_Reboot")); + } + + @Test(expected = InvalidParameterValueException.class) + public void parseActionThrowsOnUnknown() { + vmScheduleWorker.parseAction("BOGUS"); + } +} diff --git a/server/src/test/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImplTest.java b/server/src/test/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImplTest.java index bee6c4ad257..dbc05ec99f3 100644 --- a/server/src/test/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImplTest.java +++ b/server/src/test/java/org/apache/cloudstack/vm/UnmanagedVMsManagerImplTest.java @@ -666,7 +666,7 @@ public class UnmanagedVMsManagerImplTest { DeployDestination mockDest = Mockito.mock(DeployDestination.class); when(deploymentPlanningManager.planDeployment(any(), any(), any(), any())).thenReturn(mockDest); DiskProfile diskProfile = Mockito.mock(DiskProfile.class); - when(volumeManager.allocateRawVolume(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean())) + when(volumeManager.allocateRawVolume(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean())) .thenReturn(diskProfile); Map storage = new HashMap<>(); VolumeVO volume = Mockito.mock(VolumeVO.class); @@ -967,7 +967,7 @@ public class UnmanagedVMsManagerImplTest { DeployDestination mockDest = Mockito.mock(DeployDestination.class); when(deploymentPlanningManager.planDeployment(any(), any(), any(), any())).thenReturn(mockDest); DiskProfile diskProfile = Mockito.mock(DiskProfile.class); - when(volumeManager.allocateRawVolume(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean())) + when(volumeManager.allocateRawVolume(any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), anyBoolean())) .thenReturn(diskProfile); Map storage = new HashMap<>(); VolumeVO volume = Mockito.mock(VolumeVO.class); diff --git a/server/src/test/java/org/apache/cloudstack/vm/schedule/VMScheduleManagerImplTest.java b/server/src/test/java/org/apache/cloudstack/vm/schedule/VMScheduleManagerImplTest.java deleted file mode 100644 index 84b1c577f6a..00000000000 --- a/server/src/test/java/org/apache/cloudstack/vm/schedule/VMScheduleManagerImplTest.java +++ /dev/null @@ -1,282 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT 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.vm.schedule; - -import com.cloud.exception.InvalidParameterValueException; -import com.cloud.user.Account; -import com.cloud.user.AccountManager; -import com.cloud.user.User; -import com.cloud.uservm.UserVm; -import com.cloud.utils.Pair; -import com.cloud.utils.db.SearchCriteria; -import com.cloud.vm.UserVmManager; -import com.cloud.vm.VirtualMachine; -import org.apache.cloudstack.api.command.user.vm.CreateVMScheduleCmd; -import org.apache.cloudstack.api.command.user.vm.DeleteVMScheduleCmd; -import org.apache.cloudstack.api.command.user.vm.ListVMScheduleCmd; -import org.apache.cloudstack.api.command.user.vm.UpdateVMScheduleCmd; -import org.apache.cloudstack.api.response.ListResponse; -import org.apache.cloudstack.api.response.VMScheduleResponse; -import org.apache.cloudstack.context.CallContext; -import org.apache.cloudstack.vm.schedule.dao.VMScheduleDao; -import org.apache.commons.lang.time.DateUtils; -import org.junit.After; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.Mockito; -import org.mockito.MockitoAnnotations; -import org.mockito.Spy; -import org.springframework.test.util.ReflectionTestUtils; - -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.TimeZone; -import java.util.UUID; - -import static org.junit.Assert.assertNotNull; - -public class VMScheduleManagerImplTest { - - @Spy - @InjectMocks - VMScheduleManagerImpl vmScheduleManager = new VMScheduleManagerImpl(); - - @Mock - VMScheduleDao vmScheduleDao; - - @Mock - VMScheduler vmScheduler; - - @Mock - UserVmManager userVmManager; - - @Mock - AccountManager accountManager; - - private AutoCloseable closeable; - - @Before - public void setUp() { - closeable = MockitoAnnotations.openMocks(this); - Account callingAccount = Mockito.mock(Account.class); - User callingUser = Mockito.mock(User.class); - CallContext.register(callingUser, callingAccount); - } - - @After - public void tearDown() throws Exception { - closeable.close(); - } - - private void validateResponse(VMScheduleResponse response, VMSchedule vmSchedule, VirtualMachine vm) { - assertNotNull(response); - Assert.assertEquals(ReflectionTestUtils.getField(response, "id"), vmSchedule.getUuid()); - Assert.assertEquals(ReflectionTestUtils.getField(response, "vmId"), vm.getUuid()); - Assert.assertEquals(ReflectionTestUtils.getField(response, "schedule"), vmSchedule.getSchedule()); - Assert.assertEquals(ReflectionTestUtils.getField(response, "timeZone"), vmSchedule.getTimeZone()); - Assert.assertEquals(ReflectionTestUtils.getField(response, "action"), vmSchedule.getAction()); - Assert.assertEquals(ReflectionTestUtils.getField(response, "startDate"), vmSchedule.getStartDate()); - Assert.assertEquals(ReflectionTestUtils.getField(response, "endDate"), vmSchedule.getEndDate()); - } - - @Test - public void createSchedule() { - UserVm vm = Mockito.mock(UserVm.class); - VMScheduleVO vmSchedule = Mockito.mock(VMScheduleVO.class); - CreateVMScheduleCmd cmd = Mockito.mock(CreateVMScheduleCmd.class); - - Mockito.when(cmd.getVmId()).thenReturn(1L); - Mockito.when(cmd.getSchedule()).thenReturn("0 0 * * *"); - Mockito.when(cmd.getTimeZone()).thenReturn("UTC"); - Mockito.when(cmd.getAction()).thenReturn("start"); - Mockito.when(cmd.getStartDate()).thenReturn(DateUtils.addDays(new Date(), 1)); - Mockito.when(cmd.getEndDate()).thenReturn(DateUtils.addDays(new Date(), 2)); - Mockito.when(vm.getUuid()).thenReturn(UUID.randomUUID().toString()); - Mockito.when(vmScheduleDao.persist(Mockito.any(VMScheduleVO.class))).thenReturn(vmSchedule); - Mockito.when(userVmManager.getUserVm(Mockito.anyLong())).thenReturn(vm); - Mockito.doNothing().when(accountManager).checkAccess(Mockito.any(Account.class), Mockito.isNull(), Mockito.eq(false), Mockito.eq(vm)); - VMScheduleResponse response = vmScheduleManager.createSchedule(cmd); - Mockito.verify(vmScheduleDao, Mockito.times(1)).persist(Mockito.any(VMScheduleVO.class)); - - validateResponse(response, vmSchedule, vm); - } - - @Test - public void createResponse() { - VMSchedule vmSchedule = Mockito.mock(VMSchedule.class); - UserVm vm = Mockito.mock(UserVm.class); - Mockito.when(vmSchedule.getVmId()).thenReturn(1L); - Mockito.when(userVmManager.getUserVm(vmSchedule.getVmId())).thenReturn(vm); - - VMScheduleResponse response = vmScheduleManager.createResponse(vmSchedule); - validateResponse(response, vmSchedule, vm); - } - - @Test - public void listSchedule() { - UserVm vm = Mockito.mock(UserVm.class); - VMScheduleVO vmSchedule1 = Mockito.mock(VMScheduleVO.class); - VMScheduleVO vmSchedule2 = Mockito.mock(VMScheduleVO.class); - List vmScheduleList = new ArrayList<>(); - vmScheduleList.add(vmSchedule1); - vmScheduleList.add(vmSchedule2); - - Mockito.when(vm.getUuid()).thenReturn(UUID.randomUUID().toString()); - Mockito.when(userVmManager.getUserVm(1L)).thenReturn(vm); - Mockito.when( - vmScheduleDao.searchAndCount(Mockito.anyLong(), Mockito.anyLong(), Mockito.any(), - Mockito.anyBoolean(), Mockito.anyLong(), Mockito.anyLong()) - ).thenReturn(new Pair<>(vmScheduleList, vmScheduleList.size())); - Mockito.when(vmSchedule1.getVmId()).thenReturn(1L); - Mockito.when(vmSchedule2.getVmId()).thenReturn(1L); - - ListVMScheduleCmd cmd = Mockito.mock(ListVMScheduleCmd.class); - Mockito.when(cmd.getVmId()).thenReturn(1L); - - ListResponse responseList = vmScheduleManager.listSchedule(cmd); - Mockito.verify(vmScheduleDao, Mockito.times(1)).searchAndCount(Mockito.anyLong(), Mockito.anyLong(), Mockito.any(), - Mockito.anyBoolean(), Mockito.anyLong(), Mockito.anyLong()); - assertNotNull(responseList); - Assert.assertEquals(2, (int) responseList.getCount()); - Assert.assertEquals(2, responseList.getResponses().size()); - - for (int i = 0; i < responseList.getResponses().size(); i++) { - VMScheduleResponse response = responseList.getResponses().get(i); - VMScheduleVO vmSchedule = vmScheduleList.get(i); - validateResponse(response, vmSchedule, vm); - } - } - - @Test - public void updateSchedule() { - VMScheduleVO vmSchedule = Mockito.mock(VMScheduleVO.class); - UpdateVMScheduleCmd cmd = Mockito.mock(UpdateVMScheduleCmd.class); - UserVm vm = Mockito.mock(UserVm.class); - Mockito.when(cmd.getId()).thenReturn(1L); - Mockito.when(cmd.getSchedule()).thenReturn("0 0 * * *"); - Mockito.when(cmd.getTimeZone()).thenReturn("UTC"); - Mockito.when(cmd.getStartDate()).thenReturn(DateUtils.addDays(new Date(), 1)); - Mockito.when(cmd.getEndDate()).thenReturn(DateUtils.addDays(new Date(), 2)); - Mockito.when(vmScheduleDao.findById(Mockito.anyLong())).thenReturn(vmSchedule); - Mockito.when(vmScheduleDao.update(Mockito.eq(cmd.getId()), Mockito.any(VMScheduleVO.class))).thenReturn(true); - Mockito.when(vmSchedule.getVmId()).thenReturn(1L); - Mockito.when(vmSchedule.getStartDate()).thenReturn(DateUtils.addDays(new Date(), 1)); - Mockito.when(userVmManager.getUserVm(vmSchedule.getVmId())).thenReturn(vm); - - VMScheduleResponse response = vmScheduleManager.updateSchedule(cmd); - Mockito.verify(vmScheduleDao, Mockito.times(1)).update(Mockito.eq(cmd.getId()), Mockito.any(VMScheduleVO.class)); - - validateResponse(response, vmSchedule, vm); - } - - @Test - public void removeScheduleByVmId() { - UserVm vm = Mockito.mock(UserVm.class); - VMScheduleVO vmSchedule1 = Mockito.mock(VMScheduleVO.class); - VMScheduleVO vmSchedule2 = Mockito.mock(VMScheduleVO.class); - List vmScheduleList = new ArrayList<>(); - vmScheduleList.add(vmSchedule1); - vmScheduleList.add(vmSchedule2); - SearchCriteria sc = Mockito.mock(SearchCriteria.class); - - Mockito.when(vm.getId()).thenReturn(1L); - Mockito.when(vmScheduleDao.getSearchCriteriaForVMId(vm.getId())).thenReturn(sc); - Mockito.when(vmScheduleDao.search(sc, null)).thenReturn(vmScheduleList); - Mockito.when(vmSchedule1.getId()).thenReturn(1L); - Mockito.when(vmSchedule2.getId()).thenReturn(2L); - Mockito.when(vmScheduleDao.remove(sc)).thenReturn(2); - - long rowsRemoved = vmScheduleManager.removeScheduleByVmId(vm.getId(), false); - - Mockito.verify(vmScheduleDao, Mockito.times(1)).remove(sc); - Assert.assertEquals(2, rowsRemoved); - } - - @Test - public void removeSchedule() { - VMScheduleVO vmSchedule = Mockito.mock(VMScheduleVO.class); - UserVm vm = Mockito.mock(UserVm.class); - DeleteVMScheduleCmd cmd = Mockito.mock(DeleteVMScheduleCmd.class); - - Mockito.when(cmd.getId()).thenReturn(1L); - Mockito.when(cmd.getVmId()).thenReturn(1L); - Mockito.when(vmSchedule.getVmId()).thenReturn(1L); - Mockito.when(userVmManager.getUserVm(cmd.getVmId())).thenReturn(vm); - Mockito.when(vmScheduleDao.findById(Mockito.anyLong())).thenReturn(vmSchedule); - Mockito.when(vmScheduleDao.removeSchedulesForVmIdAndIds(Mockito.anyLong(), Mockito.anyList())).thenReturn(1L); - - Long rowsRemoved = vmScheduleManager.removeSchedule(cmd); - - Mockito.verify(vmScheduleDao, Mockito.times(1)).removeSchedulesForVmIdAndIds(Mockito.anyLong(), Mockito.anyList()); - Assert.assertEquals(1L, (long) rowsRemoved); - } - - @Test - public void validateStartDateEndDate() { - // Valid scenario 1 - // Start date is before end date - Date startDate = DateUtils.addDays(new Date(), 1); - Date endDate = DateUtils.addDays(new Date(), 2); - vmScheduleManager.validateStartDateEndDate(startDate, endDate, TimeZone.getDefault()); - - // Valid Scenario 2 - // Start date is before current date and end date is null - endDate = null; - vmScheduleManager.validateStartDateEndDate(startDate, endDate, TimeZone.getDefault()); - - // Invalid Scenario 2 - // Start date is before current date - startDate = DateUtils.addDays(new Date(), -1); - try { - vmScheduleManager.validateStartDateEndDate(startDate, endDate, TimeZone.getDefault()); - Assert.fail("Should have thrown InvalidParameterValueException"); - } catch (InvalidParameterValueException e) { - Assert.assertTrue(e.getMessage().contains("Invalid value for start date. Start date") && - e.getMessage().contains("can't be before current time")); - } - - // Invalid Scenario 2 - // Start date is after end date - startDate = DateUtils.addDays(new Date(), 2); - endDate = DateUtils.addDays(new Date(), 1); - try { - vmScheduleManager.validateStartDateEndDate(startDate, endDate, TimeZone.getDefault()); - Assert.fail("Should have thrown InvalidParameterValueException"); - } catch (InvalidParameterValueException e) { - Assert.assertTrue(e.getMessage().contains("Invalid value for end date. End date") && - e.getMessage().contains("can't be before start date")); - } - - // Invalid Scenario 3 - // End date is before current date - startDate = DateUtils.addDays(new Date(), 1); - endDate = DateUtils.addDays(new Date(), -1); - try { - vmScheduleManager.validateStartDateEndDate(startDate, endDate, TimeZone.getDefault()); - Assert.fail("Should have thrown InvalidParameterValueException"); - } catch (InvalidParameterValueException e) { - Assert.assertTrue(e.getMessage().contains("Invalid value for end date. End date") && - e.getMessage().contains("can't be before current time")); - } - } -} diff --git a/server/src/test/java/org/apache/cloudstack/vm/schedule/VMSchedulerImplTest.java b/server/src/test/java/org/apache/cloudstack/vm/schedule/VMSchedulerImplTest.java deleted file mode 100644 index 3f9ffc714a1..00000000000 --- a/server/src/test/java/org/apache/cloudstack/vm/schedule/VMSchedulerImplTest.java +++ /dev/null @@ -1,388 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT 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.vm.schedule; - -import com.cloud.event.ActionEventUtils; -import com.cloud.user.User; -import com.cloud.uservm.UserVm; -import com.cloud.utils.component.ComponentContext; -import com.cloud.vm.UserVmManager; -import com.cloud.vm.VirtualMachine; -import org.apache.cloudstack.api.ApiCommandResourceType; -import org.apache.cloudstack.api.command.user.vm.RebootVMCmd; -import org.apache.cloudstack.api.command.user.vm.StartVMCmd; -import org.apache.cloudstack.api.command.user.vm.StopVMCmd; -import org.apache.cloudstack.framework.jobs.AsyncJobDispatcher; -import org.apache.cloudstack.framework.jobs.AsyncJobManager; -import org.apache.cloudstack.framework.jobs.impl.AsyncJobVO; -import org.apache.cloudstack.vm.schedule.dao.VMScheduleDao; -import org.apache.cloudstack.vm.schedule.dao.VMScheduledJobDao; -import org.apache.commons.lang.time.DateUtils; -import org.junit.After; -import org.junit.Assert; -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 javax.persistence.EntityExistsException; -import java.time.ZonedDateTime; -import java.util.Calendar; -import java.util.Date; -import java.util.EnumMap; -import java.util.HashMap; -import java.util.Map; -import java.util.TimeZone; - -import static org.mockito.Mockito.when; - -@RunWith(MockitoJUnitRunner.class) -public class VMSchedulerImplTest { - @Spy - @InjectMocks - private VMSchedulerImpl vmScheduler = new VMSchedulerImpl(); - - @Mock - private UserVmManager userVmManager; - - @Mock - private VMScheduleDao vmScheduleDao; - - @Mock - private VMScheduledJobDao vmScheduledJobDao; - - @Mock - private EnumMap actionEventMap; - - @Mock - private AsyncJobManager asyncJobManager; - - @Mock - private AsyncJobDispatcher asyncJobDispatcher; - - private AutoCloseable closeable; - - private MockedStatic actionEventUtilsMocked; - - @Before - public void setUp() throws Exception { - closeable = MockitoAnnotations.openMocks(this); - actionEventUtilsMocked = Mockito.mockStatic(ActionEventUtils.class); - Mockito.when(ActionEventUtils.onScheduledActionEvent(Mockito.anyLong(), Mockito.anyLong(), Mockito.anyString(), - Mockito.anyString(), Mockito.anyLong(), Mockito.anyString(), Mockito.anyBoolean(), - Mockito.anyLong())) - .thenReturn(1L); - Mockito.when(ActionEventUtils.onCompletedActionEvent(Mockito.anyLong(), Mockito.anyLong(), Mockito.any(), - Mockito.anyString(), Mockito.anyBoolean(), - Mockito.anyString(), - Mockito.anyLong(), Mockito.anyString(), Mockito.anyLong())).thenReturn(1L); - } - - @After - public void tearDown() throws Exception { - actionEventUtilsMocked.close(); - closeable.close(); - } - - @Test - public void testProcessJobRunning() { - executeProcessJobWithVMStateAndActionNonSkipped(VirtualMachine.State.Running, VMSchedule.Action.STOP); - executeProcessJobWithVMStateAndActionNonSkipped(VirtualMachine.State.Running, VMSchedule.Action.FORCE_STOP); - executeProcessJobWithVMStateAndActionNonSkipped(VirtualMachine.State.Running, VMSchedule.Action.REBOOT); - executeProcessJobWithVMStateAndActionNonSkipped(VirtualMachine.State.Running, VMSchedule.Action.FORCE_REBOOT); - executeProcessJobWithVMStateAndActionNonSkipped(VirtualMachine.State.Stopped, VMSchedule.Action.START); - } - - private void executeProcessJobWithVMStateAndActionNonSkipped(VirtualMachine.State state, VMSchedule.Action action) { - VMScheduledJob vmScheduledJob = Mockito.mock(VMScheduledJob.class); - VirtualMachine vm = Mockito.mock(VirtualMachine.class); - - Long expectedValue = 1L; - - prepareMocksForProcessJob(vm, vmScheduledJob, state, action, expectedValue); - - Long jobId = vmScheduler.processJob(vmScheduledJob, vm); - - actionEventUtilsMocked.verify(() -> ActionEventUtils.onCompletedActionEvent(User.UID_SYSTEM, vm.getAccountId(), null, - actionEventMap.get(action), true, - String.format("Executing action (%s) for VM: %s", vmScheduledJob.getAction(), vm), - vm.getId(), ApiCommandResourceType.VirtualMachine.toString(), 0)); - Assert.assertEquals(expectedValue, jobId); - } - - private void prepareMocksForProcessJob(VirtualMachine vm, VMScheduledJob vmScheduledJob, - VirtualMachine.State vmState, VMSchedule.Action action, - Long executeJobReturnValue) { - Mockito.when(vm.getState()).thenReturn(vmState); - Mockito.when(vmScheduledJob.getAction()).thenReturn(action); - - if (executeJobReturnValue != null) { - Mockito.doReturn(executeJobReturnValue).when(vmScheduler).executeStartVMJob( - Mockito.any(VirtualMachine.class), Mockito.anyLong()); - Mockito.doReturn(executeJobReturnValue).when(vmScheduler).executeStopVMJob( - Mockito.any(VirtualMachine.class), Mockito.anyBoolean(), Mockito.anyLong()); - Mockito.doReturn(executeJobReturnValue).when(vmScheduler).executeRebootVMJob( - Mockito.any(VirtualMachine.class), Mockito.anyBoolean(), Mockito.anyLong()); - } - } - - @Test - public void testProcessJobInvalidAction() { - VMScheduledJob vmScheduledJob = Mockito.mock(VMScheduledJob.class); - VirtualMachine vm = Mockito.mock(VirtualMachine.class); - - prepareMocksForProcessJob(vm, vmScheduledJob, VirtualMachine.State.Running, VMSchedule.Action.START, null); - - Long jobId = vmScheduler.processJob(vmScheduledJob, vm); - - Assert.assertNull(jobId); - } - - @Test - public void testProcessJobVMInInvalidState() { - VMScheduledJob vmScheduledJob = Mockito.mock(VMScheduledJob.class); - VirtualMachine vm = Mockito.mock(VirtualMachine.class); - - prepareMocksForProcessJob(vm, vmScheduledJob, VirtualMachine.State.Unknown, VMSchedule.Action.START, null); - - Long jobId = vmScheduler.processJob(vmScheduledJob, vm); - - Assert.assertNull(jobId); - } - - @Test - public void testScheduleNextJobScheduleScheduleExists() { - Date now = DateUtils.setSeconds(new Date(), 0); - Date startDate = DateUtils.addDays(now, 1); - Date expectedScheduledTime = DateUtils.round(DateUtils.addMinutes(startDate, 1), Calendar.MINUTE); - UserVm vm = Mockito.mock(UserVm.class); - - VMScheduleVO vmSchedule = Mockito.mock(VMScheduleVO.class); - Mockito.when(vmSchedule.getEnabled()).thenReturn(true); - Mockito.when(vmSchedule.getSchedule()).thenReturn("* * * * *"); - Mockito.when(vmSchedule.getTimeZoneId()).thenReturn(TimeZone.getTimeZone("UTC").toZoneId()); - Mockito.when(vmSchedule.getStartDate()).thenReturn(startDate); - Mockito.when(userVmManager.getUserVm(Mockito.anyLong())).thenReturn(vm); - Mockito.when(vmScheduledJobDao.persist(Mockito.any())).thenThrow(EntityExistsException.class); - Date actualScheduledTime = vmScheduler.scheduleNextJob(vmSchedule, new Date()); - - Assert.assertEquals(expectedScheduledTime, actualScheduledTime); - } - - @Test - public void testScheduleNextJobScheduleFutureSchedule() { - Date now = DateUtils.setSeconds(new Date(), 0); - Date startDate = DateUtils.addDays(now, 1); - Date expectedScheduledTime = DateUtils.round(DateUtils.addMinutes(startDate, 1), Calendar.MINUTE); - UserVm vm = Mockito.mock(UserVm.class); - - VMScheduleVO vmSchedule = Mockito.mock(VMScheduleVO.class); - Mockito.when(vmSchedule.getEnabled()).thenReturn(true); - Mockito.when(vmSchedule.getSchedule()).thenReturn("* * * * *"); - Mockito.when(vmSchedule.getTimeZoneId()).thenReturn(TimeZone.getTimeZone("UTC").toZoneId()); - Mockito.when(vmSchedule.getStartDate()).thenReturn(startDate); - Mockito.when(userVmManager.getUserVm(Mockito.anyLong())).thenReturn(vm); - Date actualScheduledTime = vmScheduler.scheduleNextJob(vmSchedule, new Date()); - - Assert.assertEquals(expectedScheduledTime, actualScheduledTime); - } - - @Test - public void testScheduleNextJobScheduleFutureScheduleWithTimeZoneChecks() throws Exception { - // Ensure that Date vmSchedulerImpl.scheduleNextJob(VMScheduleVO vmSchedule) generates - // the correct scheduled time on basis of schedule(cron), start date - // and the timezone of the user. The system running the test can have any timezone. - String cron = "30 5 * * *"; - - Date now = DateUtils.setSeconds(new Date(), 0); - Date startDate = DateUtils.addDays(now, 1); - - UserVm vm = Mockito.mock(UserVm.class); - - VMScheduleVO vmSchedule = Mockito.mock(VMScheduleVO.class); - Mockito.when(vmSchedule.getEnabled()).thenReturn(true); - Mockito.when(vmSchedule.getSchedule()).thenReturn(cron); - Mockito.when(vmSchedule.getTimeZoneId()).thenReturn(TimeZone.getTimeZone("EST").toZoneId()); - Mockito.when(vmSchedule.getStartDate()).thenReturn(startDate); - Mockito.when(userVmManager.getUserVm(Mockito.anyLong())).thenReturn(vm); - - // The timezone of the user is EST. The cron expression is 30 5 * * *. - // The start date is 1 day from now. The expected scheduled time is 5:30 AM EST. - // The actual scheduled time is 10:30 AM UTC. - // The actual scheduled time is 5:30 AM EST. - ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(startDate.toInstant(), vmSchedule.getTimeZoneId()); - zonedDateTime = zonedDateTime.withHour(5).withMinute(30).withSecond(0).withNano(0); - Date expectedScheduledTime = Date.from(zonedDateTime.toInstant()); - - if (expectedScheduledTime.before(startDate)) { - expectedScheduledTime = Date.from(zonedDateTime.plusDays(1).toInstant()); - } - - Date actualScheduledTime = vmScheduler.scheduleNextJob(vmSchedule, new Date()); - Assert.assertEquals(expectedScheduledTime, actualScheduledTime); - } - - @Test - public void testScheduleNextJobScheduleCurrentSchedule() { - Date now = DateUtils.setSeconds(new Date(), 0); - Date expectedScheduledTime = DateUtils.round(DateUtils.addMinutes(now, 1), Calendar.MINUTE); - UserVm vm = Mockito.mock(UserVm.class); - - VMScheduleVO vmSchedule = Mockito.mock(VMScheduleVO.class); - Mockito.when(vmSchedule.getEnabled()).thenReturn(true); - Mockito.when(vmSchedule.getSchedule()).thenReturn("* * * * *"); - Mockito.when(vmSchedule.getTimeZoneId()).thenReturn(TimeZone.getTimeZone("UTC").toZoneId()); - Mockito.when(vmSchedule.getStartDate()).thenReturn(DateUtils.addDays(now, -1)); - Mockito.when(userVmManager.getUserVm(Mockito.anyLong())).thenReturn(vm); - Date actualScheduledTime = vmScheduler.scheduleNextJob(vmSchedule, new Date()); - - Assert.assertEquals(expectedScheduledTime, actualScheduledTime); - } - - @Test - public void testScheduleNextJobScheduleCurrentScheduleWithTimeZoneChecks() throws Exception { - // Ensure that Date vmSchedulerImpl.scheduleNextJob(VMScheduleVO vmSchedule) generates - // the correct scheduled time on basis of schedule(cron), start date - // and the timezone of the user. The system running the test can have any timezone. - String cron = "30 5 * * *"; - - Date now = DateUtils.setSeconds(new Date(), 0); - - UserVm vm = Mockito.mock(UserVm.class); - - VMScheduleVO vmSchedule = Mockito.mock(VMScheduleVO.class); - Mockito.when(vmSchedule.getEnabled()).thenReturn(true); - Mockito.when(vmSchedule.getSchedule()).thenReturn(cron); - Mockito.when(vmSchedule.getTimeZoneId()).thenReturn(TimeZone.getTimeZone("EST").toZoneId()); - Mockito.when(vmSchedule.getStartDate()).thenReturn(DateUtils.addDays(now, -1)); - Mockito.when(userVmManager.getUserVm(Mockito.anyLong())).thenReturn(vm); - - // The timezone of the user is EST. The cron expression is 30 5 * * *. - // The start date is 1 day ago. The expected scheduled time is 5:30 AM EST. - // The actual scheduled time is 10:30 AM UTC. - // The actual scheduled time is 5:30 AM EST. - ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(now.toInstant(), vmSchedule.getTimeZoneId()); - zonedDateTime = zonedDateTime.withHour(5).withMinute(30).withSecond(0).withNano(0); - Date expectedScheduledTime = Date.from(zonedDateTime.toInstant()); - - if (expectedScheduledTime.before(now)) { - expectedScheduledTime = DateUtils.addDays(expectedScheduledTime, 1); - } - - Date actualScheduledTime = vmScheduler.scheduleNextJob(vmSchedule, new Date()); - Assert.assertEquals(expectedScheduledTime, actualScheduledTime); - } - - @Test - public void testScheduleNextJobScheduleExpired() { - VMScheduleVO vmSchedule = Mockito.mock(VMScheduleVO.class); - Mockito.when(vmSchedule.getEndDate()).thenReturn(DateUtils.addMinutes(new Date(), -5)); - Mockito.when(vmSchedule.getEnabled()).thenReturn(true); - Date actualDate = vmScheduler.scheduleNextJob(vmSchedule, new Date()); - Assert.assertNull(actualDate); - } - - @Test - public void testScheduleNextJobScheduleDisabled() { - VMScheduleVO vmSchedule = Mockito.mock(VMScheduleVO.class); - Mockito.when(vmSchedule.getEnabled()).thenReturn(false); - Date actualDate = vmScheduler.scheduleNextJob(vmSchedule, new Date()); - Assert.assertNull(actualDate); - } - - - @Test - public void testExecuteJobs() { - /* - Test VMSchedulerImpl.executeJobs() method with a map of VMScheduledJob objects - covering all the possible scenarios - 1. When the job is executed successfully - 2. When the job is skipped (processJob returns null) - */ - - VMScheduledJobVO job1 = Mockito.mock(VMScheduledJobVO.class); - VMScheduledJobVO job2 = Mockito.mock(VMScheduledJobVO.class); - - UserVm vm1 = Mockito.mock(UserVm.class); - UserVm vm2 = Mockito.mock(UserVm.class); - - Mockito.when(job2.getVmId()).thenReturn(2L); - - Mockito.when(userVmManager.getUserVm(2L)).thenReturn(vm2); - - Mockito.doReturn(null).when(vmScheduler).processJob(job2, vm2); - - Mockito.when(vmScheduledJobDao.acquireInLockTable(job1.getId())).thenReturn(job1); - Mockito.when(vmScheduledJobDao.acquireInLockTable(job2.getId())).thenReturn(job2); - - Map jobs = new HashMap<>(); - jobs.put(1L, job1); - jobs.put(2L, job2); - - ReflectionTestUtils.setField(vmScheduler, "currentTimestamp", new Date()); - - vmScheduler.executeJobs(jobs); - - Mockito.verify(vmScheduler, Mockito.times(2)).processJob(Mockito.any(), Mockito.any()); - Mockito.verify(vmScheduledJobDao, Mockito.times(2)).acquireInLockTable(Mockito.anyLong()); - } - - @Test - public void testExecuteStopVMJob() { - VirtualMachine vm = Mockito.mock(VirtualMachine.class); - Mockito.when(asyncJobManager.submitAsyncJob(Mockito.any(AsyncJobVO.class))).thenReturn(1L); - try (MockedStatic ignored = Mockito.mockStatic(ComponentContext.class)) { - when(ComponentContext.inject(StopVMCmd.class)).thenReturn(Mockito.mock(StopVMCmd.class)); - long jobId = vmScheduler.executeStopVMJob(vm, false, 1L); - - Assert.assertEquals(1L, jobId); - } - } - - @Test - public void testExecuteRebootVMJob() { - VirtualMachine vm = Mockito.mock(VirtualMachine.class); - Mockito.when(asyncJobManager.submitAsyncJob(Mockito.any(AsyncJobVO.class))).thenReturn(1L); - try (MockedStatic ignored = Mockito.mockStatic(ComponentContext.class)) { - when(ComponentContext.inject(RebootVMCmd.class)).thenReturn(Mockito.mock(RebootVMCmd.class)); - long jobId = vmScheduler.executeRebootVMJob(vm, false, 1L); - - Assert.assertEquals(1L, jobId); - } - } - - @Test - public void testExecuteStartVMJob() { - VirtualMachine vm = Mockito.mock(VirtualMachine.class); - Mockito.when(asyncJobManager.submitAsyncJob(Mockito.any(AsyncJobVO.class))).thenReturn(1L); - try (MockedStatic ignored = Mockito.mockStatic(ComponentContext.class)) { - when(ComponentContext.inject(StartVMCmd.class)).thenReturn(Mockito.mock(StartVMCmd.class)); - long jobId = vmScheduler.executeStartVMJob(vm, 1L); - - Assert.assertEquals(1L, jobId); - } - } -} diff --git a/test/integration/component/test_vpc_network_lbrules.py b/test/integration/component/test_vpc_network_lbrules.py index 8f137adf78d..395f5876099 100644 --- a/test/integration/component/test_vpc_network_lbrules.py +++ b/test/integration/component/test_vpc_network_lbrules.py @@ -939,10 +939,6 @@ class TestVPCNetworkLBRules(cloudstackTestCase): lb_rule = self.create_LB_Rule(public_ip_1, network_1, [vm_2, vm_1]) public_ip_1.delete(self.apiclient) self.cleanup.remove(public_ip_1) - - with self.assertRaises(Exception): - lb_rules = LoadBalancerRule.list(self.apiclient, - id=lb_rule.id, - listall=True - ) + lb_rule_after_deletion = LoadBalancerRule.list(self.apiclient, id=lb_rule.id, listall=True) + self.assertEqual(lb_rule_after_deletion, None, 'Load balancer rule should be deleted.') return diff --git a/test/integration/smoke/test_kms_lifecycle.py b/test/integration/smoke/test_kms_lifecycle.py new file mode 100644 index 00000000000..79deb5bdf0e --- /dev/null +++ b/test/integration/smoke/test_kms_lifecycle.py @@ -0,0 +1,499 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +""" +Lifecycle integration tests for the KMS (Key Management Service) feature. + +Covers: + - HSM profile CRUD (add / list / update / delete) + - KMS key CRUD (create / list / update / delete) + - Key rotation + - Multi-tenancy / access isolation + - Negative scenarios (delete key in use, duplicate name, delete profile with keys) + +All tests use the built-in *database* KMS provider so that they can run in any +CI environment without real HSM hardware. +""" + +import random +import string + +from marvin.cloudstackTestCase import cloudstackTestCase +from marvin.lib.base import ( + Account, + Domain, + HSMProfile, + KMSKey, + VirtualMachine, + ServiceOffering, + Volume, +) +from marvin.lib.common import get_zone, get_domain, get_template +from marvin.lib.utils import cleanup_resources +from nose.plugins.attrib import attr + +def _random_name(prefix="test-kms"): + suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6)) + return f"{prefix}-{suffix}" + + +class TestKMSLifecycle(cloudstackTestCase): + """ + End-to-end lifecycle tests for KMS keys and HSM profiles. + + Each test gets a fresh Domain + Account pair via setUp so that + domain-level isolation is guaranteed and cleanup is straightforward. + """ + + @classmethod + def setUpClass(cls): + cls.test_client = super(TestKMSLifecycle, cls).getClsTestClient() + cls.apiclient = cls.test_client.getApiClient() + cls.zone = get_zone(cls.apiclient, cls.test_client.getZoneForTests()) + cls.domain = get_domain(cls.apiclient) + + cls._cleanup = [] + + # The built-in database provider is seeded as a public, system-owned profile named + # "HSM Database Provider". listHSMProfiles has no server-side name filter, and a root + # admin's listing without listall is restricted to admin-owned profiles, so we must + # pass listall=True and match the name client-side. + cls.default_profile = cls._find_default_profile(cls.apiclient) + if cls.default_profile and not cls.default_profile.enabled: + hsm = HSMProfile({"id": cls.default_profile.id}) + hsm.update(cls.apiclient, enabled=True) + # Re-fetch to get updated state + cls.default_profile = cls._find_default_profile(cls.apiclient) + + @staticmethod + def _find_default_profile(apiclient): + """Locate the seeded public 'HSM Database Provider' profile.""" + profiles = HSMProfile.list( + apiclient, listall=True, keyword="HSM Database Provider" + ) or [] + for profile in profiles: + if profile.name == "HSM Database Provider": + return profile + return None + + + @classmethod + def tearDownClass(cls): + super(TestKMSLifecycle, cls).tearDownClass() + + # ------------------------------------------------------------------ + # Per-test helpers + # ------------------------------------------------------------------ + def setUp(self): + self.apiclient = self.test_client.getApiClient() + self.cleanup = [] + self._create_domain_and_account() + + def tearDown(self): + self.cleanup.reverse() + cleanup_resources(self.apiclient, self.cleanup) + + def _create_domain_and_account(self, is_domain_admin=False): + """Create a child domain + account and register them for cleanup.""" + self.child_domain = Domain.create( + self.apiclient, + {"name": _random_name("kms-dom")}, + parentdomainid=self.domain.id, + ) + self.cleanup.append(self.child_domain) + + self.user_account = Account.create( + self.apiclient, + { + "firstname": "KMS", + "lastname": "Test", + "email": "kmstest@example.com", + "username": _random_name("kmsuser"), + "password": "password", + }, + domainid=self.child_domain.id, + admin=is_domain_admin, + ) + self.cleanup.append(self.user_account) + + # API client scoped to the new user + self.user_apiclient = self.test_client.getUserApiClient( + UserName=self.user_account.name, + DomainName=self.child_domain.name, + ) + + def _create_kms_key(self, name, profile_id, apiclient=None, zoneid=None, purpose=None): + api_client = apiclient or self.apiclient + zone_id = zoneid or self.zone.id + key = KMSKey.create( + api_client, + name=name, + zoneid=zone_id, + hsmprofileid=profile_id, + purpose=purpose + ) + self.cleanup.append(key) + return key + + def _create_hsm_profile(self, name, protocol="database", zoneid=None): + zone_id = zoneid or self.zone.id + profile = HSMProfile.create( + self.apiclient, + name=name, + protocol=protocol, + zoneid=zone_id, + ) + self.cleanup.append(profile) + return profile + + + # ================================================================== + # HSM Profile lifecycle tests (01 – 03, 11, 13) + # ================================================================== + + @attr(tags=["devcloud", "advanced", "advancedns", "smoke", "basic", "sg"], + required_hardware="false") + def test_01_add_hsm_profile_admin(self): + """Test: admin creates a system-wide database HSM profile.""" + profile_name = _random_name("hsm-prof") + profile = self._create_hsm_profile(name=profile_name) + self.assertIsNotNone(profile, "HSM profile creation returned None") + + self.assertEqual( + profile.name, profile_name, + "HSM profile name does not match the requested name" + ) + self.assertEqual( + profile.protocol.lower(), "database", + "HSM profile protocol should be 'database'" + ) + + @attr(tags=["devcloud", "advanced", "advancedns", "smoke", "basic", "sg"], + required_hardware="false") + def test_02_list_hsm_profiles(self): + """Test: list HSM profiles and verify a created profile is present.""" + profile_name = _random_name("hsm-list") + profile = self._create_hsm_profile(name=profile_name) + + profiles = HSMProfile.list(self.apiclient, id=profile.id) + self.assertIsNotNone(profiles, "listHSMProfiles returned None") + self.assertEqual(len(profiles), 1, "Expected exactly one HSM profile matching the ID") + self.assertEqual(profiles[0].id, profile.id, "Profile IDs do not match") + + @attr(tags=["devcloud", "advanced", "advancedns", "smoke", "basic", "sg"], + required_hardware="false") + def test_03_update_hsm_profile(self): + """Test: update the name of an existing HSM profile.""" + profile = self._create_hsm_profile(name=_random_name("hsm-upd")) + + new_name = _random_name("hsm-renamed") + updated = profile.update(self.apiclient, name=new_name) + + self.assertIsNotNone(updated, "updateHSMProfile returned None") + self.assertEqual( + updated.name, new_name, + "Profile name was not updated" + ) + + # ================================================================== + # KMS Key CRUD tests (tests 04 – 09) + # ================================================================== + + @attr(tags=["devcloud", "advanced", "advancedns", "smoke", "basic", "sg"], + required_hardware="false") + def test_04_create_kms_key_admin(self): + """Test: admin creates a KMS key in the zone, verifies fields.""" + if not self.default_profile: + self.skipTest("Default HSM profile 'default' not found") + profile = self.default_profile + + key_name = _random_name("kms-key") + key = self._create_kms_key(name=key_name, profile_id=profile.id, purpose="volume") + self.assertIsNotNone(key, "createKMSKey returned None") + + self.assertEqual(key.name, key_name, "Key name does not match") + self.assertEqual( + key.zoneid, self.zone.id, + "Key zone ID does not match the requested zone" + ) + self.assertTrue(key.enabled, "Newly created key should be enabled") + self.assertIsNotNone(key.id, "Key UUID should not be None") + + @attr(tags=["devcloud", "advanced", "advancedns", "smoke", "basic", "sg"], + required_hardware="false") + def test_05_list_kms_keys(self): + """Test: list KMS keys filtered by zone and by id.""" + if not self.default_profile: + self.skipTest("Default HSM profile 'default' not found") + profile = self.default_profile + + key = self._create_kms_key(name=_random_name("key"), profile_id=profile.id) + + # Filter by explicit key ID + keys_by_id = KMSKey.list(self.apiclient, id=key.id) + self.assertIsNotNone(keys_by_id, "listKMSKeys by id returned None") + self.assertEqual(len(keys_by_id), 1, "Expected exactly one key matching given ID") + self.assertEqual(keys_by_id[0].id, key.id, "Key IDs do not match") + + # Filter by zone + keys_by_zone = KMSKey.list(self.apiclient, zoneid=self.zone.id) + self.assertIsNotNone(keys_by_zone, "listKMSKeys by zone returned None") + found = any(k.id == key.id for k in keys_by_zone) + self.assertTrue(found, "Newly created key not found when listing by zone") + + @attr(tags=["devcloud", "advanced", "advancedns", "smoke", "basic", "sg"], + required_hardware="false") + def test_06_update_kms_key(self): + """Test: update key name, description, and enabled status.""" + if not self.default_profile: + self.skipTest("Default HSM profile 'default' not found") + profile = self.default_profile + + key = self._create_kms_key(name=_random_name("key-upd"), profile_id=profile.id) + + new_name = _random_name("key-renamed") + new_desc = "Updated description" + updated = key.update( + self.apiclient, + name=new_name, + description=new_desc, + enabled=False, + ) + + self.assertIsNotNone(updated, "updateKMSKey returned None") + self.assertEqual(updated.name, new_name, "Key name was not updated") + self.assertEqual(updated.description, new_desc, "Key description was not updated") + self.assertFalse(updated.enabled, "Key should be disabled after update") + + @attr(tags=["devcloud", "advanced", "advancedns", "smoke", "basic", "sg"], + required_hardware="false") + def test_07_create_kms_key_user(self): + """Test: domain user creates their own KMS key; verifies ownership.""" + # Admin creates the system HSM profile first + if not self.default_profile: + self.skipTest("Default HSM profile 'default' not found") + profile = self.default_profile + + key_name = _random_name("user-key") + key = self._create_kms_key(name=key_name, profile_id=profile.id, apiclient=self.user_apiclient) + self.assertIsNotNone(key, "User-level createKMSKey returned None") + + self.assertEqual(key.name, key_name, "Key name does not match") + self.assertEqual( + key.account, self.user_account.name, + "Key account should belong to the creating user account" + ) + + @attr(tags=["devcloud", "advanced", "advancedns", "smoke", "basic", "sg"], + required_hardware="false") + def test_08_list_kms_keys_user_isolation(self): + """Test: User A's keys are NOT visible to User B.""" + if not self.default_profile: + self.skipTest("Default HSM profile 'default' not found") + profile = self.default_profile + + # User A key (self.user_account) + key_a = self._create_kms_key(name=_random_name("key-a"), profile_id=profile.id, apiclient=self.user_apiclient) + + # Create User B in a separate child domain + domain_b = Domain.create( + self.apiclient, + {"name": _random_name("dom-b")}, + parentdomainid=self.domain.id, + ) + self.cleanup.append(domain_b) + account_b = Account.create( + self.apiclient, + { + "firstname": "UserB", + "lastname": "Test", + "email": "userb@example.com", + "username": _random_name("userb"), + "password": "password", + }, + domainid=domain_b.id, + admin=False, + ) + self.cleanup.append(account_b) + apiclient_b = self.test_client.getUserApiClient( + UserName=account_b.name, + DomainName=domain_b.name, + ) + + # User B should not be able to see User A's key + keys_for_b = KMSKey.list(apiclient_b, id=key_a.id) + if keys_for_b: + self.assertNotEqual( + keys_for_b[0].accountid, self.user_account.id, + "User B should not see User A's KMS keys" + ) + + @attr(tags=["devcloud", "advanced", "advancedns", "smoke", "basic", "sg"], + required_hardware="false") + def test_09_delete_kms_key(self): + """Test: delete a KMS key that is not in use; verify it is gone.""" + if not self.default_profile: + self.skipTest("Default HSM profile 'default' not found") + profile = self.default_profile + + key = self._create_kms_key(name=_random_name("key-del"), profile_id=profile.id) + + key.delete(self.apiclient) + self.cleanup.remove(key) + + # Verify the key no longer appears in listings + keys = KMSKey.list(self.apiclient, id=key.id) + self.assertTrue( + not keys, + "Deleted KMS key should not appear in listKMSKeys" + ) + + # ================================================================== + # Key rotation (test 10) + # ================================================================== + + @attr(tags=["devcloud", "advanced", "advancedns", "smoke", "basic", "sg"], + required_hardware="false") + def test_10_rotate_kms_key(self): + """Test: rotate a KMS key; verify the key version increments.""" + if not self.default_profile: + self.skipTest("Default HSM profile 'default' not found") + profile = self.default_profile + + key = self._create_kms_key(name=_random_name("key-rot"), profile_id=profile.id) + + initial_version = key.version + + key.rotate(self.apiclient) + + # Fetch the updated key details and confirm version incremented + keys = KMSKey.list(self.apiclient, id=key.id) + self.assertIsNotNone(keys, "listKMSKeys after rotation returned None") + self.assertEqual(len(keys), 1, "Expected exactly one key after rotation") + rotated_key = keys[0] + + self.assertGreater( + rotated_key.version, + initial_version, + f"Key version should increase after rotation (was {initial_version}, " + f"got {rotated_key.version})" + ) + + # ================================================================== + # Negative tests (11) + # ================================================================== + + + @attr(tags=["devcloud", "advanced", "advancedns", "smoke", "basic", "sg"], + required_hardware="false") + def test_11_delete_hsm_profile_with_keys_negative(self): + """ + Negative test: deleting an HSM profile that still has associated KMS + keys should be rejected. + """ + profile = self._create_hsm_profile(name=_random_name("hsm-with-key")) + + key = self._create_kms_key(name=_random_name("key-blocks-del"), profile_id=profile.id) + + with self.assertRaises(Exception, + msg="Deleting HSM profile with active keys should fail"): + profile.delete(self.apiclient) + + # ================================================================== + # VM Encryption tests (12) + # ================================================================== + + @attr(tags=["devcloud", "advanced", "advancedns", "smoke", "basic", "sg"], + required_hardware="false") + def test_12_deploy_vm_with_root_disk_encryption(self): + """ + Test: deploy a VM with its root disk encrypted using a KMS key. + Verify that the VM starts and the root volume has the KMS key ID. + """ + # 1. Create a KMS key for the user + key = self._create_kms_key(name=_random_name("vm-root-key"), profile_id=self.default_profile.id, apiclient=self.user_apiclient) + + # 2. Get a template and a service offering + template = get_template( + self.apiclient, + self.zone.id, + self.test_client.getParsedTestDataConfig().get("ostype", "CentOS 7.0 (64-bit)") + ) + if template == -1 or template is None: + self.fail("Check for template failed") + + service_offering = ServiceOffering.create( + self.apiclient, + self.test_client.getParsedTestDataConfig()["service_offering"] + ) + self.cleanup.append(service_offering) + + # 3. Deploy VM with root disk encryption + vm = VirtualMachine.create( + self.user_apiclient, + self.test_client.getParsedTestDataConfig()["virtual_machine"], + templateid=template.id, + accountid=self.user_account.name, + domainid=self.child_domain.id, + serviceofferingid=service_offering.id, + zoneid=self.zone.id, + rootdiskkmskeyid=key.id + ) + self.cleanup.append(vm) + + self.assertEqual( + vm.state, + "Running", + "VM should be in Running state after deployment" + ) + + # 4. Verify the root volume has the KMS key ID + volumes = Volume.list( + self.user_apiclient, + virtualmachineid=vm.id, + type='ROOT', + listall=True + ) + self.assertTrue( + volumes and len(volumes) > 0, + "VM should have at least one ROOT volume" + ) + root_volume = volumes[0] + self.assertEqual( + str(root_volume.kmskeyid), + str(key.id), + f"Root volume should have KMS key ID {key.id}, found {root_volume.kmskeyid}" + ) + + # ================================================================== + # HSM Profile cleanup (13) + # ================================================================== + + @attr(tags=["devcloud", "advanced", "advancedns", "smoke", "basic", "sg"], + required_hardware="false") + def test_13_delete_hsm_profile(self): + """Test: delete an HSM profile that has no associated keys; verify it is gone.""" + profile = self._create_hsm_profile(name=_random_name("hsm-gone")) + + profile.delete(self.apiclient) + self.cleanup.remove(profile) + + profiles = HSMProfile.list(self.apiclient, id=profile.id) + self.assertTrue( + not profiles, + "Deleted HSM profile should not appear in listHSMProfiles" + ) diff --git a/test/integration/smoke/test_network.py b/test/integration/smoke/test_network.py index b3e7fd3e42f..3a0db0feafe 100644 --- a/test/integration/smoke/test_network.py +++ b/test/integration/smoke/test_network.py @@ -470,10 +470,8 @@ class TestPortForwarding(cloudstackTestCase): except Exception as e: self.fail("NAT Rule Deletion Failed: %s" % e) - # NAT rule listing should fail as the nat rule does not exist - with self.assertRaises(Exception): - list_nat_rules(self.apiclient, - id=nat_rule.id) + nat_rules_after_deletion = list_nat_rules(self.apiclient, id=nat_rule.id) + self.assertEqual(nat_rules_after_deletion, None, "Check that the port forwarding rule has been deleted.") # Check if the Public SSH port is inaccessible with self.assertRaises(Exception): @@ -585,13 +583,8 @@ class TestPortForwarding(cloudstackTestCase): nat_rule.delete(self.apiclient) - try: - list_nat_rule_response = list_nat_rules( - self.apiclient, - id=nat_rule.id - ) - except CloudstackAPIException: - logger.debug("Nat Rule is deleted") + nat_rules_after_deletion = list_nat_rules(self.apiclient, id=nat_rule.id) + self.assertEqual(nat_rules_after_deletion, None, "Check that the port forwarding rule has been deleted.") # Check if the Public SSH port is inaccessible with self.assertRaises(Exception): diff --git a/test/integration/smoke/test_vm_autoscaling.py b/test/integration/smoke/test_vm_autoscaling.py index 782d2bce3ad..dd507ece039 100644 --- a/test/integration/smoke/test_vm_autoscaling.py +++ b/test/integration/smoke/test_vm_autoscaling.py @@ -38,6 +38,7 @@ from marvin.lib.base import (Account, DiskOffering, Domain, Project, + ResourceSchedule, ServiceOffering, Template, VirtualMachine, @@ -65,6 +66,7 @@ DEFAULT_DURATION = 120 DEFAULT_INTERVAL = 30 DEFAULT_QUIETTIME = 60 NAME_PREFIX = "AS-VmGroup-" +SCHEDULED_RESOURCE_TYPE_ASG = "AutoScaleVmGroup" CONFIG_NAME_DISK_CONTROLLER = "vmware.root.disk.controller" OS_DEFAULT = "osdefault" @@ -860,13 +862,81 @@ class TestVmAutoScaling(cloudstackTestCase): VirtualMachine.delete(vm, self.apiclient, expunge=True) - @attr(tags=["advanced"], required_hardware="false") - def test_05_remove_vmgroup(self): - """ Verify removal of AutoScaling VM Group""" - self.message("Running test_05_remove_vmgroup") + @attr(tags=["advanced", "vj", "smoke"], required_hardware="false") + def test_05_autoscaling_vmgroup_schedule_update(self): + """Verify resource scheduling updates AutoScale VM Group min/max members""" + self.message("Running test_05_autoscaling_vmgroup_schedule_update") - self.delete_vmgroup(self.autoscaling_vmgroup, self.regular_user_apiclient, cleanup=False, expected=False) - self.delete_vmgroup(self.autoscaling_vmgroup, self.regular_user_apiclient, cleanup=True, expected=True) + vmgroups = AutoScaleVmGroup.list( + self.regular_user_apiclient, + id=self.autoscaling_vmgroup.id + ) + self.assertEqual( + isinstance(vmgroups, list), + True, + "List autoscale vm groups should return a valid list" + ) + self.assertEqual( + len(vmgroups) > 0, + True, + "Expected autoscale vm group to exist" + ) + vmgroup = vmgroups[0] + + current_min_members = int(vmgroup.minmembers) + current_max_members = int(vmgroup.maxmembers) + new_min_members = current_min_members + 1 + new_max_members = current_max_members + 1 + + schedule = ResourceSchedule.create( + self.regular_user_apiclient, + SCHEDULED_RESOURCE_TYPE_ASG, + self.autoscaling_vmgroup.id, + "update", + "* * * * *", + datetime.datetime.now().astimezone().tzinfo, + (datetime.datetime.now() + datetime.timedelta(seconds=5)).strftime( + "%Y-%m-%d %H:%M:%S" + ), + enabled=True, + details=[{ + "minmembers": str(new_min_members)}, + { + "maxmembers": str(new_max_members) + }] + ) + self.cleanup.append(schedule) + self.message("Created AutoScale VM Group schedule with ID: %s" % schedule.id) + + schedules = ResourceSchedule.list( + self.regular_user_apiclient, + SCHEDULED_RESOURCE_TYPE_ASG, + self.autoscaling_vmgroup.id, + id=schedule.id + ) + self.assertEqual( + isinstance(schedules, list), + True, + "Check list response returns a valid list for ASG schedules", + ) + self.assertNotEqual(len(schedules), 0, "Check ASG schedule list") + + # Poll for up to 4 minutes to allow the scheduler worker to run and apply update + schedule_worked = False + for _ in range(0, 24): + time.sleep(10) + updated_group = AutoScaleVmGroup.list( + self.regular_user_apiclient, + id=self.autoscaling_vmgroup.id + )[0] + if int(updated_group.minmembers) == new_min_members and int(updated_group.maxmembers) == new_max_members: + schedule_worked = True + break + + self.assertTrue( + schedule_worked, + "Expected scheduled action to update AutoScale VM Group min/max members" + ) @attr(tags=["advanced"], required_hardware="false") def test_06_autoscaling_vmgroup_on_project_network(self): @@ -1211,3 +1281,11 @@ class TestVmAutoScaling(cloudstackTestCase): self.delete_vmgroup(autoscaling_vmgroup_vpc, self.regular_user_apiclient, cleanup=False, expected=False) self.delete_vmgroup(autoscaling_vmgroup_vpc, self.regular_user_apiclient, cleanup=True, expected=True) + + @attr(tags=["advanced", "vj"], required_hardware="false") + def test_05_remove_vmgroup(self): + """ Verify removal of AutoScaling VM Group""" + self.message("Running test_05_remove_vmgroup") + + self.delete_vmgroup(self.autoscaling_vmgroup, self.regular_user_apiclient, cleanup=False, expected=False) + self.delete_vmgroup(self.autoscaling_vmgroup, self.regular_user_apiclient, cleanup=True, expected=True) diff --git a/test/integration/smoke/test_vm_schedule.py b/test/integration/smoke/test_vm_schedule.py index ee1354ff80e..1e476c55fc8 100644 --- a/test/integration/smoke/test_vm_schedule.py +++ b/test/integration/smoke/test_vm_schedule.py @@ -17,7 +17,7 @@ """ P1 tests for VM Schedule """ from marvin.cloudstackTestCase import cloudstackTestCase -from marvin.lib.base import Account, ServiceOffering, VirtualMachine, VMSchedule +from marvin.lib.base import Account, ServiceOffering, VirtualMachine, ResourceSchedule from marvin.lib.common import get_domain, get_zone, get_template from marvin.lib.utils import cleanup_resources @@ -28,6 +28,8 @@ import datetime import time +RESOURCE_TYPE = "VirtualMachine" + class Services: """Test Snapshots Services""" @@ -148,8 +150,9 @@ class TestVMSchedule(cloudstackTestCase): # Create VM Schedule schedule = "0 0 1 * *" - vmschedule = VMSchedule.create( + vmschedule = ResourceSchedule.create( self.apiclient, + RESOURCE_TYPE, self.virtual_machine.id, "start", schedule, @@ -166,8 +169,8 @@ class TestVMSchedule(cloudstackTestCase): self.debug("Created VM Schedule with ID: %s" % vmschedule.id) # List VM Schedule - vmschedules = VMSchedule.list( - self.apiclient, self.virtual_machine.id, id=vmschedule.id + vmschedules = ResourceSchedule.list( + self.apiclient, RESOURCE_TYPE, self.virtual_machine.id, id=vmschedule.id ) self.assertEqual( @@ -187,9 +190,15 @@ class TestVMSchedule(cloudstackTestCase): ) self.assertEqual( - vmschedules[0].virtualmachineid, + vmschedules[0].resourceid, self.virtual_machine.id, - "Check VM ID in list resources call", + "Check resource ID in list resources call", + ) + + self.assertEqual( + vmschedules[0].resourcetype, + RESOURCE_TYPE, + "Check resource type in list resources call", ) self.assertEqual( @@ -204,9 +213,9 @@ class TestVMSchedule(cloudstackTestCase): "Check VM Schedule timezone in list resources call", ) - # Check for entry in vm_scheduled_job in db + # Check for entry in resource_scheduled_job in db vmscheduled_job = self.dbclient.execute( - "select * from vm_scheduled_job where vm_schedule_id IN (SELECT id FROM vm_schedule WHERE uuid = '%s')" + "select * from resource_scheduled_job where schedule_id IN (SELECT id FROM resource_schedule WHERE uuid = '%s')" % vmschedule.id, db="cloud", ) @@ -214,13 +223,13 @@ class TestVMSchedule(cloudstackTestCase): self.assertIsInstance( vmscheduled_job, list, - "Check if VM Schedule exists in vm_scheduled_job table", + "Check if VM Schedule exists in resource_scheduled_job table", ) self.assertGreater( len(vmscheduled_job), 0, - "Check if VM Schedule exists in vm_scheduled_job table", + "Check if VM Schedule exists in resource_scheduled_job table", ) return @@ -237,8 +246,9 @@ class TestVMSchedule(cloudstackTestCase): # Create VM Schedule with invalid virtual machine ID with self.assertRaises(Exception): - VMSchedule.create( + ResourceSchedule.create( self.apiclient, + RESOURCE_TYPE, "invalid", "start", "0 0 1 * *", @@ -251,8 +261,9 @@ class TestVMSchedule(cloudstackTestCase): # Create VM Schedule with invalid schedule with self.assertRaises(Exception): - VMSchedule.create( + ResourceSchedule.create( self.apiclient, + RESOURCE_TYPE, self.virtual_machine.id, "start", "invalid", @@ -265,8 +276,9 @@ class TestVMSchedule(cloudstackTestCase): # Create VM Schedule with invalid start date with self.assertRaises(Exception): - VMSchedule.create( + ResourceSchedule.create( self.apiclient, + RESOURCE_TYPE, self.virtual_machine.id, "start", "0 0 1 * *", @@ -277,8 +289,9 @@ class TestVMSchedule(cloudstackTestCase): # Create VM Schedule with invalid action with self.assertRaises(Exception): - VMSchedule.create( + ResourceSchedule.create( self.apiclient, + RESOURCE_TYPE, self.virtual_machine.id, "invalid", "0 0 1 * *", @@ -291,8 +304,9 @@ class TestVMSchedule(cloudstackTestCase): # test invalid end date with self.assertRaises(Exception): - VMSchedule.create( + ResourceSchedule.create( self.apiclient, + RESOURCE_TYPE, self.virtual_machine.id, "start", "0 0 1 * *", @@ -315,8 +329,9 @@ class TestVMSchedule(cloudstackTestCase): # Create VM Schedule schedule = "0 0 1 * *" - vmschedule = VMSchedule.create( + vmschedule = ResourceSchedule.create( self.apiclient, + RESOURCE_TYPE, self.virtual_machine.id, "start", schedule, @@ -351,8 +366,8 @@ class TestVMSchedule(cloudstackTestCase): self.debug("Updated VM Schedule with ID: %s" % vmschedule.id) # List VM Schedule - vmschedules = VMSchedule.list( - self.apiclient, self.virtual_machine.id, id=vmschedule.id + vmschedules = ResourceSchedule.list( + self.apiclient, RESOURCE_TYPE, self.virtual_machine.id, id=vmschedule.id ) self.assertEqual( isinstance(vmschedules, list), @@ -371,11 +386,17 @@ class TestVMSchedule(cloudstackTestCase): ) self.assertEqual( - vmschedules[0].virtualmachineid, + vmschedules[0].resourceid, self.virtual_machine.id, "Check VM ID in list resources call", ) + self.assertEqual( + vmschedules[0].resourcetype, + RESOURCE_TYPE, + "Check VM ID in list resources call", + ) + self.assertEqual( vmschedules[0].schedule, new_schedule, @@ -395,8 +416,9 @@ class TestVMSchedule(cloudstackTestCase): # Create VM Schedule schedule = "0 0 1 * *" - vmschedule = VMSchedule.create( + vmschedule = ResourceSchedule.create( self.apiclient, + RESOURCE_TYPE, self.virtual_machine.id, "start", schedule, @@ -486,8 +508,9 @@ class TestVMSchedule(cloudstackTestCase): # Create VM Schedule - start start_schedule = "*/2 * * * *" - start_vmschedule = VMSchedule.create( + start_vmschedule = ResourceSchedule.create( self.apiclient, + RESOURCE_TYPE, self.virtual_machine.id, "start", start_schedule, @@ -503,8 +526,9 @@ class TestVMSchedule(cloudstackTestCase): # Create VM Schedule - stop stop_schedule = "*/1 * * * *" - stop_vmschedule = VMSchedule.create( + stop_vmschedule = ResourceSchedule.create( self.apiclient, + RESOURCE_TYPE, self.virtual_machine.id, "stop", stop_schedule, @@ -519,8 +543,8 @@ class TestVMSchedule(cloudstackTestCase): self.debug("Created VM Schedule with ID: %s" % stop_vmschedule.id) # Verify VM Schedule is created - vmschedules = VMSchedule.list( - self.apiclient, self.virtual_machine.id, id=start_vmschedule.id + vmschedules = ResourceSchedule.list( + self.apiclient, RESOURCE_TYPE, self.virtual_machine.id, id=start_vmschedule.id ) self.assertEqual( @@ -575,15 +599,15 @@ class TestVMSchedule(cloudstackTestCase): # Verify VM Schedule is deleted self.assertEqual( - VMSchedule.list( - self.apiclient, self.virtual_machine.id, id=start_vmschedule.id + ResourceSchedule.list( + self.apiclient, RESOURCE_TYPE, self.virtual_machine.id, id=start_vmschedule.id ), None, "Check VM Schedule is deleted", ) self.assertEqual( - VMSchedule.list( - self.apiclient, self.virtual_machine.id, id=stop_vmschedule.id + ResourceSchedule.list( + self.apiclient, RESOURCE_TYPE, self.virtual_machine.id, id=stop_vmschedule.id ), None, "Check VM Schedule is deleted", diff --git a/tools/apidoc/gen_toc.py b/tools/apidoc/gen_toc.py index 7f426c4fba2..d703769eb1c 100644 --- a/tools/apidoc/gen_toc.py +++ b/tools/apidoc/gen_toc.py @@ -56,6 +56,8 @@ known_categories = { 'HypervisorGuestOsNames': 'Guest OS', 'Domain': 'Domain', 'Template': 'Template', + 'KMS': 'Key Management System', + 'HSM': 'Key Management System', 'Iso': 'ISO', 'Volume': 'Volume', 'Vlan': 'VLAN', @@ -242,6 +244,7 @@ known_categories = { 'removeQuarantinedIp': 'IP Quarantine', 'Shutdown': 'Maintenance', 'Maintenance': 'Maintenance', + 'ResourceSchedule': 'Resource Schedule', 'addObjectStoragePool': 'Object Store', 'listObjectStoragePools': 'Object Store', 'deleteObjectStoragePool': 'Object Store', diff --git a/tools/docker/supervisord.conf b/tools/docker/supervisord.conf index 93f01387575..1c14578c4cf 100644 --- a/tools/docker/supervisord.conf +++ b/tools/docker/supervisord.conf @@ -12,6 +12,7 @@ command=/bin/bash -c "mvn -pl client jetty:run -Dsimulator -Dorg.eclipse.jetty.a directory=/root stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 +redirect_stderr=true user=root [program:cloudstack-ui] diff --git a/tools/marvin/marvin/lib/base.py b/tools/marvin/marvin/lib/base.py index 636c73209a3..82155fe27e7 100755 --- a/tools/marvin/marvin/lib/base.py +++ b/tools/marvin/marvin/lib/base.py @@ -567,7 +567,8 @@ class VirtualMachine: rootdiskcontroller=None, vpcid=None, macaddress=None, datadisktemplate_diskoffering_list={}, properties=None, nicnetworklist=None, bootmode=None, boottype=None, dynamicscalingenabled=None, userdataid=None, userdatadetails=None, extraconfig=None, size=None, overridediskofferingid=None, - leaseduration=None, leaseexpiryaction=None, volumeid=None, snapshotid=None): + leaseduration=None, leaseexpiryaction=None, volumeid=None, snapshotid=None, + rootdiskkmskeyid=None): """Create the instance""" @@ -744,6 +745,9 @@ class VirtualMachine: if snapshotid: cmd.snapshotid = snapshotid + if rootdiskkmskeyid: + cmd.rootdiskkmskeyid = rootdiskkmskeyid + virtual_machine = apiclient.deployVirtualMachine(cmd, method=method) if 'password' in list(virtual_machine.__dict__.keys()): @@ -7052,6 +7056,51 @@ class VMSchedule: cmd.virtualmachineid = self.virtualmachineid return (apiclient.deleteVMSchedule(cmd)) + +class ResourceSchedule: + + def __init__(self, items): + self.__dict__.update(items) + + @classmethod + def create(cls, apiclient, resourcetype, resourceid, action, schedule, timezone, startdate, enddate=None, enabled=False, description=None, details=None): + cmd = createResourceSchedule.createResourceScheduleCmd() + cmd.resourcetype = resourcetype + cmd.resourceid = resourceid + cmd.description = description + cmd.action = action + cmd.schedule = schedule + cmd.timezone = timezone + cmd.startdate = startdate + cmd.enddate = enddate + cmd.enabled = enabled + cmd.details = details + return ResourceSchedule(apiclient.createResourceSchedule(cmd).__dict__) + + @classmethod + def list(cls, apiclient, resourcetype, resourceid, id=None, enabled=None, action=None): + cmd = listResourceSchedule.listResourceScheduleCmd() + cmd.resourcetype = resourcetype + cmd.resourceid = resourceid + cmd.id = id + cmd.enabled = enabled + cmd.action = action + return apiclient.listResourceSchedule(cmd) + + def update(self, apiclient, **kwargs): + cmd = updateResourceSchedule.updateResourceScheduleCmd() + cmd.id = self.id + [setattr(cmd, k, v) for k, v in list(kwargs.items())] + return apiclient.updateResourceSchedule(cmd) + + def delete(self, apiclient): + cmd = deleteResourceSchedule.deleteResourceScheduleCmd() + cmd.id = self.id + cmd.resourcetype = self.resourcetype + cmd.resourceid = self.resourceid + return apiclient.deleteResourceSchedule(cmd) + + class VnfTemplate: """Manage VNF template life cycle""" @@ -8109,3 +8158,86 @@ class SslCertificate: cmd = deleteSslCert.deleteSslCertCmd() cmd.id = self.id apiclient.deleteSslCert(cmd) + + +class HSMProfile: + """Manage HSM Profile life cycle""" + + def __init__(self, items): + self.__dict__.update(items) + + @classmethod + def create(cls, apiclient, name, **kwargs): + """Add HSM Profile""" + cmd = createHSMProfile.createHSMProfileCmd() + cmd.name = name + [setattr(cmd, k, v) for k, v in list(kwargs.items())] + return HSMProfile(apiclient.createHSMProfile(cmd).__dict__) + + @classmethod + def list(cls, apiclient, **kwargs): + """List HSM Profiles""" + cmd = listHSMProfiles.listHSMProfilesCmd() + [setattr(cmd, k, v) for k, v in list(kwargs.items())] + if 'account' in list(kwargs.keys()) and 'domainid' in list(kwargs.keys()): + cmd.listall = True + return apiclient.listHSMProfiles(cmd) + + def update(self, apiclient, **kwargs): + """Update HSM Profile""" + cmd = updateHSMProfile.updateHSMProfileCmd() + cmd.id = self.id + [setattr(cmd, k, v) for k, v in list(kwargs.items())] + return apiclient.updateHSMProfile(cmd) + + def delete(self, apiclient): + """Delete HSM Profile""" + cmd = deleteHSMProfile.deleteHSMProfileCmd() + cmd.id = self.id + apiclient.deleteHSMProfile(cmd) + + +class KMSKey: + """Manage KMS Key life cycle""" + + def __init__(self, items): + self.__dict__.update(items) + + @classmethod + def create(cls, apiclient, name, zoneid, hsmprofileid, **kwargs): + """Create KMS Key""" + cmd = createKMSKey.createKMSKeyCmd() + cmd.name = name + cmd.zoneid = zoneid + cmd.hsmprofileid = hsmprofileid + [setattr(cmd, k, v) for k, v in list(kwargs.items())] + return KMSKey(apiclient.createKMSKey(cmd).__dict__) + + @classmethod + def list(cls, apiclient, **kwargs): + """List KMS Keys""" + cmd = listKMSKeys.listKMSKeysCmd() + [setattr(cmd, k, v) for k, v in list(kwargs.items())] + if 'account' in list(kwargs.keys()) and 'domainid' in list(kwargs.keys()): + cmd.listall = True + return apiclient.listKMSKeys(cmd) + + def update(self, apiclient, **kwargs): + """Update KMS Key""" + cmd = updateKMSKey.updateKMSKeyCmd() + cmd.id = self.id + [setattr(cmd, k, v) for k, v in list(kwargs.items())] + return apiclient.updateKMSKey(cmd) + + def delete(self, apiclient): + """Delete KMS Key""" + cmd = deleteKMSKey.deleteKMSKeyCmd() + cmd.id = self.id + apiclient.deleteKMSKey(cmd) + + def rotate(self, apiclient, **kwargs): + """Rotate KMS Key (creates a new KEK version)""" + cmd = rotateKMSKey.rotateKMSKeyCmd() + cmd.id = self.id + [setattr(cmd, k, v) for k, v in list(kwargs.items())] + return apiclient.rotateKMSKey(cmd) diff --git a/ui/public/assets/keycloak.svg b/ui/public/assets/keycloak.svg new file mode 100644 index 00000000000..3e8115efc16 --- /dev/null +++ b/ui/public/assets/keycloak.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 483383c1f99..62df5e2c63f 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -447,6 +447,7 @@ "label.attaching": "Attaching", "label.authentication.method": "Authentication Method", "label.authentication.sshkey": "System SSH Key", +"label.authorizeurl": "Authorize URL", "label.use.existing.vcenter.credentials.from.zone": "Use existing vCenter credentials from the Zone", "label.autoscale": "AutoScale", "label.autoscalevmgroupname": "AutoScaling Group", @@ -1480,13 +1481,32 @@ "label.javadistribution": "Java Runtime Distribution", "label.javaversion": "Java Runtime Version", "label.keep": "Keep", +"label.keklabel": "KEK Label", "label.keep.mac.address.on.public.nic": "Use same MAC address for public NIC of VRs", "label.kernelversion": "Kernel Version", "label.key": "Key", +"label.keybits": "Key Bits", "label.keyboard": "Keyboard language", "label.keyboardtype": "Keyboard type", "label.keypair": "SSH key pair", "label.keypairs": "SSH key pair(s)", +"label.kms": "Key Management", +"label.kms.key": "KMS Key", +"label.kmskey": "KMS Key", +"label.kmskeyid": "KMS Key", +"label.kms.keys": "KMS Keys", +"label.create.kms.key": "Create KMS Key", +"label.update.kms.key": "Update KMS Key", +"label.rotate.kms.key": "Rotate KMS Key", +"label.delete.kms.key": "Delete KMS Key", +"label.migrate.volumes.to.kms": "Migrate Volumes to KMS", +"label.hsm.profile": "HSM Profile", +"label.hsmprofile": "HSM Profile", +"label.hsmprofileid": "HSM Profile", +"label.create.hsmprofile": "Add HSM Profile", +"label.update.hsm.profile": "Update HSM Profile", +"label.delete.hsm.profile": "Delete HSM Profile", +"label.select.kms.key.optional": "Select KMS Key (optional)", "label.kubeconfig.cluster": "Kubernetes Cluster config", "label.kubernetes": "Kubernetes", "label.kubernetes.access.details": "The kubernetes nodes can be accessed via ssh using:
ssh -i [ssh_key] -p [port_number] cloud@[public_ip_address]

where,
ssh_key: points to the ssh private key file corresponding to the key that was associated while creating the Kubernetes Cluster. If no ssh key was provided during Kubernetes cluster creation, use the ssh private key of the management server.
port_number: can be obtained from the Port Forwarding Tab (Public Port column)", @@ -1545,6 +1565,7 @@ "label.lbruleid": "Load balancer ID", "label.lbtype": "Load balancer type", "label.ldap": "LDAP", +"label.library": "Library", "label.ldapdomain": "LDAP Domain", "label.ldap.configuration": "LDAP Configuration", "label.ldap.group.name": "LDAP Group", @@ -1684,6 +1705,7 @@ "label.migrate.instance.specific.storages": "Migrate volume(s) of the Instance to specific primary storages", "label.migrate.systemvm.to": "Migrate System VM to", "label.migrate.volume": "Migrate Volume", +"label.migrate.volume.to.kms": "Migrate Volume Encryption to KMS", "message.memory.usage.info.hypervisor.additionals": "The data shown may not reflect the actual memory usage if the Instance does not have the additional hypervisor tools installed", "message.memory.usage.info.negative.value": "If the Instance's memory usage cannot be obtained from the hypervisor, the lines for free memory in the raw data graph and memory usage in the percentage graph will be disabled", "message.migrate.volume.tooltip": "Volume can be migrated to any suitable storage pool. Admin has to choose the appropriate disk offering to replace, that supports the new storage pool", @@ -1963,6 +1985,7 @@ "label.physicalnetworkid": "Physical Network", "label.physicalnetworkname": "Physical Network name", "label.physicalsize": "Physical size", +"label.pin": "PIN", "label.ping.path": "Ping path", "label.pkcs.private.certificate": "PKCS#8 private certificate", "label.plannermode": "Planner mode", @@ -2291,6 +2314,7 @@ "label.schedule": "Schedule", "label.scheduled": "Scheduled", "label.schedule.add": "Add schedule", +"label.update.members": "Update members", "label.scheduled.backups": "Scheduled backups", "label.schedules": "Schedules", "label.scope": "Scope", @@ -2638,6 +2662,7 @@ "label.to": "to", "label.token": "Token", "label.token.for.dashboard.login": "Token for dashboard login can be retrieved using following command", +"label.tokenurl": "Token URL", "label.tools": "Tools", "label.total": "Total", "label.total.network": "Total Networks", @@ -2835,7 +2860,8 @@ "label.vmlimit": "Instance limits", "label.vmname": "Instance name", "label.vms": "Instances", -"label.vmscheduleactions": "Actions", +"label.scheduleactions": "Actions", + "label.vmstate": "Instance state", "label.vmtotal": "Total of Instances", "label.vmware": "VMware", @@ -2895,7 +2921,7 @@ "label.volumefileupload": "Local file", "label.volumegroup": "Volume Group", "label.volumeid": "Volume", -"label.volumeids": "Volumes to be deleted", +"label.volumeids": "Volumes", "label.volumelimit": "Volume limits", "label.volumename": "Volume name", "label.volumes": "Volumes", @@ -3038,9 +3064,11 @@ "message.action.delete.guest.os": "Please confirm that you want to delete this guest os. System defined entry cannot be deleted.", "message.action.delete.guest.os.category": "Please confirm that you want to delete this guest os category.", "message.action.delete.guest.os.hypervisor.mapping": "Please confirm that you want to delete this guest os hypervisor mapping. System defined entry cannot be deleted.", +"message.action.delete.hsm.profile": "Please confirm that you want to delete this HSM profile.", "message.action.delete.instance.group": "Please confirm that you want to delete the Instance group.", "message.action.delete.interface.static.route": "Please confirm that you want to remove this interface Static Route?", "message.action.delete.iso": "Please confirm that you want to delete this ISO.", +"message.action.delete.kms.key": "Please confirm that you want to delete this KMS key.", "message.action.delete.network": "Please confirm that you want to delete this Network.", "message.action.delete.network.static.route": "Please confirm that you want to remove this Network Static Route", "message.action.delete.nexusvswitch": "Please confirm that you want to delete this nexus 1000v", @@ -3600,7 +3628,7 @@ "message.error.remove.nic": "There was an error", "message.error.remove.secondary.ipaddress": "There was an error removing the secondary IP Address", "message.error.remove.tungsten.routing.policy": "Removing Tungsten-Fabric Routing Policy from Network failed", -"message.error.remove.vm.schedule": "Removing Instance Schedule failed", +"message.error.remove.resource.schedule": "Removing Schedule for the resource failed", "message.error.required.input": "Please enter input", "message.error.reset.config": "Unable to reset config to default value", "message.error.retrieve.kubeconfig": "Unable to retrieve Kubernetes Cluster config", @@ -3780,6 +3808,8 @@ "message.migrate.volume.failed": "Migrating volume failed.", "message.migrate.volume.pool.auto.assign": "Primary storage for the volume will be automatically chosen based on the suitability and Instance destination", "message.migrate.volume.processing": "Migrating volume...", +"message.action.migrate.volume.to.kms": "Please confirm that you want to migrate this volume's passphrase encryption to KMS. This operation re-encrypts the volume key using the selected KMS key and cannot be undone.", +"message.action.migrate.volumes.to.kms": "Please confirm that you want to migrate volumes to KMS encryption. This operation re-encrypts volume keys using the selected KMS key and cannot be undone.", "message.migrate.with.storage": "Specify storage pool for volumes of the Instance.", "message.migrating.failed": "Migration failed.", "message.migrating.processing": "Migration in progress for", @@ -4008,7 +4038,7 @@ "message.success.config.backup.schedule": "Successfully configured Instance backup schedule", "message.success.config.health.monitor": "Successfully Configure Health Monitor", "message.success.config.sticky.policy": "Successfully configured sticky policy", -"message.success.config.vm.schedule": "Successfully configured Instance schedule", +"message.success.config.resource.schedule": "Successfully configured schedule for the resource", "message.success.copy.clipboard": "Successfully copied to clipboard", "message.success.create.account": "Successfully created Account", "message.success.create.asnrange": "Successfully created AS Range", @@ -4321,5 +4351,6 @@ "Compute*Month": "Compute * Month", "GB*Month": "GB * Month", "IP*Month": "IP * Month", -"Policy*Month": "Policy * Month" +"Policy*Month": "Policy * Month", +"message.kms.key.optional": "Optional: Select a KMS key for encryption. If not selected, legacy passphrase encryption will be used." } diff --git a/ui/public/locales/te.json b/ui/public/locales/te.json index f3b4c70ca2e..dbbda2d99c0 100644 --- a/ui/public/locales/te.json +++ b/ui/public/locales/te.json @@ -2466,7 +2466,7 @@ "label.vmlimit": "ఉదాహరణ పరిమితులు", "label.vmname": "ఉదాహరణ పేరు", "label.vms": "సందర్భాలు", - "label.vmscheduleactions": "చర్యలు", + "label.scheduleactions": "చర్యలు", "label.vmstate": "ఉదాహరణ స్థితి", "label.vmtotal": "మొత్తం సందర్భాలు", "label.vmware": "VMware", @@ -3107,7 +3107,7 @@ "message.error.remove.nic": "లోపం ఏర్పడింది", "message.error.remove.secondary.ipaddress": "ద్వితీయ IP చిరునామాను తీసివేయడంలో లోపం ఏర్పడింది", "message.error.remove.tungsten.routing.policy": "నెట్‌వర్క్ నుండి టంగ్‌స్టన్-ఫాబ్రిక్ రూటింగ్ విధానాన్ని తీసివేయడం విఫలమైంది", - "message.error.remove.vm.schedule": "ఉదాహరణ షెడ్యూల్‌ని తీసివేయడం విఫలమైంది", + "message.error.remove.resource.schedule": "వనరు కోసం షెడ్యూల్‌ను తొలగించడం విఫలమైంది", "message.error.required.input": "దయచేసి ఇన్‌పుట్‌ని నమోదు చేయండి", "message.error.reset.config": "కాన్ఫిగర్‌ని డిఫాల్ట్ విలువకు రీసెట్ చేయడం సాధ్యపడలేదు", "message.error.retrieve.kubeconfig": "Kubernetes క్లస్టర్ కాన్ఫిగరేషన్‌ని తిరిగి పొందడం సాధ్యం కాలేదు", @@ -3461,7 +3461,7 @@ "message.success.config.backup.schedule": "ఉదాహరణ బ్యాకప్ షెడ్యూల్ విజయవంతంగా కాన్ఫిగర్ చేయబడింది", "message.success.config.health.monitor": "హెల్త్ మానిటర్‌ని విజయవంతంగా కాన్ఫిగర్ చేయండి", "message.success.config.sticky.policy": "స్టిక్కీ పాలసీ విజయవంతంగా కాన్ఫిగర్ చేయబడింది", - "message.success.config.vm.schedule": "ఉదాహరణ షెడ్యూల్ విజయవంతంగా కాన్ఫిగర్ చేయబడింది", + "message.success.config.resource.schedule": "వనరు కోసం షెడ్యూల్ విజయవంతంగా కాన్ఫిగర్ చేయబడింది", "message.success.copy.clipboard": "క్లిప్‌బోర్డ్‌కి విజయవంతంగా కాపీ చేయబడింది", "message.success.create.account": "ఖాతా విజయవంతంగా సృష్టించబడింది", "message.success.create.asnrange": "AS పరిధి విజయవంతంగా సృష్టించబడింది", diff --git a/ui/src/components/view/DetailsTab.vue b/ui/src/components/view/DetailsTab.vue index 4ef94c8a5de..ff8258b8138 100644 --- a/ui/src/components/view/DetailsTab.vue +++ b/ui/src/components/view/DetailsTab.vue @@ -189,7 +189,7 @@

{{ dataResource[item].rbd_default_data_pool }}
- +
{{ $t('label.configuration.details') }}
diff --git a/ui/src/components/view/ImageDeployInstanceButton.vue b/ui/src/components/view/ImageDeployInstanceButton.vue index 2cdd5a0af46..a4d47d4464a 100644 --- a/ui/src/components/view/ImageDeployInstanceButton.vue +++ b/ui/src/components/view/ImageDeployInstanceButton.vue @@ -83,7 +83,8 @@ export default { computed: { allowed () { return (this.$route.meta.name === 'template' || - (this.$route.meta.name === 'iso' && this.resource.bootable)) + (this.$route.meta.name === 'iso' && this.resource?.bootable)) && + !!this.resource?.isready } }, methods: { @@ -91,7 +92,7 @@ export default { this.fetchResourceData() }, fetchResourceData () { - if (!this.resource || !this.resource.id) { + if (!this.resource || !this.resource.id || !this.resource.isready) { return } const params = { diff --git a/ui/src/components/view/InfoCard.vue b/ui/src/components/view/InfoCard.vue index 41c822de238..ba7ff0dc27b 100644 --- a/ui/src/components/view/InfoCard.vue +++ b/ui/src/components/view/InfoCard.vue @@ -455,6 +455,30 @@
+
+
{{ $t('label.kms.key') }}
+
+ + + {{ resource.kmskey }} + + {{ resource.kmskey }} +
+
+
+
{{ $t('label.hsm.profile') }}
+
+ + + {{ resource.hsmprofile }} + + {{ resource.hsmprofile }} +
+
{{ $t('label.network') }}
diff --git a/ui/src/components/view/ListView.vue b/ui/src/components/view/ListView.vue index e60d7331382..bfb0294c83a 100644 --- a/ui/src/components/view/ListView.vue +++ b/ui/src/components/view/ListView.vue @@ -461,6 +461,9 @@ + + @@ -803,7 +810,7 @@ {{ text && $toLocaleDate(text) }} @@ -1040,21 +1047,11 @@ /> - @@ -80,7 +84,7 @@ export default { return { vm: {}, volumes: [], - defaultColumns: ['name', 'state', 'type', 'size'], + defaultColumns: ['name', 'state', 'type', 'size', 'kmskey'], allColumns: [ { key: 'name', @@ -101,6 +105,11 @@ export default { title: this.$t('label.size'), dataIndex: 'size' }, + { + key: 'kmskey', + title: this.$t('label.kms.key'), + dataIndex: 'kmskey' + }, { key: 'storage', title: this.$t('label.storage'), diff --git a/ui/src/components/widgets/DetailsInput.vue b/ui/src/components/widgets/DetailsInput.vue index a8d39fce02b..0d9b1c00563 100644 --- a/ui/src/components/widgets/DetailsInput.vue +++ b/ui/src/components/widgets/DetailsInput.vue @@ -19,7 +19,16 @@
- + + @@ -82,6 +91,15 @@ export default { showTableHeaders: { type: Boolean, default: true + }, + optionalKeys: { + type: Array, + default: () => [] + } + }, + computed: { + optionalKeyOptions () { + return this.optionalKeys.map(k => ({ value: k })) } }, data () { @@ -94,7 +112,8 @@ export default { newKey: '', newValue: '', tableData: [], - editBuffer: {} + editBuffer: {}, + autoCompleteKey: 0 } }, created () { @@ -127,6 +146,7 @@ export default { this.updateData() this.newKey = '' this.newValue = '' + this.autoCompleteKey++ }, removeEntry (key) { this.tableData = this.tableData.filter(item => item.key !== key) diff --git a/ui/src/config/router.js b/ui/src/config/router.js index 78346d13cac..a48c3fef81e 100644 --- a/ui/src/config/router.js +++ b/ui/src/config/router.js @@ -28,6 +28,7 @@ import compute from '@/config/section/compute' import storage from '@/config/section/storage' import network from '@/config/section/network' import image from '@/config/section/image' +import kms from '@/config/section/kms' import project from '@/config/section/project' import event from '@/config/section/event' import user from '@/config/section/user' @@ -217,6 +218,7 @@ export function asyncRouterMap () { generateRouterMap(compute), generateRouterMap(storage), + generateRouterMap(kms), generateRouterMap(network), generateRouterMap(image), generateRouterMap(event), diff --git a/ui/src/config/section/compute.js b/ui/src/config/section/compute.js index 6b7a5428b1f..9262664d448 100644 --- a/ui/src/config/section/compute.js +++ b/ui/src/config/section/compute.js @@ -22,6 +22,15 @@ import { getAPI, postAPI, getBaseUrl } from '@/api' import { getLatestKubernetesIsoParams } from '@/utils/acsrepo' import kubernetesIcon from '@/assets/icons/kubernetes.svg?inline' +const attachedIsoCount = (record) => (record.isos && record.isos.length) || (record.isoid ? 1 : 0) +// Server pre-computes the effective cap (cluster-scoped vm.iso.max.count clamped to the +// hypervisor's own limit). Fall back to the hypervisor floor for older servers. +const isoMaxCount = (record) => record.isomaxcount != null + ? record.isomaxcount + : (record.hypervisor === 'KVM' ? 2 : 1) +const isoActionAvailable = (record) => + record.hypervisor !== 'External' && ['Running', 'Stopped'].includes(record.state) && record.vmtype !== 'sharedfsvm' + export default { name: 'compute', title: 'label.compute', @@ -299,7 +308,7 @@ export default { docHelp: 'adminguide/templates.html#attaching-an-iso-to-a-vm', dataView: true, popup: true, - show: (record) => { return record.hypervisor !== 'External' && ['Running', 'Stopped'].includes(record.state) && !record.isoid && record.vmtype !== 'sharedfsvm' }, + show: (record) => isoActionAvailable(record) && attachedIsoCount(record) < isoMaxCount(record), disabled: (record) => { return record.hostcontrolstate === 'Offline' || record.hostcontrolstate === 'Maintenance' }, component: shallowRef(defineAsyncComponent(() => import('@/views/compute/AttachIso.vue'))) }, @@ -307,22 +316,11 @@ export default { api: 'detachIso', icon: 'link-outlined', label: 'label.action.detach.iso', - message: 'message.detach.iso.confirm', dataView: true, - args: (record, store) => { - var args = ['virtualmachineid'] - if (record && record.hypervisor && record.hypervisor === 'VMware') { - args.push('forced') - } - return args - }, - show: (record) => { return record.hypervisor !== 'External' && ['Running', 'Stopped'].includes(record.state) && 'isoid' in record && record.isoid && record.vmtype !== 'sharedfsvm' }, + popup: true, + show: (record) => isoActionAvailable(record) && attachedIsoCount(record) > 0, disabled: (record) => { return record.hostcontrolstate === 'Offline' || record.hostcontrolstate === 'Maintenance' }, - mapping: { - virtualmachineid: { - value: (record, params) => { return record.id } - } - } + component: shallowRef(defineAsyncComponent(() => import('@/views/compute/DetachIso.vue'))) }, { api: 'updateVMAffinityGroup', @@ -914,6 +912,12 @@ export default { name: 'scaledown.policy', component: shallowRef(defineAsyncComponent(() => import('@/views/compute/AutoScaleDownPolicyTab.vue'))) }, + { + name: 'schedules', + resourceType: 'AutoScaleVmGroup', + component: shallowRef(defineAsyncComponent(() => import('@/views/compute/ResourceSchedules.vue'))), + show: () => { return 'listResourceSchedule' in store.getters.apis } + }, { name: 'events', resourceType: 'AutoScaleVmGroup', @@ -963,9 +967,9 @@ export default { dataView: true, args: (record, store) => { var args = ['name'] + args.push('maxmembers') + args.push('minmembers') if (record.state === 'DISABLED') { - args.push('maxmembers') - args.push('minmembers') args.push('interval') } return args diff --git a/ui/src/config/section/config.js b/ui/src/config/section/config.js index e190515855e..2a83b25c002 100644 --- a/ui/src/config/section/config.js +++ b/ui/src/config/section/config.js @@ -80,7 +80,7 @@ export default { docHelp: 'adminguide/accounts.html#using-an-ldap-server-for-user-authentication', permission: ['listOauthProvider'], columns: ['provider', 'enabled', 'description', 'clientid', 'secretkey', 'redirecturi'], - details: ['provider', 'description', 'enabled', 'clientid', 'secretkey', 'redirecturi'], + details: ['provider', 'description', 'enabled', 'clientid', 'secretkey', 'redirecturi', 'authorizeurl', 'tokenurl'], actions: [ { api: 'registerOauthProvider', @@ -89,11 +89,11 @@ export default { listView: true, dataView: false, args: [ - 'provider', 'description', 'clientid', 'redirecturi', 'secretkey' + 'provider', 'description', 'clientid', 'redirecturi', 'secretkey', 'authorizeurl', 'tokenurl' ], mapping: { provider: { - options: ['google', 'github'] + options: ['google', 'github', 'keycloak'] } } }, @@ -103,7 +103,7 @@ export default { label: 'label.edit', dataView: true, popup: true, - args: ['description', 'clientid', 'redirecturi', 'secretkey'] + args: ['description', 'clientid', 'redirecturi', 'secretkey', 'authorizeurl', 'tokenurl'] }, { api: 'updateOauthProvider', diff --git a/ui/src/config/section/kms.js b/ui/src/config/section/kms.js new file mode 100644 index 00000000000..648a8064b5c --- /dev/null +++ b/ui/src/config/section/kms.js @@ -0,0 +1,280 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT 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: 'kms', + title: 'label.kms', + icon: 'hdd-outlined', + show: () => { + return ['Admin'].includes(store.getters.userInfo.roletype) || store.getters.features.hashsmprofiles + }, + children: [ + { + name: 'kmskey', + title: 'label.kms.keys', + icon: 'file-text-outlined', + permission: ['listKMSKeys'], + resourceType: 'KMSKey', + columns: () => { + const fields = ['name', 'enabled', 'hsmprofile'] + if (['Admin', 'DomainAdmin'].includes(store.getters.userInfo.roletype)) { + fields.push('account') + } + if (store.getters.listAllProjects) { + fields.push('project') + } + fields.push('domainpath') + return fields + }, + details: () => { + const fields = ['id', 'name', 'description', 'version'] + if (['Admin'].includes(store.getters.userInfo.roletype)) { + fields.push('keklabel') + } + fields.push('keybits', 'enabled', 'account', 'domainpath', 'project', 'created', 'hsmprofile') + return fields + }, + related: [ + { + name: 'volume', + title: 'label.volumes', + param: 'kmskeyid' + } + ], + tabs: [ + { + name: 'details', + component: shallowRef(defineAsyncComponent(() => import('@/components/view/DetailsTab.vue'))) + }, + { + name: 'events', + resourceType: 'KmsKey', + component: shallowRef(defineAsyncComponent(() => import('@/components/view/EventsTab.vue'))), + show: () => { + return 'listEvents' in store.getters.apis + } + } + ], + searchFilters: () => { + var filters = ['zoneid', 'hsmprofileid'] + if (store.getters.userInfo.roletype === 'Admin') { + filters.push('domainid', 'account', 'projectid') + } + return filters + }, + actions: [ + { + api: 'createKMSKey', + icon: 'plus-outlined', + label: 'label.create.kms.key', + docHelp: 'adminguide/kms.html#creating-a-kms-key', + listView: true, + popup: true, + dataView: false, + args: (record, store, group) => { + return ['Admin'].includes(store.userInfo.roletype) + ? ['zoneid', 'domainid', 'account', 'projectid', 'name', 'description', 'hsmprofileid', 'keybits'] + : ['zoneid', 'name', 'description', 'hsmprofileid', 'keybits'] + }, + mapping: { + hsmprofileid: { + api: 'listHSMProfiles', + params: (record) => { return { enabled: true } } + } + } + }, + { + api: 'updateKMSKey', + icon: 'edit-outlined', + label: 'label.update.kms.key', + dataView: true, + popup: true, + args: ['id', 'name', 'description', 'enabled'], + mapping: { + id: { + value: (record) => record.id + } + } + }, + { + api: 'rotateKMSKey', + icon: 'sync-outlined', + docHelp: 'adminguide/kms.html#rotating-a-kms-key', + label: 'label.rotate.kms.key', + dataView: true, + popup: true, + args: ['id', 'keybits', 'hsmprofileid'], + mapping: { + id: { + value: (record) => record.id + } + } + }, + { + api: 'migrateVolumesToKMS', + icon: 'swap-outlined', + docHelp: 'adminguide/kms.html#migrating-existing-volumes-to-kms', + label: 'label.migrate.volumes.to.kms', + message: 'message.action.migrate.volumes.to.kms', + dataView: true, + popup: true, + show: (record, store) => { + return ['Admin'].includes(store.userInfo.roletype) + }, + args: (record, store) => { + var fields = ['domainid', 'account', 'kmskeyid', 'volumeids'] + if (!['Admin'].includes(store.userInfo.roletype)) { + fields = ['kmskeyid', 'volumeids'] + } + return fields + }, + mapping: { + kmskeyid: { + value: (record) => record.id + }, + volumeids: { + api: 'listVolumes', + params: (record) => { return { account: record.account, domainid: record.domainid, zoneid: record.zoneid } } + } + } + }, + { + api: 'deleteKMSKey', + icon: 'delete-outlined', + label: 'label.delete.kms.key', + message: 'message.action.delete.kms.key', + dataView: true, + popup: true, + args: ['id'], + mapping: { + id: { + value: (record) => record.id + } + } + } + ] + }, + { + name: 'hsmprofile', + title: 'label.hsm.profile', + icon: 'safety-outlined', + permission: ['listHSMProfiles'], + show: () => { return ['Admin'].includes(store.getters.userInfo.roletype) }, + resourceType: 'HSMProfile', + columns: () => { + const fields = ['name', 'enabled', 'ispublic'] + if (['Admin', 'DomainAdmin'].includes(store.getters.userInfo.roletype)) { + fields.push('account') + } + if (store.getters.listAllProjects) { + fields.push('project') + } + fields.push('domainpath') + return fields + }, + details: ['id', 'name', 'description', 'protocol', 'enabled', 'ispublic', 'account', 'domainpath', 'project', 'created', 'details'], + related: [ + { + name: 'kmskey', + title: 'label.kms.keys', + param: 'hsmprofileid' + } + ], + tabs: [ + { + name: 'details', + component: shallowRef(defineAsyncComponent(() => import('@/components/view/DetailsTab.vue'))) + }, + { + name: 'events', + resourceType: 'HsmProfile', + component: shallowRef(defineAsyncComponent(() => import('@/components/view/EventsTab.vue'))), + show: () => { + return 'listEvents' in store.getters.apis + } + } + ], + searchFilters: () => { + var filters = ['zoneid'] + if (store.getters.userInfo.roletype === 'Admin') { + filters.push('domainid', 'account', 'projectid') + } + return filters + }, + actions: [ + { + api: 'createHSMProfile', + icon: 'plus-outlined', + docHelp: 'adminguide/kms.html#adding-an-hsm-profile', + label: 'label.create.hsmprofile', + listView: true, + popup: true, + dataView: false, + show: (record, store) => { + return ['Admin'].includes(store.userInfo.roletype) + }, + args: (record, store, group) => { + return ['Admin'].includes(store.userInfo.roletype) + ? ['name', 'zoneid', 'vendorname', 'domainid', 'account', 'projectid', 'details', 'ispublic'] + : ['name', 'zoneid', 'vendorname', 'details'] + }, + mapping: { + details: { + optionalKeys: ['pin', 'library', 'slot', 'slot_list_index', 'token_label'] + } + } + }, + { + api: 'updateHSMProfile', + icon: 'edit-outlined', + label: 'label.update.hsm.profile', + dataView: true, + popup: true, + show: (record, store) => { + return ['Admin'].includes(store.userInfo.roletype) + }, + args: ['id', 'name', 'enabled'], + mapping: { + id: { + value: (record) => record.id + } + } + }, + { + api: 'deleteHSMProfile', + icon: 'delete-outlined', + label: 'label.delete.hsm.profile', + message: 'message.action.delete.hsm.profile', + dataView: true, + popup: true, + show: (record, store) => { + return ['Admin'].includes(store.userInfo.roletype) + }, + args: ['id'], + mapping: { + id: { + value: (record) => record.id + } + } + } + ] + } + ] +} diff --git a/ui/src/config/section/storage.js b/ui/src/config/section/storage.js index 75432314b03..14f3cf821dc 100644 --- a/ui/src/config/section/storage.js +++ b/ui/src/config/section/storage.js @@ -63,7 +63,7 @@ export default { return fields }, - details: ['name', 'id', 'type', 'storagetype', 'diskofferingdisplaytext', 'deviceid', 'sizegb', 'physicalsize', 'provisioningtype', 'utilization', 'diskkbsread', 'diskkbswrite', 'diskioread', 'diskiowrite', 'diskiopstotal', 'miniops', 'maxiops', 'path', 'deleteprotection'], + details: ['name', 'id', 'type', 'storagetype', 'diskofferingdisplaytext', 'kmskey', 'deviceid', 'sizegb', 'physicalsize', 'provisioningtype', 'utilization', 'diskkbsread', 'diskkbswrite', 'diskioread', 'diskiowrite', 'diskiopstotal', 'miniops', 'maxiops', 'path', 'deleteprotection'], related: [{ name: 'snapshot', title: 'label.snapshots', @@ -92,7 +92,7 @@ export default { } ], searchFilters: () => { - const filters = ['name', 'zoneid', 'domainid', 'account', 'state', 'tags', 'serviceofferingid', 'diskofferingid', 'isencrypted'] + const filters = ['name', 'zoneid', 'domainid', 'account', 'state', 'tags', 'serviceofferingid', 'diskofferingid', 'kmskeyid', 'isencrypted'] if (['Admin', 'DomainAdmin'].includes(store.getters.userInfo.roletype)) { filters.push('storageid') } @@ -221,6 +221,25 @@ export default { popup: true, component: shallowRef(defineAsyncComponent(() => import('@/views/storage/MigrateVolume.vue'))) }, + { + api: 'migrateVolumesToKMS', + icon: 'lock-outlined', + docHelp: 'adminguide/kms.html#migrating-existing-volumes-to-kms', + label: 'label.migrate.volume.to.kms', + message: 'message.action.migrate.volume.to.kms', + dataView: true, + popup: true, + show: (record, store) => { + return record.encryptformat && !record.kmskeyid && + ['Ready', 'Allocated'].includes(record.state) + }, + args: ['kmskeyid'], + mapping: { + volumeids: { + value: (record) => { return record.id } + } + } + }, { api: 'changeOfferingForVolume', icon: 'swap-outlined', diff --git a/ui/src/store/modules/user.js b/ui/src/store/modules/user.js index 6a818d58723..a18626e801b 100644 --- a/ui/src/store/modules/user.js +++ b/ui/src/store/modules/user.js @@ -341,6 +341,40 @@ const user = { commit('SET_DARK_MODE', darkMode) commit('SET_LATEST_VERSION', latestVersion) + const loadFeatures = (apis) => { + return new Promise(resolve => { + getAPI('listCapabilities').then(response => { + const result = response.listcapabilitiesresponse.capability + commit('SET_FEATURES', result) + if (result && result.defaultuipagesize) { + commit('SET_DEFAULT_LISTVIEW_PAGE_SIZE', result.defaultuipagesize) + } + if (result && result.customhypervisordisplayname) { + commit('SET_CUSTOM_HYPERVISOR_NAME', result.customhypervisordisplayname) + } + if (result && result.securitygroupsenabled) { + commit('SET_SHOW_SECURITY_GROUPS', result.securitygroupsenabled) + } + + if ('listHSMProfiles' in apis) { + getAPI('listHSMProfiles', { listall: true }).then(response => { + const hasHsmProfiles = (response.listhsmprofilesresponse.count > 0) + const features = Object.assign({}, store.getters.features) + features.hashsmprofiles = hasHsmProfiles + commit('SET_FEATURES', features) + resolve() + }).catch(ignored => { + resolve() + }) + } else { + resolve() + } + }).catch(() => { + resolve() + }) + }) + } + // This block is to enforce password change for first time login after admin resets password const isPwdChangeRequired = vueProps.$localStorage.get(PASSWORD_CHANGE_REQUIRED) commit('SET_PASSWORD_CHANGE_REQUIRED', isPwdChangeRequired) @@ -364,7 +398,9 @@ const user = { const result = response.listusersresponse.user[0] commit('SET_INFO', result) commit('SET_NAME', result.firstname + ' ' + result.lastname) - resolve(cachedApis) + loadFeatures(cachedApis).then(() => { + resolve(cachedApis) + }) }).catch(error => { reject(error) }) @@ -391,14 +427,16 @@ const user = { } } commit('SET_APIS', apis) - resolve(apis) - store.dispatch('GenerateRoutes', { apis }).then(() => { - store.getters.addRouters.map(route => { - router.addRoute(route) + loadFeatures(apis).then(() => { + resolve(apis) + store.dispatch('GenerateRoutes', { apis }).then(() => { + store.getters.addRouters.map(route => { + router.addRoute(route) + }) }) + hide() + message.success(i18n.global.t('message.sussess.discovering.feature')) }) - hide() - message.success(i18n.global.t('message.sussess.discovering.feature')) }).catch(error => { reject(error) }) @@ -452,22 +490,6 @@ const user = { }).catch(ignored => { }) - getAPI('listCapabilities').then(response => { - const result = response.listcapabilitiesresponse.capability - commit('SET_FEATURES', result) - if (result && result.defaultuipagesize) { - commit('SET_DEFAULT_LISTVIEW_PAGE_SIZE', result.defaultuipagesize) - } - if (result && result.customhypervisordisplayname) { - commit('SET_CUSTOM_HYPERVISOR_NAME', result.customhypervisordisplayname) - } - if (result && result.securitygroupsenabled) { - commit('SET_SHOW_SECURITY_GROUPS', result.securitygroupsenabled) - } - }).catch(error => { - reject(error) - }) - getAPI('listLdapConfigurations').then(response => { const ldapEnable = (response.ldapconfigurationresponse.count > 0) commit('SET_LDAP', ldapEnable) @@ -586,7 +608,8 @@ const user = { getAPI('listCapabilities').then(response => { const result = response.listcapabilitiesresponse.capability resolve(result) - commit('SET_FEATURES', result) + const features = Object.assign({}, store.getters.features, result) + commit('SET_FEATURES', features) }).catch(error => { reject(error) }) diff --git a/ui/src/views/AutogenView.vue b/ui/src/views/AutogenView.vue index c0603445b57..7ddec48b85c 100644 --- a/ui/src/views/AutogenView.vue +++ b/ui/src/views/AutogenView.vue @@ -350,6 +350,7 @@ showSearch optionFilterProp="label" v-model:value="form[field.name]" + @change="val => handleSelectChange(field.name, val)" :loading="field.loading" :placeholder="field.description" :filterOption="(input, option) => { @@ -374,6 +375,7 @@ showSearch optionFilterProp="label" v-model:value="form[field.name]" + @change="val => handleSelectChange(field.name, val)" :loading="field.loading" :placeholder="field.description" :filterOption="(input, option) => { @@ -481,6 +483,7 @@ :loading="field.loading" mode="multiple" v-model:value="form[field.name]" + @change="val => handleSelectChange(field.name, val)" :placeholder="field.description" v-focus="fieldIndex === firstIndex" showSearch @@ -499,7 +502,8 @@ + v-model:value="form[field.name]" + :optionalKeys="currentAction.mapping?.[field.name]?.optionalKeys || []" /> f.name === 'account') + if (accountField) { + this.form.account = null + this.listUuidOpts(accountField, { domainid: val }) + } + } else if (name === 'account') { + const volumeField = this.currentAction.paramFields.find(f => f.name === 'volumeids') + if (volumeField) { + this.form.volumeids = null + this.listUuidOpts(volumeField, { domainid: this.form.domainid, account: val }) + } + } + }, listUuidOpts (param, filters) { if (this.currentAction.mapping && param.name in this.currentAction.mapping && !this.currentAction.mapping[param.name].api) { return diff --git a/ui/src/views/auth/Login.vue b/ui/src/views/auth/Login.vue index 24065f47b1a..acb874dc75b 100644 --- a/ui/src/views/auth/Login.vue +++ b/ui/src/views/auth/Login.vue @@ -186,8 +186,8 @@ :href="getGitHubUrl(from)" class="auth-btn github-auth" style="height: 38px; width: 185px; padding: 0; margin-bottom: 5px;" > - - Sign in with Github + GitHub + Sign in with GitHub
+
@@ -231,10 +243,14 @@ export default { socialLogin: false, googleprovider: false, githubprovider: false, + keycloakprovider: false, googleredirecturi: '', githubredirecturi: '', + keycloakredirecturi: '', googleclientid: '', githubclientid: '', + keycloakclientid: '', + keycloakauthorizeurl: '', loginType: 0, state: { time: 60, @@ -325,8 +341,14 @@ export default { this.githubclientid = item.clientid this.githubredirecturi = item.redirecturi } + if (item.provider === 'keycloak') { + this.keycloakprovider = item.enabled + this.keycloakclientid = item.clientid + this.keycloakredirecturi = item.redirecturi + this.keycloakauthorizeurl = item.authorizeurl + } }) - this.socialLogin = this.googleprovider || this.githubprovider + this.socialLogin = this.googleprovider || this.githubprovider || this.keycloakprovider } }) postAPI('forgotPassword', {}).then(response => { @@ -362,6 +384,10 @@ export default { this.handleDomain() this.$store.commit('SET_OAUTH_PROVIDER_USED_TO_LOGIN', 'google') }, + handleKeycloakProviderAndDomain () { + this.handleDomain() + this.$store.commit('SET_OAUTH_PROVIDER_USED_TO_LOGIN', 'keycloak') + }, handleDomain () { const values = toRaw(this.form) if (!values.domain) { @@ -401,6 +427,20 @@ export default { return `${rootUrl}?${qs.toString()}` }, + getKeycloakUrl (from) { + const rootURl = this.keycloakauthorizeurl + const options = { + redirect_uri: this.keycloakredirecturi, + client_id: this.keycloakclientid, + response_type: 'code', + scope: 'openid email', + state: 'cloudstack' + } + + const qs = new URLSearchParams(options) + + return `${rootURl}?${qs.toString()}` + }, handleSubmit (e) { e.preventDefault() if (this.state.loginBtn) return diff --git a/ui/src/views/compute/AttachIso.vue b/ui/src/views/compute/AttachIso.vue index 60694cb8f57..daa555c4538 100644 --- a/ui/src/views/compute/AttachIso.vue +++ b/ui/src/views/compute/AttachIso.vue @@ -17,23 +17,38 @@