/*- * -\-\- * Helios Services * -- * Copyright (C) 2016 Spotify AB * -- * 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.spotify.helios.master.resources; import static com.google.common.base.Strings.isNullOrEmpty; import static com.spotify.helios.common.descriptors.Job.EMPTY_TOKEN; import static com.spotify.helios.common.protocol.JobUndeployResponse.Status.FORBIDDEN; import static com.spotify.helios.common.protocol.JobUndeployResponse.Status.HOST_NOT_FOUND; import static com.spotify.helios.common.protocol.JobUndeployResponse.Status.INVALID_ID; import static com.spotify.helios.common.protocol.JobUndeployResponse.Status.JOB_NOT_FOUND; import static com.spotify.helios.common.protocol.JobUndeployResponse.Status.OK; import static com.spotify.helios.master.http.Responses.badRequest; import static com.spotify.helios.master.http.Responses.forbidden; import static com.spotify.helios.master.http.Responses.notFound; import static javax.ws.rs.core.MediaType.APPLICATION_JSON; import com.codahale.metrics.annotation.ExceptionMetered; import com.codahale.metrics.annotation.Timed; import com.google.common.base.Optional; import com.google.common.collect.Maps; import com.spotify.helios.common.descriptors.Deployment; import com.spotify.helios.common.descriptors.HostSelector; import com.spotify.helios.common.descriptors.HostStatus; import com.spotify.helios.common.descriptors.JobId; import com.spotify.helios.common.protocol.HostDeregisterResponse; import com.spotify.helios.common.protocol.HostRegisterResponse; import com.spotify.helios.common.protocol.JobDeployResponse; import com.spotify.helios.common.protocol.JobUndeployResponse; import com.spotify.helios.common.protocol.SetGoalResponse; import com.spotify.helios.master.HostMatcher; import com.spotify.helios.master.HostNotFoundException; import com.spotify.helios.master.HostStillInUseException; import com.spotify.helios.master.JobAlreadyDeployedException; import com.spotify.helios.master.JobDoesNotExistException; import com.spotify.helios.master.JobNotDeployedException; import com.spotify.helios.master.JobPortAllocationConflictException; import com.spotify.helios.master.MasterModel; import com.spotify.helios.master.TokenVerificationException; import com.spotify.helios.master.http.PATCH; import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.function.Predicate; import java.util.regex.Pattern; import java.util.stream.Collectors; import javax.validation.Valid; import javax.ws.rs.DELETE; import javax.ws.rs.DefaultValue; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.PUT; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Response; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @Path("/hosts") public class HostsResource { private static final Logger log = LoggerFactory.getLogger(HostsResource.class); private final MasterModel model; public HostsResource(final MasterModel model) { this.model = model; } /** * Returns the list of hostnames of known hosts/agents. * @return The list of hostnames. */ @GET @Produces(APPLICATION_JSON) @Timed @ExceptionMetered public List<String> list(@QueryParam("namePattern") final String namePattern, @QueryParam("selector") final List<String> hostSelectors) { List<String> hosts = namePattern == null ? model.listHosts() : model.listHosts(namePattern); if (!hostSelectors.isEmpty()) { // check that all supplied selectors are parseable/valid final List<HostSelector> selectors = hostSelectors.stream() .map(selectorStr -> { final HostSelector parsed = HostSelector.parse(selectorStr); if (parsed == null) { throw new WebApplicationException( Response.status(Response.Status.BAD_REQUEST) .entity("Invalid host selector: " + selectorStr) .build() ); } return parsed; }) .collect(Collectors.toList()); final Map<String, Map<String, String>> hostsAndLabels = getLabels(hosts); final HostMatcher matcher = new HostMatcher(hostsAndLabels); hosts = matcher.getMatchingHosts(selectors); } return hosts; } /** * Registers a host with the cluster. The {@code host} is the name of the host. It SHOULD be * the hostname of the machine. The {@code id} should be a persistent value for the host, but * initially randomly generated. This way we don't have two machines claiming to be the same * host: at least by accident. * @param host The host to register. * @param id The randomly generated ID for the host. * @return The response. */ @PUT @Path("{host}") @Produces(APPLICATION_JSON) @Timed @ExceptionMetered public Response.Status put(@PathParam("host") final String host, @QueryParam("id") @DefaultValue("") final String id) { if (isNullOrEmpty(id)) { throw badRequest(new HostRegisterResponse(HostRegisterResponse.Status.INVALID_ID, host)); } model.registerHost(host, id); log.info("added host {}", host); return Response.Status.OK; } /** * Deregisters the host from the cluster. Will delete just about everything the cluster knows * about it. * @param host The host to deregister. * @return The response. */ @DELETE @Path("{id}") @Produces(APPLICATION_JSON) @Timed @ExceptionMetered public HostDeregisterResponse delete(@PathParam("id") final String host) { try { model.deregisterHost(host); return new HostDeregisterResponse(HostDeregisterResponse.Status.OK, host); } catch (HostNotFoundException e) { throw notFound(new HostDeregisterResponse(HostDeregisterResponse.Status.NOT_FOUND, host)); } catch (HostStillInUseException e) { throw badRequest(new HostDeregisterResponse(HostDeregisterResponse.Status.JOBS_STILL_DEPLOYED, host)); } } /** * Returns various status information about the host. * @param host The host id. * @param statusFilter An optional status filter. * @return The host status. */ @GET @Path("{id}/status") @Produces(APPLICATION_JSON) @Timed @ExceptionMetered public Optional<HostStatus> hostStatus( @PathParam("id") final String host, @QueryParam("status") @DefaultValue("") final String statusFilter) { final HostStatus status = model.getHostStatus(host); final Optional<HostStatus> response; if (status != null && (isNullOrEmpty(statusFilter) || statusFilter.equals(status.getStatus().toString()))) { response = Optional.of(status); } else { response = Optional.absent(); } log.debug("hostStatus: host={}, statusFilter={}, returning: {}", host, statusFilter, response); return response; } /** * Returns various status information about the hosts. * @param hosts The hosts. * @param statusFilter An optional status filter. * @return The response. */ @POST @Path("/statuses") @Produces(APPLICATION_JSON) @Timed @ExceptionMetered public Map<String, HostStatus> hostStatuses( final List<String> hosts, @QueryParam("status") @DefaultValue("") final String statusFilter) { final Map<String, HostStatus> statuses = Maps.newHashMap(); for (final String host : hosts) { final HostStatus status = model.getHostStatus(host); if (status != null) { if (isNullOrEmpty(statusFilter) || statusFilter.equals(status.getStatus().toString())) { statuses.put(host, status); } } } return statuses; } /** * Sets the deployment of the job identified by its {@link JobId} on the host named by * {@code host} to {@code deployment}. * @param host The host to deploy to. * @param jobId The job to deploy. * @param deployment Deployment information. * @param username The user deploying. * @param token The authorization token for this deployment. * @return The response. */ @PUT @Path("/{host}/jobs/{job}") @Produces(APPLICATION_JSON) @Timed @ExceptionMetered public JobDeployResponse jobPut( @PathParam("host") final String host, @PathParam("job") final JobId jobId, @Valid final Deployment deployment, @RequestUser final String username, @QueryParam("token") @DefaultValue(EMPTY_TOKEN) final String token) { if (!jobId.isFullyQualified()) { throw badRequest(new JobDeployResponse(JobDeployResponse.Status.INVALID_ID, host, jobId)); } try { final Deployment actualDeployment = deployment.toBuilder().setDeployerUser(username).build(); model.deployJob(host, actualDeployment, token); return new JobDeployResponse(JobDeployResponse.Status.OK, host, jobId); } catch (JobAlreadyDeployedException e) { throw badRequest(new JobDeployResponse(JobDeployResponse.Status.JOB_ALREADY_DEPLOYED, host, jobId)); } catch (HostNotFoundException e) { throw badRequest(new JobDeployResponse(JobDeployResponse.Status.HOST_NOT_FOUND, host, jobId)); } catch (JobDoesNotExistException e) { throw badRequest(new JobDeployResponse(JobDeployResponse.Status.JOB_NOT_FOUND, host, jobId)); } catch (JobPortAllocationConflictException e) { throw badRequest(new JobDeployResponse(JobDeployResponse.Status.PORT_CONFLICT, host, jobId)); } catch (TokenVerificationException e) { throw forbidden(new JobDeployResponse(JobDeployResponse.Status.FORBIDDEN, host, jobId)); } } /** * Causes the job identified by its {@link JobId} to be undeployed from the specified host. * This call will fail if the host is not found or the job is not deployed on the host. * @param host The host to undeploy from. * @param jobId The job to undeploy. * @param token The authorization token. * @return The response. */ @DELETE @Path("/{host}/jobs/{job}") @Produces(APPLICATION_JSON) @Timed @ExceptionMetered public JobUndeployResponse jobDelete(@PathParam("host") final String host, @PathParam("job") final JobId jobId, @QueryParam("token") @DefaultValue("") final String token) { if (!jobId.isFullyQualified()) { throw badRequest(new JobUndeployResponse(INVALID_ID, host, jobId)); } try { model.undeployJob(host, jobId, token); return new JobUndeployResponse(OK, host, jobId); } catch (HostNotFoundException e) { throw notFound(new JobUndeployResponse(HOST_NOT_FOUND, host, jobId)); } catch (JobNotDeployedException e) { throw notFound(new JobUndeployResponse(JOB_NOT_FOUND, host, jobId)); } catch (TokenVerificationException e) { throw forbidden(new JobUndeployResponse(FORBIDDEN, host, jobId)); } } /** * Alters the current deployment of a deployed job identified by it's job id on the specified * host. * @param host The host. * @param jobId The ID of the job. * @param deployment The new deployment. * @param token The authorization token for this job. * @return The response. */ @PATCH @Path("/{host}/jobs/{job}") @Produces(APPLICATION_JSON) @Timed @ExceptionMetered public SetGoalResponse jobPatch(@PathParam("host") final String host, @PathParam("job") final JobId jobId, @Valid final Deployment deployment, @QueryParam("token") @DefaultValue("") final String token) { if (!deployment.getJobId().equals(jobId)) { throw badRequest(new SetGoalResponse(SetGoalResponse.Status.ID_MISMATCH, host, jobId)); } try { model.updateDeployment(host, deployment, token); } catch (HostNotFoundException e) { throw notFound(new SetGoalResponse(SetGoalResponse.Status.HOST_NOT_FOUND, host, jobId)); } catch (JobNotDeployedException e) { throw notFound(new SetGoalResponse(SetGoalResponse.Status.JOB_NOT_DEPLOYED, host, jobId)); } catch (TokenVerificationException e) { throw forbidden(new SetGoalResponse(SetGoalResponse.Status.FORBIDDEN, host, jobId)); } log.info("patched job {} on host {}", deployment, host); return new SetGoalResponse(SetGoalResponse.Status.OK, host, jobId); } /** * Returns the current {@link Deployment} of {@code job} on {@code host} if it is deployed. * @param host The host where the job is deployed. * @param jobId The ID of the job. * @return The response. */ @GET @Path("/{host}/jobs/{job}") @Produces(APPLICATION_JSON) @Timed @ExceptionMetered public Optional<Deployment> jobGet(@PathParam("host") final String host, @PathParam("job") final JobId jobId) { if (!jobId.isFullyQualified()) { throw badRequest(); } return Optional.fromNullable(model.getDeployment(host, jobId)); } private Map<String, Map<String, String>> getLabels(final List<String> hosts) { return hosts.stream().collect(Collectors.toMap(Function.identity(), model::getHostLabels)); } }