View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  package org.opencastproject.adminui.endpoint;
22  
23  import static org.opencastproject.index.service.util.JSONUtils.mapToJsonObject;
24  import static org.opencastproject.index.service.util.JSONUtils.safeString;
25  import static org.opencastproject.util.DateTimeSupport.toUTC;
26  
27  import org.opencastproject.adminui.exception.JobEndpointException;
28  import org.opencastproject.index.service.resources.list.query.JobsListQuery;
29  import org.opencastproject.index.service.util.RestUtils;
30  import org.opencastproject.job.api.Incident;
31  import org.opencastproject.job.api.IncidentTree;
32  import org.opencastproject.job.api.Job;
33  import org.opencastproject.security.api.UserDirectoryService;
34  import org.opencastproject.serviceregistry.api.HostRegistration;
35  import org.opencastproject.serviceregistry.api.IncidentL10n;
36  import org.opencastproject.serviceregistry.api.IncidentService;
37  import org.opencastproject.serviceregistry.api.IncidentServiceException;
38  import org.opencastproject.serviceregistry.api.ServiceRegistry;
39  import org.opencastproject.serviceregistry.api.ServiceRegistryException;
40  import org.opencastproject.util.DateTimeSupport;
41  import org.opencastproject.util.NotFoundException;
42  import org.opencastproject.util.RestUtil;
43  import org.opencastproject.util.SmartIterator;
44  import org.opencastproject.util.data.Tuple;
45  import org.opencastproject.util.doc.rest.RestParameter;
46  import org.opencastproject.util.doc.rest.RestQuery;
47  import org.opencastproject.util.doc.rest.RestResponse;
48  import org.opencastproject.util.doc.rest.RestService;
49  import org.opencastproject.util.requests.SortCriterion;
50  import org.opencastproject.util.requests.SortCriterion.Order;
51  import org.opencastproject.workflow.api.WorkflowService;
52  
53  import com.google.gson.JsonArray;
54  import com.google.gson.JsonObject;
55  
56  import org.apache.commons.lang3.StringUtils;
57  import org.osgi.framework.BundleContext;
58  import org.osgi.service.component.annotations.Activate;
59  import org.osgi.service.component.annotations.Component;
60  import org.osgi.service.component.annotations.Reference;
61  import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
62  import org.slf4j.Logger;
63  import org.slf4j.LoggerFactory;
64  
65  import java.util.ArrayList;
66  import java.util.Collections;
67  import java.util.Comparator;
68  import java.util.List;
69  import java.util.Locale;
70  import java.util.Optional;
71  
72  import javax.servlet.http.HttpServletResponse;
73  import javax.ws.rs.GET;
74  import javax.ws.rs.Path;
75  import javax.ws.rs.Produces;
76  import javax.ws.rs.QueryParam;
77  import javax.ws.rs.WebApplicationException;
78  import javax.ws.rs.core.MediaType;
79  import javax.ws.rs.core.Response;
80  
81  @Path("/admin-ng/job")
82  @RestService(name = "JobProxyService", title = "UI Jobs",
83    abstractText = "This service provides the job data for the UI.",
84    notes = { "These Endpoints deliver informations about the job required for the UI.",
85              "<strong>Important:</strong> "
86                + "<em>This service is for exclusive use by the module admin-ui. Its API might change "
87                + "anytime without prior notice. Any dependencies other than the admin UI will be strictly ignored. "
88                + "DO NOT use this for integration of third-party applications.<em>"})
89  @Component(
90    immediate = true,
91    service = JobEndpoint.class,
92    property = {
93      "service.description=Admin UI - Job facade Endpoint",
94      "opencast.service.type=org.opencastproject.adminui.endpoint.JobEndpoint",
95      "opencast.service.path=/admin-ng/job"
96    }
97  )
98  @JaxrsResource
99  public class JobEndpoint {
100 
101   private static final Logger logger = LoggerFactory.getLogger(JobEndpoint.class);
102   public static final Response NOT_FOUND = Response.status(Response.Status.NOT_FOUND).build();
103 
104   private enum JobSort {
105     CREATOR, OPERATION, PROCESSINGHOST, PROCESSINGNODE, STATUS, STARTED, SUBMITTED, TYPE, ID
106   }
107 
108   private static final String NEGATE_PREFIX = "-";
109   private static final String WORKFLOW_STATUS_TRANSLATION_PREFIX = "EVENTS.EVENTS.DETAILS.WORKFLOWS.OPERATION_STATUS.";
110   private static final String JOB_STATUS_TRANSLATION_PREFIX = "SYSTEMS.JOBS.STATUS.";
111 
112   private WorkflowService workflowService;
113   private ServiceRegistry serviceRegistry;
114   private IncidentService incidentService;
115   private UserDirectoryService userDirectoryService;
116 
117   /** OSGi callback for the workflow service. */
118   @Reference
119   public void setWorkflowService(WorkflowService workflowService) {
120     this.workflowService = workflowService;
121   }
122 
123   /** OSGi callback for the service registry. */
124   @Reference
125   public void setServiceRegistry(ServiceRegistry serviceRegistry) {
126     this.serviceRegistry = serviceRegistry;
127   }
128 
129   /** OSGi callback for the incident service. */
130   @Reference
131   public void setIncidentService(IncidentService incidentService) {
132     this.incidentService = incidentService;
133   }
134 
135   @Reference
136   public void setUserDirectoryService(UserDirectoryService userDirectoryService) {
137     this.userDirectoryService = userDirectoryService;
138   }
139 
140   @Activate
141   protected void activate(BundleContext bundleContext) {
142     logger.info("Activate job endpoint");
143   }
144 
145   @GET
146   @Path("jobs.json")
147   @Produces(MediaType.APPLICATION_JSON)
148   @RestQuery(description = "Returns the list of active jobs", name = "jobs", restParameters = {
149           @RestParameter(name = "limit", description = "The maximum number of items to return per page", isRequired = false, type = RestParameter.Type.INTEGER),
150           @RestParameter(name = "offset", description = "The offset", isRequired = false, type = RestParameter.Type.INTEGER),
151           @RestParameter(name = "filter", description = "Filter results by hostname, status or free text query", isRequired = false, type = RestParameter.Type.STRING),
152           @RestParameter(name = "sort", description = "The sort order. May include any of the following: CREATOR, OPERATION, PROCESSINGHOST, STATUS, STARTED, SUBMITTED or TYPE. "
153                   + "The suffix must be :ASC for ascending or :DESC for descending sort order (e.g. OPERATION:DESC)", isRequired = false, type = RestParameter.Type.STRING)},
154           responses = { @RestResponse(description = "Returns the list of active jobs from Opencast", responseCode = HttpServletResponse.SC_OK) },
155           returnDescription = "The list of jobs as JSON")
156   public Response getJobs(@QueryParam("limit") final int limit, @QueryParam("offset") final int offset,
157           @QueryParam("filter") final String filter, @QueryParam("sort") final String sort) {
158     JobsListQuery query = new JobsListQuery();
159     EndpointUtil.addRequestFiltersToQuery(filter, query);
160     query.setLimit(limit);
161     query.setOffset(offset);
162 
163     String fHostname = null;
164     if (query.getHostname().isPresent())
165       fHostname = StringUtils.trimToNull(query.getHostname().get());
166     String fNodeName = null;
167     if (query.getNodeName().isPresent())
168       fNodeName = StringUtils.trimToNull(query.getNodeName().get());
169     String fStatus = null;
170     if (query.getStatus().isPresent())
171       fStatus = StringUtils.trimToNull(query.getStatus().get());
172     String fFreeText = null;
173     if (query.getFreeText().isPresent())
174       fFreeText = StringUtils.trimToNull(query.getFreeText().get());
175 
176     List<JobExtended> jobs = new ArrayList<>();
177     try {
178       String vNodeName;
179       Optional<HostRegistration> server;
180       List<HostRegistration> servers = serviceRegistry.getHostRegistrations();
181       for (Job job : serviceRegistry.getActiveJobs()) {
182         // filter workflow jobs
183         if (StringUtils.equals(WorkflowService.JOB_TYPE, job.getJobType())
184                 && StringUtils.equals("START_WORKFLOW", job.getOperation()))
185           continue;
186 
187         // filter by hostname
188         if (fHostname != null && !StringUtils.equalsIgnoreCase(job.getProcessingHost(), fHostname))
189           continue;
190 
191         server = findServerByHost(job.getProcessingHost(), servers);
192         vNodeName = server.isPresent() ? server.get().getNodeName() : "";
193 
194         // filter by node name
195         if (fNodeName != null && (server.isPresent()) && !StringUtils.equalsIgnoreCase(vNodeName, fNodeName))
196           continue;
197 
198         // filter by status
199         if (fStatus != null && !StringUtils.equalsIgnoreCase(job.getStatus().toString(), fStatus))
200           continue;
201 
202         // fitler by user free text
203         if (fFreeText != null
204               && !StringUtils.equalsIgnoreCase(job.getProcessingHost(), fFreeText)
205               && !StringUtils.equalsIgnoreCase(vNodeName, fFreeText)
206               && !StringUtils.equalsIgnoreCase(job.getJobType(), fFreeText)
207               && !StringUtils.equalsIgnoreCase(job.getOperation(), fFreeText)
208               && !StringUtils.equalsIgnoreCase(job.getCreator(), fFreeText)
209               && !StringUtils.equalsIgnoreCase(job.getStatus().toString(), fFreeText)
210               && !StringUtils.equalsIgnoreCase(Long.toString(job.getId()), fFreeText)
211               && (job.getRootJobId() != null && !StringUtils.equalsIgnoreCase(Long.toString(job.getRootJobId()), fFreeText)))
212           continue;
213         jobs.add(new JobExtended(job, vNodeName));
214       }
215     } catch (ServiceRegistryException ex) {
216       logger.error("Failed to retrieve jobs list from service registry.", ex);
217       return RestUtil.R.serverError();
218     }
219 
220     JobSort sortKey = JobSort.SUBMITTED;
221     boolean ascending = true;
222     if (StringUtils.isNotBlank(sort)) {
223       try {
224         SortCriterion sortCriterion = RestUtils.parseSortQueryParameter(sort).iterator().next();
225         sortKey = JobSort.valueOf(sortCriterion.getFieldName().toUpperCase());
226         ascending = Order.Ascending == sortCriterion.getOrder()
227                 || Order.None == sortCriterion.getOrder();
228       } catch (WebApplicationException ex) {
229         logger.warn("Failed to parse sort criterion \"{}\", invalid format.", sort);
230       } catch (IllegalArgumentException ex) {
231         logger.warn("Can not apply sort criterion \"{}\", no field with this name.", sort);
232       }
233     }
234 
235     JobComparator comparator = new JobComparator(sortKey, ascending);
236     Collections.sort(jobs, comparator);
237     List<JsonObject> json = getJobsAsJSON(new SmartIterator(
238             query.getLimit().orElse(0),
239             query.getOffset().orElse(0))
240             .applyLimitAndOffset(jobs));
241 
242     return RestUtils.okJsonList(json, offset, limit, jobs.size());
243   }
244 
245   /* Class to handle additional information related to a job */
246   class JobExtended {
247 
248     private final Job job;
249     private final String nodeName;
250 
251     JobExtended(Job job, String nodeName) {
252       this.job = job;
253       this.nodeName = nodeName;
254     }
255 
256     public Job getJob() {
257       return job;
258     }
259 
260     public String getNodeName() {
261       return nodeName;
262     }
263   }
264 
265   public List<JsonObject> getJobsAsJSON(List<JobExtended> jobs) {
266     List<JsonObject> jsonList = new ArrayList<>();
267     for (JobExtended jobEx : jobs) {
268       Job job = jobEx.getJob();
269 
270       JsonObject jobJson = new JsonObject();
271       jobJson.addProperty("id", job.getId());
272       jobJson.addProperty("type", job.getJobType());
273       jobJson.addProperty("operation", job.getOperation());
274       jobJson.addProperty("status", JOB_STATUS_TRANSLATION_PREFIX + job.getStatus().toString());
275       jobJson.addProperty("submitted", job.getDateCreated() != null ? DateTimeSupport.toUTC(job.getDateCreated().getTime()) : "");
276       jobJson.addProperty("started", job.getDateStarted() != null ? DateTimeSupport.toUTC(job.getDateStarted().getTime()) : "");
277       jobJson.addProperty("creator", safeString(job.getCreator()));
278       jobJson.addProperty("processingHost", safeString(job.getProcessingHost()));
279       jobJson.addProperty("processingNode", safeString(jobEx.getNodeName()));
280 
281       jsonList.add(jobJson);
282     }
283     return jsonList;
284   }
285 
286   /**
287    * Returns the list of incidents for a given workflow instance
288    *
289    * @param jobId
290    *          the workflow instance id
291    * @param locale
292    *          the language in which title and description shall be returned
293    * @param cascade
294    *          if true, return the incidents of the given job and those of of its descendants
295    * @return the list incidents as JSON array
296    * @throws JobEndpointException
297    * @throws NotFoundException
298    */
299 
300   public JsonArray getIncidentsAsJSON(long jobId, final Locale locale, boolean cascade)
301       throws JobEndpointException, NotFoundException {
302     final List<Incident> incidents;
303     try {
304       final IncidentTree it = incidentService.getIncidentsOfJob(jobId, cascade);
305       incidents = cascade ? flatten(it) : it.getIncidents();
306     } catch (IncidentServiceException e) {
307       throw new JobEndpointException(String.format(
308           "Not able to get the incidents for the job %d from the incident service : %s", jobId, e), e.getCause());
309     }
310 
311     JsonArray resultArray = new JsonArray();
312     for (Incident i : incidents) {
313       JsonObject incidentJson = new JsonObject();
314 
315       incidentJson.addProperty("id", i.getId());
316       incidentJson.addProperty("severity", safeString(i.getSeverity()));
317       incidentJson.addProperty("timestamp", safeString(toUTC(i.getTimestamp().getTime())));
318 
319       // Merge localized fields
320       JsonObject localized = localizeIncident(i, locale);
321       for (String key : localized.keySet()) {
322         incidentJson.add(key, localized.get(key));
323       }
324 
325       resultArray.add(incidentJson);
326     }
327 
328     return resultArray;
329   }
330 
331   /**
332    * Flatten a tree of incidents.
333    *
334    * @return a list of incidents
335    */
336   private List<Incident> flatten(IncidentTree incidentsTree) {
337     final List<Incident> incidents = new ArrayList<>();
338     incidents.addAll(incidentsTree.getIncidents());
339     for (IncidentTree descendantTree : incidentsTree.getDescendants()) {
340       incidents.addAll(flatten(descendantTree));
341     }
342     return incidents;
343   }
344 
345   /**
346    * Return localized title and description of an incident as JSON.
347    *
348    * @param incident
349    *          the incident to localize
350    * @param locale
351    *          the locale to be used to create title and description
352    * @return JSON object
353    */
354   private JsonObject localizeIncident(Incident incident, Locale locale) {
355     JsonObject localized = new JsonObject();
356 
357     try {
358       IncidentL10n loc = incidentService.getLocalization(incident.getId(), locale);
359       localized.addProperty("title", safeString(loc.getTitle()));
360       localized.addProperty("description", safeString(loc.getDescription()));
361     } catch (Exception e) {
362       localized.addProperty("title", "");
363       localized.addProperty("description", "");
364     }
365 
366     return localized;
367   }
368 
369   /**
370    * Return an incident serialized as JSON.
371    *
372    * @param id
373    *          incident id
374    * @param locale
375    *          the locale to be used to create title and description
376    * @return JSON object
377    */
378   public JsonObject getIncidentAsJSON(long id, Locale locale) throws JobEndpointException, NotFoundException {
379     final Incident incident;
380     try {
381       incident = incidentService.getIncident(id);
382     } catch (IncidentServiceException e) {
383       throw new JobEndpointException(String.format("Not able to get the incident %d: %s", id, e), e.getCause());
384     }
385 
386     Long rootJobId = null;
387     try {
388       Job job = serviceRegistry.getJob(incident.getJobId());
389       rootJobId = job.getRootJobId();
390     } catch (ServiceRegistryException e) {
391       logger.info("Could not find job \"{}\" in service registry", incident.getJobId());
392     }
393 
394     JsonObject json = new JsonObject();
395     json.addProperty("id", incident.getId());
396     json.addProperty("job_id", incident.getJobId());
397     json.addProperty("root_job_id", safeString(rootJobId));
398     json.addProperty("severity", safeString(incident.getSeverity().toString()));
399     json.addProperty("timestamp", toUTC(incident.getTimestamp().getTime()));
400     json.addProperty("processing_host", safeString(incident.getProcessingHost()));
401     json.addProperty("service_type", safeString(incident.getServiceType()));
402     json.add("technical_details", mapToJsonObject(incident.getDescriptionParameters()));
403 
404     JsonArray detailsArray = new JsonArray();
405     for (Tuple<String, String> detail : incident.getDetails()) {
406       detailsArray.add(errorDetailToJson(detail));
407     }
408     json.add("details", detailsArray);
409 
410     JsonObject localized = localizeIncident(incident, locale);
411     json.add("title", localized.get("title"));
412     json.add("description", localized.get("description"));
413 
414     return json;
415   }
416 
417   public JsonObject errorDetailToJson(Tuple<String, String> detail) {
418     JsonObject json = new JsonObject();
419     json.addProperty("name", safeString(detail.getA()));
420     json.addProperty("value", safeString(detail.getB()));
421     return json;
422   }
423 
424   private class JobComparator implements Comparator<JobExtended> {
425 
426     private JobSort sortType;
427     private boolean ascending;
428 
429     JobComparator(JobSort sortType, boolean ascending) {
430       this.sortType = sortType;
431       this.ascending = ascending;
432     }
433 
434     @Override
435     public int compare(JobExtended jobEx1, JobExtended jobEx2) {
436       int result = 0;
437       Object value1 = null;
438       Object value2 = null;
439       Job job1 = jobEx1.getJob();
440       Job job2 = jobEx2.getJob();
441       switch (sortType) {
442         case CREATOR:
443           value1 = job1.getCreator();
444           value2 = job2.getCreator();
445           break;
446         case OPERATION:
447           value1 = job1.getOperation();
448           value2 = job2.getOperation();
449           break;
450         case PROCESSINGHOST:
451           value1 = job1.getProcessingHost();
452           value2 = job2.getProcessingHost();
453           break;
454         case PROCESSINGNODE:
455           value1 = jobEx1.getNodeName();
456           value2 = jobEx2.getNodeName();
457           break;        case STARTED:
458           value1 = job1.getDateStarted();
459           value2 = job2.getDateStarted();
460           break;
461         case STATUS:
462           value1 = job1.getStatus();
463           value2 = job2.getStatus();
464           break;
465         case SUBMITTED:
466           value1 = job1.getDateCreated();
467           value2 = job2.getDateCreated();
468           break;
469         case TYPE:
470           value1 = job1.getJobType();
471           value2 = job2.getJobType();
472           break;
473         case ID:
474           value1 = job1.getId();
475           value2 = job2.getId();
476           break;
477         default:
478       }
479 
480       if (value1 == null) {
481         return value2 == null ? 0 : 1;
482       }
483       if (value2 == null) {
484         return -1;
485       }
486       try {
487         result = ((Comparable)value1).compareTo(value2);
488       } catch (ClassCastException ex) {
489         logger.debug("Can not compare \"{}\" with \"{}\"",
490                 value1, value2, ex);
491       }
492 
493       return ascending ? result : -1 * result;
494     }
495   }
496 
497   /**
498    * @param hostname of server to find in list
499    * @param servers list of all host registrations
500    */
501   private Optional<HostRegistration> findServerByHost(String hostname, List<HostRegistration> servers) {
502     return servers.stream().filter(o -> o.getBaseUrl().equals(hostname)).findFirst();
503   }
504 }