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}