All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.netflix.genie.web.apis.rest.v3.controllers.JobRestController Maven / Gradle / Ivy

The newest version!
/*
 *
 *  Copyright 2015 Netflix, Inc.
 *
 *     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.
 *
 */
package com.netflix.genie.web.apis.rest.v3.controllers;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.google.common.collect.ImmutableMap;
import com.google.common.io.ByteStreams;
import com.netflix.genie.common.dto.Application;
import com.netflix.genie.common.dto.Cluster;
import com.netflix.genie.common.dto.Command;
import com.netflix.genie.common.dto.Job;
import com.netflix.genie.common.dto.JobExecution;
import com.netflix.genie.common.dto.JobMetadata;
import com.netflix.genie.common.dto.JobRequest;
import com.netflix.genie.common.dto.JobStatus;
import com.netflix.genie.common.dto.JobStatusMessages;
import com.netflix.genie.common.dto.search.JobSearchResult;
import com.netflix.genie.common.exceptions.GenieException;
import com.netflix.genie.common.exceptions.GenieNotFoundException;
import com.netflix.genie.common.exceptions.GenieServerException;
import com.netflix.genie.common.exceptions.GenieServerUnavailableException;
import com.netflix.genie.common.exceptions.GenieUserLimitExceededException;
import com.netflix.genie.common.internal.dtos.ApiClientMetadata;
import com.netflix.genie.common.internal.dtos.ArchiveStatus;
import com.netflix.genie.common.internal.dtos.JobRequestMetadata;
import com.netflix.genie.common.internal.dtos.converters.DtoConverters;
import com.netflix.genie.common.internal.exceptions.checked.GenieCheckedException;
import com.netflix.genie.common.internal.jobs.JobConstants;
import com.netflix.genie.common.internal.util.GenieHostInfo;
import com.netflix.genie.web.agent.services.AgentRoutingService;
import com.netflix.genie.web.apis.rest.v3.hateoas.assemblers.ApplicationModelAssembler;
import com.netflix.genie.web.apis.rest.v3.hateoas.assemblers.ClusterModelAssembler;
import com.netflix.genie.web.apis.rest.v3.hateoas.assemblers.CommandModelAssembler;
import com.netflix.genie.web.apis.rest.v3.hateoas.assemblers.EntityModelAssemblers;
import com.netflix.genie.web.apis.rest.v3.hateoas.assemblers.JobExecutionModelAssembler;
import com.netflix.genie.web.apis.rest.v3.hateoas.assemblers.JobMetadataModelAssembler;
import com.netflix.genie.web.apis.rest.v3.hateoas.assemblers.JobModelAssembler;
import com.netflix.genie.web.apis.rest.v3.hateoas.assemblers.JobRequestModelAssembler;
import com.netflix.genie.web.apis.rest.v3.hateoas.assemblers.JobSearchResultModelAssembler;
import com.netflix.genie.web.data.services.DataServices;
import com.netflix.genie.web.data.services.PersistenceService;
import com.netflix.genie.web.dtos.JobSubmission;
import com.netflix.genie.web.exceptions.checked.NotFoundException;
import com.netflix.genie.web.properties.JobsActiveLimitProperties;
import com.netflix.genie.web.properties.JobsProperties;
import com.netflix.genie.web.services.AttachmentService;
import com.netflix.genie.web.services.JobDirectoryServerService;
import com.netflix.genie.web.services.JobKillService;
import com.netflix.genie.web.services.JobLaunchService;
import com.netflix.genie.web.util.MetricsConstants;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.env.Environment;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.data.web.PagedResourcesAssembler;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.PagedModel;
import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpRequest;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.ResponseExtractor;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import javax.annotation.Nullable;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.net.MalformedURLException;
import java.net.URL;
import java.time.Instant;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.Enumeration;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

/**
 * REST end-point for supporting jobs.
 *
 * @author amsharma
 * @author tgianos
 * @since 3.0.0
 */
