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.chart.repository;
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.URISyntaxException;
026import java.net.URL;
027
028import java.nio.ByteBuffer;
029
030import java.nio.file.LinkOption; // for javadoc only
031import java.nio.file.StandardCopyOption;
032import java.nio.file.Files;
033import java.nio.file.Path;
034import java.nio.file.Paths;
035
036import java.nio.file.attribute.FileAttribute; // for javadoc only
037
038import java.security.MessageDigest;
039import java.security.NoSuchAlgorithmException;
040
041import java.util.Collection;
042import java.util.Collections;
043import java.util.Iterator;
044import java.util.Map;
045import java.util.Objects;
046import java.util.LinkedHashSet;
047import java.util.Set;
048import java.util.SortedSet;
049import java.util.SortedMap;
050import java.util.TreeSet;
051import java.util.TreeMap;
052
053import java.util.zip.GZIPInputStream;
054
055import javax.xml.bind.DatatypeConverter;
056
057import com.github.zafarkhaja.semver.ParseException;
058import com.github.zafarkhaja.semver.Version;
059
060import hapi.chart.ChartOuterClass.Chart;
061import hapi.chart.MetadataOuterClass.Metadata;
062import hapi.chart.MetadataOuterClass.MetadataOrBuilder;
063
064import org.kamranzafar.jtar.TarInputStream;
065
066import org.microbean.development.annotation.Experimental;
067
068import org.microbean.helm.chart.Metadatas;
069import org.microbean.helm.chart.TapeArchiveChartLoader;
070
071import org.microbean.helm.chart.resolver.AbstractChartResolver;
072import org.microbean.helm.chart.resolver.ChartResolverException;
073
074import org.yaml.snakeyaml.Yaml;
075
076/**
077 * An {@link AbstractChartResolver} that {@linkplain #resolve(String,
078 * String) resolves} <a
079 * href="https://docs.helm.sh/developing_charts/#charts">Helm
080 * charts</a> from <a
081 * href="https://docs.helm.sh/developing_charts/#create-a-chart-repository">a
082 * given Helm chart repository</a>.
083 *
084 * @author <a href="https://about.me/lairdnelson"
085 * target="_parent">Laird Nelson</a>
086 *
087 * @see #resolve(String, String)
088 */
089@Experimental
090public class ChartRepository extends AbstractChartResolver {
091
092
093  /*
094   * Instance fields.
095   */
096
097
098  /**
099   * An {@linkplain Path#isAbsolute() absolute} {@link Path}
100   * representing a directory where Helm chart archives may be stored.
101   *
102   * <p>This field will never be {@code null}.</p>
103   */
104  private final Path archiveCacheDirectory;
105
106  /**
107   * An {@linkplain Path#isAbsolute() absolute} or relative {@link
108   * Path} representing a local copy of a chart repository's <a
109   * href="https://docs.helm.sh/developing_charts/#the-chart-repository-structure">{@code
110   * index.yaml}</a> file.
111   *
112   * <p>If the value of this field is a relative {@link Path}, then it
113   * will be considered to be relative to the value of the {@link
114   * #indexCacheDirectory} field.</p>
115   *
116   * <p>This field will never be {@code null}.</p>
117   *
118   * @see #getCachedIndexPath()
119   */
120  private final Path cachedIndexPath;
121
122  /**
123   * The {@link Index} object representing the chart repository index
124   * as described canonically by its <a
125   * href="https://docs.helm.sh/developing_charts/#the-chart-repository-structure">{@code
126   * index.yaml}</a> file.
127   *
128   * <p>This field may be {@code null}.</p>
129   *
130   * @see #getIndex()
131   *
132   * @see #downloadIndex()
133   */
134  private transient Index index;
135
136  /**
137   * An {@linkplain Path#isAbsolute() absolute} {@link Path}
138   * representing a directory that the value of the {@link
139   * #cachedIndexPath} field will be considered to be relative to.
140   *
141   * <p>This field may be {@code null}, in which case it is guaranteed
142   * that the {@link #cachedIndexPath} field's value is {@linkplain
143   * Path#isAbsolute() absolute}.</p>
144   */
145  private final Path indexCacheDirectory;
146
147  /**
148   * The name of this {@link ChartRepository}.
149   *
150   * <p>This field is never {@code null}.</p>
151   *
152   * @see #getName()
153   */
154  private final String name;
155
156  /**
157   * The {@link URI} representing the root of the chart repository
158   * represented by this {@link ChartRepository}.
159   *
160   * <p>This field is never {@code null}.</p>
161   *
162   * @see #getUri()
163   */
164  private final URI uri;
165
166
167  /*
168   * Constructors.
169   */
170
171
172  /**
173   * Creates a new {@link ChartRepository} whose {@linkplain
174   * #getCachedIndexPath() cached index path} will be a {@link Path}
175   * relative to the absolute directory represented by the value of
176   * the {@code helm.home} system property, or the value of the {@code
177   * HELM_HOME} environment variable, and bearing a name consisting of
178   * the supplied {@code name} suffixed with {@code -index.yaml}.
179   *
180   * @param name the name of this {@link ChartRepository}; must not be
181   * {@code null}
182   *
183   * @param uri the {@linkplain URI#isAbsolute() absolute} {@link URI}
184   * to the root of this {@link ChartRepository}; must not be {@code
185   * null}
186   *
187   * @exception NullPointerException if either {@code name} or {@code
188   * uri} is {@code null}
189   *
190   * @exception IllegalArgumentException if {@code uri} is {@linkplain
191   * URI#isAbsolute() not absolute}, or if there is no existing "Helm
192   * home" directory
193   *
194   * @see #ChartRepository(String, URI, Path, Path, Path)
195   *
196   * @see #getName()
197   *
198   * @see #getUri()
199   *
200   * @see #getCachedIndexPath()
201   */
202  public ChartRepository(final String name, final URI uri) {
203    this(name, uri, null, null, null);
204  }
205
206  /**
207   * Creates a new {@link ChartRepository}.
208   *
209   * @param name the name of this {@link ChartRepository}; must not be
210   * {@code null}
211   *
212   * @param uri the {@link URI} to the root of this {@link
213   * ChartRepository}; must not be {@code null}
214   *
215   * @param cachedIndexPath a {@link Path} naming the file that will
216   * store a copy of the chart repository's {@code index.yaml} file;
217   * if {@code null} then a {@link Path} relative to the absolute
218   * directory represented by the value of the {@code helm.home}
219   * system property, or the value of the {@code HELM_HOME}
220   * environment variable, and bearing a name consisting of the
221   * supplied {@code name} suffixed with {@code -index.yaml} will be
222   * used instead
223   *
224   * @exception NullPointerException if either {@code name} or {@code
225   * uri} is {@code null}
226   *
227   * @exception IllegalArgumentException if {@code uri} is {@linkplain
228   * URI#isAbsolute() not absolute}, or if there is no existing "Helm
229   * home" directory
230   *
231   * @see #ChartRepository(String, URI, Path, Path, Path)
232   *
233   * @see #getName()
234   *
235   * @see #getUri()
236   *
237   * @see #getCachedIndexPath()
238   */
239  public ChartRepository(final String name, final URI uri, final Path cachedIndexPath) {
240    this(name, uri, null, null, cachedIndexPath);
241  }
242
243  /**
244   * Creates a new {@link ChartRepository}.
245   *
246   * @param name the name of this {@link ChartRepository}; must not be
247   * {@code null}
248   *
249   * @param uri the {@link URI} to the root of this {@link
250   * ChartRepository}; must not be {@code null}
251   *
252   * @param archiveCacheDirectory an {@linkplain Path#isAbsolute()
253   * absolute} {@link Path} representing a directory where Helm chart
254   * archives may be stored; if {@code null} then a {@link Path}
255   * beginning with the absolute directory represented by the value of
256   * the {@code helm.home} system property, or the value of the {@code
257   * HELM_HOME} environment variable, appended with {@code
258   * cache/archive} will be used instead
259   *
260   * @param indexCacheDirectory an {@linkplain Path#isAbsolute()
261   * absolute} {@link Path} representing a directory that the supplied
262   * {@code cachedIndexPath} parameter value will be considered to be
263   * relative to; <strong>will be ignored and hence may be {@code
264   * null}</strong> if the supplied {@code cachedIndexPath} parameter
265   * value {@linkplain Path#isAbsolute()}
266   *
267   * @param cachedIndexPath a {@link Path} naming the file that will
268   * store a copy of the chart repository's {@code index.yaml} file;
269   * if {@code null} then a {@link Path} relative to the absolute
270   * directory represented by the value of the {@code helm.home}
271   * system property, or the value of the {@code HELM_HOME}
272   * environment variable, and bearing a name consisting of the
273   * supplied {@code name} suffixed with {@code -index.yaml} will be
274   * used instead
275   *
276   * @exception NullPointerException if either {@code name} or {@code
277   * uri} is {@code null}
278   *
279   * @exception IllegalArgumentException if {@code uri} is {@linkplain
280   * URI#isAbsolute() not absolute}, or if there is no existing "Helm
281   * home" directory, or if {@code archiveCacheDirectory} is
282   * non-{@code null} and either empty or not {@linkplain
283   * Path#isAbsolute()}
284   *
285   * @see #ChartRepository(String, URI, Path, Path, Path)
286   *
287   * @see #getName()
288   *
289   * @see #getUri()
290   *
291   * @see #getCachedIndexPath()
292   */
293  public ChartRepository(final String name, final URI uri, final Path archiveCacheDirectory, Path indexCacheDirectory, Path cachedIndexPath) {
294    super();
295    Objects.requireNonNull(name);
296    Objects.requireNonNull(uri);    
297    if (!uri.isAbsolute()) {
298      throw new IllegalArgumentException("!uri.isAbsolute(): " + uri);
299    }
300    
301    Path helmHome = null;
302
303    if (archiveCacheDirectory == null) {
304      helmHome = getHelmHome();
305      assert helmHome != null;
306      this.archiveCacheDirectory = helmHome.resolve("cache/archive");
307      assert this.archiveCacheDirectory != null;
308      assert this.archiveCacheDirectory.isAbsolute();
309    } else if (archiveCacheDirectory.toString().isEmpty()) {
310      throw new IllegalArgumentException("archiveCacheDirectory.toString().isEmpty(): " + archiveCacheDirectory);
311    } else if (!archiveCacheDirectory.isAbsolute()) {
312      throw new IllegalArgumentException("!archiveCacheDirectory.isAbsolute(): " + archiveCacheDirectory);
313    } else {
314      this.archiveCacheDirectory = archiveCacheDirectory;
315    }
316    assert this.archiveCacheDirectory != null;
317    assert this.archiveCacheDirectory.isAbsolute();
318    if (!Files.isDirectory(this.archiveCacheDirectory)) {
319      throw new IllegalArgumentException("!Files.isDirectory(this.archiveCacheDirectory): " + this.archiveCacheDirectory);
320    }
321
322    if (cachedIndexPath == null || cachedIndexPath.toString().isEmpty()) {
323      cachedIndexPath = Paths.get(new StringBuilder(name).append("-index.yaml").toString());
324    }
325    assert cachedIndexPath != null;
326
327    if (cachedIndexPath.isAbsolute()) {
328      this.indexCacheDirectory = null;
329      this.cachedIndexPath = cachedIndexPath;
330    } else {
331      if (indexCacheDirectory == null) {
332        if (helmHome == null) {
333          helmHome = getHelmHome();
334          assert helmHome != null;
335        }
336        this.indexCacheDirectory = helmHome.resolve("repository/cache");
337        assert this.indexCacheDirectory.isAbsolute();
338      } else if (!indexCacheDirectory.isAbsolute()) {
339        throw new IllegalArgumentException("!indexCacheDirectory.isAbsolute(): " + indexCacheDirectory);
340      } else {
341        this.indexCacheDirectory = indexCacheDirectory;
342        assert this.indexCacheDirectory.isAbsolute();
343      }
344      if (!Files.isDirectory(this.indexCacheDirectory)) {
345        throw new IllegalArgumentException("!Files.isDirectory(this.indexCacheDirectory): " + this.indexCacheDirectory);
346      }
347      this.cachedIndexPath = this.indexCacheDirectory.resolve(cachedIndexPath);
348    }
349    assert this.cachedIndexPath != null;
350    assert this.cachedIndexPath.isAbsolute();
351    
352    this.name = name;
353    this.uri = uri;
354  }
355
356
357  /*
358   * Instance methods.
359   */
360
361
362  /**
363   * Returns the name of this {@link ChartRepository}.
364   *
365   * <p>This method never returns {@code null}.</p>
366   *
367   * @return the non-{@code null} name of this {@link ChartRepository}
368   */
369  public final String getName() {
370    return this.name;
371  }
372
373  /**
374   * Returns the {@link URI} of the root of this {@link
375   * ChartRepository}.
376   *
377   * <p>This method never returns {@code null}.</p>
378   *
379   * <p>The {@link URI} returned by this method is guaranteed to be
380   * {@linkplain URI#isAbsolute() absolute}.</p>
381   *
382   * @return the non-{@code null}, {@linkplain URI#isAbsolute()
383   * absolute} {@link URI} of the root of this {@link ChartRepository}
384   */
385  public final URI getUri() {
386    return this.uri;
387  }
388
389  /**
390   * Returns a non-{@code null}, {@linkplain Path#isAbsolute()
391   * absolute} {@link Path} to the file that contains or will contain
392   * a copy of the chart repository's <a
393   * href="https://docs.helm.sh/developing_charts/#the-chart-repository-structure">{@code
394   * index.yaml}</a> file.
395   *
396   * <p>This method never returns {@code null}.</p>
397   *
398   * @return a non-{@code null}, {@linkplain Path#isAbsolute()
399   * absolute} {@link Path} to the file that contains or will contain
400   * a copy of the chart repository's <a
401   * href="https://docs.helm.sh/developing_charts/#the-chart-repository-structure">{@code
402   * index.yaml}</a> file
403   */
404  public final Path getCachedIndexPath() {
405    return this.cachedIndexPath;
406  }
407
408  /**
409   * Returns the {@link Index} for this {@link ChartRepository}.
410   *
411   * <p>This method never returns {@code null}.</p>
412   *
413   * <p>If this method has not been invoked before on this {@link
414   * ChartRepository}, then the {@linkplain #getCachedIndexPath()
415   * cached copy} of the chart repository's <a
416   * href="https://docs.helm.sh/developing_charts/#the-chart-repository-structure">{@code
417   * index.yaml}</a> file is parsed into an {@link Index} and that
418   * {@link Index} is stored in an instance variable before it is
419   * returned.</p>
420   *
421   * <p>If no {@linkplain #getCachedIndexPath() cached copy} of the
422   * chart repository's <a
423   * href="https://docs.helm.sh/developing_charts/#the-chart-repository-structure">{@code
424   * index.yaml}</a> file exists, then one is {@linkplain
425   * #downloadIndex() downloaded} first.</p>
426   *
427   * return the {@link Index} representing the contents of this {@link
428   * ChartRepository}; never {@code null}
429   *
430   * @exception IOException if there was a problem either parsing an
431   * <a
432   * href="https://docs.helm.sh/developing_charts/#the-chart-repository-structure">{@code
433   * index.yaml}</a> file or downloading it
434   *
435   * @exception URISyntaxException if one of the URIs in the <a
436   * href="https://docs.helm.sh/developing_charts/#the-chart-repository-structure">{@code
437   * index.yaml}</a> file is invalid
438   *
439   * @see #getIndex(boolean)
440   *
441   * @see #downloadIndex()
442   */
443  public final Index getIndex() throws IOException, URISyntaxException {
444    return this.getIndex(false);
445  }
446
447  /**
448   * Returns the {@link Index} for this {@link ChartRepository}.
449   *
450   * <p>This method never returns {@code null}.</p>
451   *
452   * <p>If this method has not been invoked before on this {@link
453   * ChartRepository}, then the {@linkplain #getCachedIndexPath()
454   * cached copy} of the chart repository's <a
455   * href="https://docs.helm.sh/developing_charts/#the-chart-repository-structure">{@code
456   * index.yaml}</a> file is parsed into an {@link Index} and that
457   * {@link Index} is stored in an instance variable before it is
458   * returned.</p>
459   *
460   * <p>If the {@linkplain #getCachedIndexPath() cached copy} of the
461   * chart repository's <a
462   * href="https://docs.helm.sh/developing_charts/#the-chart-repository-structure">{@code
463   * index.yaml}</a> file {@linkplain #isCachedIndexExpired() has
464   * expired}, then one is {@linkplain #downloadIndex() downloaded}
465   * first.</p>
466   * 
467   * @param forceDownload if {@code true} then no caching will happen
468   *
469   * @return the {@link Index} representing the contents of this {@link
470   * ChartRepository}; never {@code null}
471   *
472   * @exception IOException if there was a problem either parsing an
473   * <a
474   * href="https://docs.helm.sh/developing_charts/#the-chart-repository-structure">{@code
475   * index.yaml}</a> file or downloading it
476   *
477   * @exception URISyntaxException if one of the URIs in the <a
478   * href="https://docs.helm.sh/developing_charts/#the-chart-repository-structure">{@code
479   * index.yaml}</a> file is invalid
480   *
481   * @see #getIndex(boolean)
482   *
483   * @see #downloadIndex()
484   *
485   * @see #isCachedIndexExpired()
486   */
487  public final Index getIndex(final boolean forceDownload) throws IOException, URISyntaxException {
488    if (forceDownload || this.index == null) {
489      final Path cachedIndexPath = this.getCachedIndexPath();
490      assert cachedIndexPath != null;
491      if (forceDownload || this.isCachedIndexExpired()) {
492        this.downloadIndexTo(cachedIndexPath);
493      }
494      this.index = Index.loadFrom(cachedIndexPath);
495      assert this.index != null;
496    }
497    return this.index;
498  }
499
500  /**
501   * Returns {@code true} if the {@linkplain #getCachedIndexPath()
502   * cached copy} of the <a
503   * href="https://docs.helm.sh/developing_charts/#the-chart-repository-structure">{@code
504   * index.yaml}</a> file is to be considered stale.
505   *
506   * <p>The default implementation of this method returns the negation
507   * of the return value of an invocation of the {@link
508   * Files#isRegularFile(Path, LinkOption...)} method on the return value of the
509   * {@link #getCachedIndexPath()} method.</p>
510   *
511   * @return {@code true} if the {@linkplain #getCachedIndexPath()
512   * cached copy} of the <a
513   * href="https://docs.helm.sh/developing_charts/#the-chart-repository-structure">{@code
514   * index.yaml}</a> file is to be considered stale; {@code false} otherwise
515   *
516   * @see #getIndex(boolean)
517   */
518  public boolean isCachedIndexExpired() {
519    final Path cachedIndexPath = this.getCachedIndexPath();
520    assert cachedIndexPath != null;
521    return !Files.isRegularFile(cachedIndexPath);
522  }
523
524  /**
525   * Clears the {@link Index} stored internally by this {@link
526   * ChartRepository}, paving the way for a fresh copy to be installed
527   * by the {@link #getIndex(boolean)} method, and returns the old
528   * value.
529   *
530   * <p>This method may return {@code null} if {@code
531   * #getIndex(boolean)} has not yet been called.</p>
532   *
533   * @return the {@link Index}, or {@code null}
534   */
535  public final Index clearIndex() {
536    final Index returnValue = this.index;
537    this.index = null;
538    return returnValue;
539  }
540
541  /**
542   * Invokes the {@link #downloadIndexTo(Path)} method with the return
543   * value of the {@link #getCachedIndexPath()} method.
544   *
545   * <p>This method never returns {@code null}.</p>
546   *
547   * @return {@link Path} the {@link Path} to which the {@code
548   * index.yaml} file was downloaded; never {@code null}
549   *
550   * @exception IOException if there was a problem downloading
551   *
552   * @see #downloadIndexTo(Path)
553   */
554  public final Path downloadIndex() throws IOException {
555    return this.downloadIndexTo(this.getCachedIndexPath());
556  }
557
558  /**
559   * Downloads a copy of the chart repository's <a
560   * href="https://docs.helm.sh/developing_charts/#the-chart-repository-structure">{@code
561   * index.yaml}</a> file to the {@link Path} specified and returns
562   * the canonical representation of the {@link Path} to which the
563   * file was actually downloaded.
564   *
565   * <p>This method never returns {@code null}.</p>
566   *
567   * <p>Overrides of this method must not return {@code null}.</p>
568   *
569   * <p>The default implementation of this method actually downloads
570   * the <a
571   * href="https://docs.helm.sh/developing_charts/#the-chart-repository-structure">{@code
572   * index.yaml}</a> file to a {@linkplain
573   * Files#createTempFile(String, String, FileAttribute...) temporary
574   * file} first, and then {@linkplain StandardCopyOption#ATOMIC_MOVE
575   * atomically renames it}.</p>
576   *
577   * @param path the {@link Path} to download the <a
578   * href="https://docs.helm.sh/developing_charts/#the-chart-repository-structure">{@code
579   * index.yaml}</a> file to; may be {@code null} in which case the
580   * return value of the {@link #getCachedIndexPath()} method will be
581   * used instead
582   *
583   * @return the {@link Path} to the file; never {@code null}
584   *
585   * @exception IOException if there was a problem downloading
586   */
587  public Path downloadIndexTo(Path path) throws IOException {
588    final URI baseUri = this.getUri();
589    if (baseUri == null) {
590      throw new IllegalStateException("getUri() == null");
591    }
592    final URI indexUri = baseUri.resolve("index.yaml");
593    assert indexUri != null;
594    final URL indexUrl = indexUri.toURL();
595    assert indexUrl != null;
596    if (path == null) {
597      path = this.getCachedIndexPath();
598    }
599    assert path != null;
600    if (!path.isAbsolute()) {
601      assert this.indexCacheDirectory != null;
602      assert this.indexCacheDirectory.isAbsolute();
603      path = this.indexCacheDirectory.resolve(path);
604      assert path != null;
605      assert path.isAbsolute();
606    }
607    final Path temporaryPath = Files.createTempFile(new StringBuilder(this.getName()).append("-index-").toString(), ".yaml");
608    assert temporaryPath != null;
609    try (final BufferedInputStream stream = new BufferedInputStream(indexUrl.openStream())) {
610      Files.copy(stream, temporaryPath, StandardCopyOption.REPLACE_EXISTING);
611    } catch (final IOException throwMe) {
612      try {
613        Files.deleteIfExists(temporaryPath);
614      } catch (final IOException suppressMe) {
615        throwMe.addSuppressed(suppressMe);
616      }
617      throw throwMe;
618    }
619    return Files.move(temporaryPath, path, StandardCopyOption.ATOMIC_MOVE);
620  }
621
622  /**
623   * Creates a new {@link Index} from the contents of the {@linkplain
624   * #getCachedIndexPath() cached copy of the chart repository's
625   * <code>index.yaml</code> file} and returns it.
626   *
627   * <p>This method never returns {@code null}.</p>
628   *
629   * <p>Overrides of this method must not return {@code null}.</p>
630   *
631   * @return a new {@link Index}; never {@code null}
632   *
633   * @exception IOException if there was a problem reading the file
634   *
635   * @exception URISyntaxException if a URI in the file was invalid
636   *
637   * @see Index#loadFrom(Path)
638   */
639  public Index loadIndex() throws IOException, URISyntaxException {
640    Path path = this.getCachedIndexPath();
641    assert path != null;
642    if (!path.isAbsolute()) {
643      assert this.indexCacheDirectory != null;
644      assert this.indexCacheDirectory.isAbsolute();
645      path = this.indexCacheDirectory.resolve(path);
646      assert path != null;
647      assert path.isAbsolute();
648    }
649    return Index.loadFrom(path);
650  }
651
652  /**
653   * Given a Helm chart name and its version, returns the local {@link
654   * Path}, representing a local copy of the Helm chart as downloaded
655   * from the chart repository represented by this {@link
656   * ChartRepository}, downloading the archive if necessary.
657   *
658   * <p>This method may return {@code null}.</p>
659   *
660   * @param chartName the name of the chart whose local {@link Path}
661   * should be returned; must not be {@code null}
662   *
663   * @param chartVersion the version of the chart to select; may be
664   * {@code null} in which case "latest" semantics are implied
665   *
666   * @return the {@link Path} to the chart archive, or {@code null}
667   *
668   * @exception IOException if there was a problem downloading
669   *
670   * @exception URISyntaxException if this {@link ChartRepository}'s
671   * {@linkplain #getIndex() associated <code>Index</code>} could not
672   * be parsed
673   *
674   * @exception NullPointerException if {@code chartName} is {@code
675   * null}
676   */
677  public final Path getCachedChartPath(final String chartName, String chartVersion) throws IOException, URISyntaxException {
678    Objects.requireNonNull(chartName);
679    Path returnValue = null;
680    if (chartVersion == null) {
681      final Index index = this.getIndex(false);
682      assert index != null;
683      final Index.Entry entry = index.getEntry(chartName, null /* latest */);
684      if (entry != null) {
685        chartVersion = entry.getVersion();
686      }
687    }
688    if (chartVersion != null) {
689      assert this.archiveCacheDirectory != null;
690      final StringBuilder chartKey = new StringBuilder(chartName).append("-").append(chartVersion);
691      final String chartFilename = new StringBuilder(chartKey).append(".tgz").toString();
692      final Path cachedChartPath = this.archiveCacheDirectory.resolve(chartFilename);
693      assert cachedChartPath != null;
694      if (!Files.isRegularFile(cachedChartPath)) {
695        final Index index = this.getIndex(true);
696        assert index != null;
697        final Index.Entry entry = index.getEntry(chartName, chartVersion);
698        if (entry != null) {
699          URI chartUri = entry.getFirstUri();
700          if (chartUri != null) {
701
702            // See https://github.com/kubernetes/helm/issues/3057
703            if (!chartUri.isAbsolute()) {
704              final URI chartRepositoryUri = this.getUri();
705              assert chartRepositoryUri != null;
706              assert chartRepositoryUri.isAbsolute();
707              chartUri = chartRepositoryUri.resolve(chartUri);
708              assert chartUri != null;
709              assert chartUri.isAbsolute();
710            }
711            
712            final URL chartUrl = chartUri.toURL();
713            assert chartUrl != null;
714            final Path temporaryPath = Files.createTempFile(chartKey.append("-").toString(), ".tgz");
715            assert temporaryPath != null;
716            try (final InputStream stream = new BufferedInputStream(chartUrl.openStream())) {
717              Files.copy(stream, temporaryPath, StandardCopyOption.REPLACE_EXISTING);
718            } catch (final IOException throwMe) {
719              try {
720                Files.deleteIfExists(temporaryPath);
721              } catch (final IOException suppressMe) {
722                throwMe.addSuppressed(suppressMe);
723              }
724              throw throwMe;
725            }
726            Files.move(temporaryPath, cachedChartPath, StandardCopyOption.ATOMIC_MOVE);
727          }
728        }
729      }
730      returnValue = cachedChartPath;
731    }
732    return returnValue;
733  }
734
735  /**
736   * {@inheritDoc}
737   *
738   * <p>This implementation calls the {@link
739   * #getCachedChartPath(String, String)} method with the supplied
740   * arguments and uses a {@link TapeArchiveChartLoader} to load the
741   * resulting archive into a {@link Chart.Builder} object.</p>
742   */
743  @Override
744  public Chart.Builder resolve(final String chartName, String chartVersion) throws ChartResolverException {
745    Objects.requireNonNull(chartName);
746    Chart.Builder returnValue = null;
747    Path cachedChartPath = null;
748    try {
749      cachedChartPath = this.getCachedChartPath(chartName, chartVersion);
750    } catch (final IOException | URISyntaxException exception) {
751      throw new ChartResolverException(exception.getMessage(), exception);
752    }
753    if (cachedChartPath != null && Files.isRegularFile(cachedChartPath)) {
754      try (final TapeArchiveChartLoader loader = new TapeArchiveChartLoader()) {
755        returnValue = loader.load(new TarInputStream(new GZIPInputStream(new BufferedInputStream(Files.newInputStream(cachedChartPath)))));
756      } catch (final IOException exception) {
757        throw new ChartResolverException(exception.getMessage(), exception);
758      }
759    }
760    return returnValue;
761  }
762
763  /**
764   * Returns a {@link Path} representing "Helm home": the root
765   * directory for various Helm-related metadata as specified by
766   * either the {@code helm.home} system property or the {@code
767   * HELM_HOME} environment variable.
768   *
769   * <p>This method never returns {@code null}.</p>
770   *
771   * <p>No guarantee is made by this method regarding whether the
772   * returned {@link Path} actually denotes a directory.</p>
773   *
774   * @return a {@link Path} representing "Helm home"; never {@code
775   * null}
776   *
777   * @exception SecurityException if there are not sufficient
778   * permissions to read system properties or environment variables
779   */
780  static final Path getHelmHome() {
781    String helmHome = System.getProperty("helm.home", System.getenv("HELM_HOME"));
782    if (helmHome == null) {
783      helmHome = Paths.get(System.getProperty("user.home")).resolve(".helm").toString();
784      assert helmHome != null;
785    }
786    return Paths.get(helmHome);
787  }
788
789
790  /*
791   * Inner and nested classes.
792   */
793
794
795  /**
796   * A class representing certain of the contents of a <a
797   * href="https://docs.helm.sh/developing_charts/#the-chart-repository-structure">Helm
798   * chart repository's {@code index.yaml} file</a>.
799   *
800   * @author <a href="https://about.me/lairdnelson"
801   * target="_parent">Laird Nelson</a>
802   */
803  @Experimental
804  public static final class Index {
805
806
807    /*
808     * Instance fields.
809     */
810
811
812    /**
813     * An {@linkplain Collections#unmodifiableSortedMap(SortedMap)
814     * immutable} {@link SortedMap} of {@link SortedSet}s of {@link
815     * Entry} objects whose values represent enough information to
816     * derive a URI to a Helm chart.
817     *
818     * <p>This field is never {@code null}.</p>
819     */
820    private final SortedMap<String, SortedSet<Entry>> entries;
821
822
823    /*
824     * Constructors.
825     */
826
827
828    /**
829     * Creates a new {@link Index}.
830     *
831     * @param entries a {@link Map} of {@link SortedSet}s of {@link
832     * Entry} objects indexed by the name of the Helm chart they
833     * describe; may be {@code null}; copied by value
834     */
835    Index(final Map<? extends String, ? extends SortedSet<Entry>> entries) {
836      super();
837      if (entries == null || entries.isEmpty()) {
838        this.entries = Collections.emptySortedMap();
839      } else {
840        this.entries = Collections.unmodifiableSortedMap(deepCopy(entries));
841      }
842    }
843
844
845    /*
846     * Instance methods.
847     */
848
849
850    /**
851     * Creates and returns a new {@link Index} consisting of all this
852     * {@link Index} instance's {@linkplain #getEntries() entries}
853     * augmented with those entries from the supplied {@link Index}
854     * that this {@link Index} instance did not already contain.
855     *
856     * @param other the {@link Index} to merge in; may be {@code null}
857     *
858     * @return a new {@link Index} reflecting the merge operation
859     */
860    @Experimental
861    public final Index merge(final Index other) {
862      final Index returnValue;
863      final Map<String, SortedSet<Entry>> myEntries = this.getEntries();
864      final Map<String, SortedSet<Entry>> otherEntries;
865      if (other == null) {
866        otherEntries = null;
867      } else {
868        otherEntries = other.getEntries();
869      }
870      if (otherEntries == null || otherEntries.isEmpty()) {
871        if (myEntries == null || myEntries.isEmpty()) {
872          returnValue = new Index(null);
873        } else {
874          returnValue = new Index(myEntries);
875        }
876      } else if (myEntries == null || myEntries.isEmpty()) {
877        returnValue = new Index(otherEntries);
878      } else {
879        final Map<String, SortedSet<Entry>> mergedEntries = deepCopy(myEntries);
880        final Set<Map.Entry<String, SortedSet<Entry>>> otherEntrySet = otherEntries.entrySet();
881        if (otherEntrySet != null && !otherEntrySet.isEmpty()) {
882          for (final Map.Entry<? extends String, ? extends SortedSet<Entry>> otherEntrySetElement : otherEntrySet) {
883            if (otherEntrySetElement != null) {
884              final SortedSet<Entry> otherValues = otherEntrySetElement.getValue();
885              if (otherValues != null && !otherValues.isEmpty()) {
886                for (final Entry otherEntry : otherValues) {
887                  if (otherEntry != null) {
888                    final String otherEntryName = otherEntry.getName();
889                    final Entry myCorrespondingEntry = this.getEntry(otherEntryName, otherEntry.getVersion());
890                    if (myCorrespondingEntry == null) {
891                      SortedSet<Entry> myRelatedEntries = mergedEntries.get(otherEntryName);
892                      if (myRelatedEntries == null) {
893                        myRelatedEntries = new TreeSet<>(Collections.reverseOrder());
894                        mergedEntries.put(otherEntryName, myRelatedEntries);
895                      }
896                      assert !myRelatedEntries.contains(otherEntry);
897                      myRelatedEntries.add(otherEntry);
898                    }
899                  }
900                }
901              }
902            }
903          }
904        }
905        returnValue = new Index(mergedEntries);
906      }
907      return returnValue;
908    }
909
910    /**
911     * Returns a non-{@code null}, {@linkplain
912     * Collections#unmodifiableMap(Map) immutable} {@link Map} of
913     * {@link SortedSet}s of {@link Entry} objects, indexed by the
914     * name of the Helm chart they describe.
915     *
916     * @return a non-{@code null}, {@linkplain
917     * Collections#unmodifiableMap(Map) immutable} {@link Map} of
918     * {@link SortedSet}s of {@link Entry} objects, indexed by the
919     * name of the Helm chart they describe
920     */
921    public final Map<String, SortedSet<Entry>> getEntries() {
922      return this.entries;
923    }
924
925    /**
926     * Returns an {@link Entry} identified by the supplied {@code
927     * name} and {@code version}, if there is one.
928     *
929     * <p>This method may return {@code null}.</p>
930     *
931     * @param name the name of the Helm chart whose related {@link
932     * Entry} is desired; must not be {@code null}
933     *
934     * @param versionString the version of the Helm chart whose
935     * related {@link Entry} is desired; may be {@code null} in which
936     * case "latest" semantics are implied
937     *
938     * @return an {@link Entry}, or {@code null}
939     *
940     * @exception NullPointerException if {@code name} is {@code null}
941     */
942    public final Entry getEntry(final String name, final String versionString) {
943      Objects.requireNonNull(name);
944      Entry returnValue = null;
945      final Map<String, SortedSet<Entry>> entries = this.getEntries();
946      if (entries != null && !entries.isEmpty()) {
947        final SortedSet<Entry> entrySet = entries.get(name);
948        if (entrySet != null && !entrySet.isEmpty()) {
949          if (versionString == null) {
950            returnValue = entrySet.first();
951          } else {
952            for (final Entry entry : entrySet) {
953              // XXX TODO FIXME: probably want to make this a
954              // constraint match, not just an equality comparison
955              if (entry != null && versionString.equals(entry.getVersion())) {
956                returnValue = entry;
957                break;
958              }
959            }
960          }
961        }
962      }
963      return returnValue;
964    }
965
966
967    /*
968     * Static methods.
969     */
970
971
972    /**
973     * Creates a new {@link Index} whose contents are sourced from the
974     * YAML file located at the supplied {@link Path}.
975     *
976     * <p>This method never returns {@code null}.</p>
977     *
978     * @param path the {@link Path} to a YAML file whose contents are
979     * those of a <a
980     * href="https://docs.helm.sh/developing_charts/#the-index-file">Helm
981     * chart repository index</a>; must not be {@code null}
982     *
983     * @return a new {@link Index}; never {@code null}
984     *
985     * @exception IOException if there was a problem reading the file
986     *
987     * @exception URISyntaxException if one of the URIs in the file
988     * was invalid
989     *
990     * @exception NullPointerException if {@code path} is {@code null}
991     *
992     * @see #loadFrom(InputStream)
993     */
994    public static final Index loadFrom(final Path path) throws IOException, URISyntaxException {
995      Objects.requireNonNull(path);
996      final Index returnValue;
997      try (final BufferedInputStream stream = new BufferedInputStream(Files.newInputStream(path))) {
998        returnValue = loadFrom(stream);
999      }
1000      return returnValue;
1001    }
1002
1003    /**
1004     * Creates a new {@link Index} whose contents are sourced from the
1005     * <a
1006     * href="https://docs.helm.sh/developing_charts/#the-index-file">Helm
1007     * chart repository index</a> YAML contents represented by the
1008     * supplied {@link InputStream}.
1009     *
1010     * <p>This method never returns {@code null}.</p>
1011     *
1012     * @param stream the {@link InputStream} to a YAML file whose contents are
1013     * those of a <a
1014     * href="https://docs.helm.sh/developing_charts/#the-index-file">Helm
1015     * chart repository index</a>; must not be {@code null}
1016     *
1017     * @return a new {@link Index}; never {@code null}
1018     *
1019     * @exception IOException if there was a problem reading the file
1020     *
1021     * @exception URISyntaxException if one of the URIs in the file
1022     * was invalid
1023     *
1024     * @exception NullPointerException if {@code path} is {@code null}
1025     */
1026    public static final Index loadFrom(final InputStream stream) throws IOException, URISyntaxException {
1027      Objects.requireNonNull(stream);
1028      final Index returnValue;
1029      final Map<?, ?> yamlMap = new Yaml().loadAs(stream, Map.class);
1030      if (yamlMap == null || yamlMap.isEmpty()) {
1031        returnValue = new Index(null);
1032      } else {
1033        final SortedMap<String, SortedSet<Index.Entry>> sortedEntryMap = new TreeMap<>();
1034        @SuppressWarnings("unchecked")
1035        final Map<? extends String, ? extends Collection<? extends Map<?, ?>>> entriesMap = (Map<? extends String, ? extends Collection<? extends Map<?, ?>>>)yamlMap.get("entries");
1036        if (entriesMap != null && !entriesMap.isEmpty()) {
1037          final Collection<? extends Map.Entry<? extends String, ? extends Collection<? extends Map<?, ?>>>> entries = entriesMap.entrySet();
1038          if (entries != null && !entries.isEmpty()) {
1039            for (final Map.Entry<? extends String, ? extends Collection<? extends Map<?, ?>>> mapEntry : entries) {
1040              if (mapEntry != null) {
1041                final String entryName = mapEntry.getKey();
1042                if (entryName != null) {
1043                  final Collection<? extends Map<?, ?>> entryContents = mapEntry.getValue();
1044                  if (entryContents != null && !entryContents.isEmpty()) {
1045                    for (final Map<?, ?> entryMap : entryContents) {
1046                      if (entryMap != null && !entryMap.isEmpty()) {
1047                        final Metadata.Builder metadataBuilder = Metadata.newBuilder();
1048                        assert metadataBuilder != null;
1049                        Metadatas.populateMetadataBuilder(metadataBuilder, entryMap);
1050                        @SuppressWarnings("unchecked")
1051                        final Collection<? extends String> uriStrings = (Collection<? extends String>)entryMap.get("urls");
1052                        Set<URI> uris = new LinkedHashSet<>();
1053                        if (uriStrings != null && !uriStrings.isEmpty()) {
1054                          for (final String uriString : uriStrings) {
1055                            if (uriString != null && !uriString.isEmpty()) {
1056                              uris.add(new URI(uriString));
1057                            }
1058                          }
1059                        }
1060                        final String digest = (String)entryMap.get("digest");
1061                        SortedSet<Index.Entry> entryObjects = sortedEntryMap.get(entryName);
1062                        if (entryObjects == null) {
1063                          entryObjects = new TreeSet<>(Collections.reverseOrder());
1064                          sortedEntryMap.put(entryName, entryObjects);
1065                        }
1066                        entryObjects.add(new Index.Entry(metadataBuilder, uris, digest));
1067                      }
1068                    }
1069                  }
1070                }
1071              }
1072            }
1073          }      
1074        }
1075        returnValue = new Index(sortedEntryMap);
1076      }
1077      return returnValue;
1078    }
1079
1080    /**
1081     * Performs a deep copy of the supplied {@link Map} such that the
1082     * {@link SortedMap} returned has copies of the supplied {@link
1083     * Map}'s {@linkplain Map#values() values}.
1084     *
1085     * <p>This method may return {@code null} if {@code source} is
1086     * {@code null}.</p>
1087     *
1088     * <p>The {@link SortedMap} returned by this method is
1089     * mutable.</p>
1090     *
1091     * @param source the {@link Map} to copy; may be {@code null} in
1092     * which case {@code null} will be returned
1093     *
1094     * @return a mutable {@link SortedMap}, or {@code null}
1095     */
1096    private static final SortedMap<String, SortedSet<Entry>> deepCopy(final Map<? extends String, ? extends SortedSet<Entry>> source) {
1097      final SortedMap<String, SortedSet<Entry>> returnValue;
1098      if (source == null) {
1099        returnValue = null;
1100      } else if (source.isEmpty()) {
1101        returnValue = Collections.emptySortedMap();
1102      } else {
1103        returnValue = new TreeMap<>();
1104        final Collection<? extends Map.Entry<? extends String, ? extends SortedSet<Entry>>> entrySet = source.entrySet();
1105        if (entrySet != null && !entrySet.isEmpty()) {
1106          for (final Map.Entry<? extends String, ? extends SortedSet<Entry>> entry : entrySet) {
1107            final String key = entry.getKey();
1108            final SortedSet<Entry> value = entry.getValue();
1109            if (value == null) {
1110              returnValue.put(key, null);
1111            } else {
1112              final SortedSet<Entry> newValue = new TreeSet<>(value.comparator());
1113              newValue.addAll(value);
1114              returnValue.put(key, newValue);
1115            }
1116          }
1117        }
1118      }
1119      return returnValue;
1120    }
1121    
1122
1123    /*
1124     * Inner and nested classes.
1125     */
1126
1127
1128    /**
1129     * An entry in a <a
1130     * href="https://docs.helm.sh/developing_charts/#the-index-file">Helm
1131     * chart repository index</a>.
1132     *
1133     * @author <a href="https://about.me/lairdnelson"
1134     * target="_parent">Laird Nelson</a>
1135     */
1136    @Experimental
1137    public static final class Entry implements Comparable<Entry> {
1138
1139
1140      /*
1141       * Instance fields.
1142       */
1143
1144
1145      /**
1146       * A {@link MetadataOrBuilder} representing most of the contents
1147       * of the entry.
1148       *
1149       * <p>This field is never {@code null}.</p>
1150       */
1151      private final MetadataOrBuilder metadata;
1152
1153      /**
1154       * An {@linkplain Collections#unmodifiableSet(Set) immutable}
1155       * {@link Set} of {@link URI}s describing where the particular
1156       * Helm chart described by this {@link Entry} may be downloaded
1157       * from.
1158       *
1159       * <p>This field is never {@code null}.</p>
1160       */
1161      private final Set<URI> uris;
1162
1163      
1164      private final String digest;
1165
1166      /*
1167       * Constructors.
1168       */
1169
1170
1171      /**
1172       * Creates a new {@link Entry}.
1173       *
1174       * @param metadata a {@link MetadataOrBuilder} representing most
1175       * of the contents of the entry; must not be {@code null}
1176       *
1177       * @param uris a {@link Collection} of {@link URI}s describing
1178       * where the particular Helm chart described by this {@link
1179       * Entry} may be downloaded from; may be {@code null}; copied by
1180       * value
1181       *
1182       * @exception NullPointerException if {@code metadata} is {@code
1183       * null}
1184       *
1185       * @see #Entry(MetadataOrBuilder, Collection, String)
1186       */
1187      Entry(final MetadataOrBuilder metadata, final Collection<? extends URI> uris) {
1188        this(metadata, uris, null);
1189      }
1190
1191      /**
1192       * Creates a new {@link Entry}.
1193       *
1194       * @param metadata a {@link MetadataOrBuilder} representing most
1195       * of the contents of the entry; must not be {@code null}
1196       *
1197       * @param uris a {@link Collection} of {@link URI}s describing
1198       * where the particular Helm chart described by this {@link
1199       * Entry} may be downloaded from; may be {@code null}; copied by
1200       * value
1201       *
1202       * @param digest a SHA-256 message digest to be associated with
1203       * this {@link Entry}; may be {@code null}
1204       *
1205       * @exception NullPointerException if {@code metadata} is {@code
1206       * null}
1207       */
1208      Entry(final MetadataOrBuilder metadata, final Collection<? extends URI> uris, final String digest) {
1209        super();
1210        this.metadata = Objects.requireNonNull(metadata);
1211        if (uris == null || uris.isEmpty()) {
1212          this.uris = Collections.emptySet();
1213        } else {
1214          this.uris = new LinkedHashSet<>(uris);
1215        }
1216        this.digest = digest;
1217      }
1218
1219
1220      /*
1221       * Instance methods.
1222       */
1223
1224
1225      /**
1226       * Compares this {@link Entry} to the supplied {@link Entry} and
1227       * returns a value less than {@code 0} if this {@link Entry} is
1228       * "less than" the supplied {@link Entry}, {@code 1} if this
1229       * {@link Entry} is "greater than" the supplied {@link Entry}
1230       * and {@code 0} if this {@link Entry} is equal to the supplied
1231       * {@link Entry}.
1232       *
1233       * <p>{@link Entry} objects are compared by {@linkplain
1234       * #getName() name} first, then {@linkplain #getVersion()
1235       * version}.</p>
1236       *
1237       * <p>It is intended that this {@link
1238       * #compareTo(ChartRepository.Index.Entry)} method is
1239       * {@linkplain Comparable consistent with equals}.</p>
1240       *
1241       * @param her the {@link Entry} to compare; must not be {@code null}
1242       *
1243       * @return a value less than {@code 0} if this {@link Entry} is
1244       * "less than" the supplied {@link Entry}, {@code 1} if this
1245       * {@link Entry} is "greater than" the supplied {@link Entry}
1246       * and {@code 0} if this {@link Entry} is equal to the supplied
1247       * {@link Entry}
1248       *
1249       * @exception NullPointerException if the supplied {@link Entry}
1250       * is {@code null}
1251       */
1252      @Override
1253      public final int compareTo(final Entry her) {
1254        Objects.requireNonNull(her); // see Comparable documentation
1255        
1256        final String myName = this.getName();
1257        final String herName = her.getName();
1258        if (myName == null) {
1259          if (herName != null) {
1260            return -1;
1261          }
1262        } else if (herName == null) {
1263          return 1;
1264        } else {
1265          final int nameComparison = myName.compareTo(herName);
1266          if (nameComparison != 0) {
1267            return nameComparison;
1268          }
1269        }
1270        
1271        final String myVersionString = this.getVersion();
1272        final String herVersionString = her.getVersion();
1273        if (myVersionString == null) {
1274          if (herVersionString != null) {
1275            return -1;
1276          }
1277        } else if (herVersionString == null) {
1278          return 1;
1279        } else {
1280          Version myVersion = null;
1281          try {
1282            myVersion = Version.valueOf(myVersionString);
1283          } catch (final IllegalArgumentException | ParseException badVersion) {
1284            myVersion = null;
1285          }
1286          Version herVersion = null;
1287          try {
1288            herVersion = Version.valueOf(herVersionString);
1289          } catch (final IllegalArgumentException | ParseException badVersion) {
1290            herVersion = null;
1291          }
1292          if (myVersion == null) {
1293            if (herVersion != null) {
1294              return -1;
1295            }
1296          } else if (herVersion == null) {
1297            return 1;
1298          } else {
1299            return myVersion.compareTo(herVersion);
1300          }
1301        }
1302
1303        return 0;
1304      }
1305
1306      /**
1307       * Returns a hashcode for this {@link Entry} based off its
1308       * {@linkplain #getName() name} and {@linkplain #getVersion()
1309       * version}.
1310       *
1311       * @return a hashcode for this {@link Entry}
1312       *
1313       * @see #compareTo(ChartRepository.Index.Entry)
1314       *
1315       * @see #equals(Object)
1316       *
1317       * @see #getName()
1318       *
1319       * @see #getVersion()
1320       */
1321      @Override
1322      public final int hashCode() {
1323        int hashCode = 17;
1324
1325        final Object name = this.getName();
1326        int c = name == null ? 0 : name.hashCode();
1327        hashCode = 37 * hashCode + c;
1328
1329        final Object version = this.getVersion();
1330        c = version == null ? 0 : version.hashCode();
1331        hashCode = 37 * hashCode + c;
1332
1333        return hashCode;
1334      }
1335
1336      /**
1337       * Returns {@code true} if the supplied {@link Object} is an
1338       * {@link Entry} and has a {@linkplain #getName() name} and
1339       * {@linkplain #getVersion() version} equal to those of this
1340       * {@link Entry}.
1341       *
1342       * @param other the {@link Object} to test; may be {@code null}
1343       * in which case {@code false} will be returned
1344       *
1345       * @return {@code true} if this {@link Entry} is equal to the
1346       * supplied {@link Object}; {@code false} otherwise
1347       *
1348       * @see #compareTo(ChartRepository.Index.Entry)
1349       *
1350       * @see #getName()
1351       *
1352       * @see #getVersion()
1353       *
1354       * @see #hashCode()
1355       */
1356      @Override
1357      public final boolean equals(final Object other) {
1358        if (other == this) {
1359          return true;
1360        } else if (other instanceof Entry) {
1361          final Entry her = (Entry)other;
1362
1363          final Object myName = this.getName();
1364          if (myName == null) {
1365            if (her.getName() != null) {
1366              return false;
1367            }
1368          } else if (!myName.equals(her.getName())) {
1369            return false;
1370          }
1371
1372          final Object myVersion = this.getVersion();
1373          if (myVersion == null) {
1374            if (her.getVersion() != null) {
1375              return false;
1376            }
1377          } else if (!myVersion.equals(her.getVersion())) {
1378            return false;
1379          }
1380
1381          return true;
1382        } else {
1383          return false;
1384        }
1385      }
1386
1387      /**
1388       * Returns the {@link MetadataOrBuilder} that comprises most of
1389       * the contents of this {@link Entry}.
1390       *
1391       * <p>This method never returns {@code null}.</p>
1392       *
1393       * @return the {@link MetadataOrBuilder} that comprises most of
1394       * the contents of this {@link Entry}; never {@code null}
1395       */
1396      public final MetadataOrBuilder getMetadataOrBuilder() {
1397        return this.metadata;
1398      }
1399
1400      /**
1401       * Returns the return value of invoking the {@link
1402       * MetadataOrBuilder#getName()} method on the {@link
1403       * MetadataOrBuilder} returned by this {@link Entry}'s {@link
1404       * #getMetadataOrBuilder()} method.
1405       *
1406       * <p>This method may return {@code null}.</p>
1407       *
1408       * @return this {@link Entry}'s name, or {@code null}
1409       *
1410       * @see MetadataOrBuilder#getName()
1411       */
1412      public final String getName() {
1413        final MetadataOrBuilder metadata = this.getMetadataOrBuilder();
1414        assert metadata != null;
1415        return metadata.getName();
1416      }
1417
1418      /**
1419       * Returns the return value of invoking the {@link
1420       * MetadataOrBuilder#getVersion()} method on the {@link
1421       * MetadataOrBuilder} returned by this {@link Entry}'s {@link
1422       * #getMetadataOrBuilder()} method.
1423       *
1424       * <p>This method may return {@code null}.</p>
1425       *
1426       * @return this {@link Entry}'s version, or {@code null}
1427       *
1428       * @see MetadataOrBuilder#getVersion()
1429       */
1430      public final String getVersion() {
1431        final MetadataOrBuilder metadata = this.getMetadataOrBuilder();
1432        assert metadata != null;
1433        return metadata.getVersion();
1434      }
1435
1436      /**
1437       * Returns a non-{@code null}, {@linkplain
1438       * Collections#unmodifiableSet(Set) immutable} {@link Set} of
1439       * {@link URI}s representing the URIs from which the Helm chart
1440       * described by this {@link Entry} may be downloaded.
1441       *
1442       * <p>This method never returns {@code null}.</p>
1443       *
1444       * @return a non-{@code null}, {@linkplain
1445       * Collections#unmodifiableSet(Set) immutable} {@link Set} of
1446       * {@link URI}s representing the URIs from which the Helm chart
1447       * described by this {@link Entry} may be downloaded
1448       *
1449       * @see #getFirstUri()
1450       */
1451      public final Set<URI> getUris() {
1452        return this.uris;
1453      }
1454
1455      /**
1456       * A convenience method that returns the first {@link URI} in
1457       * the {@link Set} of {@link URI}s returned by the {@link
1458       * #getUris()} method.
1459       *
1460       * <p>This method may return {@code null}.</p>
1461       *
1462       * @return the {@linkplain SortedSet#first() first} {@link URI}
1463       * in the {@link Set} of {@link URI}s returned by the {@link
1464       * #getUris()} method, or {@code null}
1465       *
1466       * @see #getUris()
1467       */
1468      public final URI getFirstUri() {
1469        final Set<URI> uris = this.getUris();
1470        final URI returnValue;
1471        if (uris == null || uris.isEmpty()) {
1472          returnValue = null;
1473        } else {
1474          final Iterator<URI> iterator = uris.iterator();
1475          if (iterator == null || !iterator.hasNext()) {
1476            returnValue = null;
1477          } else {
1478            returnValue = iterator.next();
1479          }
1480        }
1481        return returnValue;
1482      }
1483
1484      /**
1485       * Returns the SHA-256 message digest, in hexadecimal-encoded
1486       * {@link String} form, associated with this {@link Entry}.
1487       *
1488       * <p>This method may return {@code null}.</p>
1489       *
1490       * @return the SHA-256 message digest, in hexadecimal-encoded
1491       * {@link String} form, associated with this {@link Entry}, or
1492       * {@code null}
1493       */
1494      public final String getDigest() {
1495        return this.digest;
1496      }
1497
1498      /**
1499       * Returns a non-{@code null} {@link String} representation of
1500       * this {@link Entry}.
1501       *
1502       * @return a non-{@code null} {@link String} representation of
1503       * this {@link Entry}
1504       */
1505      @Override
1506      public final String toString() {
1507        String name = this.getName();
1508        if (name == null || name.isEmpty()) {
1509          name = "unnamed";
1510        }
1511        return new StringBuilder(name).append(" ").append(this.getVersion()).toString();
1512      }
1513
1514      /**
1515       * Computes a SHA-256 message digest of the bytes readable from
1516       * the supplied {@link InputStream} and returns the result of
1517       * {@linkplain DatatypeConverter#printHexBinary(byte[])
1518       * hexadecimal-encoding it}.
1519       *
1520       * <p>This method never returns {@code null}.</p>
1521       *
1522       * @param inputStream the {@link InputStream} to read from; must
1523       * not be {@code null}
1524       *
1525       * @return a {@linkplain
1526       * DatatypeConverter#printHexBinary(byte[]) hexadecimal-encoded}
1527       * SHA-256 message digest; never {@code null}
1528       *
1529       * @exception NullPointerException if {@code inputStream} is
1530       * {@code null}
1531       *
1532       * @exception IOException if an input or output error occurs
1533       */
1534      @Experimental
1535      public static final String getDigest(final InputStream inputStream) throws IOException {
1536        Objects.requireNonNull(inputStream);
1537        MessageDigest md = null;
1538        try {
1539          md = MessageDigest.getInstance("SHA-256");
1540        } catch (final NoSuchAlgorithmException noSuchAlgorithmException) {
1541          // SHA-256 is guaranteed to exist.
1542          throw new InternalError(noSuchAlgorithmException);
1543        }
1544        assert md != null;
1545        final ByteBuffer buffer = toByteBuffer(inputStream);
1546        assert buffer != null;
1547        md.update(buffer);
1548        return DatatypeConverter.printHexBinary(md.digest());
1549      }
1550
1551      /**
1552       * Returns a {@link ByteBuffer} representing the supplied {@link
1553       * InputStream}.
1554       *
1555       * <p>This method never returns {@code null}.</p>
1556       *
1557       * @param stream the {@link InputStream} to represent; may be
1558       * {@code null}
1559       *
1560       * @return a non-{@code null} {@link ByteBuffer}
1561       *
1562       * @exception IOException if an input or output error occurs
1563       */
1564      private static final ByteBuffer toByteBuffer(final InputStream stream) throws IOException {
1565        return ByteBuffer.wrap(read(stream));
1566      }
1567
1568      /**
1569       * Fully reads the supplied {@link InputStream} into a {@code
1570       * byte} array and returns it.
1571       *
1572       * <p>This method never returns {@code null}.</p>
1573       *
1574       * @param stream the {@link InputStream} to read; may be {@code
1575       * null}
1576       *
1577       * @return a non-{@code null} {@code byte} array containing the
1578       * readable contents of the supplied {@link InputStream}
1579       *
1580       * @exception IOException if an input or output error occurs
1581       */
1582      private static final byte[] read(final InputStream stream) throws IOException {
1583        byte[] returnValue = null;
1584        if (stream == null) {
1585          returnValue = new byte[0];
1586        } else {
1587          try (final ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
1588            int bytesRead;
1589            final byte[] byteArray = new byte[4096];
1590            while ((bytesRead = stream.read(byteArray, 0, byteArray.length)) != -1) {
1591              buffer.write(byteArray, 0, bytesRead);
1592            }
1593            buffer.flush();
1594            returnValue = buffer.toByteArray();
1595          }
1596        }
1597        return returnValue;
1598      }
1599  
1600      
1601    }
1602    
1603  }
1604  
1605}