001/* -*- mode: Java; c-basic-offset: 2; indent-tabs-mode: nil; coding: utf-8-unix -*-
002 *
003 * Copyright © 2017-2018 microBean.
004 *
005 * Licensed under the Apache License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 *     http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
014 * implied.  See the License for the specific language governing
015 * permissions and limitations under the License.
016 */
017package org.microbean.helm;
018
019import java.io.Closeable;
020import java.io.IOException;
021
022import java.net.InetAddress;
023import java.net.MalformedURLException;
024
025import java.util.Collections;
026import java.util.HashMap;
027import java.util.Map;
028import java.util.Objects;
029
030import hapi.services.tiller.ReleaseServiceGrpc;
031import hapi.services.tiller.ReleaseServiceGrpc.ReleaseServiceBlockingStub;
032import hapi.services.tiller.ReleaseServiceGrpc.ReleaseServiceFutureStub;
033import hapi.services.tiller.ReleaseServiceGrpc.ReleaseServiceStub;
034import hapi.services.tiller.Tiller.GetVersionResponse;
035
036import hapi.version.VersionOuterClass.VersionOrBuilder;
037
038import io.fabric8.kubernetes.client.Config;
039import io.fabric8.kubernetes.client.ConfigAware;
040import io.fabric8.kubernetes.client.DefaultKubernetesClient; // for javadoc only
041import io.fabric8.kubernetes.client.HttpClientAware;
042import io.fabric8.kubernetes.client.KubernetesClient;
043import io.fabric8.kubernetes.client.KubernetesClientException; // for javadoc only
044import io.fabric8.kubernetes.client.LocalPortForward;
045
046import io.grpc.ManagedChannel;
047import io.grpc.ManagedChannelBuilder;
048import io.grpc.Metadata;
049
050import io.grpc.stub.MetadataUtils;
051
052import okhttp3.OkHttpClient;
053
054import org.microbean.development.annotation.Issue;
055
056import org.microbean.kubernetes.Pods;
057
058/**
059 * A convenience class for communicating with a <a
060 * href="https://docs.helm.sh/glossary/#tiller"
061 * target="_parent">Tiller server</a>.
062 *
063 * @author <a href="https://about.me/lairdnelson"
064 * target="_parent">Laird Nelson</a>
065 *
066 * @see ReleaseServiceGrpc
067 */
068public class Tiller implements ConfigAware<Config>, Closeable {
069
070
071  /*
072   * Static fields.
073   */
074
075
076  /**
077   * The version of Tiller {@link Tiller} instances expect.
078   *
079   * <p>This field is never {@code null}.</p>
080   */
081  public static final String VERSION = "2.8.1";
082
083  /**
084   * The Kubernetes namespace into which Tiller server instances are
085   * most commonly installed.
086   *
087   * <p>This field is never {@code null}.</p>
088   */
089  public static final String DEFAULT_NAMESPACE = "kube-system";
090
091  /**
092   * The port on which Tiller server instances most commonly listen.
093   */
094  public static final int DEFAULT_PORT = 44134;
095
096  /**
097   * The Kubernetes labels with which most Tiller instances are
098   * annotated.
099   *
100   * <p>This field is never {@code null}.</p>
101   */
102  public static final Map<String, String> DEFAULT_LABELS;
103  
104  /**
105   * A {@link Metadata} that ensures that certain Tiller-related
106   * headers are passed with every gRPC call.
107   *
108   * <p>This field is never {@code null}.</p>
109   */
110  private static final Metadata metadata = new Metadata();
111
112
113  /*
114   * Static initializer.
115   */
116  
117
118  /**
119   * Static initializer; initializes the {@link #DEFAULT_LABELS}
120   * {@code static} field (among others).
121   */
122  static {
123    final Map<String, String> labels = new HashMap<>();
124    labels.put("name", "tiller");
125    labels.put("app", "helm");
126    DEFAULT_LABELS = Collections.unmodifiableMap(labels);
127    metadata.put(Metadata.Key.of("x-helm-api-client", Metadata.ASCII_STRING_MARSHALLER), VERSION);
128  }
129
130
131  /*
132   * Instance fields.
133   */
134
135
136  /**
137   * The {@link Config} available at construction time.
138   *
139   * <p>This field may be {@code null}.</p>
140   *
141   * @see #getConfiguration()
142   */
143  private final Config config;
144
145  /**
146   * The {@link LocalPortForward} being used to communicate (most
147   * commonly) with a Kubernetes pod housing a Tiller server.
148   *
149   * <p>This field may be {@code null}.</p>
150   *
151   * @see #Tiller(LocalPortForward)
152   */
153  private final LocalPortForward portForward;
154
155  /**
156   * The {@link ManagedChannel} over which communications with a
157   * Tiller server will be conducted.
158   *
159   * <p>This field is never {@code null}.</p>
160   */
161  private final ManagedChannel channel;
162
163
164  /*
165   * Constructors.
166   */
167
168
169  /**
170   * Creates a new {@link Tiller} that will use the supplied {@link
171   * ManagedChannel} for communication.
172   *
173   * @param channel the {@link ManagedChannel} over which
174   * communications will be conducted; must not be {@code null}
175   *
176   * @exception NullPointerException if {@code channel} is {@code
177   * null}
178   */
179  public Tiller(final ManagedChannel channel) {
180    super();
181    Objects.requireNonNull(channel);
182    this.config = null;
183    this.portForward = null;
184    this.channel = channel;
185  }
186
187  /**
188   * Creates a new {@link Tiller} that will use information from the
189   * supplied {@link LocalPortForward} to establish a communications
190   * channel with the Tiller server.
191   *
192   * @param portForward the {@link LocalPortForward} to use; must not
193   * be {@code null}
194   *
195   * @exception NullPointerException if {@code portForward} is {@code
196   * null}
197   */
198  public Tiller(final LocalPortForward portForward) {
199    super();
200    Objects.requireNonNull(portForward);
201    this.config = null;
202    this.portForward = null; // yes, null
203    this.channel = this.buildChannel(portForward);
204  }
205
206  /**
207   * Creates a new {@link Tiller} that will forward a local port to
208   * port {@code 44134} on a Pod housing Tiller in the {@code
209   * kube-system} namespace running in the Kubernetes cluster with
210   * which the supplied {@link KubernetesClient} is capable of
211   * communicating.
212   *
213   * <p>The {@linkplain Pods#getFirstReadyPod(Listable) first ready
214   * Pod} with a {@code name} label whose value is {@code tiller} and
215   * with an {@code app} label whose value is {@code helm} is deemed
216   * to be the pod housing the Tiller instance to connect to.  (This
217   * duplicates the default logic of the {@code helm} command line
218   * executable.)</p>
219   *
220   * @param <T> a {@link KubernetesClient} implementation that is also
221   * an {@link HttpClientAware} implementation, such as {@link
222   * DefaultKubernetesClient}
223   *
224   * @param client the {@link KubernetesClient}-and-{@link
225   * HttpClientAware} implementation that can communicate with a
226   * Kubernetes cluster; must not be {@code null}
227   *
228   * @exception MalformedURLException if there was a problem
229   * identifying a Pod within the cluster that houses a Tiller instance
230   *
231   * @exception NullPointerException if {@code client} is {@code null}
232   */
233  public <T extends HttpClientAware & KubernetesClient> Tiller(final T client) throws MalformedURLException {
234    this(client, DEFAULT_NAMESPACE, DEFAULT_PORT, DEFAULT_LABELS);
235  }
236
237  /**
238   * Creates a new {@link Tiller} that will forward a local port to
239   * port {@code 44134} on a Pod housing Tiller in the supplied
240   * namespace running in the Kubernetes cluster with which the
241   * supplied {@link KubernetesClient} is capable of communicating.
242   *
243   * <p>The {@linkplain Pods#getFirstReadyPod(Listable) first ready
244   * Pod} with a {@code name} label whose value is {@code tiller} and
245   * with an {@code app} label whose value is {@code helm} is deemed
246   * to be the pod housing the Tiller instance to connect to.  (This
247   * duplicates the default logic of the {@code helm} command line
248   * executable.)</p>
249   *
250   * @param <T> a {@link KubernetesClient} implementation that is also
251   * an {@link HttpClientAware} implementation, such as {@link
252   * DefaultKubernetesClient}
253   *
254   * @param client the {@link KubernetesClient}-and-{@link
255   * HttpClientAware} implementation that can communicate with a
256   * Kubernetes cluster; must not be {@code null}; no reference to
257   * this object is retained by this {@link Tiller} instance
258   *
259   * @param namespaceHousingTiller the namespace within which a Tiller
260   * instance is hopefully running; if {@code null}, then the value of
261   * {@link #DEFAULT_NAMESPACE} will be used instead
262   *
263   * @exception MalformedURLException if there was a problem
264   * identifying a Pod within the cluster that houses a Tiller instance
265   *
266   * @exception NullPointerException if {@code client} is {@code null}
267   *
268   * @exception KubernetesClientException if there was a problem
269   * connecting to Kubernetes
270   *
271   * @exception TillerException if a ready Tiller pod could not be
272   * found and consequently a connection could not be established
273   */
274  public <T extends HttpClientAware & KubernetesClient> Tiller(final T client, final String namespaceHousingTiller) throws MalformedURLException {
275    this(client, namespaceHousingTiller, DEFAULT_PORT, DEFAULT_LABELS);
276  }
277
278  /**
279   * Creates a new {@link Tiller} that will forward a local port to
280   * the supplied (remote) port on a Pod housing Tiller in the supplied
281   * namespace running in the Kubernetes cluster with which the
282   * supplied {@link KubernetesClient} is capable of communicating.
283   *
284   * <p>The {@linkplain Pods#getFirstReadyPod(Listable) first ready
285   * Pod} with labels matching the supplied {@code tillerLabels} is
286   * deemed to be the pod housing the Tiller instance to connect
287   * to.</p>
288   *
289   * @param <T> a {@link KubernetesClient} implementation that is also
290   * an {@link HttpClientAware} implementation, such as {@link
291   * DefaultKubernetesClient}
292   *
293   * @param client the {@link KubernetesClient}-and-{@link
294   * HttpClientAware} implementation that can communicate with a
295   * Kubernetes cluster; must not be {@code null}; no reference to
296   * this object is retained by this {@link Tiller} instance
297   *
298   * @param namespaceHousingTiller the namespace within which a Tiller
299   * instance is hopefully running; if {@code null}, then the value of
300   * {@link #DEFAULT_NAMESPACE} will be used instead
301   *
302   * @param tillerPort the remote port to attempt to forward a local
303   * port to; normally {@code 44134}
304   *
305   * @param tillerLabels a {@link Map} representing the Kubernetes
306   * labels (and their values) identifying a Pod housing a Tiller
307   * instance; if {@code null} then the value of {@link
308   * #DEFAULT_LABELS} will be used instead
309   *
310   * @exception MalformedURLException if there was a problem
311   * identifying a Pod within the cluster that houses a Tiller instance
312   *
313   * @exception NullPointerException if {@code client} is {@code null}
314   *
315   * @exception KubernetesClientException if there was a problem
316   * connecting to Kubernetes
317   *
318   * @exception TillerException if a ready Tiller pod could not be
319   * found and consequently a connection could not be established
320   */
321  public <T extends HttpClientAware & KubernetesClient> Tiller(final T client,
322                                                               String namespaceHousingTiller,
323                                                               int tillerPort,
324                                                               Map<String, String> tillerLabels) throws MalformedURLException {
325    super();
326    Objects.requireNonNull(client);
327    this.config = client.getConfiguration();
328    if (namespaceHousingTiller == null || namespaceHousingTiller.isEmpty()) {
329      namespaceHousingTiller = DEFAULT_NAMESPACE;
330    }
331    if (tillerPort <= 0) {
332      tillerPort = DEFAULT_PORT;
333    }
334    if (tillerLabels == null) {
335      tillerLabels = DEFAULT_LABELS;
336    }
337    final OkHttpClient httpClient = client.getHttpClient();
338    if (httpClient == null) {
339      throw new IllegalArgumentException("client", new IllegalStateException("client.getHttpClient() == null"));
340    }
341    LocalPortForward portForward = null;
342    
343    this.portForward = Pods.forwardPort(httpClient, client.pods().inNamespace(namespaceHousingTiller).withLabels(tillerLabels), tillerPort);
344    if (this.portForward == null) {
345      throw new TillerException("Could not forward port to a Ready Tiller pod's port " + tillerPort + " in namespace " + namespaceHousingTiller + " with labels " + tillerLabels);
346    }
347    this.channel = this.buildChannel(this.portForward);
348  }
349
350
351  /*
352   * Instance methods.
353   */
354
355
356  /**
357   * Returns any {@link Config} available at construction time.
358   *
359   * <p>This method may return {@code null}.</p>
360   *
361   * @return a {@link Config}, or {@code null}
362   */
363  @Override
364  public Config getConfiguration() {
365    return this.config;
366  }
367  
368
369  /**
370   * Creates a {@link ManagedChannel} for communication with Tiller
371   * from the information contained in the supplied {@link
372   * LocalPortForward}.
373   *
374   * <p><strong>Note:</strong> This method is (deliberately) called
375   * from constructors so must have stateless semantics.</p>
376   *
377   * <p>This method never returns {@code null}.</p>
378   *
379   * <p>Overrides of this method must not return {@code null}.</p>
380   *
381   * @param portForward a {@link LocalPortForward}; must not be {@code
382   * null}
383   *
384   * @return a non-{@code null} {@link ManagedChannel}
385   *
386   * @exception NullPointerException if {@code portForward} is {@code
387   * null}
388   *
389   * @exception IllegalArgumentException if {@code portForward}'s
390   * {@link LocalPortForward#getLocalAddress()} method returns {@code
391   * null}
392   */
393  @Issue(id = "42", uri = "https://github.com/microbean/microbean-helm/issues/42")
394  protected ManagedChannel buildChannel(final LocalPortForward portForward) {
395    Objects.requireNonNull(portForward);
396    @Issue(id = "43", uri = "https://github.com/microbean/microbean-helm/issues/43")
397    final InetAddress localAddress = portForward.getLocalAddress();
398    if (localAddress == null) {
399      throw new IllegalArgumentException("portForward", new IllegalStateException("portForward.getLocalAddress() == null"));
400    }
401    final String hostAddress = localAddress.getHostAddress();
402    if (hostAddress == null) {
403      throw new IllegalArgumentException("portForward", new IllegalStateException("portForward.getLocalAddress().getHostAddress() == null"));
404    }
405    return ManagedChannelBuilder.forAddress(hostAddress, portForward.getLocalPort()).usePlaintext(true).build();
406  }
407
408  /**
409   * Closes this {@link Tiller} after use; any {@link
410   * LocalPortForward} or {@link ManagedChannel} <strong>used or
411   * created</strong> by or for this {@link Tiller} instance will be
412   * closed or {@linkplain ManagedChannel#shutdown() shut down}
413   * appropriately.
414   *
415   * @exception IOException if there was a problem closing the
416   * underlying connection to a Tiller instance
417   *
418   * @see LocalPortForward#close()
419   *
420   * @see ManagedChannel#shutdown()
421   */
422  @Override
423  public void close() throws IOException {
424    if (this.channel != null) {
425      this.channel.shutdown();
426    }
427    if (this.portForward != null) {
428      this.portForward.close();
429    }
430  }
431
432  /**
433   * Returns the gRPC-generated {@link ReleaseServiceBlockingStub}
434   * object that represents the capabilities of the Tiller server.
435   *
436   * <p>This method will never return {@code null}.</p>
437   *
438   * <p>Overrides of this method must never return {@code null}.</p>
439   *
440   * @return a non-{@code null} {@link ReleaseServiceBlockingStub}
441   *
442   * @see ReleaseServiceBlockingStub
443   */
444  public ReleaseServiceBlockingStub getReleaseServiceBlockingStub() {
445    ReleaseServiceBlockingStub returnValue = null;
446    if (this.channel != null) {
447      returnValue = MetadataUtils.attachHeaders(ReleaseServiceGrpc.newBlockingStub(this.channel), metadata);
448    }
449    return returnValue;
450  }
451
452  /**
453   * Returns the gRPC-generated {@link ReleaseServiceFutureStub}
454   * object that represents the capabilities of the Tiller server.
455   *
456   * <p>This method will never return {@code null}.</p>
457   *
458   * <p>Overrides of this method must never return {@code null}.</p>
459   *
460   * @return a non-{@code null} {@link ReleaseServiceFutureStub}
461   *
462   * @see ReleaseServiceFutureStub
463   */
464  public ReleaseServiceFutureStub getReleaseServiceFutureStub() {
465    ReleaseServiceFutureStub returnValue = null;
466    if (this.channel != null) {
467      returnValue = MetadataUtils.attachHeaders(ReleaseServiceGrpc.newFutureStub(this.channel), metadata);
468    }
469    return returnValue;
470  }
471
472  /**
473   * Returns the gRPC-generated {@link ReleaseServiceStub}
474   * object that represents the capabilities of the Tiller server.
475   *
476   * <p>This method will never return {@code null}.</p>
477   *
478   * <p>Overrides of this method must never return {@code null}.</p>
479   *
480   * @return a non-{@code null} {@link ReleaseServiceStub}
481   *
482   * @see ReleaseServiceStub
483   */  
484  public ReleaseServiceStub getReleaseServiceStub() {
485    ReleaseServiceStub returnValue = null;
486    if (this.channel != null) {
487      returnValue = MetadataUtils.attachHeaders(ReleaseServiceGrpc.newStub(this.channel), metadata);
488    }
489    return returnValue;
490  }
491
492  public VersionOrBuilder getVersion() throws IOException {
493    final ReleaseServiceBlockingStub stub = this.getReleaseServiceBlockingStub();
494    assert stub != null;
495    final GetVersionResponse response = stub.getVersion(null);
496    assert response != null;
497    return response.getVersion();
498  }
499  
500}