001/* -*- mode: Java; c-basic-offset: 2; indent-tabs-mode: nil; coding: utf-8-unix -*-
002 *
003 * Copyright © 2017 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.BufferedInputStream;
020import java.io.ByteArrayOutputStream;
021import java.io.InputStream;
022import java.io.IOException;
023
024import java.net.URI;
025import java.net.URL;
026
027import java.util.Arrays;
028import java.util.ArrayList;
029import java.util.Base64;
030import java.util.HashMap;
031import java.util.List;
032import java.util.Map;
033import java.util.Objects;
034
035import io.fabric8.kubernetes.client.DefaultKubernetesClient;
036import io.fabric8.kubernetes.client.KubernetesClient;
037import io.fabric8.kubernetes.client.KubernetesClientException;
038
039import io.fabric8.kubernetes.api.model.Container;
040import io.fabric8.kubernetes.api.model.ContainerPort;
041import io.fabric8.kubernetes.api.model.EnvVar;
042import io.fabric8.kubernetes.api.model.HTTPGetAction;
043import io.fabric8.kubernetes.api.model.IntOrString;
044import io.fabric8.kubernetes.api.model.ObjectMeta;
045import io.fabric8.kubernetes.api.model.PodSpec;
046import io.fabric8.kubernetes.api.model.PodTemplateSpec;
047import io.fabric8.kubernetes.api.model.Probe;
048import io.fabric8.kubernetes.api.model.Secret;
049import io.fabric8.kubernetes.api.model.SecretVolumeSource;
050import io.fabric8.kubernetes.api.model.Service;
051import io.fabric8.kubernetes.api.model.ServicePort;
052import io.fabric8.kubernetes.api.model.ServiceSpec;
053import io.fabric8.kubernetes.api.model.Status;
054import io.fabric8.kubernetes.api.model.Volume;
055import io.fabric8.kubernetes.api.model.VolumeMount;
056
057import io.fabric8.kubernetes.api.model.extensions.Deployment;
058import io.fabric8.kubernetes.api.model.extensions.DeploymentSpec;
059import io.fabric8.kubernetes.api.model.extensions.DeploymentBuilder;
060
061/**
062 * A class that idiomatically but faithfully emulates the
063 * Tiller-installing behavior of the {@code helm init} command.
064 *
065 * <p>In general, this class follows the logic as expressed in <a
066 * href="https://github.com/kubernetes/helm/blob/master/cmd/helm/installer/install.go">the
067 * {@code install.go} source code from the Helm project</a>,
068 * problematic or not.  The intent is to have an installer, usable as
069 * an idiomatic Java library, that behaves just like {@code helm
070 * init}.</p>
071 *
072 * <p><strong>Note:</strong> This class is experimental and its API is
073 * subject to change without notice.</p>
074 *
075 * @author <a href="https://about.me/lairdnelson"
076 * target="_parent">Laird Nelson</a>
077 *
078 * @see #init()
079 *
080 * @see <a
081 * href="https://github.com/kubernetes/helm/blob/master/cmd/helm/installer/install.go">The
082 * {@code install.go} source code from the Helm project</a>
083 */
084public class TillerInstaller {
085
086
087  /*
088   * Static fields.
089   */
090
091  
092  /*
093   * Atomic static fields.
094   */
095  
096  private static final Integer ONE = Integer.valueOf(1);
097
098  private static final String DEFAULT_IMAGE_PULL_POLICY = "IfNotPresent";
099  
100  private static final String DEFAULT_NAME = "tiller";
101
102  private static final String DEFAULT_NAMESPACE = "kube-system";
103
104  private static final String TILLER_TLS_CERTS_PATH = "/etc/certs";
105
106  /**
107   * The version of Tiller to install.
108   */
109  public static final String VERSION = "2.5.0";
110
111  /*
112   * Derivative static fields.
113   */
114  
115  private static final String DEFAULT_IMAGE_NAME = "gcr.io/kubernetes-helm/" + DEFAULT_NAME + ":v" + VERSION;
116
117  private static final String DEFAULT_DEPLOYMENT_NAME = DEFAULT_NAME + "-deploy";
118
119  private static final String SECRET_NAME = DEFAULT_NAME + "-secret";
120
121
122  /*
123   * Instance fields.
124   */
125
126  
127  private final KubernetesClient kubernetesClient;
128
129  private final String tillerNamespace;
130
131
132  /*
133   * Constructors.
134   */
135
136
137  public TillerInstaller() {
138    this(new DefaultKubernetesClient());
139  }
140  
141  public TillerInstaller(final KubernetesClient kubernetesClient) {
142    super();
143    Objects.requireNonNull(kubernetesClient);
144    this.kubernetesClient = kubernetesClient;
145    final String tillerNamespace = System.getenv("TILLER_NAMESPACE");
146    if (tillerNamespace == null || tillerNamespace.isEmpty()) {
147      this.tillerNamespace = DEFAULT_NAMESPACE;
148    } else {
149      this.tillerNamespace = tillerNamespace;
150    }
151  }
152
153
154  /*
155   * Instance methods.
156   */
157  
158
159  public void init() {
160    try {
161      this.init(false, null, null, null, null, null, null, null, false, false, false, null, null, null);
162    } catch (final IOException willNotHappen) {
163      throw new AssertionError(willNotHappen);
164    }
165  }
166  
167  public void init(final boolean upgrade) {
168    try {
169      this.init(upgrade, null, null, null, null, null, null, null, false, false, false, null, null, null);
170    } catch (final IOException willNotHappen) {
171      throw new AssertionError(willNotHappen);
172    }
173  }
174
175  public void init(final boolean upgrade,
176                   String namespace,
177                   final String deploymentName,
178                   final String serviceName,
179                   Map<String, String> labels,
180                   final String serviceAccountName,
181                   final String imageName,
182                   final String imagePullPolicy,
183                   final boolean hostNetwork,
184                   final boolean tls,
185                   final boolean verifyTls,
186                   final URI tlsKeyUri,
187                   final URI tlsCertUri,
188                   final URI tlsCaCertUri)
189    throws IOException {
190    namespace = normalizeNamespace(namespace);
191    labels = normalizeLabels(labels);
192    try {
193      this.install(namespace,
194                   deploymentName,
195                   serviceName,
196                   labels,
197                   serviceAccountName,
198                   imageName,
199                   imagePullPolicy,
200                   hostNetwork,
201                   tls,
202                   verifyTls,
203                   tlsKeyUri,
204                   tlsCertUri,
205                   tlsCaCertUri);
206    } catch (final KubernetesClientException kubernetesClientException) {
207      final Status status = kubernetesClientException.getStatus();
208      if (status == null || !"AlreadyExists".equals(status.getReason())) {
209        throw kubernetesClientException;
210      } else if (upgrade) {
211        this.upgrade(namespace,
212                     deploymentName,
213                     serviceName,
214                     serviceAccountName,
215                     imageName,
216                     imagePullPolicy,
217                     labels);
218      }
219    }
220  }
221
222  public void install() {
223    try {
224      this.install(null, null, null, null, null, null, null, false, false, false, null, null, null);
225    } catch (final IOException willNotHappen) {
226      throw new AssertionError(willNotHappen);
227    }
228  }
229
230  public void install(String namespace,
231                      final String deploymentName,
232                      final String serviceName,
233                      Map<String, String> labels,
234                      final String serviceAccountName,
235                      final String imageName,
236                      final String imagePullPolicy,
237                      final boolean hostNetwork,
238                      final boolean tls,
239                      final boolean verifyTls,
240                      final URI tlsKeyUri,
241                      final URI tlsCertUri,
242                      final URI tlsCaCertUri)
243    throws IOException {
244    namespace = normalizeNamespace(namespace);
245    labels = normalizeLabels(labels);
246    final Deployment deployment =
247      this.createDeployment(namespace,
248                            deploymentName,
249                            labels,
250                            serviceAccountName,
251                            imageName,
252                            imagePullPolicy,
253                            hostNetwork,
254                            tls,
255                            verifyTls);
256        
257    this.kubernetesClient.extensions().deployments().inNamespace(namespace).create(deployment);
258
259    final Service service = this.createService(namespace, serviceName, labels);
260    this.kubernetesClient.services().inNamespace(namespace).create(service);
261    
262    if (tls) {
263      final Secret secret =
264        this.createSecret(namespace,
265                          tlsKeyUri,
266                          tlsCertUri,
267                          tlsCaCertUri,
268                          labels);
269      this.kubernetesClient.secrets().inNamespace(namespace).create(secret);
270    }
271    
272  }
273
274  public void upgrade() {
275    this.upgrade(null, null, null, null, null, null, null);
276  }
277  
278  public void upgrade(String namespace,
279                      final String deploymentName,
280                      String serviceName,
281                      final String serviceAccountName,
282                      String imageName,
283                      String imagePullPolicy,
284                      final Map<String, String> labels) {
285    namespace = normalizeNamespace(namespace);
286    serviceName = normalizeServiceName(serviceName);
287
288    this.kubernetesClient.extensions()
289      .deployments()
290      .inNamespace(namespace)
291      .withName(normalizeDeploymentName(deploymentName))
292      .edit()
293      .editSpec()
294      .editTemplate()
295      .editSpec()
296      .editContainer(0)
297      .withImage(normalizeImageName(imageName))
298      .withImagePullPolicy(normalizeImagePullPolicy(imagePullPolicy))
299      .and()
300      .withServiceAccountName(normalizeServiceAccountName(serviceAccountName))
301      .endSpec()
302      .endTemplate()
303      .endSpec()
304      .done();
305
306    // TODO: this way of emulating install.go's check to see if the
307    // Service exists...not sure it's right
308    final Service service = this.kubernetesClient.services()
309      .inNamespace(namespace)
310      .withName(serviceName)
311      .get();
312    if (service == null) {
313      this.createService(namespace, serviceName, normalizeLabels(labels));
314    }
315    
316  }
317  
318  protected Service createService(String namespace,
319                                  String serviceName,
320                                  Map<String, String> labels) {
321    labels = normalizeLabels(labels);
322
323    final Service service = new Service();
324    
325    final ObjectMeta metadata = new ObjectMeta();
326    metadata.setNamespace(normalizeNamespace(namespace));
327    metadata.setName(normalizeServiceName(serviceName));
328    metadata.setLabels(labels);
329    service.setMetadata(metadata);
330
331    service.setSpec(this.createServiceSpec(labels));
332    return service;
333  }
334
335  protected Deployment createDeployment(String namespace,
336                                        String deploymentName,
337                                        Map<String, String> labels,
338                                        final String serviceAccountName,
339                                        final String imageName,
340                                        final String imagePullPolicy,
341                                        final boolean hostNetwork,
342                                        final boolean tls,
343                                        final boolean verifyTls) {
344    labels = normalizeLabels(labels);
345
346    final Deployment deployment = new Deployment();
347
348    final ObjectMeta metadata = new ObjectMeta();
349    metadata.setNamespace(normalizeNamespace(namespace));
350    metadata.setName(normalizeDeploymentName(deploymentName));
351    metadata.setLabels(labels);
352    deployment.setMetadata(metadata);
353
354    deployment.setSpec(this.createDeploymentSpec(labels, serviceAccountName, imageName, imagePullPolicy, namespace, hostNetwork, tls, verifyTls));
355    return deployment;
356  }
357
358  protected Secret createSecret(String namespace,
359                                final URI tlsKeyUri,
360                                final URI tlsCertUri,
361                                final URI tlsCaCertUri,
362                                Map<String, String> labels)
363    throws IOException {
364    labels = normalizeLabels(labels);
365    
366    final Secret secret = new Secret();
367    secret.setType("Opaque");
368
369    final Map<String, String> secretData = new HashMap<>();
370    
371    try (final InputStream tlsKeyStream = read(tlsKeyUri)) {
372      if (tlsKeyStream != null) {
373        secretData.put("tls.key", Base64.getEncoder().encodeToString(toByteArray(tlsKeyStream)));
374      }
375    }
376
377    try (final InputStream tlsCertStream = read(tlsCertUri)) {
378      if (tlsCertStream != null) {
379        secretData.put("tls.crt", Base64.getEncoder().encodeToString(toByteArray(tlsCertStream)));
380      }
381    }
382    
383    try (final InputStream tlsCaCertStream = read(tlsCaCertUri)) {
384      if (tlsCaCertStream != null) {
385        secretData.put("ca.crt", Base64.getEncoder().encodeToString(toByteArray(tlsCaCertStream)));
386      }
387    }
388
389    secret.setData(secretData);
390
391    final ObjectMeta metadata = new ObjectMeta();
392    metadata.setNamespace(normalizeNamespace(namespace));
393    metadata.setName(SECRET_NAME);
394    metadata.setLabels(labels);
395    secret.setMetadata(metadata);
396    
397    return secret;
398  }
399  
400  protected DeploymentSpec createDeploymentSpec(Map<String, String> labels,
401                                                final String serviceAccountName,
402                                                final String imageName,
403                                                final String imagePullPolicy,
404                                                final String namespace,
405                                                final boolean hostNetwork,
406                                                final boolean tls,
407                                                final boolean verifyTls) {    
408    labels = normalizeLabels(labels);
409    final DeploymentSpec deploymentSpec = new DeploymentSpec();
410    final PodTemplateSpec podTemplateSpec = new PodTemplateSpec();
411    final ObjectMeta metadata = new ObjectMeta();
412    metadata.setLabels(labels);
413    podTemplateSpec.setMetadata(metadata);
414    final PodSpec podSpec = new PodSpec();
415    podSpec.setServiceAccountName(normalizeServiceAccountName(serviceAccountName));
416    podSpec.setContainers(Arrays.asList(this.createContainer(imageName, imagePullPolicy, namespace, tls, verifyTls)));
417    podSpec.setHostNetwork(Boolean.valueOf(hostNetwork));
418    final Map<String, String> nodeSelector = new HashMap<>();
419    nodeSelector.put("beta.kubernetes.io/os", "linux");
420    podSpec.setNodeSelector(nodeSelector);    
421    if (tls) {
422      final Volume volume = new Volume();
423      volume.setName(DEFAULT_NAME + "-certs");
424      final SecretVolumeSource secretVolumeSource = new SecretVolumeSource();
425      secretVolumeSource.setSecretName(SECRET_NAME);
426      volume.setSecret(secretVolumeSource);
427      podSpec.setVolumes(Arrays.asList(volume));
428    }
429    podTemplateSpec.setSpec(podSpec);
430    deploymentSpec.setTemplate(podTemplateSpec);    
431    return deploymentSpec;
432  }
433
434  protected Container createContainer(final String imageName,
435                                      final String imagePullPolicy,
436                                      final String namespace,
437                                      final boolean tls,
438                                      final boolean verifyTls) {
439    final Container container = new Container();
440    container.setName(DEFAULT_NAME);
441    container.setImage(normalizeImageName(imageName));
442    container.setImagePullPolicy(normalizeImagePullPolicy(imagePullPolicy));
443
444    final ContainerPort containerPort = new ContainerPort();
445    containerPort.setContainerPort(Integer.valueOf(44134));
446    containerPort.setName(DEFAULT_NAME);
447    container.setPorts(Arrays.asList(containerPort));
448
449    final List<EnvVar> env = new ArrayList<>();
450    
451    final EnvVar tillerNamespace = new EnvVar();
452    tillerNamespace.setName("TILLER_NAMESPACE");
453    tillerNamespace.setValue(normalizeNamespace(namespace));
454    env.add(tillerNamespace);
455
456    if (tls) {
457      final EnvVar tlsVerify = new EnvVar();
458      tlsVerify.setName("TILLER_TLS_VERIFY");
459      tlsVerify.setValue(verifyTls ? "1" : "");
460      env.add(tlsVerify);
461      
462      final EnvVar tlsEnable = new EnvVar();
463      tlsEnable.setName("TILLER_TLS_ENABLE");
464      tlsEnable.setValue("1");
465      env.add(tlsEnable);
466
467      final EnvVar tlsCerts = new EnvVar();
468      tlsCerts.setName("TILLER_TLS_CERTS");
469      tlsCerts.setValue(TILLER_TLS_CERTS_PATH);
470      env.add(tlsCerts);
471    }
472    
473    container.setEnv(env);
474    
475
476    final IntOrString port44135 = new IntOrString(Integer.valueOf(44135));
477    
478    final HTTPGetAction livenessHttpGetAction = new HTTPGetAction();
479    livenessHttpGetAction.setPath("/liveness");
480    livenessHttpGetAction.setPort(port44135);
481    final Probe livenessProbe = new Probe();
482    livenessProbe.setHttpGet(livenessHttpGetAction);
483    livenessProbe.setInitialDelaySeconds(ONE);
484    livenessProbe.setTimeoutSeconds(ONE);
485    container.setLivenessProbe(livenessProbe);
486
487    final HTTPGetAction readinessHttpGetAction = new HTTPGetAction();
488    readinessHttpGetAction.setPath("/readiness");
489    readinessHttpGetAction.setPort(port44135);
490    final Probe readinessProbe = new Probe();
491    readinessProbe.setHttpGet(readinessHttpGetAction);
492    readinessProbe.setInitialDelaySeconds(ONE);
493    readinessProbe.setTimeoutSeconds(ONE);
494    container.setReadinessProbe(readinessProbe);
495
496    if (tls) {
497      final VolumeMount volumeMount = new VolumeMount();
498      volumeMount.setName(DEFAULT_NAME + "-certs");
499      volumeMount.setReadOnly(true);
500      volumeMount.setMountPath(TILLER_TLS_CERTS_PATH);
501      container.setVolumeMounts(Arrays.asList(volumeMount));
502    }
503
504    return container;
505  }
506
507  protected ServiceSpec createServiceSpec(Map<String, String> labels) {
508    labels = normalizeLabels(labels);
509    final ServiceSpec serviceSpec = new ServiceSpec();
510    serviceSpec.setType("ClusterIP");
511
512    final ServicePort servicePort = new ServicePort();
513    servicePort.setName(DEFAULT_NAME);
514    servicePort.setPort(Integer.valueOf(44134));
515    servicePort.setTargetPort(new IntOrString(DEFAULT_NAME));
516    serviceSpec.setPorts(Arrays.asList(servicePort));
517
518    serviceSpec.setSelector(labels);
519    return serviceSpec;
520  }
521
522  protected final String normalizeNamespace(String namespace) {
523    if (namespace == null || namespace.isEmpty()) {
524      namespace = this.tillerNamespace;
525      if (namespace == null || namespace.isEmpty()) {
526        namespace = DEFAULT_NAMESPACE;
527      }
528    }
529    return namespace;
530  }
531
532
533  /*
534   * Static methods.
535   */
536
537  
538  protected static final Map<String, String> normalizeLabels(Map<String, String> labels) {
539    if (labels == null) {
540      labels = new HashMap<>();
541    }
542    if (!labels.containsKey("app")) {
543      labels.put("app", "helm");
544    }
545    if (!labels.containsKey("name")) {
546      labels.put("name", DEFAULT_NAME);
547    }
548    return labels;
549  }
550  
551  protected static final String normalizeDeploymentName(final String deploymentName) {
552    if (deploymentName == null || deploymentName.isEmpty()) {
553      return DEFAULT_DEPLOYMENT_NAME;
554    } else {
555      return deploymentName;
556    }
557  }
558  
559  protected static final String normalizeImageName(final String imageName) {
560    if (imageName == null || imageName.isEmpty()) {
561      return DEFAULT_IMAGE_NAME;
562    } else {
563      return imageName;
564    }
565  }
566  
567  protected static final String normalizeImagePullPolicy(final String imagePullPolicy) {
568    if (!"Always".equals(imagePullPolicy) &&
569        !"IfNotPresent".equals(imagePullPolicy) &&
570        !"Never".equals(imagePullPolicy)) {
571      return DEFAULT_IMAGE_PULL_POLICY;
572    } else {
573      return imagePullPolicy;
574    }
575  }
576
577  protected static final String normalizeServiceAccountName(final String serviceAccountName) {
578    return serviceAccountName == null ? "" : serviceAccountName;
579  }
580  
581  protected static final String normalizeServiceName(final String serviceName) {
582    if (serviceName == null || serviceName.isEmpty()) {
583      return DEFAULT_DEPLOYMENT_NAME; // yes, DEFAULT_*DEPLOYMENT*_NAME
584    } else {
585      return serviceName;
586    }
587  }
588
589  private static final InputStream read(final URI uri) throws IOException {
590    final InputStream returnValue;
591    if (uri == null) {
592      returnValue = null;
593    } else {
594      final URL url = uri.toURL();    
595      final InputStream uriStream = url.openStream();
596      if (uriStream == null) {
597        returnValue = null;
598      } else {
599        returnValue = new BufferedInputStream(uriStream);
600      }
601    }
602    return returnValue;
603  }
604
605  private static final byte[] toByteArray(final InputStream inputStream) throws IOException {
606    // Interesting historical anecdotes at https://stackoverflow.com/a/1264737/208288.
607    byte[] returnValue = null;
608    if (inputStream != null) {
609      final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
610      returnValue = new byte[4096]; // arbitrary size
611      int bytesRead;
612      while ((bytesRead = inputStream.read(returnValue, 0, returnValue.length)) != -1) {
613        buffer.write(returnValue, 0, bytesRead);
614      }      
615      buffer.flush();      
616      returnValue = buffer.toByteArray();
617    }
618    return returnValue;
619  }
620  
621}