@RestController
@RequestMapping(value = "/api/v3/jobs")
@Slf4j
public class JobRestController {
    private static final String TRANSFER_ENCODING_HEADER = "Transfer-Encoding";
    private static final String FORWARDED_FOR_HEADER = "X-Forwarded-For";
    private static final String NAME_HEADER_COOKIE = "cookie";
    private static final String JOB_API_BASE_PATH = "/api/v3/jobs/";
    private static final String COMMA = ",";
    private static final String EMPTY_STRING = "";
    private static final String USER_JOB_LIMIT_EXCEEDED_COUNTER_NAME = "genie.jobs.submit.rejected.jobs-limit.counter";
    private static final Pattern HTTP_HEADER_FILTER_PATTERN = Pattern.compile("^GENIE_.*");

    private final JobLaunchService jobLaunchService;
    private final ApplicationModelAssembler applicationModelAssembler;
    private final ClusterModelAssembler clusterModelAssembler;
    private final CommandModelAssembler commandModelAssembler;
    private final JobModelAssembler jobModelAssembler;
    private final JobRequestModelAssembler jobRequestModelAssembler;
    private final JobExecutionModelAssembler jobExecutionModelAssembler;
    private final JobMetadataModelAssembler jobMetadataModelAssembler;
    private final JobSearchResultModelAssembler jobSearchResultModelAssembler;
    private final String hostname;
    private final RestTemplate restTemplate;
    private final JobDirectoryServerService jobDirectoryServerService;
    private final JobsProperties jobsProperties;
    private final AgentRoutingService agentRoutingService;
    private final PersistenceService persistenceService;
    private final Environment environment;
    private final AttachmentService attachmentService;
    private final JobKillService jobKillService;

    // Metrics
    private final MeterRegistry registry;
    private final Counter submitJobWithoutAttachmentsRate;
    private final Counter submitJobWithAttachmentsRate;

    /**
     * Constructor.
     *
     * @param jobLaunchService          The {@link JobLaunchService} implementation to use
     * @param dataServices              The {@link DataServices} instance to use
     * @param entityModelAssemblers     The encapsulation of all the V3 resource assemblers
     * @param genieHostInfo             Information about the host that the Genie process is running on
     * @param restTemplate              The rest template for http requests
     * @param jobDirectoryServerService The service to handle serving back job directory resources
     * @param jobsProperties            All the properties associated with jobs
     * @param registry                  The metrics registry to use
     * @param agentRoutingService       Agent routing service
     * @param environment               The application environment to pull dynamic properties from
     * @param attachmentService         The attachment service to use to save attachments.
     * @param jobKillService            The service to kill running jobs
     */
    @Autowired
    @SuppressWarnings("checkstyle:parameternumber")
    public JobRestController(
        final JobLaunchService jobLaunchService,
        final DataServices dataServices,
        final EntityModelAssemblers entityModelAssemblers,
        final GenieHostInfo genieHostInfo,
        @Qualifier("genieRestTemplate") final RestTemplate restTemplate,
        final JobDirectoryServerService jobDirectoryServerService,
        final JobsProperties jobsProperties,
        final MeterRegistry registry,
        final AgentRoutingService agentRoutingService,
        final Environment environment,
        final AttachmentService attachmentService,
        final JobKillService jobKillService
    ) {
        this.jobLaunchService = jobLaunchService;
        this.applicationModelAssembler = entityModelAssemblers.getApplicationModelAssembler();
        this.clusterModelAssembler = entityModelAssemblers.getClusterModelAssembler();
        this.commandModelAssembler = entityModelAssemblers.getCommandModelAssembler();
        this.jobModelAssembler = entityModelAssemblers.getJobModelAssembler();
        this.jobRequestModelAssembler = entityModelAssemblers.getJobRequestModelAssembler();
        this.jobExecutionModelAssembler = entityModelAssemblers.getJobExecutionModelAssembler();
        this.jobMetadataModelAssembler = entityModelAssemblers.getJobMetadataModelAssembler();
        this.jobSearchResultModelAssembler = entityModelAssemblers.getJobSearchResultModelAssembler();
        this.hostname = genieHostInfo.getHostname();
        this.restTemplate = restTemplate;
        this.jobDirectoryServerService = jobDirectoryServerService;
        this.jobsProperties = jobsProperties;
        this.agentRoutingService = agentRoutingService;
        this.persistenceService = dataServices.getPersistenceService();
        this.environment = environment;
        this.attachmentService = attachmentService;
        this.jobKillService = jobKillService;
        this.registry = registry;

        // Set up the metrics
        this.submitJobWithoutAttachmentsRate = registry.counter("genie.api.v3.jobs.submitJobWithoutAttachments.rate");
        this.submitJobWithAttachmentsRate = registry.counter("genie.api.v3.jobs.submitJobWithAttachments.rate");
    }

