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.IOException;
021import java.io.InputStream;
022
023import java.net.URI;
024import java.net.URISyntaxException;
025
026import java.nio.file.Files;
027import java.nio.file.Path;
028import java.nio.file.Paths;
029
030import java.time.Instant;
031
032import java.util.Collection;
033import java.util.Collections;
034import java.util.Date;
035import java.util.LinkedHashSet;
036import java.util.Map;
037import java.util.Objects;
038import java.util.Set;
039
040import java.util.regex.Pattern;
041
042import hapi.chart.ChartOuterClass.Chart;
043
044import org.microbean.development.annotation.Experimental;
045
046import org.yaml.snakeyaml.Yaml;
047
048import org.yaml.snakeyaml.constructor.SafeConstructor;
049
050import org.microbean.helm.chart.resolver.AbstractChartResolver;
051import org.microbean.helm.chart.resolver.ChartResolverException;
052
053/**
054 * A repository of {@link ChartRepository} instances, normally built
055 * from a Helm {@code repositories.yaml} file.
056 *
057 * @author <a href="https://about.me/lairdnelson"
058 * target="_parent">Laird Nelson</a>
059 */
060@Experimental
061public class ChartRepositoryRepository extends AbstractChartResolver {
062
063
064  /*
065   * Static fields.
066   */
067
068
069  /**
070   * A {@link Pattern} that matches a single solidus ("{@code /}").
071   *
072   * <p>This field is never {@code null}.</p>
073   */
074  private static final Pattern slashPattern = Pattern.compile("/");
075
076
077  /*
078   * Instance fields.
079   */
080
081
082  /**
083   * The ({@linkplain Collections#unmodifiableSet(Set) immutable})
084   * {@link Set} of {@link ChartRepository} instances managed by this
085   * {@link ChartRepositoryRepository}.
086   *
087   * <p>This field is never {@code null}.</p>
088   *
089   * @see #ChartRepositoryRepository(Set)
090   *
091   * @see #getChartRepositories()
092   */
093  private final Set<ChartRepository> chartRepositories;
094
095
096  /*
097   * Constructors.
098   */
099
100
101  /**
102   * Creates a new {@link ChartRepositoryRepository}.
103   *
104   * @param chartRepositories the {@link Set} of {@link
105   * ChartRepository} instances to be managed by this {@link
106   * ChartRepositoryRepository}; may be {@code null}; copied by value
107   *
108   * @see #getChartRepositories()
109   */
110  public ChartRepositoryRepository(final Set<? extends ChartRepository> chartRepositories) {
111    super();
112    if (chartRepositories == null || chartRepositories.isEmpty()) {
113      this.chartRepositories = Collections.emptySet();
114    } else {
115      this.chartRepositories = Collections.unmodifiableSet(new LinkedHashSet<>(chartRepositories));
116    }
117  }
118
119
120  /*
121   * Instance methods.
122   */
123
124
125  /**
126   * Returns the non-{@code null} {@linkplain
127   * Collections#unmodifiableSet(Set) immutable} {@link Set} of {@link
128   * ChartRepository} instances managed by this {@link
129   * ChartRepositoryRepository}.
130   *
131   * @return the non-{@code null} {@linkplain
132   * Collections#unmodifiableSet(Set) immutable} {@link Set} of {@link
133   * ChartRepository} instances managed by this {@link
134   * ChartRepositoryRepository}
135   */
136  public final Set<ChartRepository> getChartRepositories() {
137    return this.chartRepositories;
138  }
139
140  /**
141   * Returns the {@link ChartRepository} managed by this {@link
142   * ChartRepositoryRepository} with the supplied {@code name}, or
143   * {@code null} if there is no such {@link ChartRepository}.
144   *
145   * @param name the {@linkplain ChartRepository#getName() name} of
146   * the {@link ChartRepository} to return; must not be {@code null}
147   *
148   * @return the {@link ChartRepository} managed by this {@link
149   * ChartRepositoryRepository} with the supplied {@code name}, or
150   * {@code null}
151   *
152   * @exception NullPointerException if {@code name} is {@code null}
153   */
154  public ChartRepository getChartRepository(final String name) {
155    Objects.requireNonNull(name);
156    ChartRepository returnValue = null;
157    final Collection<? extends ChartRepository> repos = this.getChartRepositories();
158    if (repos != null && !repos.isEmpty()) {
159      for (final ChartRepository repo : repos) {
160        if (repo != null && name.equals(repo.getName())) {
161          returnValue = repo;
162        }
163      }
164    }
165    return returnValue;
166  }
167
168  /**
169   * {@inheritDoc}
170   *
171   * <p>This implementation splits the supplied slash-delimited {@code
172   * chartName} into a <em>chart repository name</em> and a <em>chart
173   * name</em>, uses the chart repository name to {@linkplain
174   * #getChartRepository(String) locate a suitable
175   * <code>ChartRepository</code>}, and then calls {@link
176   * ChartRepository#resolve(String, String)} with the chart name and
177   * the supplied {@code chartVersion}, and returns the result.</p>
178   *
179   * @param chartName a slash-separated {@link String} whose first
180   * component is a {@linkplain ChartRepository#getName() chart
181   * repository name} and whose second component is a Helm chart name;
182   * must not be {@code null}
183   *
184   * @param chartVersion the version of the chart to resolve; may be
185   * {@code null} in which case "latest" semantics are implied
186   *
187   * @return a {@link Chart.Builder}, or {@code null}
188   *
189   * @see #resolve(String, String, String)
190   */
191  @Override
192  public Chart.Builder resolve(final String chartName, final String chartVersion) throws ChartResolverException {
193    Objects.requireNonNull(chartName);
194    Chart.Builder returnValue = null;
195    final String[] parts = slashPattern.split(chartName, 2);
196    if (parts != null && parts.length == 2) {
197      returnValue = this.resolve(parts[0], parts[1], chartVersion);
198    }
199    return returnValue;
200  }
201
202  /**
203   * Uses the supplied {@code repositoryName}, {@code chartName} and
204   * {@code chartVersion} parameters to find an appropriate Helm chart
205   * and returns it in the form of a {@link Chart.Builder} object.
206   *
207   * <p>This implementation uses the supplied {@code repositoryName}
208   * to {@linkplain #getChartRepository(String) locate a suitable
209   * <code>ChartRepository</code>}, and then calls {@link
210   * ChartRepository#resolve(String, String)} with the chart name and
211   * the supplied {@code chartVersion}, and returns the result.</p>
212   *
213   * @param repositoryName a {@linkplain ChartRepository#getName()
214   * chart repository name}; must not be {@code null}
215   *
216   * @param chartName a Helm chart name; must not be {@code null}
217   *
218   * @param chartVersion the version of the Helm chart to select; may
219   * be {@code null} in which case "latest" semantics are implied
220   *
221   * @return a {@link Chart.Builder}, or {@code null}
222   *
223   * @exception ChartResolverException if there was a problem with
224   * resolution
225   *
226   * @exception NullPointerException if {@code repositoryName} or
227   * {@code chartName} is {@code null}
228   *
229   * @see #getChartRepository(String)
230   *
231   * @see ChartRepository#getName()
232   *
233   * @see ChartRepository#resolve(String, String)
234   */
235  public Chart.Builder resolve(final String repositoryName, final String chartName, final String chartVersion) throws ChartResolverException {
236    Objects.requireNonNull(repositoryName);
237    Objects.requireNonNull(chartName);
238    Chart.Builder returnValue = null;
239    final ChartRepository repo = this.getChartRepository(repositoryName);
240    if (repo != null) {
241      final Chart.Builder candidate = repo.resolve(chartName, chartVersion);
242      if (candidate != null) {
243        returnValue = candidate;
244      }
245    }
246    return returnValue;
247  }
248
249
250  /*
251   * Static methods.
252   */
253
254  
255  /**
256   * Creates and returns a new {@link ChartRepositoryRepository} from
257   * the contents of a {@code repositories.yaml} file typically
258   * located in the {@code ~/.helm/repository} directory.
259   *
260   * <p>This method never returns {@code null}.</p>
261   *
262   * @return a new {@link ChartRepositoryRepository}; never {@code
263   * null}
264   *
265   * @exception IOException if there was a problem reading the file
266   *
267   * @exception URISyntaxException if there was an invalid URI in the
268   * file
269   *
270   * @see #fromYaml(InputStream, Path, Path)
271   */
272  public static final ChartRepositoryRepository fromHelmRepositoriesYaml() throws IOException, URISyntaxException {
273    try (final InputStream stream = new BufferedInputStream(Files.newInputStream(ChartRepository.getHelmHome().resolve("repository/repositories.yaml")))) {
274      return fromYaml(stream);
275    }
276  }
277
278  /**
279   * Creates and returns a new {@link ChartRepositoryRepository} from
280   * the contents of a {@code repositories.yaml} file represented by
281   * the supplied {@link InputStream}.
282   *
283   * @param stream the {@link InputStream} to read from; must not be
284   * {@code null}
285   *
286   * @return a new {@link ChartRepositoryRepository}; never {@code
287   * null}
288   *
289   * @exception IOException if there was a problem reading the file
290   *
291   * @exception URISyntaxException if there was an invalid URI in the
292   * file
293   *
294   * @see #fromYaml(InputStream, Path, Path)
295   */
296  public static final ChartRepositoryRepository fromYaml(final InputStream stream) throws IOException, URISyntaxException {
297    return fromYaml(stream, null, null);
298  }
299
300  /**
301   * Creates and returns a new {@link ChartRepositoryRepository} from
302   * the contents of a {@code repositories.yaml} file represented by
303   * the supplied {@link InputStream}.
304   *
305   * @param stream the {@link InputStream} to read from; must not be
306   * {@code null}
307   *
308   * @param archiveCacheDirectory an {@linkplain Path#isAbsolute()
309   * absolute} {@link Path} representing a directory where Helm chart
310   * archives may be stored; if {@code null} then a {@link Path}
311   * beginning with the absolute directory represented by the value of
312   * the {@code helm.home} system property, or the value of the {@code
313   * HELM_HOME} environment variable, appended with {@code
314   * cache/archive} will be used instead
315   *
316   * @param indexCacheDirectory an {@linkplain Path#isAbsolute()
317   * absolute} {@link Path} representing a directory that the supplied
318   * {@code cachedIndexPath} parameter value will be considered to be
319   * relative to; will be ignored and hence may be {@code null} if the
320   * supplied {@code cachedIndexPath} parameter value {@linkplain
321   * Path#isAbsolute()}
322   *
323   * @return a new {@link ChartRepositoryRepository}; never {@code
324   * null}
325   *
326   * @exception IOException if there was a problem reading the file
327   *
328   * @exception URISyntaxException if there was an invalid URI in the
329   * file
330   *
331   * @see #fromYaml(InputStream, Path, Path, ChartRepositoryFactory)
332   */
333  public static final ChartRepositoryRepository fromYaml(final InputStream stream, Path archiveCacheDirectory, Path indexCacheDirectory) throws IOException, URISyntaxException {
334    return fromYaml(stream, archiveCacheDirectory, indexCacheDirectory, null);
335  }
336
337  /**
338   * Creates and returns a new {@link ChartRepositoryRepository} from
339   * the contents of a {@code repositories.yaml} file represented by
340   * the supplied {@link InputStream}.
341   *
342   * @param stream the {@link InputStream} to read from; must not be
343   * {@code null}
344   *
345   * @param archiveCacheDirectory an {@linkplain Path#isAbsolute()
346   * absolute} {@link Path} representing a directory where Helm chart
347   * archives may be stored; if {@code null} then a {@link Path}
348   * beginning with the absolute directory represented by the value of
349   * the {@code helm.home} system property, or the value of the {@code
350   * HELM_HOME} environment variable, appended with {@code
351   * cache/archive} will be used instead
352   *
353   * @param indexCacheDirectory an {@linkplain Path#isAbsolute()
354   * absolute} {@link Path} representing a directory that the supplied
355   * {@code cachedIndexPath} parameter value will be considered to be
356   * relative to; will be ignored and hence may be {@code null} if the
357   * supplied {@code cachedIndexPath} parameter value {@linkplain
358   * Path#isAbsolute()}
359   *
360   * @param factory a {@link ChartRepositoryFactory} that can create
361   * {@link ChartRepository} instances; may be {@code null} in which
362   * case the {@link ChartRepository#ChartRepository(String, URI,
363   * Path, Path, Path)} constructor will be used instead
364   *
365   * @return a new {@link ChartRepositoryRepository}; never {@code
366   * null}
367   *
368   * @exception IOException if there was a problem reading the file
369   *
370   * @exception URISyntaxException if there was an invalid URI in the
371   * file
372   */
373  public static final ChartRepositoryRepository fromYaml(final InputStream stream,
374                                                         Path archiveCacheDirectory,
375                                                         Path indexCacheDirectory,
376                                                         ChartRepositoryFactory factory)
377    throws IOException, URISyntaxException {
378    Objects.requireNonNull(stream);
379    if (factory == null) {
380      factory = ChartRepository::new;
381    }
382    Path helmHome = null;
383    if (archiveCacheDirectory == null) {
384      helmHome = ChartRepository.getHelmHome();
385      assert helmHome != null;
386      archiveCacheDirectory = helmHome.resolve("cache/archive");
387      assert archiveCacheDirectory != null;
388    }
389    if (!Files.isDirectory(archiveCacheDirectory)) {
390      throw new IllegalArgumentException("!Files.isDirectory(archiveCacheDirectory): " + archiveCacheDirectory);
391    }
392    if (indexCacheDirectory == null) {
393      if (helmHome == null) {
394        helmHome = ChartRepository.getHelmHome();
395        assert helmHome != null;
396      }
397      indexCacheDirectory = helmHome.resolve("repository/cache");
398      assert indexCacheDirectory != null;
399    }
400    if (!Files.isDirectory(indexCacheDirectory)) {
401      throw new IllegalArgumentException("!Files.isDirectory(indexCacheDirectory): " + indexCacheDirectory);
402    }
403    final Map<?, ?> map = new Yaml(new SafeConstructor()).load(stream);
404    if (map == null || map.isEmpty()) {
405      throw new IllegalArgumentException("No data readable from stream: " + stream);
406    }
407    final Set<ChartRepository> chartRepositories;
408    @SuppressWarnings("unchecked")      
409    final Collection<? extends Map<?, ?>> repositories = (Collection<? extends Map<?, ?>>)map.get("repositories");
410    if (repositories == null || repositories.isEmpty()) {
411      chartRepositories = Collections.emptySet();
412    } else {
413      chartRepositories = new LinkedHashSet<>();
414      for (final Map<?, ?> repositoryMap : repositories) {
415        if (repositoryMap != null && !repositoryMap.isEmpty()) {
416          final String name = Objects.requireNonNull((String)repositoryMap.get("name"));
417          final URI uri = new URI((String)repositoryMap.get("url"));
418          Path cachedIndexPath = Objects.requireNonNull(Paths.get((String)repositoryMap.get("cache")));
419          if (!cachedIndexPath.isAbsolute()) {
420            cachedIndexPath = indexCacheDirectory.resolve(cachedIndexPath);
421            assert cachedIndexPath.isAbsolute();
422          }
423          
424          // final ChartRepository chartRepository = new ChartRepository(name, uri, archiveCacheDirectory, indexCacheDirectory, cachedIndexPath);
425          final ChartRepository chartRepository = factory.createChartRepository(name, uri, archiveCacheDirectory, indexCacheDirectory, cachedIndexPath);
426          if (chartRepository == null) {
427            throw new IllegalStateException("factory.createChartRepository() == null");
428          }
429          chartRepositories.add(chartRepository);
430        }      
431      }
432    }
433    return new ChartRepositoryRepository(chartRepositories);
434  }
435
436
437  /*
438   * Inner and nested classes.
439   */
440  
441
442  /**
443   * A factory for {@link ChartRepository} instances.
444   *
445   * @author <a href="https://about.me/lairdnelson"
446   * target="_parent">Laird Nelson</a>
447   *
448   * @see ChartRepository
449   */
450  @FunctionalInterface
451  public static interface ChartRepositoryFactory {
452
453    /**
454     * Creates a new {@link ChartRepository} and returns it.
455     *
456     * @param name the name of the chart repository; must not be
457     * {@code null}
458     *
459     * @param uri the {@link URI} to the root of the chart repository;
460     * must not be {@code null}
461     *
462     * @param archiveCacheDirectory an {@linkplain Path#isAbsolute()
463     * absolute} {@link Path} representing a directory where Helm chart
464     * archives may be stored; if {@code null} then often a {@link Path}
465     * beginning with the absolute directory represented by the value of
466     * the {@code helm.home} system property, or the value of the {@code
467     * HELM_HOME} environment variable, appended with {@code
468     * cache/archive} will be used instead
469     *
470     * @param indexCacheDirectory an {@linkplain Path#isAbsolute()
471     * absolute} {@link Path} representing a directory that the supplied
472     * {@code cachedIndexPath} parameter value will be considered to be
473     * relative to; <strong>will be ignored and hence may be {@code
474     * null}</strong> if the supplied {@code cachedIndexPath} parameter
475     * value {@linkplain Path#isAbsolute() is absolute}
476     *
477     * @param cachedIndexPath a {@link Path} naming the file that will
478     * store a copy of the chart repository's {@code index.yaml} file;
479     * if {@code null} then a {@link Path} relative to the absolute
480     * directory represented by the value of the {@code helm.home}
481     * system property, or the value of the {@code HELM_HOME}
482     * environment variable, and bearing a name consisting of the
483     * supplied {@code name} suffixed with {@code -index.yaml} will
484     * often be used instead
485     *
486     * @exception NullPointerException if either {@code name} or {@code
487     * uri} is {@code null}
488     *
489     * @exception IllegalArgumentException if {@code uri} is {@linkplain
490     * URI#isAbsolute() not absolute}, or if there is no existing "Helm
491     * home" directory, or if {@code archiveCacheDirectory} is
492     * non-{@code null} and either empty or not {@linkplain
493     * Path#isAbsolute()}
494     *
495     * @return a new, non-{@code null} {@link ChartRepository}
496     *
497     * @see ChartRepository#ChartRepository(String, URI, Path, Path,
498     * Path)
499     */
500    public ChartRepository createChartRepository(final String name, final URI uri, final Path archiveCacheDirectory, final Path indexCacheDirectory, final Path cachedIndexPath);
501    
502  }
503 
504}