Copilot commented on code in PR #13148: URL: https://github.com/apache/cloudstack/pull/13148#discussion_r3224827151
########## 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<Long> 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<Long> getIds() { + return ids; + } + + @Override + public void execute() { + resourceScheduleManager.removeSchedule(getResourceType(), getResourceId(), getId(), getIds()); + SuccessResponse response = new SuccessResponse(getCommandName()); + setResponseObject(response); + } Review Comment: `deleteResourceSchedule` always returns `SuccessResponse` without checking whether anything was actually deleted. This can report success even when `id/ids` don’t match any schedules. Consider checking the `Long` returned by `resourceScheduleManager.removeSchedule(...)` and throwing an appropriate `ServerApiException` (similar to `DeleteVMScheduleCmd`) when 0 rows are removed. ########## server/src/main/java/org/apache/cloudstack/schedule/ResourceScheduleManagerImpl.java: ########## @@ -0,0 +1,444 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES 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.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<BaseScheduleWorker> workerList; + + private Map<ApiCommandResourceType, BaseScheduleWorker> workerMap; + + @Override + public boolean configure(String name, Map<String, Object> 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<Class<?>> getCommands() { + final List<Class<?>> 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<String, String> 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 = String.format("%s - %s", parsedAction.name(), DateUtil.getHumanReadableSchedule(cronExpression)); + } + + 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<ResourceScheduleResponse>) status -> { + ResourceScheduleVO scheduleVO = resourceScheduleDao.persist(new ResourceScheduleVO( + resourceType, internalResourceId, + finalDescription, cronExpression.toString(), timeZoneId, + finalAction, finalStartDate, finalEndDate, enabled)); + + if (details != null && !details.isEmpty()) { + List<ResourceScheduleDetailVO> detailVOs = new ArrayList<>(); + for (Map.Entry<String, String> 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()); + return createResponse(scheduleVO, details); + }); + } + + ResourceScheduleResponse createResponse(ResourceSchedule schedule, Map<String, String> 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<ResourceScheduleResponse> listSchedule(Long id, List<Long> 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<Long> scheduleIds = getIdsListFromCmd(id, ids); + if (action != null) { + action = worker.parseAction(action).name(); + } + + Pair<List<ResourceScheduleVO>, Integer> result = resourceScheduleDao.searchAndCount( + scheduleIds, resourceType, internalResourceId, action, enabled, startIndex, pageSize); + + ListResponse<ResourceScheduleResponse> response = new ListResponse<>(); + List<ResourceScheduleResponse> 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<String, String> 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)); + + if (details != null && !details.isEmpty()) { + worker.validateDetails(worker.parseAction(scheduleVO.getActionName()), details); + } + + CronExpression cronExpression = Objects.requireNonNullElse( + DateUtil.parseSchedule(schedule), + DateUtil.parseSchedule(scheduleVO.getSchedule()) + ); + + if (description == null && scheduleVO.getDescription() == null) { + description = String.format("%s - %s", scheduleVO.getActionName(), DateUtil.getHumanReadableSchedule(cronExpression)); + } + + 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()); + } + + if (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<ResourceScheduleResponse>) status -> { + resourceScheduleDao.update(id, scheduleVO); + + if (details != null) { + if (details.isEmpty()) { + resourceScheduleDetailsDao.removeDetails(id); + } else { + List<ResourceScheduleDetailVO> detailVOs = new ArrayList<>(); + for (Map.Entry<String, String> 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()); + + // Re-load details if they weren't fully replaced + Map<String, String> 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<ResourceScheduleVO> schedules = resourceScheduleDao.search( + resourceScheduleDao.getSearchCriteriaForResource(resourceType, resourceId), null); + List<Long> 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<Long> 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<Long> ids = getIdsListFromCmd(id, idsList); + Pair<List<ResourceScheduleVO>, Integer> result = resourceScheduleDao.searchAndCount(ids, resourceType, internalResourceId, null, null, null, null); + List<ResourceScheduleVO> schedulesToRemove = result.first(); + List<Long> scheduleIdsToRemove = schedulesToRemove.stream().map(ResourceScheduleVO::getId).collect(Collectors.toList()); + return Transaction.execute((TransactionCallback<Long>) status -> { + worker.removeScheduledJobs(scheduleIdsToRemove); + + CallContext.current().setEventResourceId(internalResourceId); + CallContext.current().setEventResourceType(worker.getApiResourceType()); + return resourceScheduleDao.removeSchedulesForResourceAndIds(resourceType, internalResourceId, scheduleIdsToRemove); Review Comment: `removeSchedule` builds `scheduleIdsToRemove` from a filtered query, but if the provided `id/ids` don’t match any schedules for the given resource, `scheduleIdsToRemove` becomes empty. Passing an empty list into `resourceScheduleDao.removeSchedulesForResourceAndIds(...)` removes *all* schedules for the resource (because the DAO only applies the ID filter when the list is non-empty). Add an explicit guard (return 0 / throw) when `scheduleIdsToRemove` is empty, and/or make `removeSchedulesForResourceAndIds` require a non-empty id list and use `removeAllSchedulesForResource` for the “delete all” case. ########## server/src/main/java/org/apache/cloudstack/schedule/ResourceScheduleManagerImpl.java: ########## @@ -0,0 +1,444 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES 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.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<BaseScheduleWorker> workerList; + + private Map<ApiCommandResourceType, BaseScheduleWorker> workerMap; + + @Override + public boolean configure(String name, Map<String, Object> 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<Class<?>> getCommands() { + final List<Class<?>> 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<String, String> 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 = String.format("%s - %s", parsedAction.name(), DateUtil.getHumanReadableSchedule(cronExpression)); + } + + 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<ResourceScheduleResponse>) status -> { + ResourceScheduleVO scheduleVO = resourceScheduleDao.persist(new ResourceScheduleVO( + resourceType, internalResourceId, + finalDescription, cronExpression.toString(), timeZoneId, + finalAction, finalStartDate, finalEndDate, enabled)); + + if (details != null && !details.isEmpty()) { + List<ResourceScheduleDetailVO> detailVOs = new ArrayList<>(); + for (Map.Entry<String, String> 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()); + return createResponse(scheduleVO, details); + }); + } + + ResourceScheduleResponse createResponse(ResourceSchedule schedule, Map<String, String> 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<ResourceScheduleResponse> listSchedule(Long id, List<Long> 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<Long> scheduleIds = getIdsListFromCmd(id, ids); + if (action != null) { + action = worker.parseAction(action).name(); + } + + Pair<List<ResourceScheduleVO>, Integer> result = resourceScheduleDao.searchAndCount( + scheduleIds, resourceType, internalResourceId, action, enabled, startIndex, pageSize); + + ListResponse<ResourceScheduleResponse> response = new ListResponse<>(); + List<ResourceScheduleResponse> 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<String, String> 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)); + + if (details != null && !details.isEmpty()) { + worker.validateDetails(worker.parseAction(scheduleVO.getActionName()), details); + } + + CronExpression cronExpression = Objects.requireNonNullElse( + DateUtil.parseSchedule(schedule), + DateUtil.parseSchedule(scheduleVO.getSchedule()) + ); + + if (description == null && scheduleVO.getDescription() == null) { + description = String.format("%s - %s", scheduleVO.getActionName(), DateUtil.getHumanReadableSchedule(cronExpression)); + } + + 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()); + } + + if (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<ResourceScheduleResponse>) status -> { + resourceScheduleDao.update(id, scheduleVO); + + if (details != null) { + if (details.isEmpty()) { + resourceScheduleDetailsDao.removeDetails(id); + } else { + List<ResourceScheduleDetailVO> detailVOs = new ArrayList<>(); + for (Map.Entry<String, String> 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()); + + // Re-load details if they weren't fully replaced + Map<String, String> 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<ResourceScheduleVO> schedules = resourceScheduleDao.search( + resourceScheduleDao.getSearchCriteriaForResource(resourceType, resourceId), null); + List<Long> 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<Long> 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<Long> ids = getIdsListFromCmd(id, idsList); + Pair<List<ResourceScheduleVO>, Integer> result = resourceScheduleDao.searchAndCount(ids, resourceType, internalResourceId, null, null, null, null); + List<ResourceScheduleVO> schedulesToRemove = result.first(); + List<Long> scheduleIdsToRemove = schedulesToRemove.stream().map(ResourceScheduleVO::getId).collect(Collectors.toList()); Review Comment: `removeSchedule` does not validate that at least one schedule id was requested. If both `id` and `idsList` are null/empty, `getIdsListFromCmd(...)` returns an empty list and the subsequent DAO query/removal operates without an ID filter, effectively deleting *all* schedules for the resource. If “delete all” is not an intended API behavior, add a validation error when no ids are provided; if it *is* intended, it should be an explicit parameter/command to avoid accidental mass deletion. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
