Support list/query async jobs by resource (#12983)

* Add resource filtering to async job query commands

* Fix logical condition in AsyncJobDaoImpl and ResourceIdSupport

* resource type case-insensitive validation

* fix resource type and id search

---------

Co-authored-by: mprokopchuk <mprokopchuk@gmail.com>
Co-authored-by: mprokopchuk <mprokopchuk@apple.com>
This commit is contained in:
Suresh Kumar Anaparti 2026-04-13 15:29:34 +05:30 committed by Daan Hoogland
parent 7c7b2ae75d
commit 47c5bb8ee7
9 changed files with 268 additions and 28 deletions

View File

@ -127,8 +127,8 @@ public enum ApiCommandResourceType {
}
public static ApiCommandResourceType fromString(String value) {
if (StringUtils.isNotEmpty(value) && EnumUtils.isValidEnum(ApiCommandResourceType.class, value)) {
return valueOf(value);
if (StringUtils.isNotBlank(value) && EnumUtils.isValidEnumIgnoreCase(ApiCommandResourceType.class, value)) {
return EnumUtils.getEnumIgnoreCase(ApiCommandResourceType.class, value);
}
return null;
}

View File

@ -19,6 +19,7 @@ package org.apache.cloudstack.api.command.user.job;
import java.util.Date;
import org.apache.cloudstack.api.APICommand;
import org.apache.cloudstack.api.ApiArgValidator;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.api.BaseListAccountResourcesCmd;
import org.apache.cloudstack.api.Parameter;
@ -40,6 +41,12 @@ public class ListAsyncJobsCmd extends BaseListAccountResourcesCmd {
@Parameter(name = ApiConstants.MANAGEMENT_SERVER_ID, type = CommandType.UUID, entityType = ManagementServerResponse.class, description = "The id of the management server", since="4.19")
private Long managementServerId;
@Parameter(name = ApiConstants.RESOURCE_ID, validations = {ApiArgValidator.UuidString}, type = CommandType.STRING, description = "the ID of the resource associated with the job", since="4.22.1")
private String resourceId;
@Parameter(name = ApiConstants.RESOURCE_TYPE, type = CommandType.STRING, description = "the type of the resource associated with the job", since="4.22.1")
private String resourceType;
/////////////////////////////////////////////////////
/////////////////// Accessors ///////////////////////
/////////////////////////////////////////////////////
@ -52,6 +59,14 @@ public class ListAsyncJobsCmd extends BaseListAccountResourcesCmd {
return managementServerId;
}
public String getResourceId() {
return resourceId;
}
public String getResourceType() {
return resourceType;
}
/////////////////////////////////////////////////////
/////////////// API Implementation///////////////////
/////////////////////////////////////////////////////

View File

@ -16,8 +16,8 @@
// under the License.
package org.apache.cloudstack.api.command.user.job;
import org.apache.cloudstack.api.APICommand;
import org.apache.cloudstack.api.ApiArgValidator;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.api.BaseCmd;
import org.apache.cloudstack.api.Parameter;
@ -34,9 +34,15 @@ public class QueryAsyncJobResultCmd extends BaseCmd {
//////////////// API parameters /////////////////////
/////////////////////////////////////////////////////
@Parameter(name = ApiConstants.JOB_ID, type = CommandType.UUID, entityType = AsyncJobResponse.class, required = true, description = "The ID of the asynchronous job")
@Parameter(name = ApiConstants.JOB_ID, type = CommandType.UUID, entityType = AsyncJobResponse.class, description = "The ID of the asynchronous job")
private Long id;
@Parameter(name = ApiConstants.RESOURCE_ID, validations = {ApiArgValidator.UuidString}, type = CommandType.STRING, description = "the ID of the resource associated with the job", since="4.22.1")
private String resourceId;
@Parameter(name = ApiConstants.RESOURCE_TYPE, type = CommandType.STRING, description = "the type of the resource associated with the job", since="4.22.1")
private String resourceType;
/////////////////////////////////////////////////////
/////////////////// Accessors ///////////////////////
/////////////////////////////////////////////////////
@ -45,6 +51,14 @@ public class QueryAsyncJobResultCmd extends BaseCmd {
return id;
}
public String getResourceId() {
return resourceId;
}
public String getResourceType() {
return resourceType;
}
/////////////////////////////////////////////////////
/////////////// API Implementation///////////////////
/////////////////////////////////////////////////////

View File

@ -23,12 +23,30 @@ import org.apache.cloudstack.framework.jobs.impl.AsyncJobVO;
import com.cloud.utils.db.GenericDao;
import javax.annotation.Nullable;
public interface AsyncJobDao extends GenericDao<AsyncJobVO, Long> {
AsyncJobVO findInstancePendingAsyncJob(String instanceType, long instanceId);
List<AsyncJobVO> findInstancePendingAsyncJobs(String instanceType, Long accountId);
/**
* Finds async job matching the given parameters.
* Non-null parameters are added to search criteria.
* Returns the most recent job by creation date.
* <p>
* When searching by resourceId and resourceType, only one active job
* is expected per resource, so returning a single result is sufficient.
*
* @param id job ID
* @param resourceId resource ID (instanceId)
* @param resourceType resource type (instanceType)
* @return matching job or null
*/
@Nullable
AsyncJobVO findJob(Long id, Long resourceId, String resourceType);
AsyncJobVO findPseudoJob(long threadId, long msid);
void cleanupPseduoJobs(long msid);

View File

@ -22,6 +22,8 @@ import java.util.Date;
import java.util.List;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.cloudstack.framework.jobs.impl.AsyncJobVO;
import org.apache.cloudstack.jobs.JobInfo;
@ -45,6 +47,7 @@ public class AsyncJobDaoImpl extends GenericDaoBase<AsyncJobVO, Long> implements
private final SearchBuilder<AsyncJobVO> expiringUnfinishedAsyncJobSearch;
private final SearchBuilder<AsyncJobVO> expiringCompletedAsyncJobSearch;
private final SearchBuilder<AsyncJobVO> failureMsidAsyncJobSearch;
private final SearchBuilder<AsyncJobVO> byIdResourceIdResourceTypeSearch;
private final GenericSearchBuilder<AsyncJobVO, Long> asyncJobTypeSearch;
private final GenericSearchBuilder<AsyncJobVO, Long> pendingNonPseudoAsyncJobsSearch;
@ -95,6 +98,12 @@ public class AsyncJobDaoImpl extends GenericDaoBase<AsyncJobVO, Long> implements
failureMsidAsyncJobSearch.and("job_cmd", failureMsidAsyncJobSearch.entity().getCmd(), Op.IN);
failureMsidAsyncJobSearch.done();
byIdResourceIdResourceTypeSearch = createSearchBuilder();
byIdResourceIdResourceTypeSearch.and("id", byIdResourceIdResourceTypeSearch.entity().getId(), SearchCriteria.Op.EQ);
byIdResourceIdResourceTypeSearch.and("instanceId", byIdResourceIdResourceTypeSearch.entity().getInstanceId(), SearchCriteria.Op.EQ);
byIdResourceIdResourceTypeSearch.and("instanceType", byIdResourceIdResourceTypeSearch.entity().getInstanceType(), SearchCriteria.Op.EQ);
byIdResourceIdResourceTypeSearch.done();
asyncJobTypeSearch = createSearchBuilder(Long.class);
asyncJobTypeSearch.select(null, SearchCriteria.Func.COUNT, asyncJobTypeSearch.entity().getId());
asyncJobTypeSearch.and("job_info", asyncJobTypeSearch.entity().getCmdInfo(),Op.LIKE);
@ -140,6 +149,30 @@ public class AsyncJobDaoImpl extends GenericDaoBase<AsyncJobVO, Long> implements
return listBy(sc);
}
@Override
public AsyncJobVO findJob(Long id, Long resourceId, String resourceType) {
SearchCriteria<AsyncJobVO> sc = byIdResourceIdResourceTypeSearch.create();
if (id == null && resourceId == null && StringUtils.isBlank(resourceType)) {
logger.debug("findJob called with all null parameters");
return null;
}
if (id != null) {
sc.setParameters("id", id);
}
if (resourceId != null && StringUtils.isNotBlank(resourceType)) {
sc.setParameters("instanceType", resourceType);
sc.setParameters("instanceId", resourceId);
}
Filter filter = new Filter(AsyncJobVO.class, "created", false, 0L, 1L);
List<AsyncJobVO> result = searchIncludingRemoved(sc, filter, Boolean.FALSE, false);
if (CollectionUtils.isNotEmpty(result)) {
return result.get(0);
}
return null;
}
@Override
public AsyncJobVO findPseudoJob(long threadId, long msid) {
SearchCriteria<AsyncJobVO> sc = pseudoJobSearch.create();

View File

@ -32,6 +32,7 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TimeZone;
import java.util.function.Consumer;
@ -39,6 +40,7 @@ import java.util.stream.Collectors;
import javax.inject.Inject;
import com.cloud.api.query.ResourceIdSupport;
import com.cloud.bgp.ASNumber;
import com.cloud.bgp.ASNumberRange;
import com.cloud.configuration.ConfigurationService;
@ -57,6 +59,7 @@ import org.apache.cloudstack.affinity.AffinityGroup;
import org.apache.cloudstack.affinity.AffinityGroupResponse;
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.ApiConstants.DomainDetails;
import org.apache.cloudstack.api.ApiConstants.HostDetails;
@ -219,6 +222,7 @@ import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotDataFactory;
import org.apache.cloudstack.engine.subsystem.api.storage.SnapshotInfo;
import org.apache.cloudstack.framework.jobs.AsyncJob;
import org.apache.cloudstack.framework.jobs.AsyncJobManager;
import org.apache.cloudstack.framework.jobs.dao.AsyncJobDao;
import org.apache.cloudstack.gui.theme.GuiThemeJoin;
import org.apache.cloudstack.management.ManagementServerHost;
import org.apache.cloudstack.network.BgpPeerVO;
@ -447,7 +451,7 @@ import com.cloud.vm.snapshot.dao.VMSnapshotDao;
import sun.security.x509.X509CertImpl;
public class ApiResponseHelper implements ResponseGenerator {
public class ApiResponseHelper implements ResponseGenerator, ResourceIdSupport {
protected Logger logger = LogManager.getLogger(ApiResponseHelper.class);
private static final DecimalFormat s_percentFormat = new DecimalFormat("##.##");
@ -529,6 +533,8 @@ public class ApiResponseHelper implements ResponseGenerator {
RoutedIpv4Manager routedIpv4Manager;
@Inject
ResourceIconManager resourceIconManager;
@Inject
AsyncJobDao asyncJobDao;
public static String getPrettyDomainPath(String path) {
if (path == null) {
@ -2304,16 +2310,26 @@ public class ApiResponseHelper implements ResponseGenerator {
@Override
public AsyncJobResponse queryJobResult(final QueryAsyncJobResultCmd cmd) {
final Account caller = CallContext.current().getCallingAccount();
ApiCommandResourceType resourceType = getResourceType(cmd.getResourceType());
String resourceTypeName = Optional.ofNullable(resourceType).map(ApiCommandResourceType::name).orElse(null);
final AsyncJob job = _entityMgr.findByIdIncludingRemoved(AsyncJob.class, cmd.getId());
if (job == null) {
throw new InvalidParameterValueException("Unable to find a job by id " + cmd.getId());
Long resourceId = getResourceId(resourceType, cmd.getResourceId());
Long jobId = cmd.getId();
if (jobId == null && resourceId == null) {
throw new InvalidParameterValueException("Expected parameter job id or parameters resource type and resource id");
}
final AsyncJob job = asyncJobDao.findJob(jobId, resourceId, resourceTypeName);
if (job == null) {
throw new InvalidParameterValueException("Unable to find a job by id " + jobId + " resource type "
+ cmd.getResourceType() + " resource id " + cmd.getResourceId());
}
jobId = job.getId();
final User userJobOwner = _accountMgr.getUserIncludingRemoved(job.getUserId());
final Account jobOwner = _accountMgr.getAccount(userJobOwner.getAccountId());
final Account caller = CallContext.current().getCallingAccount();
//check permissions
if (_accountMgr.isNormalUser(caller.getId())) {
//regular users can see only jobs they own
@ -2324,7 +2340,7 @@ public class ApiResponseHelper implements ResponseGenerator {
_accountMgr.checkAccess(caller, null, true, jobOwner);
}
return createAsyncJobResponse(_jobMgr.queryJob(cmd.getId(), true));
return createAsyncJobResponse(_jobMgr.queryJob(jobId, true));
}
public AsyncJobResponse createAsyncJobResponse(AsyncJob job) {
@ -5704,4 +5720,14 @@ protected Map<String, ResourceIcon> getResourceIconsUsingOsCategory(List<Templat
consoleSessionResponse.setObjectName("consolesession");
return consoleSessionResponse;
}
@Override
public EntityManager getEntityManager() {
return _entityMgr;
}
@Override
public AccountManager getAccountManager() {
return _accountMgr;
}
}

View File

@ -31,7 +31,6 @@ import java.util.ListIterator;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@ -370,7 +369,7 @@ import com.cloud.vm.dao.VMInstanceDao;
import com.cloud.vm.dao.VMInstanceDetailsDao;
@Component
public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements QueryService, Configurable {
public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements QueryService, Configurable, ResourceIdSupport {
private static final String ID_FIELD = "id";
@ -869,26 +868,14 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q
Integer entryTime = cmd.getEntryTime();
Integer duration = cmd.getDuration();
Long startId = cmd.getStartId();
final String resourceUuid = cmd.getResourceId();
final String resourceTypeStr = cmd.getResourceType();
final String resourceUuid = getResourceUuid(cmd.getResourceId());
final ApiCommandResourceType resourceType = getResourceType(cmd.getResourceType());
final String stateStr = cmd.getState();
ApiCommandResourceType resourceType = null;
Long resourceId = null;
if (resourceTypeStr != null) {
resourceType = ApiCommandResourceType.fromString(resourceTypeStr);
if (resourceType == null) {
throw new InvalidParameterValueException(String.format("Invalid %s", ApiConstants.RESOURCE_TYPE));
}
}
if (resourceUuid != null) {
if (resourceTypeStr == null) {
if (resourceType == null) {
throw new InvalidParameterValueException(String.format("%s parameter must be used with %s parameter", ApiConstants.RESOURCE_ID, ApiConstants.RESOURCE_TYPE));
}
try {
UUID.fromString(resourceUuid);
} catch (IllegalArgumentException ex) {
throw new InvalidParameterValueException(String.format("Invalid %s", ApiConstants.RESOURCE_ID));
}
Object object = entityManager.findByUuidIncludingRemoved(resourceType.getAssociatedClass(), resourceUuid);
if (object instanceof InternalIdentity) {
resourceId = ((InternalIdentity)object).getId();
@ -3205,6 +3192,20 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q
sc.setParameters("executingMsid", msHost.getMsid());
}
if (cmd.getResourceType() != null) {
ApiCommandResourceType resourceType = getResourceType(cmd.getResourceType());
sc.addAnd("instanceType", SearchCriteria.Op.EQ, resourceType.toString());
final String resourceId = getResourceUuid(cmd.getResourceId());
if (resourceId == null) {
throw new InvalidParameterValueException("Invalid resource id for the resource type " + resourceType);
}
sc.addAnd("instanceUuid", SearchCriteria.Op.EQ, resourceId);
} else if (cmd.getResourceId() != null) {
throw new InvalidParameterValueException("Resource type must be specified for the resource id");
}
return _jobJoinDao.searchAndCount(sc, searchFilter);
}
@ -6288,4 +6289,14 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q
return new ConfigKey<?>[] {AllowUserViewDestroyedVM, UserVMDeniedDetails, UserVMReadOnlyDetails, SortKeyAscending,
AllowUserViewAllDomainAccounts, AllowUserViewAllDataCenters, SharePublicTemplatesWithOtherDomains, ReturnVmStatsOnVmList};
}
@Override
public EntityManager getEntityManager() {
return entityManager;
}
@Override
public AccountManager getAccountManager() {
return accountMgr;
}
}

View File

@ -0,0 +1,123 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT 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.api.query;
import com.cloud.exception.InvalidParameterValueException;
import com.cloud.user.Account;
import com.cloud.user.AccountManager;
import com.cloud.utils.db.EntityManager;
import org.apache.cloudstack.acl.ControlledEntity;
import org.apache.cloudstack.api.ApiCommandResourceType;
import org.apache.cloudstack.api.ApiConstants;
import org.apache.cloudstack.api.InternalIdentity;
import org.apache.cloudstack.context.CallContext;
import org.apache.commons.lang3.StringUtils;
import java.util.Optional;
import java.util.UUID;
import static org.apache.cloudstack.acl.SecurityChecker.AccessType;
/**
* Support interface for converting resource UUIDs to internal IDs
* with validation and access control.
*
* @author mprokopchuk
*/
public interface ResourceIdSupport {
EntityManager getEntityManager();
AccountManager getAccountManager();
/**
* Converts resource UUID to internal database ID with access control checks.
*
* @param resourceType type of the resource
* @param resourceUuid UUID of the resource
* @return internal resource ID or null if parameters are null
* @throws InvalidParameterValueException if only one parameter provided or resource not found
*/
default Long getResourceId(ApiCommandResourceType resourceType, String resourceUuid) {
String uuid = getResourceUuid(resourceUuid);
if (resourceType == null && uuid == null) {
return null;
} else if ((resourceType == null) ^ (uuid == null)) {
throw new InvalidParameterValueException(String.format("Both %s and %s required",
ApiConstants.RESOURCE_ID, ApiConstants.RESOURCE_TYPE));
}
Object object = getEntityManager().findByUuidIncludingRemoved(resourceType.getAssociatedClass(), resourceUuid);
if (!(object instanceof InternalIdentity)) {
throw new InvalidParameterValueException(String.format("Invalid %s", ApiConstants.RESOURCE_ID));
}
Long resourceId = ((InternalIdentity) object).getId();
Account caller = CallContext.current().getCallingAccount();
boolean isRootAdmin = getAccountManager().isRootAdmin(caller.getId());
if (!isRootAdmin && object instanceof ControlledEntity) {
ControlledEntity entity = (ControlledEntity) object;
boolean sameOwner = entity.getAccountId() == caller.getId();
getAccountManager().checkAccess(caller, AccessType.ListEntry, sameOwner, entity);
}
return resourceId;
}
/**
* Parses and validates resource type string.
*
* @param resourceType resource type as string
* @return parsed resource type or null if not provided
* @throws InvalidParameterValueException if provided type is invalid
*/
default ApiCommandResourceType getResourceType(String resourceType) {
Optional<String> resourceTypeOpt = Optional.ofNullable(resourceType).filter(StringUtils::isNotBlank);
// return null if resource type was not provided
if (resourceTypeOpt.isEmpty()) {
return null;
}
// return value or throw exception if provided resource type is invalid
return resourceTypeOpt
.map(ApiCommandResourceType::fromString)
.orElseThrow(() -> new InvalidParameterValueException(String.format("Invalid %s",
ApiConstants.RESOURCE_TYPE)));
}
/**
* Validates resource UUID format.
*
* @param resourceUuid UUID string to validate
* @return validated UUID or null if not provided
* @throws InvalidParameterValueException if UUID format is invalid
*/
default String getResourceUuid(String resourceUuid) {
if (StringUtils.isBlank(resourceUuid)) {
return null;
}
try {
UUID.fromString(resourceUuid);
} catch (IllegalArgumentException ex) {
throw new InvalidParameterValueException(String.format("Invalid %s", ApiConstants.RESOURCE_ID));
}
return resourceUuid;
}
}

View File

@ -272,7 +272,7 @@ public class QueryManagerImplTest {
public void searchForEventsFailResourceIdInvalid() {
ListEventsCmd cmd = setupMockListEventsCmd();
Mockito.when(cmd.getResourceId()).thenReturn("random");
Mockito.when(cmd.getResourceType()).thenReturn(ApiCommandResourceType.VirtualMachine.toString());
Mockito.lenient().when(cmd.getResourceType()).thenReturn(ApiCommandResourceType.VirtualMachine.toString());
queryManager.searchForEvents(cmd);
}