    /**
     * Submit a new job.
     *
     * @param jobRequest         The job request information
     * @param clientHost         client host sending the request
     * @param userAgent          The user agent string
     * @param httpServletRequest The http servlet request
     * @return The submitted job
     * @throws GenieException        For any error
     * @throws GenieCheckedException For V4 Agent Execution errors
     */
    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
    @ResponseStatus(HttpStatus.ACCEPTED)
    public ResponseEntity submitJob(
        @Valid @RequestBody final JobRequest jobRequest,
        @RequestHeader(value = FORWARDED_FOR_HEADER, required = false) @Nullable final String clientHost,
        @RequestHeader(value = HttpHeaders.USER_AGENT, required = false) @Nullable final String userAgent,
        final HttpServletRequest httpServletRequest
    ) throws GenieException, GenieCheckedException {
        log.info("[submitJob] Called json method type to submit job: {}", jobRequest);
        this.submitJobWithoutAttachmentsRate.increment();
        return this.handleSubmitJob(jobRequest, null, clientHost, userAgent, httpServletRequest);
    }

    /**
     * Submit a new job with attachments.
     *
     * @param jobRequest         The job request information
     * @param attachments        The attachments for the job
     * @param clientHost         client host sending the request
     * @param userAgent          The user agent string
     * @param httpServletRequest The http servlet request
     * @return The submitted job
     * @throws GenieException        For any error
     * @throws GenieCheckedException For V4 Agent Execution errors
     */
    @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    @ResponseStatus(HttpStatus.ACCEPTED)
    public ResponseEntity submitJob(
        @Valid @RequestPart("request") final JobRequest jobRequest,
        @RequestPart(value = "attachment", required = false) @Nullable final MultipartFile[] attachments,
        @RequestHeader(value = FORWARDED_FOR_HEADER, required = false) @Nullable final String clientHost,
        @RequestHeader(value = HttpHeaders.USER_AGENT, required = false) @Nullable final String userAgent,
        final HttpServletRequest httpServletRequest
    ) throws GenieException, GenieCheckedException {
        log.info(
            "[submitJob] Called multipart method to submit job: {}, with {} attachments",
            jobRequest,
            attachments == null ? 0 : attachments.length
        );
        this.submitJobWithAttachmentsRate.increment();
        return this.handleSubmitJob(jobRequest, attachments, clientHost, userAgent, httpServletRequest);
    }

    private ResponseEntity handleSubmitJob(
        final JobRequest jobRequest,
        @Nullable final MultipartFile[] attachments,
        @Nullable final String clientHost,
        @Nullable final String userAgent,
        final HttpServletRequest httpServletRequest
    ) throws GenieException, GenieCheckedException {

        // This node may reject this job
        this.checkRejectJob(jobRequest);

        // get client's host from the context
        final String localClientHost;
        if (StringUtils.isNotBlank(clientHost)) {
            localClientHost = clientHost.split(COMMA)[0];
        } else {
            localClientHost = httpServletRequest.getRemoteAddr();
        }

        // Get attachments metadata
        int numAttachments = 0;
        long totalSizeOfAttachments = 0L;
        if (attachments != null) {
            numAttachments = attachments.length;
            for (final MultipartFile attachment : attachments) {
                totalSizeOfAttachments += attachment.getSize();
            }
        }

        final JobRequestMetadata metadata = new JobRequestMetadata(
            new ApiClientMetadata(localClientHost, userAgent),
            null,
            numAttachments,
            totalSizeOfAttachments,
            this.getGenieHeaders(httpServletRequest)
        );

        final JobSubmission.Builder jobSubmissionBuilder = new JobSubmission.Builder(
            DtoConverters.toV4JobRequest(jobRequest),
            metadata
        );

        if (attachments != null) {
            jobSubmissionBuilder.withAttachments(
                this.attachmentService.saveAttachments(
                    jobRequest.getId().orElse(null),
                    Arrays
                        .stream(attachments)
                        .map(MultipartFile::getResource)
                        .collect(Collectors.toSet())
                )
            );
        }

        final String jobId = this.jobLaunchService.launchJob(jobSubmissionBuilder.build());

        final HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setLocation(
            ServletUriComponentsBuilder
                .fromCurrentRequest()
                .path("/{id}")
                .buildAndExpand(jobId)
                .toUri()
        );

        return new ResponseEntity<>(httpHeaders, HttpStatus.ACCEPTED);
    }

