package com.thinkbiganalytics.jobrepo.rest.controller; /*- * #%L * thinkbig-job-repository-controller * %% * Copyright (C) 2017 ThinkBig Analytics * %% * Licensed 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. * #L% */ import com.thinkbiganalytics.DateTimeUtil; import com.thinkbiganalytics.jobrepo.query.model.ExecutedJob; import com.thinkbiganalytics.jobrepo.query.model.ExecutedStep; import com.thinkbiganalytics.jobrepo.query.model.FeedHealth; import com.thinkbiganalytics.jobrepo.query.model.JobStatusCount; import com.thinkbiganalytics.jobrepo.query.model.SearchResult; import com.thinkbiganalytics.jobrepo.query.model.transform.JobModelTransform; import com.thinkbiganalytics.jobrepo.query.model.transform.JobStatusTransform; import com.thinkbiganalytics.jobrepo.query.model.transform.ModelUtils; import com.thinkbiganalytics.jobrepo.repository.rest.model.JobAction; import com.thinkbiganalytics.jobrepo.security.OperationsAccessControl; import com.thinkbiganalytics.jobrepo.service.JobExecutionException; import com.thinkbiganalytics.jobrepo.service.JobService; import com.thinkbiganalytics.metadata.api.MetadataAccess; import com.thinkbiganalytics.metadata.api.feed.OpsManagerFeedProvider; import com.thinkbiganalytics.metadata.api.jobrepo.job.BatchJobExecution; import com.thinkbiganalytics.metadata.api.jobrepo.job.BatchJobExecutionProvider; import com.thinkbiganalytics.metadata.api.jobrepo.step.BatchStepExecution; import com.thinkbiganalytics.metadata.api.jobrepo.step.BatchStepExecutionProvider; import com.thinkbiganalytics.rest.model.RestResponseStatus; import com.thinkbiganalytics.security.AccessController; import org.apache.commons.lang3.StringUtils; import org.joda.time.Period; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import java.util.Collections; import java.util.List; import java.util.stream.Collectors; import javax.inject.Inject; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.Consumes; import javax.ws.rs.DefaultValue; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiResponse; import io.swagger.annotations.ApiResponses; /** * Provides rest endpoints for control and monitoring of the pipeline */ @Api(tags = "Operations Manager - Jobs", produces = "application/json") @Path(JobsRestController.BASE) public class JobsRestController { public static final String BASE = "/v1/jobs"; @Inject OpsManagerFeedProvider opsFeedManagerFeedProvider; @Inject BatchJobExecutionProvider jobExecutionProvider; @Inject BatchStepExecutionProvider stepExecutionProvider; @Inject private MetadataAccess metadataAccess; @Inject private JobService jobService; @Inject private AccessController accessController; @GET @Path("/{executionId}") @Produces(MediaType.APPLICATION_JSON) @ApiOperation("Gets the specified job.") @ApiResponses({ @ApiResponse(code = 200, message = "Returns the job.", response = ExecutedJob.class), @ApiResponse(code = 400, message = "The executionId is not a valid integer.", response = RestResponseStatus.class) }) public ExecutedJob getJob(@PathParam("executionId") String executionId, @QueryParam(value = "includeSteps") @DefaultValue("false") boolean includeSteps) { this.accessController.checkPermission(AccessController.SERVICES, OperationsAccessControl.ACCESS_OPS); return metadataAccess.read(() -> { ExecutedJob executedJob = null; BatchJobExecution jobExecution = jobExecutionProvider.findByJobExecutionId(Long.parseLong(executionId)); if (jobExecution != null) { if (includeSteps) { executedJob = JobModelTransform.executedJob(jobExecution); } else { executedJob = JobModelTransform.executedJobSimple(jobExecution); } } return executedJob; }); } /** * Get the progress of each of the steps of the job execution for the given job instance id * * @return A list of each step and its progress, or an HTTP error code on failure */ @GET @Path("/{executionId}/steps") @Produces(MediaType.APPLICATION_JSON) @ApiOperation("Gets the steps of the specified job.") @ApiResponses({ @ApiResponse(code = 200, message = "Returns the steps.", response = ExecutedStep.class, responseContainer = "List"), @ApiResponse(code = 400, message = "The executionId is not a valid integer.", response = RestResponseStatus.class) }) public List<ExecutedStep> getJobSteps(@PathParam("executionId") String executionId) { this.accessController.checkPermission(AccessController.SERVICES, OperationsAccessControl.ACCESS_OPS); return metadataAccess.read(() -> { List<? extends BatchStepExecution> steps = stepExecutionProvider.getSteps(Long.parseLong(executionId)); return JobModelTransform.executedSteps(steps); }); } /** * Restart the job associated with the given instance id * * @return A status message and the appropriate http status code */ @POST @Path("/{executionId}/restart") @Produces(MediaType.APPLICATION_JSON) @ApiOperation(value = "Restarts the specified job.", hidden = true) @ApiResponses({ @ApiResponse(code = 200, message = "Returns the job.", response = ExecutedJob.class), @ApiResponse(code = 404, message = "The executionId is not a valid integer.", response = RestResponseStatus.class) }) public ExecutedJob restartJob(@PathParam("executionId") Long executionId) throws JobExecutionException { this.accessController.checkPermission(AccessController.SERVICES, OperationsAccessControl.ADMIN_OPS); ExecutedJob job = metadataAccess.commit(() -> { Long newJobExecutionId = this.jobService.restartJobExecution(executionId); if (newJobExecutionId != null) { BatchJobExecution jobExecution = jobExecutionProvider.findByJobExecutionId(newJobExecutionId); if (jobExecution != null) { return JobModelTransform.executedJob(jobExecution); } else { return null; } } else { return null; } }); if (job == null) { throw new JobExecutionException("Could not restart the job with execution Id of " + executionId); } return job; } /** * Stop the job associated with the given instance id * * @param executionId The job instance id * @return A status message and the appropriate http status code */ @POST @Path("/{executionId}/stop") @Consumes({MediaType.APPLICATION_JSON, MediaType.APPLICATION_FORM_URLENCODED}) @Produces(MediaType.APPLICATION_JSON) @ApiOperation(value = "Stops the specified job.", hidden = true) @ApiResponses({ @ApiResponse(code = 200, message = "Returns the job.", response = ExecutedJob.class), @ApiResponse(code = 404, message = "The executionId is not a valid integer.", response = RestResponseStatus.class) }) public ExecutedJob stopJob(@PathParam("executionId") Long executionId, JobAction jobAction) { this.accessController.checkPermission(AccessController.SERVICES, OperationsAccessControl.ADMIN_OPS); metadataAccess.commit(() -> { boolean stopped = this.jobService.stopJobExecution(executionId); return stopped; }); return getJob(executionId.toString(), jobAction.isIncludeSteps()); } /** * Abandon the job associated with the given instance id * * @param executionId The job instance id * @return A status message and the appropriate http status code */ @POST @Path("/{executionId}/abandon") @Consumes({MediaType.APPLICATION_JSON, MediaType.APPLICATION_FORM_URLENCODED}) @Produces(MediaType.APPLICATION_JSON) @ApiOperation("Abandons the specified job.") @ApiResponses({ @ApiResponse(code = 200, message = "Returns the abandoned job.", response = ExecutedJob.class), @ApiResponse(code = 204, message = "The job could not be found."), @ApiResponse(code = 404, message = "The executionId is not a valid integer.", response = RestResponseStatus.class) }) public ExecutedJob abandonJob(@PathParam("executionId") Long executionId, JobAction jobAction) { this.accessController.checkPermission(AccessController.SERVICES, OperationsAccessControl.ADMIN_OPS); metadataAccess.commit(() -> { this.jobService.abandonJobExecution(executionId); return null; }); return getJob(executionId.toString(), jobAction.isIncludeSteps()); } /** * Abandon the job associated with the given instance id * * @param feedName Full feed name (including category) for which all jobs are to be abandoned * @return Feed Health status */ @POST @Path("/abandon-all/{feedName}") @Consumes({MediaType.APPLICATION_JSON, MediaType.APPLICATION_FORM_URLENCODED}) @Produces(MediaType.APPLICATION_JSON) @ApiOperation("Abandons all jobs for the specified feed.") @ApiResponses( @ApiResponse(code = 200, message = "Returns the feed health.", response = FeedHealth.class) ) public FeedHealth abandonAllJobs(@Context HttpServletRequest request, @PathParam("feedName") String feedName) { this.accessController.checkPermission(AccessController.SERVICES, OperationsAccessControl.ADMIN_OPS); return metadataAccess.commit(() -> { opsFeedManagerFeedProvider.abandonFeedJobs(feedName); return null; }); } /** * Fail the job associated with the given instance id * * @param executionId The job instance id * @return A status message and the appropriate http status code */ @POST @Path("/{executionId}/fail") @Consumes({MediaType.APPLICATION_JSON, MediaType.APPLICATION_FORM_URLENCODED}) @Produces(MediaType.APPLICATION_JSON) @ApiOperation("Fails the specified job.") @ApiResponses({ @ApiResponse(code = 200, message = "Returns the job.", response = ExecutedJob.class), @ApiResponse(code = 404, message = "The executionId is not a valid integer.", response = RestResponseStatus.class) }) public ExecutedJob failJob(@PathParam("executionId") Long executionId, JobAction jobAction) { this.accessController.checkPermission(AccessController.SERVICES, OperationsAccessControl.ADMIN_OPS); metadataAccess.commit(() -> { this.jobService.failJobExecution(executionId); return null; }); return getJob(executionId.toString(), jobAction.isIncludeSteps()); } @GET @Path("/") @Produces(MediaType.APPLICATION_JSON) @ApiOperation("Lists all jobs.") @ApiResponses({ @ApiResponse(code = 200, message = "Returns the jobs.", response = SearchResult.class), @ApiResponse(code = 400, message = "The sort cannot be empty.", response = RestResponseStatus.class), @ApiResponse(code = 404, message = "The start or limit is not a valid integer.", response = RestResponseStatus.class), @ApiResponse(code = 500, message = "The sort contains an invalid value.", response = RestResponseStatus.class) }) public SearchResult findJobs(@QueryParam("sort") @DefaultValue("") String sort, @QueryParam("limit") @DefaultValue("10") Integer limit, @QueryParam("start") @DefaultValue("1") Integer start, @QueryParam("filter") String filter, @Context HttpServletRequest request) { return metadataAccess.read(() -> { Page<ExecutedJob> page = jobExecutionProvider.findAll(filter, pageRequest(start, limit, sort)).map(jobExecution -> JobModelTransform.executedJobSimple(jobExecution)); return ModelUtils.toSearchResult(page); }); } @GET @Path("/list") @ApiOperation("Lists all jobs.") @ApiResponses({ @ApiResponse(code = 200, message = "Returns the jobs.", response = SearchResult.class), @ApiResponse(code = 400, message = "The sort cannot be empty.", response = RestResponseStatus.class), @ApiResponse(code = 404, message = "The start or limit is not a valid integer.", response = RestResponseStatus.class), @ApiResponse(code = 500, message = "The sort contains an invalid value.", response = RestResponseStatus.class) }) public List<ExecutedJob> findJobsList(@QueryParam("sort") @DefaultValue("") String sort, @QueryParam("limit") @DefaultValue("10") Integer limit, @QueryParam("start") @DefaultValue("1") Integer start, @QueryParam("filter") String filter, @Context HttpServletRequest request) { this.accessController.checkPermission(AccessController.SERVICES, OperationsAccessControl.ACCESS_OPS); return metadataAccess.read(() -> { Page<ExecutedJob> page = jobExecutionProvider.findAll(filter, pageRequest(start, limit, sort)).map(jobExecution -> JobModelTransform.executedJobSimple(jobExecution)); return page != null ? page.getContent() : Collections.emptyList(); }); } @GET @Path("/running") @Produces(MediaType.APPLICATION_JSON) @ApiOperation("Gets the list of running jobs.") @ApiResponses({ @ApiResponse(code = 200, message = "Returns the jobs.", response = SearchResult.class), @ApiResponse(code = 400, message = "The sort cannot be empty.", response = RestResponseStatus.class), @ApiResponse(code = 404, message = "The start or limit is not a valid integer.", response = RestResponseStatus.class), @ApiResponse(code = 500, message = "The sort contains an invalid value.", response = RestResponseStatus.class) }) public SearchResult findRunningJobs(@QueryParam("sort") @DefaultValue("") String sort, @QueryParam("limit") @DefaultValue("10") Integer limit, @QueryParam("start") @DefaultValue("1") Integer start, @QueryParam("filter") String filter, @Context HttpServletRequest request) { this.accessController.checkPermission(AccessController.SERVICES, OperationsAccessControl.ACCESS_OPS); return metadataAccess.read(() -> { String defaultFilter = ensureDefaultFilter(filter, jobExecutionProvider.RUNNING_FILTER); Page<ExecutedJob> page = jobExecutionProvider.findAll(defaultFilter, pageRequest(start, limit, sort)).map(jobExecution -> JobModelTransform.executedJobSimple(jobExecution)); return ModelUtils.toSearchResult(page); }); } @GET @Path("/failed") @Produces(MediaType.APPLICATION_JSON) @ApiOperation("Gets the list of failed jobs.") @ApiResponses({ @ApiResponse(code = 200, message = "Returns the jobs.", response = SearchResult.class), @ApiResponse(code = 400, message = "The sort cannot be empty.", response = RestResponseStatus.class), @ApiResponse(code = 404, message = "The start or limit is not a valid integer.", response = RestResponseStatus.class), @ApiResponse(code = 500, message = "The sort contains an invalid value.", response = RestResponseStatus.class) }) public SearchResult findFailedJobs(@QueryParam("sort") @DefaultValue("") String sort, @QueryParam("limit") @DefaultValue("10") Integer limit, @QueryParam("start") @DefaultValue("1") Integer start, @QueryParam("filter") String filter, @Context HttpServletRequest request) { return metadataAccess.read(() -> { String defaultFilter = ensureDefaultFilter(filter, jobExecutionProvider.FAILED_FILTER); Page<ExecutedJob> page = jobExecutionProvider.findAll(defaultFilter, pageRequest(start, limit, sort)).map(jobExecution -> JobModelTransform.executedJobSimple(jobExecution)); return ModelUtils.toSearchResult(page); }); } @GET @Path("/stopped") @Produces(MediaType.APPLICATION_JSON) @ApiOperation("Gets the list of failed jobs.") @ApiResponses({ @ApiResponse(code = 200, message = "Returns the jobs.", response = SearchResult.class), @ApiResponse(code = 400, message = "The sort cannot be empty.", response = RestResponseStatus.class), @ApiResponse(code = 404, message = "The start or limit is not a valid integer.", response = RestResponseStatus.class), @ApiResponse(code = 500, message = "The sort contains an invalid value.", response = RestResponseStatus.class) }) public SearchResult findStoppedJobs(@QueryParam("sort") @DefaultValue("") String sort, @QueryParam("limit") @DefaultValue("10") Integer limit, @QueryParam("start") @DefaultValue("1") Integer start, @QueryParam("filter") String filter, @Context HttpServletRequest request) { this.accessController.checkPermission(AccessController.SERVICES, OperationsAccessControl.ACCESS_OPS); return metadataAccess.read(() -> { String defaultFilter = ensureDefaultFilter(filter, jobExecutionProvider.STOPPED_FILTER); Page<ExecutedJob> page = jobExecutionProvider.findAll(defaultFilter, pageRequest(start, limit, sort)).map(jobExecution -> JobModelTransform.executedJobSimple(jobExecution)); return ModelUtils.toSearchResult(page); }); } @GET @Path("/completed") @Produces(MediaType.APPLICATION_JSON) @ApiOperation("Gets the list of completed jobs.") @ApiResponses({ @ApiResponse(code = 200, message = "Returns the jobs.", response = SearchResult.class), @ApiResponse(code = 400, message = "The sort cannot be empty.", response = RestResponseStatus.class), @ApiResponse(code = 404, message = "The start or limit is not a valid integer.", response = RestResponseStatus.class), @ApiResponse(code = 500, message = "The sort contains an invalid value.", response = RestResponseStatus.class) }) public SearchResult findCompletedJobs(@QueryParam("sort") @DefaultValue("") String sort, @QueryParam("limit") @DefaultValue("10") Integer limit, @QueryParam("start") @DefaultValue("1") Integer start, @QueryParam("filter") String filter, @Context HttpServletRequest request) { this.accessController.checkPermission(AccessController.SERVICES, OperationsAccessControl.ACCESS_OPS); return metadataAccess.read(() -> { String defaultFilter = ensureDefaultFilter(filter, jobExecutionProvider.COMPLETED_FILTER); Page<ExecutedJob> page = jobExecutionProvider.findAll(defaultFilter, pageRequest(start, limit, sort)).map(jobExecution -> JobModelTransform.executedJobSimple(jobExecution)); return ModelUtils.toSearchResult(page); }); } @GET @Path("/abandoned") @Produces(MediaType.APPLICATION_JSON) @ApiOperation("Gets the list of abandoned jobs.") @ApiResponses({ @ApiResponse(code = 200, message = "Returns the jobs.", response = SearchResult.class), @ApiResponse(code = 400, message = "The sort cannot be empty.", response = RestResponseStatus.class), @ApiResponse(code = 404, message = "The start or limit is not a valid integer.", response = RestResponseStatus.class), @ApiResponse(code = 500, message = "The sort contains an invalid value.", response = RestResponseStatus.class) }) public SearchResult findAbandonedJobs(@QueryParam("sort") @DefaultValue("") String sort, @QueryParam("limit") @DefaultValue("10") Integer limit, @QueryParam("start") @DefaultValue("1") Integer start, @QueryParam("filter") String filter, @Context HttpServletRequest request) { this.accessController.checkPermission(AccessController.SERVICES, OperationsAccessControl.ACCESS_OPS); return metadataAccess.read(() -> { String defaultFilter = ensureDefaultFilter(filter, jobExecutionProvider.ABANDONED_FILTER); Page<ExecutedJob> page = jobExecutionProvider.findAll(defaultFilter, pageRequest(start, limit, sort)).map(jobExecution -> JobModelTransform.executedJobSimple(jobExecution)); return ModelUtils.toSearchResult(page); }); } @GET @Path("/daily-status-count") @Produces(MediaType.APPLICATION_JSON) @ApiOperation("Gets the daily statistics.") @ApiResponses( @ApiResponse(code = 200, message = "Returns the daily stats.", response = JobStatusCount.class, responseContainer = "List") ) public List<JobStatusCount> findDailyStatusCount(@QueryParam("period") String periodString) { this.accessController.checkPermission(AccessController.SERVICES, OperationsAccessControl.ACCESS_OPS); Period period = DateTimeUtil.period(periodString); return metadataAccess.read(() -> { List<com.thinkbiganalytics.metadata.api.jobrepo.job.JobStatusCount> counts = jobExecutionProvider.getJobStatusCountByDateFromNow(period, null); if (counts != null) { List<JobStatusCount> jobStatusCounts = counts.stream().map(c -> JobStatusTransform.jobStatusCount(c)).collect(Collectors.toList()); JobStatusTransform.ensureDateFromPeriodExists(jobStatusCounts, period); return jobStatusCounts; } return Collections.emptyList(); }); } @GET @Path("/running-failed-counts") @Produces(MediaType.APPLICATION_JSON) @ApiOperation("Gets the daily statistics.") @ApiResponses( @ApiResponse(code = 200, message = "Returns the daily stats.", response = JobStatusCount.class, responseContainer = "List") ) public List<JobStatusCount> getRunningOrFailedJobCounts() { this.accessController.checkPermission(AccessController.SERVICES, OperationsAccessControl.ACCESS_OPS); return metadataAccess.read(() -> { List<com.thinkbiganalytics.metadata.api.jobrepo.job.JobStatusCount> counts = jobExecutionProvider.getJobStatusCount(jobExecutionProvider.RUNNING_OR_FAILED_FILTER); if (counts != null) { return counts.stream().map(c -> JobStatusTransform.jobStatusCount(c)).collect(Collectors.toList()); } return Collections.emptyList(); }); } /** * This will evaluate the {@code incomingFilter} and append/set the value including the {@code defaultFilter} and return a new String with the updated filter */ private String ensureDefaultFilter(String incomingFilter, String defaultFilter) { String filter = incomingFilter; if (StringUtils.isBlank(filter) || !StringUtils.containsIgnoreCase(filter, defaultFilter)) { if (StringUtils.isNotBlank(filter)) { if (StringUtils.endsWith(filter, ",")) { filter += defaultFilter; } else { filter += "," + defaultFilter; } } else { filter = defaultFilter; } } return filter; } private PageRequest pageRequest(Integer start, Integer limit, String sort) { if (StringUtils.isNotBlank(sort)) { Sort.Direction dir = Sort.Direction.ASC; if (sort.startsWith("-")) { dir = Sort.Direction.DESC; sort = sort.substring(1); } return new PageRequest((start / limit), limit, dir, sort); } else { return new PageRequest((start / limit), limit); } } }