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