    private Map getGenieHeaders(final HttpServletRequest httpServletRequest) {
        final ImmutableMap.Builder mapBuilder = ImmutableMap.builder();
        final Enumeration headerNames = httpServletRequest.getHeaderNames();
        while (headerNames.hasMoreElements()) {
            final String headerName = headerNames.nextElement();
            if (HTTP_HEADER_FILTER_PATTERN.matcher(headerName).matches()) {
                final String headerValue = httpServletRequest.getHeader(headerName);
                if (headerValue != null) {
                    mapBuilder.put(headerName, headerValue);
                }
            }
        }
        return mapBuilder.build();
    }

    // TODO: refactor this ad-hoc checks into a component allowing more flexible logic (and can be replaced/extended)
    private void checkRejectJob(
        final JobRequest jobRequest
    ) throws GenieServerUnavailableException, GenieUserLimitExceededException {

        if (!this.environment.getProperty(JobConstants.JOB_SUBMISSION_ENABLED_PROPERTY_KEY, Boolean.class, true)) {
            // Job Submission is disabled
            throw new GenieServerUnavailableException(
                this.environment.getProperty(
                    JobConstants.JOB_SUBMISSION_DISABLED_MESSAGE_KEY,
                    JobConstants.JOB_SUBMISSION_DISABLED_DEFAULT_MESSAGE
                )
            );
        }

        final JobsActiveLimitProperties activeLimit = this.jobsProperties.getActiveLimit();
        if (activeLimit.isEnabled()) {
            final String user = jobRequest.getUser();
            log.debug("Checking user limits for {}", user);
            final long activeJobsLimit = activeLimit.getUserLimit(user);
            final long activeJobsCount = this.persistenceService.getActiveJobCountForUser(user);
            if (activeJobsCount >= activeJobsLimit) {
                this.registry.counter(
                    USER_JOB_LIMIT_EXCEEDED_COUNTER_NAME,
                    MetricsConstants.TagKeys.USER,
                    user,
                    MetricsConstants.TagKeys.JOBS_USER_LIMIT,
                    String.valueOf(activeJobsLimit)
                ).increment();

                throw GenieUserLimitExceededException.createForActiveJobsLimit(
                    user,
                    activeJobsCount,
                    activeJobsLimit
                );
            }
        }
    }

    /**
     * Get job information for given job id.
     *
     * @param id id for job to look up
     * @return the Job
     * @throws GenieException For any error
     */
    @GetMapping(value = "/{id}", produces = MediaTypes.HAL_JSON_VALUE)
    public EntityModel getJob(@PathVariable("id") final String id) throws GenieException {
        log.info("[getJob] Called for job with id: {}", id);
        return this.jobModelAssembler.toModel(this.persistenceService.getJob(id));
    }

