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}