From c4d7ea1b9adca9c33857820b7f928d001976799a Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Mon, 5 Feb 2024 13:57:52 +0100 Subject: [PATCH] feat: HelmService provides linting features Signed-off-by: Marc Nuri --- .../jkube/kit/common/JKubeException.java | 22 ++ jkube-kit/helm/pom.xml | 4 + .../jkube/kit/resource/helm/HelmConfig.java | 3 + .../jkube/kit/resource/helm/HelmService.java | 49 ++++- .../kit/resource/helm/HelmServiceUtil.java | 13 ++ .../kit/resource/helm/HelmServiceLintIT.java | 205 ++++++++++++++++++ .../resource/helm/HelmServiceUtilTest.java | 25 ++- 7 files changed, 311 insertions(+), 10 deletions(-) create mode 100644 jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/JKubeException.java create mode 100644 jkube-kit/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/HelmServiceLintIT.java diff --git a/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/JKubeException.java b/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/JKubeException.java new file mode 100644 index 0000000000..16748c6b38 --- /dev/null +++ b/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/JKubeException.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2019 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at: + * + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package org.eclipse.jkube.kit.common; + +public class JKubeException extends RuntimeException { + + public JKubeException(String message) { + super(message); + } + +} diff --git a/jkube-kit/helm/pom.xml b/jkube-kit/helm/pom.xml index 72a9b264e9..38e267f188 100644 --- a/jkube-kit/helm/pom.xml +++ b/jkube-kit/helm/pom.xml @@ -42,6 +42,10 @@ commons-codec commons-codec + + com.marcnuri.helm-java + helm-java + org.eclipse.jkube diff --git a/jkube-kit/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmConfig.java b/jkube-kit/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmConfig.java index b6b35a1d25..a35a3ac841 100644 --- a/jkube-kit/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmConfig.java +++ b/jkube-kit/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmConfig.java @@ -71,6 +71,9 @@ public class HelmConfig { private HelmRepository stableRepository; private HelmRepository snapshotRepository; private String security; + private boolean lintStrict; + private boolean lintQuiet; + @JsonProperty("dependencies") private List dependencies; diff --git a/jkube-kit/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmService.java b/jkube-kit/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmService.java index 7606de43c4..391787a32b 100644 --- a/jkube-kit/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmService.java +++ b/jkube-kit/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmService.java @@ -16,6 +16,8 @@ import java.io.File; import java.io.IOException; import java.nio.charset.Charset; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -28,7 +30,11 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import com.marcnuri.helm.Helm; +import com.marcnuri.helm.LintCommand; +import com.marcnuri.helm.LintResult; import org.eclipse.jkube.kit.common.JKubeConfiguration; +import org.eclipse.jkube.kit.common.JKubeException; import org.eclipse.jkube.kit.common.KitLogger; import org.eclipse.jkube.kit.common.RegistryConfig; import org.eclipse.jkube.kit.common.RegistryServerConfiguration; @@ -155,6 +161,34 @@ public void uploadHelmChart(HelmConfig helm) throws BadUploadException, IOExcept } } + public void lint(HelmConfig helmConfig) { + for (HelmConfig.HelmType helmType : helmConfig.getTypes()) { + final Path helmPackage = resolveTarballFile(helmConfig, helmType); + logger.info("Linting %s %s", helmConfig.getChart(), helmConfig.getVersion()); + logger.info("Using packaged file: %s", helmPackage.toFile().getAbsolutePath()); + final LintCommand lintCommand = new Helm(helmPackage).lint(); + if (helmConfig.isLintStrict()) { + lintCommand.strict(); + } + if (helmConfig.isLintQuiet()) { + lintCommand.quiet(); + } + final LintResult lintResult = lintCommand.call(); + if (lintResult.isFailed()) { + for (String message : lintResult.getMessages()) { + // [[W]] see AnsiUtil.COLOR_MAP and computeEmphasisColor to understand the color guides + logger.error("[[W]]%s", message); + } + throw new JKubeException("Linting failed"); + } else { + for (String message : lintResult.getMessages()) { + logger.info("[[W]]%s", message); + } + logger.info("Linting successful"); + } + } + } + private void uploadHelmChart(HelmConfig helmConfig, HelmRepository helmRepository) throws IOException, BadUploadException { @@ -162,18 +196,17 @@ private void uploadHelmChart(HelmConfig helmConfig, HelmRepository helmRepositor for (HelmConfig.HelmType helmType : helmConfig.getTypes()) { logger.info("Uploading Helm Chart \"%s\" to %s", helmConfig.getChart(), helmRepository.getName()); logger.debug("OutputDir: %s", helmConfig.getOutputDir()); - - final File tarballOutputDir = - new File(Objects.requireNonNull(helmConfig.getTarballOutputDir(), - "Tarball output directory is required"), helmType.getOutputDir()); - final File tarballFile = new File(tarballOutputDir, String.format("%s-%s%s.%s", - helmConfig.getChart(), helmConfig.getVersion(), resolveHelmClassifier(helmConfig), helmConfig.getChartExtension())); - - helmUploaderManager.getHelmUploader(helmRepository.getType()).uploadSingle(tarballFile, helmRepository); + helmUploaderManager.getHelmUploader(helmRepository.getType()) + .uploadSingle(resolveTarballFile(helmConfig, helmType).toFile(), helmRepository); logger.info("Upload Successful"); } } + private static Path resolveTarballFile(HelmConfig helmConfig, HelmConfig.HelmType helmType) { + return Paths.get(Objects.requireNonNull(helmConfig.getTarballOutputDir(), "Tarball output directory is required")) + .resolve(helmType.getOutputDir()) + .resolve(String.format("%s-%s%s.%s", helmConfig.getChart(), helmConfig.getVersion(), resolveHelmClassifier(helmConfig), helmConfig.getChartExtension())); + } static File prepareSourceDir(HelmConfig helmConfig, HelmConfig.HelmType type) throws IOException { final File sourceDir = new File(helmConfig.getSourceDir(), type.getSourceDir()); diff --git a/jkube-kit/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmServiceUtil.java b/jkube-kit/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmServiceUtil.java index dee59cf17a..d29d669616 100644 --- a/jkube-kit/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmServiceUtil.java +++ b/jkube-kit/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmServiceUtil.java @@ -37,6 +37,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.function.BooleanSupplier; import java.util.function.Supplier; import java.util.function.UnaryOperator; import java.util.stream.Collectors; @@ -73,6 +74,9 @@ public class HelmServiceUtil { protected static final String PROPERTY_SECURITY = "jkube.helm.security"; protected static final String DEFAULT_SECURITY = "~/.m2/settings-security.xml"; + protected static final String PROPERTY_HELM_LINT_STRICT = "jkube.helm.lint.strict"; + protected static final String PROPERTY_HELM_LINT_QUIET = "jkube.helm.lint.quiet"; + private HelmServiceUtil() { } public static HelmConfig.HelmConfigBuilder initHelmConfig( @@ -110,6 +114,8 @@ public static HelmConfig.HelmConfigBuilder initHelmConfig( helmConfig.setTarFileClassifier(resolveFromPropertyOrDefault(PROPERTY_TARBALL_CLASSIFIER, project, helmConfig::getTarFileClassifier, () -> EMPTY)); helmConfig.setTarballOutputDir(resolveFromPropertyOrDefault(PROPERTY_TARBALL_OUTPUT_DIR, project, helmConfig::getTarballOutputDir, helmConfig::getOutputDir)); + helmConfig.setLintStrict(resolveBooleanFromPropertyOrDefault(PROPERTY_HELM_LINT_STRICT, project, helmConfig::isLintStrict)); + helmConfig.setLintQuiet(resolveBooleanFromPropertyOrDefault(PROPERTY_HELM_LINT_QUIET, project, helmConfig::isLintQuiet)); return helmConfig.toBuilder(); } @@ -165,6 +171,13 @@ static String resolveFromPropertyOrDefault(String property, JavaProject project, .orElseGet(defaultValue == null ? () -> null : defaultValue)); } + static boolean resolveBooleanFromPropertyOrDefault(String property, JavaProject project, BooleanSupplier getter) { + return Optional.ofNullable(getProperty(property, project)) + .filter(StringUtils::isNotBlank) + .map(Boolean::parseBoolean) + .orElse(getter.getAsBoolean()); + } + static List getAdditionalFiles(HelmConfig helm, JavaProject project) { List additionalFiles = new ArrayList<>(); if (helm.getAdditionalFiles() != null) { diff --git a/jkube-kit/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/HelmServiceLintIT.java b/jkube-kit/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/HelmServiceLintIT.java new file mode 100644 index 0000000000..0f7a0ef7a5 --- /dev/null +++ b/jkube-kit/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/HelmServiceLintIT.java @@ -0,0 +1,205 @@ +package org.eclipse.jkube.kit.resource.helm; + +import com.marcnuri.helm.Helm; +import org.eclipse.jkube.kit.common.JKubeConfiguration; +import org.eclipse.jkube.kit.common.JKubeException; +import org.eclipse.jkube.kit.common.KitLogger; +import org.eclipse.jkube.kit.config.resource.ResourceServiceConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.Comparator; + +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.endsWith; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@DisplayName("HelmService.uploadHelmChart") +class HelmServiceLintIT { + + @TempDir + private Path tempDir; + private KitLogger kitLogger; + private Path outputDir; + private HelmConfig helmConfig; + private HelmService helmService; + + @BeforeEach + void setUp() { + kitLogger = spy(new KitLogger.SilentLogger()); + outputDir = tempDir.resolve("output"); + helmConfig = HelmConfig.builder() + .chart("helm-test") + .version("0.1.0") + .chartExtension("tgz") + .types(Arrays.asList(HelmConfig.HelmType.KUBERNETES, HelmConfig.HelmType.OPENSHIFT)) + .tarballOutputDir(outputDir.toFile().getAbsolutePath()) + .build(); + helmService = new HelmService(JKubeConfiguration.builder().build(), new ResourceServiceConfig(), kitLogger); + } + + @Nested + class Valid { + + @BeforeEach + void validChartPackage() throws IOException { + final Helm helm = Helm.create().withName("helm-test").withDir(tempDir).call(); + // Create templates as file (instead of dir) to force a warning + Files.walk(tempDir.resolve("helm-test").resolve("templates")) + .sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + Files.createFile(tempDir.resolve("helm-test").resolve("templates")); + helm + .packageIt().withDestination(outputDir.resolve("kubernetes")).call() + .packageIt().withDestination(outputDir.resolve("openshift")).call(); + } + + @Test + void genericInfoMessage() { + helmService.lint(helmConfig); + verify(kitLogger, atLeastOnce()) + .info("Linting %s %s", "helm-test", "0.1.0"); + } + + @Test + void kubernetesPriorInfoMessage() { + helmService.lint(helmConfig); + verify(kitLogger, times(1)) + .info(eq("Using packaged file: %s"), endsWith("kubernetes" + File.separator + "helm-test-0.1.0.tgz")); + } + + @Test + void openshiftPriorInfoMessage() { + helmService.lint(helmConfig); + verify(kitLogger, times(1)) + .info(eq("Using packaged file: %s"), endsWith("openshift" + File.separator + "helm-test-0.1.0.tgz")); + } + + @Test + void lintInfoMessageInWhite() { + helmService.lint(helmConfig); + verify(kitLogger, atLeastOnce()) + .info("[[W]]%s", "[INFO] Chart.yaml: icon is recommended"); + } + + @Test + void successMessage() { + helmService.lint(helmConfig); + verify(kitLogger, atLeastOnce()).info("Linting successful"); + } + + @Nested + class Strict { + @BeforeEach + void setUp() { + helmConfig = helmConfig.toBuilder().lintStrict(true).build(); + helmService = new HelmService(JKubeConfiguration.builder().build(), new ResourceServiceConfig(), kitLogger); + } + + @Test + void lintErrorMessageInWhite() { + assertThatExceptionOfType(JKubeException.class).isThrownBy(() -> helmService.lint(helmConfig)); + verify(kitLogger, atLeastOnce()) + .error("[[W]]%s", "[WARNING] templates/: not a directory"); + } + + @Test + void lintingException() { + assertThatExceptionOfType(JKubeException.class) + .isThrownBy(() -> helmService.lint(helmConfig)) + .withMessage("Linting failed"); + } + } + + @Nested + class Quiet { + @BeforeEach + void setUp() { + helmConfig = helmConfig.toBuilder().lintQuiet(true).build(); + helmService = new HelmService(JKubeConfiguration.builder().build(), new ResourceServiceConfig(), kitLogger); + } + + @Test + void lintInfoMessageOmitted() { + helmService.lint(helmConfig); + verify(kitLogger, never()) + .info("[[W]]%s", "[INFO] Chart.yaml: icon is recommended"); + } + + @Test + void lintWarnMessage() { + helmService.lint(helmConfig); + verify(kitLogger, atLeastOnce()) + .info("[[W]]%s", "[WARNING] templates/: not a directory"); + } + } + + } + + @Nested + class Invalid { + + @BeforeEach + void invalidChartPackage() throws IOException { + final Helm chart = Helm.create().withName("helm-test").withDir(tempDir).call(); + Files.write(tempDir.resolve("helm-test").resolve("Chart.yaml"), + "\nicon: ://invalid-url".getBytes(StandardCharsets.UTF_8), + StandardOpenOption.APPEND + ); + chart + .packageIt().withDestination(outputDir.resolve("kubernetes")).call() + .packageIt().withDestination(outputDir.resolve("openshift")).call(); + } + + @Test + void genericInfoMessage() { + assertThatExceptionOfType(JKubeException.class).isThrownBy(() -> helmService.lint(helmConfig)); + verify(kitLogger, atLeastOnce()) + .info("Linting %s %s", "helm-test", "0.1.0"); + } + + @Test + void kubernetesPriorInfoMessage() { + assertThatExceptionOfType(JKubeException.class).isThrownBy(() -> helmService.lint(helmConfig)); + verify(kitLogger, times(1)) + .info(eq("Using packaged file: %s"), endsWith("kubernetes" + File.separator + "helm-test-0.1.0.tgz")); + } + + @Test + void openshiftPriorInfoMessageNotThrownDueToPriorExceptionHaltingProcessing() { + assertThatExceptionOfType(JKubeException.class).isThrownBy(() -> helmService.lint(helmConfig)); + verify(kitLogger, never()) + .info(eq("Using packaged file: %s"), endsWith("openshift" + File.separator + "helm-test-0.1.0.tgz")); + } + + @Test + void lintErrorMessageInWhite() { + assertThatExceptionOfType(JKubeException.class).isThrownBy(() -> helmService.lint(helmConfig)); + verify(kitLogger, atLeastOnce()) + .error("[[W]]%s", "[ERROR] Chart.yaml: invalid icon URL '://invalid-url'"); + } + + @Test + void lintingException() { + assertThatExceptionOfType(JKubeException.class) + .isThrownBy(() -> helmService.lint(helmConfig)) + .withMessage("Linting failed"); + } + } +} diff --git a/jkube-kit/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/HelmServiceUtilTest.java b/jkube-kit/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/HelmServiceUtilTest.java index ece97ce988..576454b688 100644 --- a/jkube-kit/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/HelmServiceUtilTest.java +++ b/jkube-kit/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/HelmServiceUtilTest.java @@ -86,7 +86,9 @@ void initHelmConfig_withNoConfig_shouldInitConfigWithDefaultValues() throws IOEx .hasFieldOrPropertyWithValue("types", Collections.singletonList(HelmConfig.HelmType.KUBERNETES)) .hasFieldOrPropertyWithValue("additionalFiles", Collections.emptyList()) .hasFieldOrPropertyWithValue("parameterTemplates", Collections.emptyList()) - .hasFieldOrProperty("icon"); + .hasFieldOrProperty("icon") + .hasFieldOrPropertyWithValue("lintStrict", false) + .hasFieldOrPropertyWithValue("lintQuiet", false); assertThat(result.getSourceDir()).endsWith("target/classes/META-INF/jkube/"); assertThat(result.getOutputDir()).endsWith("target/jkube/helm/artifact-id"); assertThat(result.getTarballOutputDir()).endsWith("target/jkube/helm/artifact-id"); @@ -103,6 +105,8 @@ void initHelmConfig_withOriginalConfig_shouldInitConfigWithoutOverriding() throw .maintainers(Collections.emptyList()) .sourceDir("sources") .outputDir("output") + .lintStrict(true) + .lintQuiet(true) .build(); // When final HelmConfig result = HelmServiceUtil @@ -120,7 +124,9 @@ void initHelmConfig_withOriginalConfig_shouldInitConfigWithoutOverriding() throw .hasFieldOrPropertyWithValue("sources", Collections.emptyList()) .hasFieldOrPropertyWithValue("maintainers", Collections.emptyList()) .hasFieldOrPropertyWithValue("sourceDir", "sources") - .hasFieldOrPropertyWithValue("outputDir", "output"); + .hasFieldOrPropertyWithValue("outputDir", "output") + .hasFieldOrPropertyWithValue("lintStrict", true) + .hasFieldOrPropertyWithValue("lintQuiet", true); } @Test @@ -142,6 +148,21 @@ void initHelmConfig_withTypeProperty_shouldInitConfigWithForSpecifiedTypes() thr ); } + @Test + void initHelmConfig_withLintProperties_shouldInitConfigWithLintSettings() throws IOException { + // Given + javaProject.getProperties().put("jkube.helm.lint.strict", "True"); + javaProject.getProperties().put("jkube.helm.lint.quiet", "trUe"); + // When + final HelmConfig result = HelmServiceUtil + .initHelmConfig(HelmConfig.HelmType.KUBERNETES, javaProject, templateDir, null) + .build(); + // Then + assertThat(result) + .hasFieldOrPropertyWithValue("lintStrict", true) + .hasFieldOrPropertyWithValue("lintQuiet", true); + } + @Test void initHelmConfig_whenValuesSchemaJsonPresentInProjectBaseDir_thenAddToHelmConfig() throws IOException { // Given