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