    /**
     * Get the status of the given job if it exists.
     *
     * @param id The id of the job to get status for
     * @return The status of the job as one of: {@link JobStatus}
     * @throws NotFoundException When no job with {@literal id} exists
     */
    @GetMapping(value = "/{id}/status", produces = MediaType.APPLICATION_JSON_VALUE)
    public JsonNode getJobStatus(@PathVariable("id") final String id) throws NotFoundException {
        log.info("[getJobStatus] Called for job with id: {}", id);
        final JsonNodeFactory factory = JsonNodeFactory.instance;
        return factory
            .objectNode()
            .set(
                "status",
                factory.textNode(DtoConverters.toV3JobStatus(this.persistenceService.getJobStatus(id)).toString())
            );
    }

    /**
     * Get jobs for given filter criteria.
     *
     * @param id               id for job
     * @param name             name of job (can be a SQL-style pattern such as HIVE%)
     * @param user             user who submitted job
     * @param statuses         statuses of jobs to find
     * @param tags             tags for the job
     * @param clusterName      the name of the cluster
     * @param clusterId        the id of the cluster
     * @param commandName      the name of the command run by the job
     * @param commandId        the id of the command run by the job
     * @param minStarted       The time which the job had to start after in order to be return (inclusive)
     * @param maxStarted       The time which the job had to start before in order to be returned (exclusive)
     * @param minFinished      The time which the job had to finish after in order to be return (inclusive)
     * @param maxFinished      The time which the job had to finish before in order to be returned (exclusive)
     * @param grouping         The grouping the job should be a member of
     * @param groupingInstance The grouping instance the job should be a member of
     * @param page             page information for job
     * @param assembler        The paged resources assembler to use
     * @return successful response, or one with HTTP error code
     * @throws GenieException For any error
     */
    @GetMapping(produces = MediaTypes.HAL_JSON_VALUE)
    @ResponseStatus(HttpStatus.OK)
    @SuppressWarnings("checkstyle:parameternumber")
    public PagedModel> findJobs(
        @RequestParam(value = "id", required = false) @Nullable final String id,
        @RequestParam(value = "name", required = false) @Nullable final String name,
        @RequestParam(value = "user", required = false) @Nullable final String user,
        @RequestParam(value = "status", required = false) @Nullable final Set statuses,
        @RequestParam(value = "tag", required = false) @Nullable final Set tags,
        @RequestParam(value = "clusterName", required = false) @Nullable final String clusterName,
        @RequestParam(value = "clusterId", required = false) @Nullable final String clusterId,
        @RequestParam(value = "commandName", required = false) @Nullable final String commandName,
        @RequestParam(value = "commandId", required = false) @Nullable final String commandId,
        @RequestParam(value = "minStarted", required = false) @Nullable final Long minStarted,
        @RequestParam(value = "maxStarted", required = false) @Nullable final Long maxStarted,
        @RequestParam(value = "minFinished", required = false) @Nullable final Long minFinished,
        @RequestParam(value = "maxFinished", required = false) @Nullable final Long maxFinished,
        @RequestParam(value = "grouping", required = false) @Nullable final String grouping,
        @RequestParam(value = "groupingInstance", required = false) @Nullable final String groupingInstance,
        @PageableDefault(sort = {"created"}, direction = Sort.Direction.DESC) final Pageable page,
        final PagedResourcesAssembler assembler
    ) throws GenieException {
        log.info(
            "[getJobs] Called with "
                + "[id | jobName | user | statuses | clusterName "
                + "| clusterId | minStarted | maxStarted | minFinished | maxFinished | grouping | groupingInstance "
                + "| page]\n"
                + "{} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {}",
            id,
            name,
            user,
            statuses,
            tags,
            clusterName,
            clusterId,
            commandName,
            commandId,
            minStarted,
            maxStarted,
            minFinished,
            maxFinished,
            grouping,
            groupingInstance,
            page
        );

        Set enumStatuses = null;
        if (statuses != null && !statuses.isEmpty()) {
            enumStatuses = EnumSet.noneOf(JobStatus.class);
            for (final String status : statuses) {
                if (StringUtils.isNotBlank(status)) {
                    enumStatuses.add(JobStatus.parse(status));
                }
            }
        }

        // Build the self link which will be used for the next, previous, etc links
        final Link self = WebMvcLinkBuilder
            .linkTo(
                WebMvcLinkBuilder
                    .methodOn(JobRestController.class)
                    .findJobs(
                        id,
                        name,
                        user,
                        statuses,
                        tags,
                        clusterName,
                        clusterId,
                        commandName,
                        commandId,
                        minStarted,
                        maxStarted,
                        minFinished,
                        maxFinished,
                        grouping,
                        groupingInstance,
                        page,
                        assembler
                    )
            ).withSelfRel();

        return assembler.toModel(
            this.persistenceService.findJobs(
                id,
                name,
                user,
                enumStatuses,
                tags,
                clusterName,
                clusterId,
                commandName,
                commandId,
                minStarted == null ? null : Instant.ofEpochMilli(minStarted),
                maxStarted == null ? null : Instant.ofEpochMilli(maxStarted),
                minFinished == null ? null : Instant.ofEpochMilli(minFinished),
                maxFinished == null ? null : Instant.ofEpochMilli(maxFinished),
                grouping,
                groupingInstance,
                page
            ),
            this.jobSearchResultModelAssembler,
            self
        );
    }

