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