/* * Copyright (C) 2015 Mesosphere, 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.mesosphere.mesos.rx.java; import com.mesosphere.mesos.rx.java.recordio.RecordIOOperator; import com.mesosphere.mesos.rx.java.util.MessageCodec; import com.mesosphere.mesos.rx.java.util.UserAgent; import com.mesosphere.mesos.rx.java.util.UserAgentEntry; import io.netty.buffer.ByteBuf; import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.util.concurrent.DefaultThreadFactory; import io.reactivex.netty.RxNetty; import io.reactivex.netty.protocol.http.client.*; import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import rx.Observable; import rx.Subscriber; import rx.Subscription; import rx.exceptions.Exceptions; import rx.functions.Func1; import java.net.URI; import java.net.URISyntaxException; import java.util.Base64; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import static com.mesosphere.mesos.rx.java.util.UserAgentEntries.userAgentEntryForGradleArtifact; import static com.mesosphere.mesos.rx.java.util.UserAgentEntries.userAgentEntryForMavenArtifact; import static rx.Observable.just; /** * This class performs the necessary work to create an {@link Observable} of {@code Receive} from Mesos' * HTTP APIs, one of which is the * <a target="_blank" href="https://github.com/apache/mesos/blob/master/docs/scheduler-http-api.md">HTTP Scheduler API</a> * @param <Send> The type of Objects to be sent to Mesos * @param <Receive> The type of Objects to expect from Mesos * @see MesosClientBuilder */ public final class MesosClient<Send, Receive> { private static final Logger LOGGER = LoggerFactory.getLogger(MesosClient.class); private static final String MESOS_STREAM_ID = "Mesos-Stream-Id"; @NotNull private final ExecutorService exec = Executors.newSingleThreadExecutor( new DefaultThreadFactory("stream-monitor-thread", true) ); @NotNull private final URI mesosUri; @NotNull private final MessageCodec<Receive> receiveCodec; @NotNull private final Send subscribe; @NotNull private final Function<Observable<Receive>, Observable<Optional<SinkOperation<Send>>>> streamProcessor; @NotNull // @VisibleForTesting final Func1<Send, Observable<HttpClientRequest<ByteBuf>>> createPost; @NotNull private final UserAgent userAgent; @NotNull private final AtomicReference<String> mesosStreamId = new AtomicReference<>(null); @NotNull private final Observable.Transformer<byte[], byte[]> backpressureTransformer; MesosClient( @NotNull final URI mesosUri, @NotNull final Function<Class<?>, UserAgentEntry> applicationUserAgentEntry, @NotNull final MessageCodec<Send> sendCodec, @NotNull final MessageCodec<Receive> receiveCodec, @NotNull final Send subscribe, @NotNull final Function<Observable<Receive>, Observable<Optional<SinkOperation<Send>>>> streamProcessor, @NotNull final Observable.Transformer<byte[], byte[]> backpressureTransformer ) { this.mesosUri = mesosUri; this.receiveCodec = receiveCodec; this.subscribe = subscribe; this.streamProcessor = streamProcessor; this.backpressureTransformer = backpressureTransformer; userAgent = new UserAgent( applicationUserAgentEntry, userAgentEntryForMavenArtifact("com.mesosphere.mesos.rx.java", "mesos-rxjava-client"), userAgentEntryForGradleArtifact("rxnetty") ); createPost = curryCreatePost(mesosUri, sendCodec, receiveCodec, userAgent, mesosStreamId); } /** * Sends the subscribe call to Mesos and starts processing the stream of {@code Receive} events. * The {@code streamProcessor} function provided to the constructor will be applied to the stream of events * received from Mesos. * <p> * The stream processing will then process any {@link SinkOperation} that should be sent to Mesos. * @return The subscription representing the processing of the event stream. This subscription can then be used * to block the invoking thread using {@link AwaitableSubscription#await()} (For example to block a main thread * from exiting while events are being processed.) */ @NotNull public AwaitableSubscription openStream() { final URI uri = resolveMesosUri(mesosUri); final HttpClient<ByteBuf, ByteBuf> httpClient = RxNetty.<ByteBuf, ByteBuf>newHttpClientBuilder(uri.getHost(), getPort(uri)) .withName(userAgent.getEntries().get(0).getName()) .pipelineConfigurator(new HttpClientPipelineConfigurator<>()) .build(); final Observable<Receive> receives = createPost.call(subscribe) .flatMap(httpClient::submit) .subscribeOn(Rx.io()) .flatMap(verifyResponseOk(subscribe, mesosStreamId, receiveCodec.mediaType())) .lift(new RecordIOOperator()) .compose(backpressureTransformer) .observeOn(Rx.compute()) /* Begin temporary back-pressure */ .buffer(250, TimeUnit.MILLISECONDS) .flatMap(Observable::from) /* end temporary back-pressure */ .map(receiveCodec::decode) ; final Observable<SinkOperation<Send>> sends = streamProcessor.apply(receives) .filter(Optional::isPresent) .map(Optional::get); final Subscriber<SinkOperation<Send>> subscriber = new SinkSubscriber<>(httpClient, createPost); final SubscriberDecorator<SinkOperation<Send>> decorator = new SubscriberDecorator<>(subscriber); final Subscription subscription = sends .subscribeOn(Rx.compute()) .observeOn(Rx.compute()) .subscribe(decorator); return new ObservableAwaitableSubscription(Observable.from(exec.submit(decorator)), subscription); } /** * The Mesos HTTP Scheduler API will send a redirect to a client if it is not the leader. The client that is * constructed during {@link #openStream} is bound to a specific host and port, due to this behavior * we "probe" Mesos to try and find out where it's "master" is before we configure the client. * * This method will attempt to send a simple GET to {@code mesosUri}, however instead of going to the path * specified, it will go to {@code /redirect} and construct a uri relative to mesosUri using the host and port * returned in the Location header of the response. */ @NotNull private static URI resolveMesosUri(final @NotNull URI mesosUri) { final String redirectUri = createRedirectUri(mesosUri); LOGGER.info("Probing Mesos server at {}", redirectUri); // When sending an individual request (rather than creating a client) RxNetty WILL follow redirects, // so here we tell it not to do that for this request by creating the config object below. final HttpClient.HttpClientConfig config = HttpClient.HttpClientConfig.Builder.fromDefaultConfig() .setFollowRedirect(false) .build(); final HttpClientResponse<ByteBuf> redirectResponse = RxNetty.createHttpRequest(HttpClientRequest.createGet(redirectUri), config) .toBlocking() .first(); return getUriFromRedirectResponse(mesosUri, redirectResponse); } @NotNull // @VisibleForTesting static URI getUriFromRedirectResponse(final @NotNull URI mesosUri, @NotNull final HttpClientResponse<ByteBuf> redirectResponse) { if (redirectResponse.getStatus().equals(HttpResponseStatus.TEMPORARY_REDIRECT)) { final String location = redirectResponse.getHeaders().get("Location"); final URI newUri = resolveRelativeUri(mesosUri, location); LOGGER.info("Using new Mesos URI: {}", newUri); return newUri; } else { LOGGER.info("Continuing with Mesos URI as-is"); return mesosUri; } } @NotNull // @VisibleForTesting static URI resolveRelativeUri(final @NotNull URI mesosUri, final String location) { final URI relativeUri = mesosUri.resolve(location); try { return new URI( relativeUri.getScheme(), relativeUri.getUserInfo(), relativeUri.getHost(), relativeUri.getPort(), mesosUri.getPath(), mesosUri.getQuery(), mesosUri.getFragment() ); } catch (URISyntaxException e) { throw new RuntimeException(e); } } @NotNull // @VisibleForTesting static String createRedirectUri(@NotNull final URI uri) { return uri.toString().replaceAll("/api/v1/(?:scheduler|executor)", "/redirect"); } @NotNull // @VisibleForTesting static <Send> Func1<HttpClientResponse<ByteBuf>, Observable<ByteBuf>> verifyResponseOk( @NotNull final Send subscription, @NotNull final AtomicReference<String> mesosStreamId, @NotNull final String receiveMediaType ) { return resp -> { final HttpResponseStatus status = resp.getStatus(); final int code = status.code(); final String contentType = resp.getHeaders().get(HttpHeaderNames.CONTENT_TYPE); if (code == 200 && receiveMediaType.equals(contentType)) { if (resp.getHeaders().contains(MESOS_STREAM_ID)) { final String streamId = resp.getHeaders().get(MESOS_STREAM_ID); mesosStreamId.compareAndSet(null, streamId); } return resp.getContent(); } else { final HttpResponseHeaders headers = resp.getHeaders(); return ResponseUtils.attemptToReadErrorResponse(resp).flatMap(msg -> { final List<Map.Entry<String, String>> entries = headers.entries(); final MesosClientErrorContext context = new MesosClientErrorContext(code, msg, entries); if (code == 200) { // this means that even though we got back a 200 it's not the sort of response we were expecting // For example hitting an endpoint that returns an html document instead of a document of type // `receiveMediaType` throw new MesosException( subscription, context.withMessage( String.format( "Response had Content-Type \"%s\" expected \"%s\"", contentType, receiveMediaType ) ) ); } else if (400 <= code && code < 500) { throw new Mesos4xxException(subscription, context); } else if (500 <= code && code < 600) { throw new Mesos5xxException(subscription, context); } else { LOGGER.warn("Unhandled error: context = {}", context); // This shouldn't actually ever happen, but it's here for completeness of the if-else tree // that always has to result in an exception being thrown so the compiler is okay with this // lambda throw new IllegalStateException("Unhandled error"); } }); } }; } // @VisibleForTesting static int getPort(@NotNull final URI uri) { final int uriPort = uri.getPort(); if (uriPort > 0) { return uriPort; } else { switch (uri.getScheme()) { case "http": return 80; case "https": return 443; default: throw new IllegalArgumentException("URI Scheme must be http or https"); } } } /** * Turns an Observable into an AwaitableSubscription */ private static final class ObservableAwaitableSubscription implements AwaitableSubscription { @NotNull private final Observable<Optional<Throwable>> observable; @NotNull private final Subscription subscription; private ObservableAwaitableSubscription( @NotNull final Observable<Optional<Throwable>> observable, @NotNull final Subscription subscription ) { this.observable = observable; this.subscription = subscription; } @Override public void await() throws Throwable { final Optional<Throwable> last = observable.toBlocking().last(); subscription.unsubscribe(); if (last.isPresent()) { throw last.get(); } } @Override public void unsubscribe() { subscription.unsubscribe(); } @Override public boolean isUnsubscribed() { return subscription.isUnsubscribed(); } } /** * Decorator for {@link Subscriber} that will keep track of "complete"ness and when complete will put an * item onto a blocking queue. {@link Callable#call()} will then block until there is an item on the queue. * If the item in the queue contains an exception, that exception will be throw back up to the user. */ // @VisibleForTesting static final class SubscriberDecorator<T> extends Subscriber<T> implements Callable<Optional<Throwable>> { @NotNull private final Subscriber<T> delegate; @NotNull private final BlockingQueue<Optional<Throwable>> queue; SubscriberDecorator(@NotNull final Subscriber<T> delegate) { this.delegate = delegate; queue = new LinkedBlockingDeque<>(); } /** * Returns the first item from the queue that marks the end of the stream. * If an exception happens when {@link Subscriber#onError(Throwable)} or {@link Subscriber#onCompleted()} is * called for {@code delegate} the exception from that method invocation will be the item returned. * @return The item that ended the stream. * @throws InterruptedException if waiting on the stream to end is interrupted. */ @Override public Optional<Throwable> call() throws InterruptedException { return queue.take(); } @Override public void onNext(final T t) { try { delegate.onNext(t); } catch (Exception e) { onError(e); } } @Override public void onError(final Throwable e) { Exceptions.throwIfFatal(e); try { delegate.onError(e); queue.offer(Optional.of(e)); } catch (Exception e1) { queue.offer(Optional.of(e1)); } } @Override public void onCompleted() { try { delegate.onCompleted(); queue.offer(Optional.empty()); } catch (Exception e) { queue.offer(Optional.of(e)); } } } @NotNull // @VisibleForTesting static <Send, Receive> Func1<Send, Observable<HttpClientRequest<ByteBuf>>> curryCreatePost( @NotNull final URI mesosUri, @NotNull final MessageCodec<Send> sendCodec, @NotNull final MessageCodec<Receive> receiveCodec, @NotNull final UserAgent userAgent, @NotNull final AtomicReference<String> mesosStreamId ) { return (Send s) -> { final byte[] bytes = sendCodec.encode(s); HttpClientRequest<ByteBuf> request = HttpClientRequest.createPost(mesosUri.getPath()) .withHeader("User-Agent", userAgent.toString()) .withHeader("Content-Type", sendCodec.mediaType()) .withHeader("Accept", receiveCodec.mediaType()); final String streamId = mesosStreamId.get(); if (streamId != null) { request = request.withHeader(MESOS_STREAM_ID, streamId); } final String userInfo = mesosUri.getUserInfo(); if (userInfo != null) { request = request.withHeader( HttpHeaderNames.AUTHORIZATION.toString(), String.format("Basic %s", Base64.getEncoder().encodeToString(userInfo.getBytes())) ); } return just( request .withContent(bytes) ); }; } }