    /**
     * Kill job based on given job ID.
     *
     * @param id            id for job to kill
     * @param forwardedFrom The host this request was forwarded from if present
     * @param request       the servlet request
     * @throws GenieServerException For any error
     */
    @DeleteMapping(value = "/{id}")
    @ResponseStatus(HttpStatus.ACCEPTED)
    public void killJob(
        @PathVariable("id") final String id,
        @RequestHeader(name = JobConstants.GENIE_FORWARDED_FROM_HEADER, required = false)
        @Nullable final String forwardedFrom,
        final HttpServletRequest request
    ) throws GenieException {
        log.info(
            "[killJob] Called for job: {}.{}",
            id,
            forwardedFrom == null ? EMPTY_STRING : " Forwarded from " + forwardedFrom
        );

        this.jobKillService.killJob(id, JobStatusMessages.JOB_KILLED_BY_USER, request);
    }

    /**
     * Get the original job request.
     *
     * @param id The id of the job
     * @return The job request
     * @throws NotFoundException If no job with {@literal id} exists
     */
    @GetMapping(value = "/{id}/request", produces = MediaTypes.HAL_JSON_VALUE)
    @ResponseStatus(HttpStatus.OK)
    public EntityModel getJobRequest(@PathVariable("id") final String id) throws NotFoundException {
        log.info("[getJobRequest] Called for job request with id {}", id);
        return this.jobRequestModelAssembler.toModel(
            new JobRequestModelAssembler.JobRequestWrapper(
                id,
                DtoConverters.toV3JobRequest(this.persistenceService.getJobRequest(id))
            )
        );
    }

    /**
     * Get the execution information about a job.
     *
     * @param id The id of the job
     * @return The job execution
     * @throws GenieException On any internal error
     */
    @GetMapping(value = "/{id}/execution", produces = MediaTypes.HAL_JSON_VALUE)
    @ResponseStatus(HttpStatus.OK)
    public EntityModel getJobExecution(
        @PathVariable("id") final String id
    ) throws GenieException {
        log.info("[getJobExecution] Called for job execution with id {}", id);
        return this.jobExecutionModelAssembler.toModel(this.persistenceService.getJobExecution(id));
    }

    /**
     * Get the metadata information about a job.
     *
     * @param id The id of the job
     * @return The job metadata
     * @throws GenieException On any internal error
     * @since 3.3.5
     */
    @GetMapping(value = "/{id}/metadata", produces = MediaTypes.HAL_JSON_VALUE)
    @ResponseStatus(HttpStatus.OK)
    public EntityModel getJobMetadata(@PathVariable("id") final String id) throws GenieException {
        log.info("[getJobMetadata] Called for job metadata with id {}", id);
        return this.jobMetadataModelAssembler.toModel(this.persistenceService.getJobMetadata(id));
    }

