diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResource.java index 5f38329807e0..e9a368505a16 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResource.java @@ -26,6 +26,7 @@ import com.dotmarketing.exception.DoesNotExistException; import com.dotmarketing.exception.DotRuntimeException; import com.dotmarketing.portlets.cmsmaintenance.factories.CMSMaintenanceFactory; +import com.dotmarketing.quartz.QuartzUtils; import com.dotmarketing.util.Config; import com.dotmarketing.util.DateUtil; import com.dotmarketing.util.FileUtil; @@ -43,6 +44,9 @@ import io.vavr.Lazy; import io.vavr.control.Try; import org.apache.commons.io.IOUtils; +import org.quartz.CronTrigger; +import org.quartz.JobDetail; +import org.quartz.Trigger; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -89,6 +93,7 @@ import java.util.Date; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -1484,6 +1489,194 @@ private static boolean containsDotCMSFrame(final StackTraceElement[] stack) { return false; } + /** + * Lists all Quartz scheduler jobs across every job group, with trigger details + * including next fire time, misfire policy, and current running status. + *

+ * Jobs whose detail or trigger cannot be loaded (e.g. {@code ClassNotFoundException} + * after an upgrade removed the job class) are still surfaced with an {@code error} + * field so they can be deleted via {@link #deleteSystemJob}. Jobs with no trigger + * are skipped, mirroring the legacy {@code system_jobs.jsp} behavior. + * + * @param request The current {@link HttpServletRequest} + * @param response The current {@link HttpServletResponse} + * @return List of Quartz job descriptors. + */ + @Operation( + summary = "List Quartz scheduler jobs", + description = "Returns every Quartz scheduler job across all job groups, with " + + "trigger details (next fire time, misfire instruction) and current " + + "running status. Errored jobs (e.g. class not found after upgrade) " + + "are returned with an 'error' field so admins can clean them up. " + + "Note: these are Quartz scheduler jobs, NOT the JobQueueManager " + + "jobs exposed at /api/v1/jobs — those are a separate system." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "List of Quartz scheduler jobs", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntitySystemJobListView.class))), + @ApiResponse(responseCode = "401", + description = "Unauthorized - authentication required", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "403", + description = "Forbidden - CMS Administrator role and Maintenance portlet access required", + content = @Content(mediaType = "application/json")) + }) + @GET + @Path("/_systemJobs") + @NoCache + @Produces({MediaType.APPLICATION_JSON}) + public final ResponseEntitySystemJobListView listSystemJobs( + @Parameter(hidden = true) @Context final HttpServletRequest request, + @Parameter(hidden = true) @Context final HttpServletResponse response) { + + assertBackendUser(request, response); + + final List> jobs = new ArrayList<>(); + final org.quartz.Scheduler scheduler = QuartzUtils.getScheduler(); + final SimpleDateFormat fireTimeFormat = new SimpleDateFormat("yyyy-MM-dd 'at' HH:mm:ss z"); + final String[] groups; + try { + groups = scheduler.getJobGroupNames(); + } catch (Exception e) { + Logger.error(this, "Unable to read Quartz job group names: " + e.getMessage(), e); + throw new DotRuntimeException("Unable to read Quartz scheduler groups", e); + } + + for (final String group : groups) { + final String[] taskNames; + try { + taskNames = scheduler.getJobNames(group); + } catch (Exception e) { + Logger.warn(this, "Unable to read job names for group '" + group + "': " + e.getMessage()); + continue; + } + + for (final String taskName : taskNames) { + try { + final JobDetail detail = scheduler.getJobDetail(taskName, group); + final Trigger[] triggers = scheduler.getTriggersOfJob(taskName, group); + final Trigger trigger = (triggers != null && triggers.length > 0) ? triggers[0] : null; + if (trigger == null) { + continue; + } + + final Map job = new LinkedHashMap<>(); + job.put("name", taskName); + job.put("className", detail.getJobClass().getSimpleName()); + job.put("group", group); + job.put("durable", detail.isDurable()); + job.put("stateful", detail.isStateful()); + job.put("volatile", detail.isVolatile()); + job.put("running", QuartzUtils.isJobRunning(detail.getName(), detail.getGroup())); + + if (trigger.getNextFireTime() != null) { + job.put("nextFireTime", trigger.getNextFireTime().getTime()); + job.put("nextFireTimeFormatted", + fireTimeFormat.format(trigger.getNextFireTime())); + } + + if (trigger instanceof CronTrigger) { + final int misfire = trigger.getMisfireInstruction(); + if (misfire == CronTrigger.MISFIRE_INSTRUCTION_DO_NOTHING) { + job.put("misfireInstruction", "DO_NOTHING"); + } else if (misfire == CronTrigger.MISFIRE_INSTRUCTION_FIRE_ONCE_NOW) { + job.put("misfireInstruction", "FIRE_ONCE_NOW"); + } else { + job.put("misfireInstruction", "UNKNOWN"); + } + } + + jobs.add(job); + } catch (Exception e) { + final Map errorJob = new LinkedHashMap<>(); + errorJob.put("name", taskName); + errorJob.put("group", group); + errorJob.put("error", e.getMessage()); + jobs.add(errorJob); + } + } + } + + return new ResponseEntitySystemJobListView(jobs); + } + + /** + * Deletes a Quartz scheduler job by its group and name. Primarily used to remove + * errored jobs that can no longer execute (e.g., class not found after an upgrade). + * Returns 404 if the job does not exist in the scheduler. + * + * @param request The current {@link HttpServletRequest} + * @param response The current {@link HttpServletResponse} + * @param group Quartz job group name + * @param name Quartz job name + * @return Confirmation containing the deleted job's name and group. + */ + @Operation( + summary = "Delete a Quartz scheduler job", + description = "Removes a Quartz scheduler job (and all associated triggers) " + + "from the scheduler by its group and name. Used to clean up errored " + + "or orphaned jobs after upgrades. Returns 404 if no matching job " + + "exists in the scheduler." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", + description = "Job deleted", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ResponseEntitySystemJobDeleteView.class))), + @ApiResponse(responseCode = "401", + description = "Unauthorized - authentication required", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "403", + description = "Forbidden - CMS Administrator role and Maintenance portlet access required", + content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "404", + description = "No job matches the supplied group and name", + content = @Content(mediaType = "application/json")) + }) + @DELETE + @Path("/_systemJobs/{group}/{name}") + @NoCache + @Produces({MediaType.APPLICATION_JSON}) + public final ResponseEntitySystemJobDeleteView deleteSystemJob( + @Parameter(hidden = true) @Context final HttpServletRequest request, + @Parameter(hidden = true) @Context final HttpServletResponse response, + @Parameter(description = "Quartz job group name", required = true) + @PathParam("group") final String group, + @Parameter(description = "Quartz job name", required = true) + @PathParam("name") final String name) { + + final User user = assertBackendUser(request, response).getUser(); + + Logger.info(this, "Deleting Quartz job with name=" + name + " and group=" + group); + SecurityLogger.logInfo(this.getClass(), String.format( + "User '%s' (ip=%s) is deleting Quartz job name='%s' group='%s'", + user.getUserId(), request.getRemoteAddr(), name, group)); + + final boolean removed; + try { + removed = QuartzUtils.removeJob(name, group); + } catch (Exception e) { + Logger.error(this, "Failed to delete Quartz job name=" + name + " group=" + group + + ": " + e.getMessage(), e); + throw new DotRuntimeException("Failed to delete Quartz job: " + e.getMessage(), e); + } + + if (!removed) { + throw new NotFoundException(String.format( + "No Quartz job found with name='%s' and group='%s'", name, group)); + } + + Logger.info(this, "Quartz job with name=" + name + " and group=" + group + " deleted"); + + final Map result = new LinkedHashMap<>(); + result.put("deleted", true); + result.put("name", name); + result.put("group", group); + return new ResponseEntitySystemJobDeleteView(result); + } + /** * Resolves the {@link MaintenanceJobHelper} CDI bean. Extracted to a protected method so * unit tests can override it without requiring a live CDI container. diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/ResponseEntitySystemJobDeleteView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/ResponseEntitySystemJobDeleteView.java new file mode 100644 index 000000000000..081e46c879a5 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/ResponseEntitySystemJobDeleteView.java @@ -0,0 +1,17 @@ +package com.dotcms.rest.api.v1.maintenance; + +import com.dotcms.rest.ResponseEntityView; + +import java.util.Map; + +/** + * Response wrapper for the delete Quartz system job endpoint. + * + * @author hassandotcms + */ +public class ResponseEntitySystemJobDeleteView extends ResponseEntityView> { + + public ResponseEntitySystemJobDeleteView(final Map entity) { + super(entity); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/ResponseEntitySystemJobListView.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/ResponseEntitySystemJobListView.java new file mode 100644 index 000000000000..ac508d9e5556 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/maintenance/ResponseEntitySystemJobListView.java @@ -0,0 +1,18 @@ +package com.dotcms.rest.api.v1.maintenance; + +import com.dotcms.rest.ResponseEntityView; + +import java.util.List; +import java.util.Map; + +/** + * Response wrapper for the list Quartz system jobs endpoint. + * + * @author hassandotcms + */ +public class ResponseEntitySystemJobListView extends ResponseEntityView>> { + + public ResponseEntitySystemJobListView(final List> entity) { + super(entity); + } +} diff --git a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml index d9d49f275be9..d5abd48c153a 100644 --- a/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml +++ b/dotCMS/src/main/webapp/WEB-INF/openapi/openapi.yaml @@ -11791,6 +11791,76 @@ paths: description: default response tags: - Maintenance + /v1/maintenance/_systemJobs: + get: + description: "Returns every Quartz scheduler job across all job groups, with\ + \ trigger details (next fire time, misfire instruction) and current running\ + \ status. Errored jobs (e.g. class not found after upgrade) are returned with\ + \ an 'error' field so admins can clean them up. Note: these are Quartz scheduler\ + \ jobs, NOT the JobQueueManager jobs exposed at /api/v1/jobs — those are a\ + \ separate system." + operationId: listSystemJobs + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ResponseEntitySystemJobListView" + description: List of Quartz scheduler jobs + "401": + content: + application/json: {} + description: Unauthorized - authentication required + "403": + content: + application/json: {} + description: Forbidden - CMS Administrator role and Maintenance portlet + access required + summary: List Quartz scheduler jobs + tags: + - Maintenance + /v1/maintenance/_systemJobs/{group}/{name}: + delete: + description: Removes a Quartz scheduler job (and all associated triggers) from + the scheduler by its group and name. Used to clean up errored or orphaned + jobs after upgrades. Returns 404 if no matching job exists in the scheduler. + operationId: deleteSystemJob + parameters: + - description: Quartz job group name + in: path + name: group + required: true + schema: + type: string + - description: Quartz job name + in: path + name: name + required: true + schema: + type: string + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/ResponseEntitySystemJobDeleteView" + description: Job deleted + "401": + content: + application/json: {} + description: Unauthorized - authentication required + "403": + content: + application/json: {} + description: Forbidden - CMS Administrator role and Maintenance portlet + access required + "404": + content: + application/json: {} + description: No job matches the supplied group and name + summary: Delete a Quartz scheduler job + tags: + - Maintenance /v1/maintenance/_threads: get: description: "Returns a full JVM thread dump as structured JSON, including state,\ @@ -32389,6 +32459,58 @@ components: type: array items: type: string + ResponseEntitySystemJobDeleteView: + type: object + properties: + entity: + type: object + additionalProperties: + type: object + errors: + type: array + items: + $ref: "#/components/schemas/ErrorEntity" + i18nMessagesMap: + type: object + additionalProperties: + type: string + messages: + type: array + items: + $ref: "#/components/schemas/MessageEntity" + pagination: + $ref: "#/components/schemas/Pagination" + permissions: + type: array + items: + type: string + ResponseEntitySystemJobListView: + type: object + properties: + entity: + type: array + items: + type: object + additionalProperties: + type: object + errors: + type: array + items: + $ref: "#/components/schemas/ErrorEntity" + i18nMessagesMap: + type: object + additionalProperties: + type: string + messages: + type: array + items: + $ref: "#/components/schemas/MessageEntity" + pagination: + $ref: "#/components/schemas/Pagination" + permissions: + type: array + items: + type: string ResponseEntityTagCreateView: type: object properties: diff --git a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResourceIntegrationTest.java b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResourceIntegrationTest.java index 199d4aea41a0..1b1a4411a7e1 100644 --- a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResourceIntegrationTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/maintenance/MaintenanceResourceIntegrationTest.java @@ -36,7 +36,9 @@ import com.dotcms.util.IntegrationTestInitService; import com.dotmarketing.beans.Host; import com.dotmarketing.business.APILocator; +import com.dotmarketing.init.DotInitScheduler; import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.quartz.QuartzUtils; import com.dotmarketing.util.UUIDGenerator; import com.liferay.portal.model.User; import com.liferay.portal.util.WebKeys; @@ -47,6 +49,7 @@ import java.time.Instant; import java.util.Arrays; import java.util.Collections; +import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -62,6 +65,11 @@ import org.junit.FixMethodOrder; import org.junit.Test; import org.junit.runners.MethodSorters; +import org.quartz.CronTrigger; +import org.quartz.JobDetail; +import org.quartz.JobExecutionContext; +import org.quartz.StatefulJob; +import org.quartz.Trigger; /** * Integration tests for the maintenance tools REST endpoints in {@link MaintenanceResource}. @@ -819,6 +827,121 @@ public void test_killSession_asNonAdmin_throwsSecurity() { resource.killSession(createRequestForUser(nonAdminUser), mockResponse, "tok"); } + // ==================== GET /_systemJobs ==================== + + /** + * Given scenario: a custom Quartz job is scheduled with a known misfire instruction + * ({@link CronTrigger#MISFIRE_INSTRUCTION_DO_NOTHING}), then admin + * calls listSystemJobs + * Expected result: the scheduled job appears with the EXACT field values produced by + * the scheduler: className equals the test class name, durable=true + * (set on the JobDetail), stateful=true (TestNoOpJob implements + * StatefulJob), volatile=false, running=false (just scheduled), + * nextFireTime is a future epoch ms, nextFireTimeFormatted matches + * the documented "yyyy-MM-dd 'at' HH:mm:ss z" pattern, and + * misfireInstruction is the mapped enum string "DO_NOTHING". + */ + @Test + public void test_listSystemJobs_asAdmin_returnsScheduledJob() throws Exception { + final String jobName = "test-list-job-" + UUIDGenerator.generateUuid(); + final String jobGroup = "test-jobs-group"; + final long beforeSchedule = System.currentTimeMillis(); + scheduleTestJob(jobName, jobGroup, CronTrigger.MISFIRE_INSTRUCTION_DO_NOTHING); + try { + final ResponseEntitySystemJobListView result = + resource.listSystemJobs(createAdminRequest(), mockResponse); + + assertNotNull(result); + final List> jobs = result.getEntity(); + assertNotNull(jobs); + + Map mine = null; + for (final Map job : jobs) { + if (jobName.equals(job.get("name")) && jobGroup.equals(job.get("group"))) { + mine = job; + break; + } + } + assertNotNull("The freshly scheduled test job must appear in the response", mine); + + // Class & flags — exact value assertions, not just "field exists" + assertEquals(TestNoOpJob.class.getSimpleName(), mine.get("className")); + assertEquals("durable was set to true on the JobDetail", + Boolean.TRUE, mine.get("durable")); + assertEquals("TestNoOpJob implements StatefulJob, so detail.isStateful() must be true", + Boolean.TRUE, mine.get("stateful")); + assertEquals(Boolean.FALSE, mine.get("volatile")); + assertEquals("Job was just scheduled, not currently executing", + Boolean.FALSE, mine.get("running")); + + // nextFireTime must be a future Long + final Object nextFireRaw = mine.get("nextFireTime"); + assertTrue("nextFireTime must be a Long epoch ms, was " + nextFireRaw, + nextFireRaw instanceof Long); + assertTrue("nextFireTime must be in the future for a 5-minute cron", + ((Long) nextFireRaw) > beforeSchedule); + + // Formatted timestamp must match the documented pattern + final Object formatted = mine.get("nextFireTimeFormatted"); + assertTrue("nextFireTimeFormatted must match \"yyyy-MM-dd 'at' HH:mm:ss z\", was " + + formatted, + formatted instanceof String + && ((String) formatted).matches( + "\\d{4}-\\d{2}-\\d{2} at \\d{2}:\\d{2}:\\d{2} \\S+")); + + // Misfire instruction must be the DO_NOTHING branch we explicitly scheduled with + assertEquals("Scheduled with MISFIRE_INSTRUCTION_DO_NOTHING — resource must map it", + "DO_NOTHING", mine.get("misfireInstruction")); + } finally { + QuartzUtils.removeJob(jobName, jobGroup); + } + } + + /** + * Given scenario: a non-admin user calls listSystemJobs + * Expected result: SecurityException — the maintenance portlet requires admin + */ + @Test(expected = SecurityException.class) + public void test_listSystemJobs_asNonAdmin_throwsSecurity() { + resource.listSystemJobs(createRequestForUser(nonAdminUser), mockResponse); + } + + // ==================== DELETE /_systemJobs/{group}/{name} ==================== + + /** + * Given scenario: a test job is scheduled, then admin DELETEs it via the resource + * Expected result: response indicates deleted=true with the matching name/group, and + * the job is gone from the scheduler + */ + @Test + public void test_deleteSystemJob_asAdmin_removesScheduledJob() throws Exception { + final String jobName = "test-delete-job-" + UUIDGenerator.generateUuid(); + final String jobGroup = "test-jobs-group"; + scheduleTestJob(jobName, jobGroup); + + final ResponseEntitySystemJobDeleteView result = + resource.deleteSystemJob(createAdminRequest(), mockResponse, jobGroup, jobName); + + assertNotNull(result); + final Map entity = result.getEntity(); + assertEquals(Boolean.TRUE, entity.get("deleted")); + assertEquals(jobName, entity.get("name")); + assertEquals(jobGroup, entity.get("group")); + assertNull("Job must be removed from the scheduler", + QuartzUtils.getScheduler().getJobDetail(jobName, jobGroup)); + } + + /** + * Given scenario: admin DELETEs a job that does not exist + * Expected result: NotFoundException — the resource must not silently succeed + */ + @Test(expected = NotFoundException.class) + public void test_deleteSystemJob_nonExistent_throwsNotFound() { + resource.deleteSystemJob(createAdminRequest(), mockResponse, + "no-such-group-" + UUIDGenerator.generateUuid(), + "no-such-job-" + UUIDGenerator.generateUuid()); + } + // ==================== DELETE /_sessions ==================== /** @@ -991,4 +1114,42 @@ private static com.dotcms.cache.DynamicTTLCache sessionCach field.setAccessible(true); return (com.dotcms.cache.DynamicTTLCache) field.get(null); } + + /** Schedules a {@link TestNoOpJob} with the scheduler's default misfire policy. */ + private static void scheduleTestJob(final String jobName, final String jobGroup) + throws Exception { + scheduleTestJob(jobName, jobGroup, Trigger.MISFIRE_INSTRUCTION_SMART_POLICY); + } + + /** + * Schedules a {@link TestNoOpJob} directly on the Quartz scheduler with an explicit + * misfire instruction. Mirrors the {@code QuartzUtilsTest#test_schedule_delete_job} + * pattern: raw {@link JobDetail} + {@link CronTrigger} so the {@code Class} reference + * is used directly and no {@link Class#forName(String)} lookup is needed (which would + * fail for inner classes). The 5-minute cron is harmless because {@link TestNoOpJob#execute} + * is a no-op. + */ + private static void scheduleTestJob(final String jobName, final String jobGroup, + final int misfireInstruction) throws Exception { + final JobDetail detail = new JobDetail(jobName, jobGroup, TestNoOpJob.class); + detail.setDurability(true); + final CronTrigger trigger = new CronTrigger(jobName + "_trigger", jobGroup, + jobName, jobGroup, new Date(), null, + DotInitScheduler.CRON_EXPRESSION_EVERY_5_MINUTES); + trigger.setMisfireInstruction(misfireInstruction); + QuartzUtils.getScheduler().addJob(detail, true); + QuartzUtils.getScheduler().scheduleJob(trigger); + } + + /** + * Inert Quartz {@link StatefulJob} used by the {@code _systemJobs} tests. Mirrors the + * inner-class pattern in {@code QuartzUtilsTest.TestJob}: never fires (we only need + * scheduler-side visibility), so {@link #execute} is a no-op. + */ + public static class TestNoOpJob implements StatefulJob { + @Override + public void execute(final JobExecutionContext context) { + // intentional no-op + } + } }