    /**
     * Get the cluster the job was run on or is currently running on.
     *
     * @param id The id of the job to get the cluster for
     * @return The cluster
     * @throws NotFoundException When either the job or the cluster aren't found
     */
    @GetMapping(value = "/{id}/cluster", produces = MediaTypes.HAL_JSON_VALUE)
    @ResponseStatus(HttpStatus.OK)
    public EntityModel getJobCluster(@PathVariable("id") final String id) throws NotFoundException {
        log.info("[getJobCluster] Called for job with id {}", id);
        return this.clusterModelAssembler.toModel(DtoConverters.toV3Cluster(this.persistenceService.getJobCluster(id)));
    }

    /**
     * Get the command the job was run with or is currently running with.
     *
     * @param id The id of the job to get the command for
     * @return The command
     * @throws NotFoundException When either the job or the command aren't found
     */
    @GetMapping(value = "/{id}/command", produces = MediaTypes.HAL_JSON_VALUE)
    @ResponseStatus(HttpStatus.OK)
    public EntityModel getJobCommand(@PathVariable("id") final String id) throws NotFoundException {
        log.info("[getJobCommand] Called for job with id {}", id);
        return this.commandModelAssembler.toModel(DtoConverters.toV3Command(this.persistenceService.getJobCommand(id)));
    }

    /**
     * Get the applications used ot run the job.
     *
     * @param id The id of the job to get the applications for
     * @return The applications
     * @throws NotFoundException When either the job or the applications aren't found
     */
    @GetMapping(value = "/{id}/applications", produces = MediaTypes.HAL_JSON_VALUE)
    @ResponseStatus(HttpStatus.OK)
    public List> getJobApplications(
        @PathVariable("id") final String id
    ) throws NotFoundException {
        log.info("[getJobApplications] Called for job with id {}", id);

        return this.persistenceService
            .getJobApplications(id)
            .stream()
            .map(DtoConverters::toV3Application)
            .map(this.applicationModelAssembler::toModel)
            .collect(Collectors.toList());
    }

    /**
     * Get the job output directory.
     *
     * @param id            The id of the job to get output for
     * @param forwardedFrom The host this request was forwarded from if present
     * @param request       the servlet request
     * @param response      the servlet response
     * @throws NotFoundException When no job with {@literal id} exists
     * @throws GenieException    on any Genie internal error
     */
    @GetMapping(
        value = {
            "/{id}/output",
            "/{id}/output/",
            "/{id}/output/**"
        }
    )
    public void getJobOutput(
        @PathVariable("id") final String id,
        @RequestHeader(name = JobConstants.GENIE_FORWARDED_FROM_HEADER, required = false)
        @Nullable final String forwardedFrom,
        final HttpServletRequest request,
        final HttpServletResponse response
    ) throws GenieException, NotFoundException {

        final String path = ControllerUtils.getRemainingPath(request);
        log.info(
            "[getJobOutput] Called to get output path: \"{}\" for job: \"{}\".{}",
            path,
            id,
            forwardedFrom == null ? EMPTY_STRING : " Requested forwarded from: " + forwardedFrom
        );

        final URL baseUrl;
        try {
            baseUrl = forwardedFrom == null
                ? ControllerUtils.getRequestRoot(request, path)
                : ControllerUtils.getRequestRoot(new URL(forwardedFrom), path);
        } catch (final MalformedURLException e) {
            throw new GenieServerException("Unable to parse base request url", e);
        }

        final ArchiveStatus archiveStatus = this.persistenceService.getJobArchiveStatus(id);

        if (archiveStatus == ArchiveStatus.PENDING) {
            final String jobHostname;
            try {
                jobHostname = this.agentRoutingService
                    .getHostnameForAgentConnection(id)
                    .orElseThrow(() -> new NotFoundException("No hostname found for job - " + id));
            } catch (NotFoundException e) {
                throw new GenieServerException("Failed to route request", e);
            }

            final boolean shouldForward = !this.hostname.equals(jobHostname);
            final boolean canForward = forwardedFrom == null && this.jobsProperties.getForwarding().isEnabled();

            if (shouldForward && canForward) {
                // Forward request to another node
                forwardRequest(id, path, jobHostname, request, response);
                return;
            } else if (!canForward && shouldForward) {
                // Should forward but can't
                throw new GenieServerException("Job files are not local, but forwarding is disabled");
            }
        }

        // In any other case, delegate the request to the service
        log.debug("Fetching requested resource \"{}\" for job \"{}\"", path, id);
        this.jobDirectoryServerService.serveResource(id, baseUrl, path, request, response);
    }

    private void forwardRequest(
        final String id,
        final String path,
        final String jobHostname,
        final HttpServletRequest request,
        final HttpServletResponse response
    ) throws GenieException {
        log.info("Job {} is not run on this node. Forwarding to {}", id, jobHostname);
        final String forwardHost = this.buildForwardHost(jobHostname);

        try {
            this.restTemplate.execute(
                forwardHost + JOB_API_BASE_PATH + id + "/output/" + path,
                HttpMethod.GET,
                forwardRequest -> copyRequestHeaders(request, forwardRequest),
                (ResponseExtractor) forwardResponse -> {
                    response.setStatus(forwardResponse.getStatusCode().value());
                    copyResponseHeaders(response, forwardResponse);
                    // Documentation I could find pointed to the HttpEntity reading the bytes off
                    // the stream so this should resolve memory problems if the file returned is large
                    ByteStreams.copy(forwardResponse.getBody(), response.getOutputStream());
                    return null;
                }
            );
        } catch (final HttpClientErrorException.NotFound e) {
            throw new GenieNotFoundException("Not Found (via: " + forwardHost + ")", e);
        } catch (final HttpStatusCodeException e) {
            throw new GenieException(e.getStatusCode().value(), "Proxied request failed: " + e.getMessage(), e);
        } catch (final Exception e) {
            log.error("Failed getting the remote job output from {}. Error: {}", forwardHost, e.getMessage());
            throw new GenieServerException("Proxied request error:" + e.getMessage(), e);
        }
    }

    private String buildForwardHost(final String jobHostname) {
        return this.jobsProperties.getForwarding().getScheme()
            + "://"
            + jobHostname
            + ":"
            + this.jobsProperties.getForwarding().getPort();
    }

    private void copyRequestHeaders(final HttpServletRequest request, final ClientHttpRequest forwardRequest) {
        // Copy all the headers (necessary for ACCEPT and security headers especially). Do not copy the cookie header.
        final HttpHeaders headers = forwardRequest.getHeaders();
        final Enumeration headerNames = request.getHeaderNames();
        if (headerNames != null) {
            while (headerNames.hasMoreElements()) {
                final String headerName = headerNames.nextElement();
                if (!NAME_HEADER_COOKIE.equals(headerName)) {
                    final String headerValue = request.getHeader(headerName);
                    log.debug("Request Header: name = {} value = {}", headerName, headerValue);
                    headers.add(headerName, headerValue);
                }
            }
        }
        // Lets add the cookie as an header
        final Cookie[] cookies = request.getCookies();
        if (cookies != null && cookies.length > 0) {
            StringBuilder builder = null;
            for (final Cookie cookie : request.getCookies()) {
                if (builder == null) {
                    builder = new StringBuilder();
                } else {
                    builder.append(",");
                }
                builder.append(cookie.getName()).append("=").append(cookie.getValue());
            }
            if (builder != null) {
                final String cookieValue = builder.toString();
                headers.add(NAME_HEADER_COOKIE, cookieValue);
                log.debug("Request Header: name = {} value = {}", NAME_HEADER_COOKIE, cookieValue);
            }
        }
        // This method only called when need to forward so add the forwarded from header
        headers.add(JobConstants.GENIE_FORWARDED_FROM_HEADER, request.getRequestURL().toString());
    }

    private void copyResponseHeaders(final HttpServletResponse response, final ClientHttpResponse forwardResponse) {
        final HttpHeaders headers = forwardResponse.getHeaders();
        for (final Map.Entry header : headers.toSingleValueMap().entrySet()) {
            //
            // Do not add transfer encoding header since it forces Apache to truncate the response. Ideally we should
            // only copy headers that are needed.
            //
            if (!TRANSFER_ENCODING_HEADER.equalsIgnoreCase(header.getKey())) {
                response.setHeader(header.getKey(), header.getValue());
            }
        }
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy