From 50c0dcf9255b4974c87b15a5b3029499feb4400e Mon Sep 17 00:00:00 2001 From: Rohan Kumar Date: Thu, 13 Jul 2023 23:06:08 +0530 Subject: [PATCH] feat (jkube-kit/resource/helm) : Support for Pushing Helm Charts to OCI (#2091) + Add SHAUtil for generating SHA digests (used in oci helm push) + Add OCIRepositoryUploader as an additional HelmUploader + Add OCI repository related helper classes in `org.eclipse.jkube.kit.resource.helm.oci` package Signed-off-by: Rohan Kumar --- jkube-kit/common/pom.xml | 6 + .../kit/common/util/Fabric8HttpUtil.java | 19 +- .../jkube/kit/common/util/SHAUtil.java | 72 +++++ .../kit/common/util/Fabric8HttpUtilTest.java | 3 +- .../jkube/kit/common/util/SHAUtilTest.java | 67 +++++ .../common/util/TestFabric8HttpResponse.java | 62 ---- jkube-kit/parent/pom.xml | 8 + jkube-kit/resource/helm/pom.xml | 7 + .../ArtifactoryHelmRepositoryUploader.java | 6 +- .../ChartMuseumHelmRepositoryUploader.java | 6 +- .../kit/resource/helm/HelmRepository.java | 25 +- .../jkube/kit/resource/helm/HelmService.java | 4 +- .../jkube/kit/resource/helm/HelmUploader.java | 4 +- .../resource/helm/HelmUploaderManager.java | 5 +- .../helm/NexusHelmRepositoryUploader.java | 6 +- .../resource/helm/OCIRepositoryUploader.java | 89 ++++++ .../helm/StandardRepositoryUploader.java | 23 +- .../kit/resource/helm/oci/OCIManifest.java | 37 +++ .../resource/helm/oci/OCIManifestLayer.java | 33 +++ .../resource/helm/oci/OCIRegistryClient.java | 188 ++++++++++++ .../helm/oci/OCIRegistryEndpoint.java | 57 ++++ .../helm/oci/OCIRegistryInterceptor.java | 166 +++++++++++ .../resources/META-INF/jkube/helm-uploaders | 3 +- .../resource/helm/HelmServiceUploadIT.java | 49 +++ .../helm/HelmUploaderManagerTest.java | 6 +- .../helm/OCIRepositoryUploaderTest.java | 119 ++++++++ .../helm/TestMockResponseProvider.java | 39 +++ .../helm/oci/OCIManifestLayerTest.java | 68 +++++ .../resource/helm/oci/OCIManifestTest.java | 84 ++++++ .../helm/oci/OCIRegistryClientTest.java | 279 ++++++++++++++++++ .../helm/oci/OCIRegistryEndpointTest.java | 68 +++++ .../helm/oci/OCIRegistryInterceptorTest.java | 185 ++++++++++++ .../src/test/resources/test-oci-manifest.json | 13 + 33 files changed, 1680 insertions(+), 126 deletions(-) create mode 100644 jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/util/SHAUtil.java create mode 100644 jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/util/SHAUtilTest.java delete mode 100644 jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/util/TestFabric8HttpResponse.java create mode 100644 jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/OCIRepositoryUploader.java create mode 100644 jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/oci/OCIManifest.java create mode 100644 jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/oci/OCIManifestLayer.java create mode 100644 jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryClient.java create mode 100644 jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryEndpoint.java create mode 100644 jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryInterceptor.java create mode 100644 jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/OCIRepositoryUploaderTest.java create mode 100644 jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/TestMockResponseProvider.java create mode 100644 jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/oci/OCIManifestLayerTest.java create mode 100644 jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/oci/OCIManifestTest.java create mode 100644 jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryClientTest.java create mode 100644 jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryEndpointTest.java create mode 100644 jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryInterceptorTest.java create mode 100644 jkube-kit/resource/helm/src/test/resources/test-oci-manifest.json diff --git a/jkube-kit/common/pom.xml b/jkube-kit/common/pom.xml index c7c2afa7ab..b0006f326c 100644 --- a/jkube-kit/common/pom.xml +++ b/jkube-kit/common/pom.xml @@ -130,6 +130,12 @@ junit-jupiter-params test + + io.fabric8 + kubernetes-client-api + test-jar + test + io.fabric8 openshift-server-mock diff --git a/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/util/Fabric8HttpUtil.java b/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/util/Fabric8HttpUtil.java index 0041fe7bc4..81f088df82 100644 --- a/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/util/Fabric8HttpUtil.java +++ b/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/util/Fabric8HttpUtil.java @@ -14,6 +14,7 @@ package org.eclipse.jkube.kit.common.util; import io.fabric8.kubernetes.client.http.HttpResponse; +import org.apache.commons.lang3.StringUtils; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; @@ -34,15 +35,17 @@ private Fabric8HttpUtil() { } * @param response Http Response of a particular request * @return map containing various components of header as key value pairs */ - public static Map extractAuthenticationChallengeIntoMap(HttpResponse response) { - String wwwAuthenticateHeader = response.header(WWW_AUTHENTICATE); - String[] wwwAuthenticateHeaders = wwwAuthenticateHeader.split(","); + public static Map extractAuthenticationChallengeIntoMap(HttpResponse response) { Map result = new HashMap<>(); - for (String challenge : wwwAuthenticateHeaders) { - if (challenge.contains("=")) { - String[] challengeParts = challenge.split("="); - if (challengeParts.length == 2) { - result.put(challengeParts[0], strip(challengeParts[1], "\"")); + String wwwAuthenticateHeader = response.header(WWW_AUTHENTICATE); + if (StringUtils.isNotBlank(wwwAuthenticateHeader)) { + String[] wwwAuthenticateHeaders = wwwAuthenticateHeader.split(","); + for (String challenge : wwwAuthenticateHeaders) { + if (challenge.contains("=")) { + String[] challengeParts = challenge.split("="); + if (challengeParts.length == 2) { + result.put(challengeParts[0], strip(challengeParts[1], "\"")); + } } } } diff --git a/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/util/SHAUtil.java b/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/util/SHAUtil.java new file mode 100644 index 0000000000..c902749a5d --- /dev/null +++ b/jkube-kit/common/src/main/java/org/eclipse/jkube/kit/common/util/SHAUtil.java @@ -0,0 +1,72 @@ +/** + * 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.util; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class SHAUtil { + private static final int BUFFER_SIZE = 8192; + private static final String SHA_256 = "SHA-256"; + private SHAUtil() { } + + public static String generateSHA256(File file) throws IOException { + return generateSHA(file, SHA_256); + } + + public static String generateSHA256(String input) { + MessageDigest digest = createNewMessageDigest(SHA_256); + return bytesToHex(digest.digest(input.getBytes(StandardCharsets.UTF_8))); + } + + public static String generateSHA(File file, String algorithm) throws IOException { + try (InputStream input = Files.newInputStream(file.toPath())) { + MessageDigest shaDigest = createNewMessageDigest(algorithm); + byte[] buffer = new byte[BUFFER_SIZE]; + int len = input.read(buffer); + + while (len != -1) { + shaDigest.update(buffer, 0, len); + len = input.read(buffer); + } + + return bytesToHex(shaDigest.digest()); + } + } + + static MessageDigest createNewMessageDigest(String algorithm) { + try { + return MessageDigest.getInstance(algorithm); + } catch (NoSuchAlgorithmException e) { + throw new IllegalArgumentException("Error while generating SHA, no such algorithm : " + algorithm); + } + } + + private static String bytesToHex(byte[] hash) { + StringBuilder hexString = new StringBuilder(2 * hash.length); + for (byte b : hash) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } +} \ No newline at end of file diff --git a/jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/util/Fabric8HttpUtilTest.java b/jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/util/Fabric8HttpUtilTest.java index e99fe45832..b660c8568b 100644 --- a/jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/util/Fabric8HttpUtilTest.java +++ b/jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/util/Fabric8HttpUtilTest.java @@ -14,6 +14,7 @@ package org.eclipse.jkube.kit.common.util; import io.fabric8.kubernetes.client.http.HttpResponse; +import io.fabric8.kubernetes.client.http.TestHttpResponse; import org.junit.jupiter.api.Test; import java.io.UnsupportedEncodingException; @@ -53,7 +54,7 @@ void extractAuthenticationChallengeIntoMap_whenWwwHeaderProvided_thenParseDataIn String wwwAuthenticateValue = "Bearer realm=\"https://auth.example.com/token\",service=\"registry.example.com\",scope=\"repository:myuser/test-chart:pull\""; Map> responseHeaders = new HashMap<>(); responseHeaders.put("WWW-Authenticate", Collections.singletonList(wwwAuthenticateValue)); - HttpResponse response = new TestFabric8HttpResponse(HTTP_OK, responseHeaders, null, null); + HttpResponse response = new TestHttpResponse(responseHeaders).withCode(HTTP_OK); // When Map wwwAuthenticateAsMap = Fabric8HttpUtil.extractAuthenticationChallengeIntoMap(response); diff --git a/jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/util/SHAUtilTest.java b/jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/util/SHAUtilTest.java new file mode 100644 index 0000000000..cd7536edb9 --- /dev/null +++ b/jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/util/SHAUtilTest.java @@ -0,0 +1,67 @@ +/** + * 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.util; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.security.MessageDigest; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +class SHAUtilTest { + @Test + void generateSHA256_whenFileProvided_shouldGenerateDigest() throws IOException { + // Given + File file = new File(Objects.requireNonNull(getClass().getResource("/crontab-cr.yml")).getFile()); + + // When + String result = SHAUtil.generateSHA256(file); + + // Then + assertThat(result).isEqualTo("da723cee661dd0368f1b50274720e189372c336c2f78b9848826b7ff4f303b61"); + } + + @Test + void generateSHA256_whenStringProvided_shouldGenerateDigest() { + // Given + When + String result = SHAUtil.generateSHA256("somerandomstring"); + + // Then + assertThat(result).isEqualTo("39b5931a5dd8980254782830a1134644035573a1d75a50527e233f45f6afc5ce"); + } + + @Test + void createNewMessageDigest_whenValidAlgorithmProvided_thenCreateMessageDigestObj() { + // Given + String algorithm = "SHA-256"; + // When + MessageDigest digest = SHAUtil.createNewMessageDigest(algorithm); + // Then + assertThat(digest.getAlgorithm()).isEqualTo(algorithm); + } + + @Test + void createNewMessageDigest_whenInvalidAlgorithmProvided_thenThrowException() { + // Given + String algorithm = "invalid"; + // When + Then + assertThatIllegalArgumentException() + .isThrownBy(() -> SHAUtil.createNewMessageDigest(algorithm)) + .withMessage("Error while generating SHA, no such algorithm : invalid"); + } +} diff --git a/jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/util/TestFabric8HttpResponse.java b/jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/util/TestFabric8HttpResponse.java deleted file mode 100644 index 7cc1c536c5..0000000000 --- a/jkube-kit/common/src/test/java/org/eclipse/jkube/kit/common/util/TestFabric8HttpResponse.java +++ /dev/null @@ -1,62 +0,0 @@ -/** - * 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.util; - -import io.fabric8.kubernetes.client.http.HttpRequest; -import io.fabric8.kubernetes.client.http.HttpResponse; -import org.apache.commons.lang3.StringUtils; - -import java.util.List; -import java.util.Map; -import java.util.Optional; - -public class TestFabric8HttpResponse implements HttpResponse { - private final int code; - private final Map> headers; - private final String body; - private final String message; - - public TestFabric8HttpResponse(int code, Map> headers, String body, String message) { - this.code = code; - this.headers = headers; - this.body = body; - this.message = message; - } - - @Override - public int code() { return code; } - - @Override - public byte[] body() { - if (StringUtils.isNotBlank(body)) { - return body.getBytes(); - } - return null; - } - - @Override - public HttpRequest request() { return null; } - - @Override - public Optional> previousResponse() { return Optional.empty(); } - - @Override - public List headers(String s) { return headers.get(s); } - - @Override - public Map> headers() { return headers; } - - @Override - public String message() { return message; } -} diff --git a/jkube-kit/parent/pom.xml b/jkube-kit/parent/pom.xml index c0689938b0..76d0a11b93 100644 --- a/jkube-kit/parent/pom.xml +++ b/jkube-kit/parent/pom.xml @@ -677,6 +677,14 @@ + + io.fabric8 + kubernetes-client-api + test-jar + ${version.kubernetes-client} + test + + org.junit.jupiter junit-jupiter-engine diff --git a/jkube-kit/resource/helm/pom.xml b/jkube-kit/resource/helm/pom.xml index 0fd55c3f0f..f3f7522cb6 100644 --- a/jkube-kit/resource/helm/pom.xml +++ b/jkube-kit/resource/helm/pom.xml @@ -49,6 +49,13 @@ test test-jar + + io.fabric8 + kubernetes-client-api + test-jar + test + + org.junit.jupiter junit-jupiter-engine diff --git a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/ArtifactoryHelmRepositoryUploader.java b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/ArtifactoryHelmRepositoryUploader.java index 362bbeb367..2e5ec2d5c4 100644 --- a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/ArtifactoryHelmRepositoryUploader.java +++ b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/ArtifactoryHelmRepositoryUploader.java @@ -13,14 +13,12 @@ */ package org.eclipse.jkube.kit.resource.helm; -import org.eclipse.jkube.kit.common.KitLogger; - import java.io.File; public class ArtifactoryHelmRepositoryUploader extends StandardRepositoryUploader { - public ArtifactoryHelmRepositoryUploader(KitLogger logger) { - super("PUT", logger, HelmRepository.HelmRepoType.ARTIFACTORY); + public ArtifactoryHelmRepositoryUploader() { + super("PUT", HelmRepository.HelmRepoType.ARTIFACTORY); } @Override diff --git a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/ChartMuseumHelmRepositoryUploader.java b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/ChartMuseumHelmRepositoryUploader.java index 9906687bd6..161fab0288 100644 --- a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/ChartMuseumHelmRepositoryUploader.java +++ b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/ChartMuseumHelmRepositoryUploader.java @@ -13,14 +13,12 @@ */ package org.eclipse.jkube.kit.resource.helm; -import org.eclipse.jkube.kit.common.KitLogger; - import java.io.File; public class ChartMuseumHelmRepositoryUploader extends StandardRepositoryUploader { - public ChartMuseumHelmRepositoryUploader(KitLogger logger) { - super("POST", logger, HelmRepository.HelmRepoType.CHARTMUSEUM); + public ChartMuseumHelmRepositoryUploader() { + super("POST", HelmRepository.HelmRepoType.CHARTMUSEUM); } @Override diff --git a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmRepository.java b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmRepository.java index 9ffdd57b15..b3e694b3b1 100644 --- a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmRepository.java +++ b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmRepository.java @@ -20,9 +20,6 @@ import lombok.NoArgsConstructor; import lombok.Setter; -import java.io.File; -import java.io.IOException; -import java.net.HttpURLConnection; import java.util.Optional; @Builder(toBuilder = true) @@ -50,28 +47,14 @@ public void setType(String type) { } public enum HelmRepoType { - CHARTMUSEUM(HelmRepositoryConnectionUtils::getConnectionForUploadToChartMuseum), - ARTIFACTORY(HelmRepositoryConnectionUtils::getConnectionForUploadToArtifactory), - NEXUS(HelmRepositoryConnectionUtils::getConnectionForUploadToNexus); - - private final ConnectionCreator connectionCreator; - - HelmRepoType(ConnectionCreator connectionCreator) { - this.connectionCreator = connectionCreator; - } - - public HttpURLConnection createConnection(File file, HelmRepository repository) throws IOException { - return connectionCreator.createConnectionForUploadToArtifactory(file, repository); - } + CHARTMUSEUM, + ARTIFACTORY, + NEXUS, + OCI; public static HelmRepoType parseString(String repoType) { return Optional.ofNullable(repoType).map(String::toUpperCase).map(HelmRepoType::valueOf).orElse(null); } - - @FunctionalInterface - protected interface ConnectionCreator { - HttpURLConnection createConnectionForUploadToArtifactory(File file, HelmRepository repository) throws IOException; - } } @Override diff --git a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmService.java b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmService.java index 97d5083f98..fe156b96de 100644 --- a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmService.java +++ b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmService.java @@ -159,8 +159,8 @@ public void uploadHelmChart(HelmConfig helm) throws BadUploadException, IOExcept private void uploadHelmChart(HelmConfig helmConfig, HelmRepository helmRepository) throws IOException, BadUploadException { + final HelmUploaderManager helmUploaderManager = new HelmUploaderManager(); for (HelmConfig.HelmType helmType : helmConfig.getTypes()) { - final HelmUploaderManager helmUploaderManager = new HelmUploaderManager(logger); logger.info("Uploading Helm Chart \"%s\" to %s", helmConfig.getChart(), helmRepository.getName()); logger.debug("OutputDir: %s", helmConfig.getOutputDir()); @@ -170,7 +170,7 @@ private void uploadHelmChart(HelmConfig helmConfig, HelmRepository helmRepositor 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(logger, tarballFile, helmRepository); } } diff --git a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmUploader.java b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmUploader.java index 26b46b504b..3b79256cc9 100644 --- a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmUploader.java +++ b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmUploader.java @@ -13,10 +13,12 @@ */ package org.eclipse.jkube.kit.resource.helm; +import org.eclipse.jkube.kit.common.KitLogger; + import java.io.File; import java.io.IOException; public interface HelmUploader { HelmRepository.HelmRepoType getType(); - void uploadSingle(File file, HelmRepository repository) throws IOException, BadUploadException ; + void uploadSingle(KitLogger kitLogger, File file, HelmRepository repository) throws IOException, BadUploadException ; } \ No newline at end of file diff --git a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmUploaderManager.java b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmUploaderManager.java index 2d8d67b836..6b639e85d3 100644 --- a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmUploaderManager.java +++ b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/HelmUploaderManager.java @@ -13,7 +13,6 @@ */ package org.eclipse.jkube.kit.resource.helm; -import org.eclipse.jkube.kit.common.KitLogger; import org.eclipse.jkube.kit.common.util.PluginServiceFactory; import java.util.List; @@ -25,8 +24,8 @@ public class HelmUploaderManager { private final List helmUploaderList; - public HelmUploaderManager(KitLogger log) { - this.helmUploaderList = new PluginServiceFactory<>(log).createServiceObjects(SERVICE_PATHS); + public HelmUploaderManager() { + this.helmUploaderList = new PluginServiceFactory<>(null).createServiceObjects(SERVICE_PATHS); } public HelmUploader getHelmUploader(HelmRepository.HelmRepoType type) { diff --git a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/NexusHelmRepositoryUploader.java b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/NexusHelmRepositoryUploader.java index dd0cc4380b..11a341d781 100644 --- a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/NexusHelmRepositoryUploader.java +++ b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/NexusHelmRepositoryUploader.java @@ -13,13 +13,11 @@ */ package org.eclipse.jkube.kit.resource.helm; -import org.eclipse.jkube.kit.common.KitLogger; - import java.io.File; public class NexusHelmRepositoryUploader extends StandardRepositoryUploader { - public NexusHelmRepositoryUploader(KitLogger logger) { - super("PUT", logger, HelmRepository.HelmRepoType.NEXUS); + public NexusHelmRepositoryUploader() { + super("PUT", HelmRepository.HelmRepoType.NEXUS); } @Override diff --git a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/OCIRepositoryUploader.java b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/OCIRepositoryUploader.java new file mode 100644 index 0000000000..a1cc6a52a4 --- /dev/null +++ b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/OCIRepositoryUploader.java @@ -0,0 +1,89 @@ +/** + * 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.resource.helm; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import io.fabric8.kubernetes.client.http.HttpClient; +import io.fabric8.kubernetes.client.utils.HttpClientUtils; +import io.fabric8.kubernetes.client.utils.Serialization; +import org.eclipse.jkube.kit.common.KitLogger; +import org.eclipse.jkube.kit.common.util.SHAUtil; +import org.eclipse.jkube.kit.resource.helm.oci.OCIRegistryClient; +import org.eclipse.jkube.kit.resource.helm.oci.OCIRegistryInterceptor; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.Charset; + +public class OCIRepositoryUploader implements HelmUploader { + private static final ObjectMapper yamlObjectMapper = new ObjectMapper(new YAMLFactory()); + + @Override + public HelmRepository.HelmRepoType getType() { + return HelmRepository.HelmRepoType.OCI; + } + + @Override + public void uploadSingle(KitLogger logger, File file, HelmRepository repository) + throws IOException, BadUploadException { + Chart chartConfig = createChartMetadataFromGeneratedChartYamlFile(file); + HttpClient.Factory httpClientFactory = HttpClientUtils.getHttpClientFactory(); + try (HttpClient httpClient = httpClientFactory.newBuilder() + .addOrReplaceInterceptor(OCIRegistryInterceptor.NAME, new OCIRegistryInterceptor(httpClientFactory, repository)) + .build()) { + OCIRegistryClient oci = new OCIRegistryClient(repository, httpClient); + + uploadChartToOCIRegistry(logger, oci, chartConfig, repository, file); + } + } + + private void uploadChartToOCIRegistry(KitLogger logger, OCIRegistryClient oci, Chart chartConfig, HelmRepository repository, File file) throws IOException, BadUploadException { + String chartMetadataContentPayload = Serialization.asJson(chartConfig); + String chartTarballBlobDigest = SHAUtil.generateSHA256(file); + String chartMetadataBlobDigest = SHAUtil.generateSHA256(chartMetadataContentPayload); + long chartMetadataPayloadSize = chartMetadataContentPayload.getBytes(Charset.defaultCharset()).length; + long chartTarballSize = file.length(); + + String chartTarballDockerContentDigest = uploadBlobIfNotExist(logger, oci, chartConfig.getName(), chartTarballBlobDigest, chartTarballSize, null, file); + String chartConfigDockerContentDigest = uploadBlobIfNotExist(logger, oci, chartConfig.getName(), chartMetadataBlobDigest, chartMetadataPayloadSize, chartMetadataContentPayload, null); + + String manifestDockerContentDigest = oci.uploadOCIManifest(chartConfig.getName(), chartConfig.getVersion(), chartConfigDockerContentDigest, chartTarballDockerContentDigest, chartMetadataPayloadSize, chartTarballSize); + logger.info("Pushed: %s/%s/%s:%s", oci.getBaseUrl(), repository.getUsername(), chartConfig.getName(), chartConfig.getVersion()); + logger.info("Digest: %s", manifestDockerContentDigest); + } + + private String uploadBlobIfNotExist(KitLogger logger, OCIRegistryClient oci, String chartName, String blob, long blobSize, String blobContentStr, File blobFile) throws IOException, BadUploadException { + boolean alreadyUploaded = oci.isLayerUploadedAlready(chartName, blob); + if (alreadyUploaded) { + logger.info("Skipping push, BLOB already exists on target registry: %s", blob); + return String.format("sha256:%s", blob); + } else { + return uploadBlob(oci, chartName, blob, blobSize, blobContentStr, blobFile); + } + } + + private String uploadBlob(OCIRegistryClient oci, String chartName, String blob, long blobSize, String blobContentStr, File blobFile) throws IOException, BadUploadException { + String uploadUrl = oci.initiateUploadProcess(chartName); + return oci.uploadBlob(uploadUrl, blob, blobSize, blobContentStr, blobFile); + } + + private Chart createChartMetadataFromGeneratedChartYamlFile(File chartFile) throws IOException { + File chartMetadataFile = new File(chartFile.getParentFile(), "Chart.yaml"); + if (chartMetadataFile.exists()) { + return yamlObjectMapper.readValue(chartMetadataFile, Chart.class); + } + throw new IllegalStateException("Could not found Chart.yaml file in " + chartMetadataFile.getPath()); + } +} \ No newline at end of file diff --git a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/StandardRepositoryUploader.java b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/StandardRepositoryUploader.java index e03bb4d203..7daab23899 100644 --- a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/StandardRepositoryUploader.java +++ b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/StandardRepositoryUploader.java @@ -31,13 +31,11 @@ public abstract class StandardRepositoryUploader implements HelmUploader { private final String method; - private final KitLogger logger; private final HelmRepository.HelmRepoType type; private static final long HELM_UPLOAD_TIMEOUT_MINUTES = 30; - protected StandardRepositoryUploader(String method, KitLogger logger, HelmRepository.HelmRepoType type) { + protected StandardRepositoryUploader(String method, HelmRepository.HelmRepoType type) { this.method = method; - this.logger = logger; this.type = type; } @@ -49,21 +47,22 @@ public HelmRepository.HelmRepoType getType() { } @Override - public void uploadSingle(File file, HelmRepository repository) throws IOException, BadUploadException { + public void uploadSingle(KitLogger logger, File file, HelmRepository repository) throws IOException, BadUploadException { String uploadUrl = url(file, repository); try (HttpClient httpClient = HttpClientUtils.getHttpClientFactory().newBuilder().tag(new RequestConfigBuilder().withRequestRetryBackoffLimit(0).build()).build()) { - HttpRequest.Builder httpRequestBuilder = httpClient.newHttpRequestBuilder(); - httpRequestBuilder.uri(uploadUrl); - // At this point username and password are always populated since this is requirement in HelmService - httpRequestBuilder.header("Authorization", String.format("Basic %s", Base64Util.encodeToString(repository.getUsername() + ":" + repository.getPassword()))); - httpRequestBuilder.method(method, "application/gzip", Files.newInputStream(file.toPath()), file.length()); - HttpResponse response = get(httpClient.sendAsync(httpRequestBuilder.build(), byte[].class), Duration.ofMinutes(HELM_UPLOAD_TIMEOUT_MINUTES)); - handleHttpResponse(response); + HttpRequest httpRequest = httpClient.newHttpRequestBuilder() + .method(method, "application/gzip", Files.newInputStream(file.toPath()), file.length()) + // At this point username and password are always populated since this is requirement in HelmService + .header("Authorization", String.format("Basic %s", Base64Util.encodeToString(repository.getUsername() + ":" + repository.getPassword()))) + .uri(uploadUrl) + .build(); + HttpResponse response = get(httpClient.sendAsync(httpRequest, byte[].class), Duration.ofMinutes(HELM_UPLOAD_TIMEOUT_MINUTES)); + handleHttpResponse(logger, response); } } - private void handleHttpResponse(HttpResponse response) throws BadUploadException { + private void handleHttpResponse(KitLogger logger, HttpResponse response) throws BadUploadException { if (!response.isSuccessful()) { String responseStr; if (response.body() != null) { diff --git a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/oci/OCIManifest.java b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/oci/OCIManifest.java new file mode 100644 index 0000000000..1d3023baab --- /dev/null +++ b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/oci/OCIManifest.java @@ -0,0 +1,37 @@ +/** + * 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.resource.helm.oci; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.Singular; + +import java.util.List; + +@Builder(toBuilder = true) +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@EqualsAndHashCode +public class OCIManifest { + private int schemaVersion; + private OCIManifestLayer config; + @Singular + private List layers; +} diff --git a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/oci/OCIManifestLayer.java b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/oci/OCIManifestLayer.java new file mode 100644 index 0000000000..9793a5bc7b --- /dev/null +++ b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/oci/OCIManifestLayer.java @@ -0,0 +1,33 @@ +/** + * 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.resource.helm.oci; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Builder(toBuilder = true) +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@EqualsAndHashCode +public class OCIManifestLayer { + private String mediaType; + private String digest; + private long size; +} \ No newline at end of file diff --git a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryClient.java b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryClient.java new file mode 100644 index 0000000000..072dd622ec --- /dev/null +++ b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryClient.java @@ -0,0 +1,188 @@ +/** + * 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.resource.helm.oci; + +import io.fabric8.kubernetes.client.http.HttpClient; +import io.fabric8.kubernetes.client.http.HttpRequest; +import io.fabric8.kubernetes.client.http.HttpResponse; +import io.fabric8.kubernetes.client.utils.Serialization; +import io.fabric8.kubernetes.client.utils.URLUtils; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jkube.kit.resource.helm.BadUploadException; +import org.eclipse.jkube.kit.resource.helm.HelmRepository; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Duration; +import java.util.Optional; + +import static java.net.HttpURLConnection.HTTP_ACCEPTED; +import static java.net.HttpURLConnection.HTTP_NOT_FOUND; +import static java.net.HttpURLConnection.HTTP_OK; +import static org.apache.commons.lang3.StringUtils.EMPTY; +import static org.eclipse.jkube.kit.common.util.AsyncUtil.get; + +public class OCIRegistryClient { + private static final String DOCKER_CONTENT_DIGEST = "Docker-Content-Digest"; + private static final String USER_AGENT = "EclipseJKube"; + private static final String OCI_IMAGE_MANIFEST_MEDIA_TYPE = "application/vnd.oci.image.manifest.v1+json"; + private static final String HELM_CONFIG_MEDIA_TYPE = "application/vnd.cncf.helm.config.v1+json"; + private static final String HELM_CHART_CONTENT_MEDIA_TYPE = "application/vnd.cncf.helm.chart.content.v1.tar+gzip"; + private static final String LOCATION_HEADER = "Location"; + private static final long OCI_UPLOAD_HTTP_REQUEST_TIMEOUT = 30; + private final HelmRepository repository; + private final OCIRegistryEndpoint ociRegistryEndpoint; + private final HttpClient httpClient; + + public OCIRegistryClient(HelmRepository repository, HttpClient httpClient) { + this.repository = repository; + this.ociRegistryEndpoint = new OCIRegistryEndpoint(repository.getUrl()); + this.httpClient = httpClient; + } + + public String getBaseUrl() throws MalformedURLException { + return ociRegistryEndpoint.getBaseUrl(); + } + + public String initiateUploadProcess(String chartName) throws IOException { + String uploadProcessInitiateUrl = ociRegistryEndpoint.getBlobUploadInitUrl(chartName); + HttpRequest httpRequest = createBaseOCIHttpRequest() + .post("application/json", EMPTY) + .uri(uploadProcessInitiateUrl) + .build(); + HttpResponse response = get(httpClient.sendAsync(httpRequest, byte[].class), Duration.ofMinutes(OCI_UPLOAD_HTTP_REQUEST_TIMEOUT)); + + int responseCode = response.code(); + if (responseCode != HTTP_ACCEPTED) { + throw new IllegalStateException("Failure in initiating upload request: " + response.message()); + } else { + String locationHeader = parseLocationHeaderFromResponse(response, ociRegistryEndpoint.getBaseUrl()); + if (StringUtils.isBlank(locationHeader)) { + throw new IllegalStateException(String.format("No %s header found in upload initiation response", LOCATION_HEADER)); + } + return locationHeader; + } + } + + public String uploadOCIManifest(String chartName, String chartVersion, String chartConfigDigest, String chartTarballDigest, long chartConfigPayloadSize, long chartTarballContentSize) throws IOException, BadUploadException { + String manifestUrl = ociRegistryEndpoint.getManifestUrl(chartName, chartVersion); + String manifestPayload = createChartManifestPayload(chartConfigDigest, chartTarballDigest, chartConfigPayloadSize, chartTarballContentSize); + InputStream requestBodyInputStream = new ByteArrayInputStream(manifestPayload.getBytes(StandardCharsets.UTF_8)); + long contentLength = manifestPayload.getBytes().length; + HttpRequest httpRequest = createBaseOCIHttpRequest() + .header("Host", new URL(repository.getUrl()).getHost()) + .uri(manifestUrl) + .method("PUT", OCI_IMAGE_MANIFEST_MEDIA_TYPE, requestBodyInputStream, contentLength) + .build(); + + HttpResponse response = get(httpClient.sendAsync(httpRequest, byte[].class), Duration.ofMinutes(OCI_UPLOAD_HTTP_REQUEST_TIMEOUT)); + + if (!response.isSuccessful()) { + handleFailure(response); + } + return extractDockerContentDigestFromResponseHeaders(response); + } + + public String uploadBlob(String uploadUrl, String blobDigest, long blobSize, String blobContentStr, File blobFile) throws IOException, BadUploadException { + uploadUrl = new URLUtils.URLBuilder(uploadUrl).addQueryParameter("digest", String.format("sha256:%s", blobDigest)).toString(); + InputStream blobContentInputStream = blobFile != null ? Files.newInputStream(blobFile.toPath()) : new ByteArrayInputStream(blobContentStr.getBytes()); + HttpRequest httpRequest = createBaseOCIHttpRequest() + .uri(uploadUrl) + .method("PUT", "application/octet-stream", blobContentInputStream, blobSize) + .build(); + HttpResponse response = get(httpClient.sendAsync(httpRequest, byte[].class), Duration.ofMinutes(OCI_UPLOAD_HTTP_REQUEST_TIMEOUT)); + + if (!response.isSuccessful()) { + handleFailure(response); + } + return extractDockerContentDigestFromResponseHeaders(response); + } + + public boolean isLayerUploadedAlready(String chartName, String digest) throws IOException { + String blobExistenceCheckUrl = ociRegistryEndpoint.getBlobUrl(chartName, digest); + HttpRequest httpRequest = createBaseOCIHttpRequest().uri(blobExistenceCheckUrl).build(); + HttpResponse response = get(httpClient.sendAsync(httpRequest, byte[].class), Duration.ofMinutes(OCI_UPLOAD_HTTP_REQUEST_TIMEOUT)); + + int responseCode = response.code(); + if (responseCode == HTTP_NOT_FOUND) { + return false; + } + return responseCode == HTTP_OK; + } + + private void handleFailure(HttpResponse response) throws BadUploadException { + int responseCode = response.code(); + String responseStr = Optional.ofNullable(response.body()) + .map(String::new) + .orElse(Optional.ofNullable(response.message()).orElse("No details provided")); + if (responseCode == HttpURLConnection.HTTP_BAD_REQUEST) { + throw new BadUploadException(responseStr); + } else { + throw new IllegalStateException("Received " + responseCode + " : " + responseStr); + } + } + + private String extractDockerContentDigestFromResponseHeaders(HttpResponse response) { + String dockerContentDigest = response.header(DOCKER_CONTENT_DIGEST); + if (StringUtils.isNotBlank(dockerContentDigest)) { + return dockerContentDigest; + } + throw new IllegalStateException("No " + DOCKER_CONTENT_DIGEST + " header found in upload response"); + } + + private HttpRequest.Builder createBaseOCIHttpRequest() { + HttpRequest.Builder httpRequestBuilder = httpClient.newHttpRequestBuilder(); + httpRequestBuilder.header("User-Agent", USER_AGENT); + return httpRequestBuilder; + } + + private String parseLocationHeaderFromResponse(HttpResponse response, String baseUrl) { + String locationHeader = response.header(LOCATION_HEADER); + + // Only path is returned via GitHub Container Registry + if (locationHeader != null && locationHeader.startsWith("/")) { + locationHeader = baseUrl + locationHeader; + } + return locationHeader; + } + + private String createChartManifestPayload(String chartConfigDigest, String chartTarballDigest, long chartConfigPayloadSize, + long chartTarballContentSize) { + OCIManifest manifest = createChartManifest(chartConfigDigest, chartTarballDigest, chartConfigPayloadSize, chartTarballContentSize); + return Serialization.asJson(manifest); + } + + private OCIManifest createChartManifest(String digest, String layerDigest, long chartConfigPayloadSize, long chartTarballContentSize) { + return OCIManifest.builder() + .schemaVersion(2) + .config(OCIManifestLayer.builder() + .mediaType(HELM_CONFIG_MEDIA_TYPE) + .digest(digest) + .size(chartConfigPayloadSize) + .build()) + .layer(OCIManifestLayer.builder() + .mediaType(HELM_CHART_CONTENT_MEDIA_TYPE) + .digest(layerDigest) + .size(chartTarballContentSize) + .build()) + .build(); + } +} \ No newline at end of file diff --git a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryEndpoint.java b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryEndpoint.java new file mode 100644 index 0000000000..791f012efb --- /dev/null +++ b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryEndpoint.java @@ -0,0 +1,57 @@ +/** + * 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.resource.helm.oci; + + +import org.apache.commons.lang3.StringUtils; + +import java.net.MalformedURLException; +import java.net.URL; + +public class OCIRegistryEndpoint { + private final String url; + + public OCIRegistryEndpoint(String baseUrl) { + this.url = StringUtils.removeEnd(baseUrl, "/"); + } + + public String getBlobUrl(String chartName, String digest) throws MalformedURLException { + return String.format("%s/%s/blobs/sha256:%s", getV2ApiUrl(), chartName, digest); + } + + public String getBlobUploadInitUrl(String chartName) throws MalformedURLException { + return String.format("%s/%s/blobs/uploads/", getV2ApiUrl(), chartName); + } + + public String getManifestUrl(String chartName, String version) throws MalformedURLException { + return String.format("%s/%s/manifests/%s", getV2ApiUrl(), chartName, version); + } + + public String getV2ApiUrl() throws MalformedURLException { + URL registryUrl = new URL(url); + + return String.format("%s/v2%s", getBaseUrl(), registryUrl.getPath()); + } + + public String getBaseUrl() throws MalformedURLException { + URL registryUrl = new URL(url); + + String portString = ""; + if (registryUrl.getPort() > 0) { + portString = String.format(":%d", registryUrl.getPort()); + } + + return String.format("%s://%s%s", registryUrl.getProtocol(), registryUrl.getHost(), portString); + } +} diff --git a/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryInterceptor.java b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryInterceptor.java new file mode 100644 index 0000000000..b09805bd22 --- /dev/null +++ b/jkube-kit/resource/helm/src/main/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryInterceptor.java @@ -0,0 +1,166 @@ +/** + * 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.resource.helm.oci; + +import io.fabric8.kubernetes.client.http.BasicBuilder; +import io.fabric8.kubernetes.client.http.HttpClient; +import io.fabric8.kubernetes.client.http.HttpRequest; +import io.fabric8.kubernetes.client.http.HttpResponse; +import io.fabric8.kubernetes.client.http.Interceptor; +import io.fabric8.kubernetes.client.utils.Serialization; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.jkube.kit.common.util.Base64Util; +import org.eclipse.jkube.kit.resource.helm.HelmRepository; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.HttpURLConnection; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; +import static org.eclipse.jkube.kit.common.util.AsyncUtil.get; +import static org.eclipse.jkube.kit.common.util.Fabric8HttpUtil.extractAuthenticationChallengeIntoMap; +import static org.eclipse.jkube.kit.common.util.Fabric8HttpUtil.toFormData; + +public class OCIRegistryInterceptor implements Interceptor { + private static final String TOKEN_KEY = "token"; + private static final String ACCESS_TOKEN_KEY = "access_token"; + private static final long OCI_REGISTRY_AUTH_REQUEST_TIMEOUT_MINUTES = 1; + private static final String WWW_AUTHENTICATE = "WWW-Authenticate"; + public static final String NAME = "OCI_TOKEN"; + private static final String AUTHORIZATION = "Authorization"; + private static final String BEARER = "Bearer "; + + private final HelmRepository repository; + private final HttpClient httpClient; + private String accessToken; + + public OCIRegistryInterceptor(HttpClient.Factory httpClientFactory, HelmRepository helmRepository) { + repository = helmRepository; + httpClient = httpClientFactory.newBuilder().build(); + } + + @Override + public void before(BasicBuilder headerBuilder, HttpRequest request, RequestTags tags) { + if (StringUtils.isNotBlank(accessToken)) { + headerBuilder.setHeader(AUTHORIZATION, BEARER + accessToken); + } + } + + @Override + public CompletableFuture afterFailure(BasicBuilder headerBuilder, HttpResponse response, RequestTags tags) { + if (response.code() == HTTP_UNAUTHORIZED) { + if (StringUtils.isNotBlank(accessToken) && StringUtils.isBlank(response.request().header(AUTHORIZATION))) { + headerBuilder.setHeader(AUTHORIZATION, BEARER + accessToken); + return CompletableFuture.completedFuture(true); + } + if (StringUtils.isBlank(response.header(WWW_AUTHENTICATE))) { + throw new IllegalStateException("Got 401 but no " + WWW_AUTHENTICATE + " found in response headers "); + } + return refreshToken(headerBuilder, response); + } + return CompletableFuture.completedFuture(false); + + } + + private CompletableFuture refreshToken(BasicBuilder headerBuilder, HttpResponse response) { + try { + String updatedAccessToken = submitHttpRequestForAuthenticationChallenge(response); + if (StringUtils.isNotBlank(updatedAccessToken)) { + accessToken = updatedAccessToken; + headerBuilder.setHeader(AUTHORIZATION, BEARER + accessToken); + return CompletableFuture.completedFuture(true); + } + return CompletableFuture.completedFuture(false); + } catch (IOException e) { + throw new IllegalStateException("Failure while refreshing token from OCI registry: ", e); + } + } + + private String submitHttpRequestForAuthenticationChallenge(HttpResponse response) throws IOException { + Map authChallengeHeader = extractAuthenticationChallengeIntoMap(response); + String authenticationUrl = authChallengeHeader.get("Bearer realm"); + String scope = authChallengeHeader.get("scope"); + if (!scope.contains("push")) { + scope += ",push"; + } + String service = authChallengeHeader.get("service"); + + return submitGetRequest(authenticationUrl, scope, service); + } + + private String submitGetRequest(String url, String scope, String service) throws IOException { + String authUrlWithQueryParams = String.format("%s?service=%s&scope=%s", url, service, scope); + HttpRequest httpRequest = httpClient.newHttpRequestBuilder() + .header(AUTHORIZATION, String.format("Basic %s", Base64Util.encodeToString(repository.getUsername() + ":" + repository.getPassword()))) + .uri(authUrlWithQueryParams) + .build(); + HttpResponse response = get(httpClient.sendAsync(httpRequest, byte[].class), Duration.ofMinutes(OCI_REGISTRY_AUTH_REQUEST_TIMEOUT_MINUTES)); + + int responseCode = response.code(); + if (responseCode == HttpURLConnection.HTTP_OK) { + return parseAccessTokenFromResponse(new String(response.body())); + } else if (responseCode == HttpURLConnection.HTTP_BAD_METHOD) { // DockerHub uses post for authentication + return submitPostRequest(url, scope, service); + } + return null; + } + + private String submitPostRequest(String url, String scope, String service) throws IOException { + String postDataString = createPostFormDataForDockerAuth(scope, service); + HttpRequest httpRequest = httpClient.newHttpRequestBuilder() + .header("Content-Length", Integer.toString(postDataString.getBytes().length)) + .method("POST", "application/x-www-form-urlencoded", postDataString) + .uri(url) + .build(); + + HttpResponse response = get(httpClient.sendAsync(httpRequest, byte[].class), Duration.ofMinutes(OCI_REGISTRY_AUTH_REQUEST_TIMEOUT_MINUTES)); + if (response.isSuccessful()) { + return parseAccessTokenFromResponse(response.bodyString()); + } + return null; + } + + private String parseAccessTokenFromResponse(String responseBody) { + Map responseBodyObj = Serialization.unmarshal(responseBody, Map.class); + String tokenFound = null; + if (responseBodyObj.containsKey(TOKEN_KEY)) { + tokenFound = (String) responseBodyObj.get(TOKEN_KEY); + } + if (responseBodyObj.containsKey(ACCESS_TOKEN_KEY)) { + tokenFound = (String) responseBodyObj.get(ACCESS_TOKEN_KEY); + } + + if (StringUtils.isNotBlank(tokenFound)) { + return tokenFound; + } + return null; + } + + private String createPostFormDataForDockerAuth(String scope, String service) throws UnsupportedEncodingException { + Map postFormData = new HashMap<>(); + postFormData.put("grant_type", "password"); + postFormData.put("refresh_token", repository.getPassword()); + postFormData.put("service", service); + postFormData.put("scope", scope); + postFormData.put("client_id", "EclipseJKube"); + postFormData.put("username", repository.getUsername()); + postFormData.put("password", repository.getPassword()); + + return toFormData(postFormData); + } +} diff --git a/jkube-kit/resource/helm/src/main/resources/META-INF/jkube/helm-uploaders b/jkube-kit/resource/helm/src/main/resources/META-INF/jkube/helm-uploaders index 1c88bb86bf..fc2e844639 100644 --- a/jkube-kit/resource/helm/src/main/resources/META-INF/jkube/helm-uploaders +++ b/jkube-kit/resource/helm/src/main/resources/META-INF/jkube/helm-uploaders @@ -1,3 +1,4 @@ org.eclipse.jkube.kit.resource.helm.ArtifactoryHelmRepositoryUploader,100 org.eclipse.jkube.kit.resource.helm.ChartMuseumHelmRepositoryUploader,100 -org.eclipse.jkube.kit.resource.helm.NexusHelmRepositoryUploader,100 \ No newline at end of file +org.eclipse.jkube.kit.resource.helm.NexusHelmRepositoryUploader,100 +org.eclipse.jkube.kit.resource.helm.OCIRepositoryUploader,100 \ No newline at end of file diff --git a/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/HelmServiceUploadIT.java b/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/HelmServiceUploadIT.java index 449f387d75..4579941eeb 100644 --- a/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/HelmServiceUploadIT.java +++ b/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/HelmServiceUploadIT.java @@ -51,6 +51,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import static java.util.Collections.singletonMap; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.spy; @@ -422,6 +423,54 @@ void tgzExtensionHandling() throws Exception { } } + @DisplayName("OCI repository specifics") + @Nested + class OCI { + + @BeforeEach + void setUp() { + helmConfig.getSnapshotRepository().setType("OCI"); + } + + @Test + @DisplayName("Sends chart metadata file, tarball and manifest file in separate requests") + void withSuccessfulUpload_shouldUploadBlobsAndUpdateManifest() throws Exception { + // Given + helmConfig.setChartExtension("tar.gz"); + Files.write( + Paths.get(helmConfig.getOutputDir()).resolve("kubernetes") + .resolve("Helm-Chart-1337-SNAPSHOT.tar.gz"), + "I'm a tar.gz, not a .tgz".getBytes(StandardCharsets.UTF_8)); + Files.write(Paths.get(helmConfig.getOutputDir()).resolve("kubernetes").resolve("Chart.yaml"), + "---\napiVersion: v1\nname: test-chart\nversion: 0.0.1".getBytes(StandardCharsets.UTF_8)); + mockServer.expect().post() + .withPath("/v2/test-chart/blobs/uploads/") + .andReply(new TestMockResponseProvider(202, singletonMap("Location", "/v2/test-chart/blobs/upload/first-upload-endpoint"), null)) + .once(); + mockServer.expect().post() + .withPath("/v2/test-chart/blobs/uploads/") + .andReply(new TestMockResponseProvider(202, singletonMap("Location", "/v2/test-chart/blobs/upload/second-upload-endpoint"), null)) + .once(); + mockServer.expect().put() + .withPath("/v2/test-chart/blobs/upload/first-upload-endpoint?digest=sha256%3Ac7051faa2fb28d147b34070a6bce25eaf1ee6bb4ca3b47af5ee6148d50079154") + .andReply(new TestMockResponseProvider(200, singletonMap("Docker-Content-Digest", "dockerdigest1"), null)) + .once(); + mockServer.expect().put() + .withPath("/v2/test-chart/blobs/upload/second-upload-endpoint?digest=sha256%3A530abfbfb3897f927efcac9610a26e97c7455d2bf5a7a5b8be02e646f10a041f") + .andReply(new TestMockResponseProvider(200, singletonMap("Docker-Content-Digest", "dockerdigest2"), null)) + .once(); + mockServer.expect().put() + .withPath("/v2/test-chart/manifests/0.0.1") + .andReply(new TestMockResponseProvider(200, singletonMap("Docker-Content-Digest", "manifestdigest"), null)) + .once(); + // When + helmService.uploadHelmChart(helmConfig); + // Then + assertThat(mockServer.getLastRequest().getBody().readUtf8()) + .isEqualTo("{\"schemaVersion\":2,\"config\":{\"mediaType\":\"application/vnd.cncf.helm.config.v1+json\",\"digest\":\"dockerdigest2\",\"size\":57},\"layers\":[{\"mediaType\":\"application/vnd.cncf.helm.chart.content.v1.tar+gzip\",\"digest\":\"dockerdigest1\",\"size\":24}]}"); + } + } + private void expect(HttpMethod method, String path, ServerResponse response) { responses.computeIfAbsent( new SimpleRequest(method, path), diff --git a/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/HelmUploaderManagerTest.java b/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/HelmUploaderManagerTest.java index 4722612c15..ec428bc601 100644 --- a/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/HelmUploaderManagerTest.java +++ b/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/HelmUploaderManagerTest.java @@ -13,7 +13,6 @@ */ package org.eclipse.jkube.kit.resource.helm; -import org.eclipse.jkube.kit.common.KitLogger; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -27,7 +26,8 @@ private static Stream testData() { return Stream.of( Arguments.of("ARTIFACTORY", ArtifactoryHelmRepositoryUploader.class), Arguments.of("NEXUS", NexusHelmRepositoryUploader.class), - Arguments.of("CHARTMUSEUM", ChartMuseumHelmRepositoryUploader.class) + Arguments.of("CHARTMUSEUM", ChartMuseumHelmRepositoryUploader.class), + Arguments.of("OCI", OCIRepositoryUploader.class) ); } @@ -35,7 +35,7 @@ private static Stream testData() { @MethodSource("testData") void getHelmUploader_whenValidHelmTypeProvided_thenReturnAppropriateUploader(HelmRepository.HelmRepoType type, Class helmUploaderType) { // Given - HelmUploaderManager helmUploaderManager = new HelmUploaderManager(new KitLogger.SilentLogger()); + HelmUploaderManager helmUploaderManager = new HelmUploaderManager(); // When HelmUploader helmUploader = helmUploaderManager.getHelmUploader(type); diff --git a/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/OCIRepositoryUploaderTest.java b/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/OCIRepositoryUploaderTest.java new file mode 100644 index 0000000000..95b28bacfa --- /dev/null +++ b/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/OCIRepositoryUploaderTest.java @@ -0,0 +1,119 @@ +/** + * 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.resource.helm; + +import org.apache.commons.io.FileUtils; +import org.eclipse.jkube.kit.common.KitLogger; +import org.eclipse.jkube.kit.common.util.SHAUtil; +import org.eclipse.jkube.kit.resource.helm.oci.OCIRegistryClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedConstruction; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.Charset; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OCIRepositoryUploaderTest { + private OCIRepositoryUploader ociRepositoryUploader; + private KitLogger logger; + private HelmRepository helmRepository; + @TempDir + private File tempDir; + private File chartFile; + private String chartTarballContentDigest; + + @BeforeEach + void setUp() throws IOException { + logger = spy(new KitLogger.SilentLogger()); + helmRepository = HelmRepository.builder() + .url("https://r.example.com/myuser") + .username("myuser") + .build(); + ociRepositoryUploader = new OCIRepositoryUploader(); + File chartMetadataFile = new File(tempDir, "Chart.yaml"); + chartFile = new File(tempDir, "test-chart-0.0.1.tar.gz"); + assertThat(chartFile.createNewFile()).isTrue(); + assertThat(chartMetadataFile.createNewFile()).isTrue(); + FileUtils.write(chartMetadataFile, "---\napiVersion: v1\nname: test-chart\nversion: 0.0.1", Charset.defaultCharset()); + chartTarballContentDigest = SHAUtil.generateSHA256(chartFile); + } + + @Test + void uploadSingle_whenChartBlobsAlreadyUploaded_thenLogPushSkip() throws BadUploadException, IOException { + try (MockedConstruction ociMockedConstruction = mockConstruction(OCIRegistryClient.class, (mock, ctx) -> { + when(mock.getBaseUrl()).thenReturn("https://r.example.com"); + when(mock.isLayerUploadedAlready(anyString(), anyString())).thenReturn(true); + when(mock.uploadOCIManifest(anyString(), anyString(), anyString(), anyString(), anyLong(), anyLong())).thenReturn("sha256:uploadmanifestdigest"); + })) { + // When + ociRepositoryUploader.uploadSingle(logger, chartFile, helmRepository); + + // Then + assertThat(ociMockedConstruction.constructed()).hasSize(1); + verify(logger).info("Skipping push, BLOB already exists on target registry: %s", chartTarballContentDigest); + verify(logger).info("Pushed: %s/%s/%s:%s", "https://r.example.com", "myuser", "test-chart", "0.0.1"); + verify(logger).info("Digest: %s", "sha256:uploadmanifestdigest"); + } + } + + @Test + void uploadSingle_whenChartPushFailed_thenThrowException() { + try (MockedConstruction ignore = mockConstruction(OCIRegistryClient.class, (mock, ctx) -> { + when(mock.getBaseUrl()).thenReturn("https://r.example.com"); + when(mock.isLayerUploadedAlready(anyString(), anyString())).thenReturn(false); + when(mock.initiateUploadProcess(anyString())).thenReturn("https://r.example.com/v2/myuser/blobs/uploads/random-uuid?state=testing"); + when(mock.uploadBlob(anyString(), anyString(), anyLong(), anyString(), any())) + .thenThrow(new BadUploadException("invalid upload data")); + })) { + // When + assertThatExceptionOfType(BadUploadException.class) + .isThrownBy(() -> ociRepositoryUploader.uploadSingle(logger, chartFile, helmRepository)) + .withMessage("invalid upload data"); + } + } + + @Test + void uploadSingle_whenChartSuccessfullyPushedToRegistry_thenLogDockerContentManifest() throws BadUploadException, IOException { + try (MockedConstruction ociMockedConstruction = mockConstruction(OCIRegistryClient.class, (mock, ctx) -> { + when(mock.getBaseUrl()).thenReturn("https://r.example.com"); + when(mock.isLayerUploadedAlready(anyString(), anyString())).thenReturn(false); + when(mock.initiateUploadProcess(anyString())).thenReturn("https://r.example.com/v2/myuser/blobs/uploads/random-uuid?state=testing"); + when(mock.uploadBlob(anyString(), anyString(), anyLong(), isNull(), any())).thenReturn("sha256:charttarballdigest"); + when(mock.uploadBlob(anyString(), anyString(), anyLong(), anyString(), isNull())).thenReturn("sha256:chartconfigdigest"); + when(mock.uploadOCIManifest(anyString(), anyString(), anyString(), anyString(), anyLong(), anyLong())).thenReturn("sha256:uploadmanifestdigest"); + })) { + // When + ociRepositoryUploader.uploadSingle(logger, chartFile, helmRepository); + + // Then + assertThat(ociMockedConstruction.constructed()).hasSize(1); + verify(logger, times(1)).info("Pushed: %s/%s/%s:%s", "https://r.example.com", "myuser", "test-chart", "0.0.1"); + verify(logger).info("Digest: %s", "sha256:uploadmanifestdigest"); + } + } +} diff --git a/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/TestMockResponseProvider.java b/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/TestMockResponseProvider.java new file mode 100644 index 0000000000..ef6f0b26c0 --- /dev/null +++ b/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/TestMockResponseProvider.java @@ -0,0 +1,39 @@ +package org.eclipse.jkube.kit.resource.helm; + +import io.fabric8.mockwebserver.utils.ResponseProvider; +import okhttp3.Headers; +import okhttp3.mockwebserver.RecordedRequest; + +import java.util.Map; + +public class TestMockResponseProvider implements ResponseProvider { + private final int code; + private final Map headers; + private final Object body; + + public TestMockResponseProvider(int code, Map headers, Object body) { + this.code = code; + this.headers = headers; + this.body = body; + } + + @Override + public int getStatusCode(RecordedRequest recordedRequest) { + return code; + } + + @Override + public Headers getHeaders() { + return Headers.of(headers); + } + + @Override + public void setHeaders(Headers headers) { + + } + + @Override + public Object getBody(RecordedRequest recordedRequest) { + return body; + } +} diff --git a/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/oci/OCIManifestLayerTest.java b/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/oci/OCIManifestLayerTest.java new file mode 100644 index 0000000000..e5c7529bdd --- /dev/null +++ b/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/oci/OCIManifestLayerTest.java @@ -0,0 +1,68 @@ +/** + * 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.resource.helm.oci; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +class OCIManifestLayerTest { + @Test + void rawDeserialization() throws IOException { + // Given + final ObjectMapper mapper = new ObjectMapper(); + // When + final OCIManifestLayer layer = mapper.readValue( + "{\"size\":312,\"mediaType\": \"application/vnd.cncf.helm.config.v1+json\",\"digest\": \"sha256:0adb1aa9fee705698ee731ed26da27b0c2e15961d81d0616cfa38f134575f4e8\"}", + OCIManifestLayer.class); + // Then + assertOCIManifestLayer(layer); + } + + @Test + void builder() { + // Given + OCIManifestLayer.OCIManifestLayerBuilder layerBuilder = OCIManifestLayer.builder() + .size(312) + .mediaType("application/vnd.cncf.helm.config.v1+json") + .digest("sha256:0adb1aa9fee705698ee731ed26da27b0c2e15961d81d0616cfa38f134575f4e8"); + + // When + OCIManifestLayer layer = layerBuilder.build(); + + // Then + assertOCIManifestLayer(layer); + } + + private void assertOCIManifestLayer(OCIManifestLayer layer) { + assertThat(layer) + .hasFieldOrPropertyWithValue("size", 312L) + .hasFieldOrPropertyWithValue("mediaType", "application/vnd.cncf.helm.config.v1+json") + .hasFieldOrPropertyWithValue("digest", "sha256:0adb1aa9fee705698ee731ed26da27b0c2e15961d81d0616cfa38f134575f4e8"); + } + + @Test + void equalsAndHashCodeShouldMatch() { + // Given + OCIManifestLayer l1 = OCIManifestLayer.builder().digest("sha256:016b77128b6bdf63ce4000e38fc36dcb15dfd6feea2d244a2c797a2d4f75a2de").build(); + OCIManifestLayer l2 = OCIManifestLayer.builder().digest("sha256:016b77128b6bdf63ce4000e38fc36dcb15dfd6feea2d244a2c797a2d4f75a2de").build(); + // When + Then + assertThat(l1) + .isEqualTo(l2) + .hasSameHashCodeAs(l2); + } +} diff --git a/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/oci/OCIManifestTest.java b/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/oci/OCIManifestTest.java new file mode 100644 index 0000000000..d17c4194fb --- /dev/null +++ b/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/oci/OCIManifestTest.java @@ -0,0 +1,84 @@ +/** + * 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.resource.helm.oci; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +class OCIManifestTest { + @Test + void rawDeserialization() throws IOException { + // Given + final ObjectMapper mapper = new ObjectMapper(); + // When + final OCIManifest result = mapper.readValue( + getClass().getResourceAsStream("/test-oci-manifest.json"), + OCIManifest.class); + // Then + assertOCIManifest(result); + } + + @Test + void builder() { + // Given + OCIManifest.OCIManifestBuilder manifestBuilder = OCIManifest.builder() + .schemaVersion(2) + .config(OCIManifestLayer.builder() + .size(312) + .mediaType("application/vnd.cncf.helm.config.v1+json") + .digest("sha256:fe8b2f27ce12b302342d4a5da2b2945ab869c7acb9e1b718c5426d91ce38cfc4") + .build()) + .layer(OCIManifestLayer.builder() + .size(9272) + .mediaType("application/vnd.cncf.helm.chart.content.v1.tar+gzip") + .digest("sha256:fe8b2f27ce12b302342d4a5da2b2945ab869c7acb9e1b718c5426d91ce38cfc4") + .build()); + + // When + OCIManifest manifest = manifestBuilder.build(); + + // Then + assertOCIManifest(manifest); + } + + private void assertOCIManifest(OCIManifest manifest) { + assertThat(manifest) + .hasFieldOrPropertyWithValue("schemaVersion", 2) + .hasFieldOrPropertyWithValue("config.mediaType", "application/vnd.cncf.helm.config.v1+json") + .hasFieldOrPropertyWithValue("config.digest", "sha256:fe8b2f27ce12b302342d4a5da2b2945ab869c7acb9e1b718c5426d91ce38cfc4") + .hasFieldOrPropertyWithValue("config.size", 312L) + .extracting(OCIManifest::getLayers) + .asList() + .singleElement(InstanceOfAssertFactories.type(OCIManifestLayer.class)) + .hasFieldOrPropertyWithValue("size", 9272L) + .hasFieldOrPropertyWithValue("mediaType", "application/vnd.cncf.helm.chart.content.v1.tar+gzip") + .hasFieldOrPropertyWithValue("digest", "sha256:fe8b2f27ce12b302342d4a5da2b2945ab869c7acb9e1b718c5426d91ce38cfc4"); + } + + @Test + void equalsAndHashCodeShouldMatch() { + // Given + OCIManifest m1 = OCIManifest.builder().schemaVersion(2).build(); + OCIManifest m2 = OCIManifest.builder().schemaVersion(2).build(); + // When + Then + assertThat(m1) + .isEqualTo(m2) + .hasSameHashCodeAs(m2); + } +} diff --git a/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryClientTest.java b/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryClientTest.java new file mode 100644 index 0000000000..2c6e020f0a --- /dev/null +++ b/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryClientTest.java @@ -0,0 +1,279 @@ +/** + * 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.resource.helm.oci; + +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jkube.kit.resource.helm.BadUploadException; +import org.eclipse.jkube.kit.resource.helm.HelmRepository; +import org.eclipse.jkube.kit.resource.helm.TestMockResponseProvider; + +import io.fabric8.kubernetes.client.http.HttpClient; +import io.fabric8.kubernetes.client.utils.HttpClientUtils; +import io.fabric8.mockwebserver.DefaultMockServer; +import org.apache.http.HttpHeaders; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static java.net.HttpURLConnection.HTTP_ACCEPTED; +import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; +import static java.net.HttpURLConnection.HTTP_CREATED; +import static java.net.HttpURLConnection.HTTP_NOT_FOUND; +import static java.net.HttpURLConnection.HTTP_OK; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +class OCIRegistryClientTest { + private OCIRegistryClient oci; + private String chartName; + private String chartVersion; + private String chartTarballBlobDigest; + private String chartConfigBlobDigest; + private long chartConfigPayloadSizeInBytes; + private File chartFile; + private HttpClient httpClient; + private DefaultMockServer server; + @TempDir + File temporaryFolder; + + @BeforeEach + void setUp() throws IOException { + chartName = "test-chart"; + chartVersion = "0.0.1"; + chartConfigBlobDigest = "f2ab3e153f678e5f01062717a203f4ca47a556159bcbb1e8a3ec5d84b5dd7aef"; + chartTarballBlobDigest = "98c4987b6502c7eb8e29a8844e0e1f1d19a8925594f8271ae70f9a51412e737a"; + chartConfigPayloadSizeInBytes = 10; + chartFile = new File(temporaryFolder, "test-chart-0.0.1.tar.gz"); + Files.write(chartFile.toPath(), "helm-chart-content".getBytes(StandardCharsets.UTF_8)); + server = new DefaultMockServer(); + server.start(); + httpClient = HttpClientUtils.getHttpClientFactory().newBuilder().build(); + HelmRepository helmRepository = HelmRepository.builder() + .url(String.format("%s/myuser", getServerUrl())) + .build(); + oci = new OCIRegistryClient(helmRepository, httpClient); + } + + @AfterEach + void tearDown() { + server.shutdown(); + httpClient.close(); + } + + private String getServerUrl() { + return String.format("http://%s:%d", server.getHostName(), server.getPort()); + } + + @Test + void getBaseUrl_whenInvoked_shouldReturnRegistryUrl() throws MalformedURLException { + assertThat(oci.getBaseUrl()).isEqualTo(getServerUrl()); + } + + @Test + void initiateUploadProcess_whenRegistryResponseSuccessfulAndContainsLocation_thenReturnUploadUrl() throws IOException { + // Given + Map uploadResponseHeaders = new HashMap<>(); + String responseLocationHeader = getServerUrl() + "/v2/myuser/test-chart/blobs/uploads/17f1053c-fcd7-47a7-a34b-bbf23bbdf906?_state=uploadstate"; + uploadResponseHeaders.put(HttpHeaders.LOCATION, responseLocationHeader); + server.expect().post() + .withPath("/v2/myuser/test-chart/blobs/uploads/") + .andReply(new TestMockResponseProvider(HTTP_ACCEPTED, uploadResponseHeaders, null)) + .once(); + + // When + String uploadUrl = oci.initiateUploadProcess(chartName); + + // Then + assertThat(uploadUrl).isEqualTo(responseLocationHeader); + } + + @Test + void initiateUploadProcess_whenRegistryResponseSuccessfulButLocationHeaderContainsPathOnly_thenReturnUploadUrl() throws IOException { + // Given + Map uploadResponseHeaders = new HashMap<>(); + String responseLocationHeader = "/v2/myuser/test-chart/blobs/uploads/17f1053c-fcd7-47a7-a34b-bbf23bbdf906?_state=uploadstate"; + uploadResponseHeaders.put(HttpHeaders.LOCATION, responseLocationHeader); + server.expect().post() + .withPath("/v2/myuser/test-chart/blobs/uploads/") + .andReply(new TestMockResponseProvider(HTTP_ACCEPTED, uploadResponseHeaders, null)) + .once(); + + // When + String uploadUrl = oci.initiateUploadProcess(chartName); + + // Then + assertThat(uploadUrl).isEqualTo(String.format("http://%s:%d%s", server.getHostName(), server.getPort(), responseLocationHeader)); + } + + @Test + void initiateUploadProcess_whenRegistryResponseSuccessfulButNoHeader_thenThrowException() { + // Given + server.expect().post() + .withPath("/v2/myuser/test-chart/blobs/uploads/") + .andReturn(HTTP_ACCEPTED, "") + .once(); + + // When + Then + assertThatIllegalStateException() + .isThrownBy(() -> oci.initiateUploadProcess(chartName)) + .withMessage("No Location header found in upload initiation response"); + } + + @Test + void initiateUploadProcess_whenRegistryResponseFailure_thenThrowException() { + // Given + server.expect().post() + .withPath("/v2/myuser/test-chart/blobs/uploads/") + .andReturn(HTTP_NOT_FOUND, "") + .once(); + + // When + Then + assertThatIllegalStateException() + .isThrownBy(() -> oci.initiateUploadProcess(chartName)) + .withMessage("Failure in initiating upload request: Not Found"); + } + + @Test + void uploadOCIManifest_whenManifestSuccessfullyPushed_thenReturnDockerContentDigest() throws BadUploadException, IOException { + // Given + String responseDockerContentDigestHeader = "sha256:createdmanifestdigest"; + server.expect().put() + .withPath("/v2/myuser/test-chart/manifests/0.0.1") + .andReply(new TestMockResponseProvider(HTTP_CREATED, Collections.singletonMap("Docker-Content-Digest", responseDockerContentDigestHeader), null)) + .once(); + + // When + String dockerContentDigest = oci.uploadOCIManifest(chartName, chartVersion, chartConfigBlobDigest, chartTarballBlobDigest, chartConfigPayloadSizeInBytes, chartFile.length()); + + // Then + assertThat(dockerContentDigest).isEqualTo(responseDockerContentDigestHeader); + } + + @Test + void uploadOCIManifest_whenRegistryRejectedManifest_thenThrowException() { + // Given + server.expect().put() + .withPath("/v2/myuser/test-chart/manifests/0.0.1") + .andReply(new TestMockResponseProvider(HTTP_BAD_REQUEST, Collections.emptyMap(), "invalid manifest")) + .once(); + + // When + Then + assertThatExceptionOfType(BadUploadException.class) + .isThrownBy(() -> oci.uploadOCIManifest(chartName, chartVersion, chartConfigBlobDigest, chartTarballBlobDigest, chartConfigPayloadSizeInBytes, chartFile.length())) + .withMessage("invalid manifest"); + } + + @Test + void uploadOCIManifest_whenManifestSuccessfullyPushedButNoDockerContentDigest_thenThrowException() { + // Given + server.expect().put() + .withPath("/v2/myuser/test-chart/manifests/0.0.1") + .andReply(new TestMockResponseProvider(HTTP_CREATED, Collections.emptyMap(), null)) + .once(); + + // When + assertThatIllegalStateException() + .isThrownBy(() -> oci.uploadOCIManifest(chartName, chartVersion, chartConfigBlobDigest, chartTarballBlobDigest, chartConfigPayloadSizeInBytes, chartFile.length())) + .withMessage("No Docker-Content-Digest header found in upload response"); + } + + @Test + void uploadBlob_whenBlobSuccessfullyPushedToRegistry_thenReturnDockerContentDigest() throws BadUploadException, IOException { + // Given + String blobUploadUrl = getServerUrl() + "/v2/myuser/test-chart/blobs/uploads/17f1053c-fcd7-47a7-a34b-bbf23bbdf906?_state=XZnxHKS"; + Map responseHeaders = new HashMap<>(); + responseHeaders.put("Docker-Content-Digest", "sha256:016b77128b6bdf63ce4000e38fc36dcb15dfd6feea2d244a2c797a2d4f75a2de"); + server.expect().put() + .withPath("/v2/myuser/test-chart/blobs/uploads/17f1053c-fcd7-47a7-a34b-bbf23bbdf906?_state=XZnxHKS&digest=sha256%3A98c4987b6502c7eb8e29a8844e0e1f1d19a8925594f8271ae70f9a51412e737a") + .andReply(new TestMockResponseProvider(HTTP_CREATED, responseHeaders, null)) + .once(); + + // When + String dockerContentDigest = oci.uploadBlob(blobUploadUrl, chartTarballBlobDigest, chartFile.length(), null, chartFile); + + // Then + assertThat(dockerContentDigest) + .isEqualTo("sha256:016b77128b6bdf63ce4000e38fc36dcb15dfd6feea2d244a2c797a2d4f75a2de"); + } + + @Test + void uploadBlob_whenBlobRejectedByRegistry_thenThrowException() { + // Given + String blobUploadUrl = getServerUrl() + "/v2/myuser/test-chart/blobs/uploads/17f1053c-fcd7-47a7-a34b-bbf23bbdf906?_state=XZnxHKS"; + server.expect().put() + .withPath("/v2/myuser/test-chart/blobs/uploads/17f1053c-fcd7-47a7-a34b-bbf23bbdf906?_state=XZnxHKS&digest=sha256%3A98c4987b6502c7eb8e29a8844e0e1f1d19a8925594f8271ae70f9a51412e737a") + .andReply(new TestMockResponseProvider(HTTP_BAD_REQUEST, Collections.emptyMap(), "invalid data")) + .once(); + + // When + Then + assertThatExceptionOfType(BadUploadException.class) + .isThrownBy(() -> oci.uploadBlob(blobUploadUrl, chartTarballBlobDigest, chartFile.length(), null, chartFile)) + .withMessage("invalid data"); + } + + @Test + void uploadBlob_whenBlobSuccessfullyPushedToRegistryButNoDockerContentDigest_thenThrowException() throws BadUploadException, IOException { + // Given + String blobUploadUrl = getServerUrl() + "/v2/myuser/test-chart/blobs/uploads/17f1053c-fcd7-47a7-a34b-bbf23bbdf906?_state=XZnxHKS"; + server.expect().put() + .withPath("/v2/myuser/test-chart/blobs/uploads/17f1053c-fcd7-47a7-a34b-bbf23bbdf906?_state=XZnxHKS&digest=sha256%3A98c4987b6502c7eb8e29a8844e0e1f1d19a8925594f8271ae70f9a51412e737a") + .andReply(new TestMockResponseProvider(HTTP_CREATED, Collections.emptyMap(), null)) + .once(); + + // When + Then + assertThatIllegalStateException() + .isThrownBy(() -> oci.uploadBlob(blobUploadUrl, chartTarballBlobDigest, chartFile.length(), null, chartFile)) + .withMessage("No Docker-Content-Digest header found in upload response"); + } + + @Test + void isLayerUploadedAlready_whenRegistryReturns200_thenReturnTrue() throws IOException { + // Given + server.expect().get() + .withPath("/v2/myuser/test-chart/blobs/sha256:" + chartConfigBlobDigest) + .andReply(new TestMockResponseProvider(HTTP_OK, Collections.emptyMap(), null)) + .once(); + + // When + boolean result = oci.isLayerUploadedAlready(chartName, chartConfigBlobDigest); + + // Then + assertThat(result).isTrue(); + } + + @Test + void isLayerUploadedAlready_whenRegistryReturns404_thenReturnFalse() throws IOException { + // Given + server.expect().get() + .withPath("/v2/myuser/test-chart/blobs/sha256:" + chartConfigBlobDigest) + .andReply(new TestMockResponseProvider(HTTP_NOT_FOUND, Collections.emptyMap(), null)) + .once(); + + // When + boolean result = oci.isLayerUploadedAlready(chartName, chartConfigBlobDigest); + + // Then + assertThat(result).isFalse(); + } +} diff --git a/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryEndpointTest.java b/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryEndpointTest.java new file mode 100644 index 0000000000..4b55f1f55a --- /dev/null +++ b/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryEndpointTest.java @@ -0,0 +1,68 @@ +/** + * 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.resource.helm.oci; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.MalformedURLException; + +import static org.assertj.core.api.Assertions.assertThat; + +class OCIRegistryEndpointTest { + private OCIRegistryEndpoint registryEndpoint; + + @BeforeEach + void setUp() { + this.registryEndpoint = new OCIRegistryEndpoint("https://r.example.com/myuser"); + } + + @Test + void getBaseUrl_whenInvoked_shouldReturnBaseUrl() throws MalformedURLException { + assertThat(registryEndpoint.getBaseUrl()).isEqualTo("https://r.example.com"); + } + + @Test + void getBaseUrl_whenInvokedWithRegistrySpecifyingPort_thenShouldReturnBaseUrlAsExpected() throws MalformedURLException { + // Given + this.registryEndpoint = new OCIRegistryEndpoint("http://localhost:5000/myuser"); + + // When + String url = registryEndpoint.getBaseUrl(); + + // Then + assertThat(url).isEqualTo("http://localhost:5000"); + } + + @Test + void getV2ApiUrl_whenInvoked_shouldReturnV2Url() throws MalformedURLException { + assertThat(registryEndpoint.getV2ApiUrl()).isEqualTo("https://r.example.com/v2/myuser"); + } + + @Test + void getBlobUploadInitUrl_whenInvoked_shouldReturnBlobUploadInitUrl() throws MalformedURLException { + assertThat(registryEndpoint.getBlobUploadInitUrl("test-chart")).isEqualTo("https://r.example.com/v2/myuser/test-chart/blobs/uploads/"); + } + + @Test + void getManifestUrl_whenInvoked_shouldReturnManifestUrl() throws MalformedURLException { + assertThat(registryEndpoint.getManifestUrl("test-chart", "0.0.1")).isEqualTo("https://r.example.com/v2/myuser/test-chart/manifests/0.0.1"); + } + + @Test + void getBlobUrl_whenInvoked_shouldReturnBlobUrl() throws MalformedURLException { + assertThat(registryEndpoint.getBlobUrl("test-chart", "7ed393daf1ffc94803c08ffcbecb798fa58e786bebffbab02da5458f68d0ecb0")) + .isEqualTo("https://r.example.com/v2/myuser/test-chart/blobs/sha256:7ed393daf1ffc94803c08ffcbecb798fa58e786bebffbab02da5458f68d0ecb0"); + } +} diff --git a/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryInterceptorTest.java b/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryInterceptorTest.java new file mode 100644 index 0000000000..72c63534af --- /dev/null +++ b/jkube-kit/resource/helm/src/test/java/org/eclipse/jkube/kit/resource/helm/oci/OCIRegistryInterceptorTest.java @@ -0,0 +1,185 @@ +/** + * 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.resource.helm.oci; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.eclipse.jkube.kit.resource.helm.HelmRepository; + +import io.fabric8.kubernetes.client.http.HttpRequest; +import io.fabric8.kubernetes.client.http.HttpResponse; +import io.fabric8.kubernetes.client.http.TestHttpResponse; +import io.fabric8.kubernetes.client.utils.HttpClientUtils; +import io.fabric8.mockwebserver.DefaultMockServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.apache.http.HttpHeaders; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import static java.net.HttpURLConnection.HTTP_BAD_METHOD; +import static java.net.HttpURLConnection.HTTP_NOT_FOUND; +import static java.net.HttpURLConnection.HTTP_OK; +import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +class OCIRegistryInterceptorTest { + private OCIRegistryInterceptor ociRegistryInterceptor; + private DefaultMockServer server; + private String authUrl; + + @BeforeEach + void setUp() { + server = new DefaultMockServer(); + server.start(); + ociRegistryInterceptor = new OCIRegistryInterceptor(HttpClientUtils.getHttpClientFactory(), + HelmRepository.builder() + .username("myuser") + .password("secret") + .build()); + authUrl = String.format("http://%s:%d/token", server.getHostName(), server.getPort()); + } + + @AfterEach + void tearDown() { + server.shutdown(); + } + + @Test + void afterFailure_whenResponseCodeNot401_thenReturnFalse() { + // Given + HttpRequest.Builder builder = mock(HttpRequest.Builder.class, Mockito.RETURNS_SELF); + HttpResponse response = new TestHttpResponse().withCode(HTTP_NOT_FOUND); + + // When + Then + assertThat(ociRegistryInterceptor.afterFailure(builder, response, null)).isCompletedWithValue(false); + } + + @Test + void afterFailure_whenResponseHasNoWwwHeader_thenThrowException() { + // Given + HttpRequest.Builder builder = mock(HttpRequest.Builder.class, Mockito.RETURNS_SELF); + HttpResponse response = new TestHttpResponse().withCode(HTTP_UNAUTHORIZED); + + // When + assertThatIllegalStateException() + .isThrownBy(() -> ociRegistryInterceptor.afterFailure(builder, response, null)) + .withMessage("Got 401 but no WWW-Authenticate found in response headers "); + } + + @Test + void afterFailure_whenAuthCallFails_thenReturnFalse() { + server.expect().get() + .withPath("/token?service=localhost&scope=repository:myuser/test-chart:pull,push") + .andReturn(HTTP_UNAUTHORIZED, "{\"message\":\"unauthorized\"}") + .once(); + String service = "localhost"; + String wwwHeader = createWwwHeader(authUrl, service); + Map> unAuthorizedResponseHeaders = Collections.singletonMap(HttpHeaders.WWW_AUTHENTICATE, Collections.singletonList(wwwHeader)); + HttpRequest.Builder builder = mock(HttpRequest.Builder.class, Mockito.RETURNS_SELF); + HttpResponse response = new TestHttpResponse(unAuthorizedResponseHeaders).withCode(HTTP_UNAUTHORIZED); + + // When + CompletableFuture result = ociRegistryInterceptor.afterFailure(builder, response, null); + + // Then + assertThat(result).isCompletedWithValue(false); + verify(builder, times(0)).setHeader(anyString(), anyString()); + } + + @Test + void afterFailure_whenUnauthenticated_thenShouldAuthenticateWithGetAndFetchAccessToken() throws IOException { + server.expect().get() + .withPath("/token?service=localhost&scope=repository:myuser/test-chart:pull,push") + .andReturn(HTTP_OK, "{\"token\":\"mytoken\"}") + .once(); + String service = "localhost"; + String wwwHeader = createWwwHeader(authUrl, service); + Map> unAuthorizedResponseHeaders = Collections.singletonMap(HttpHeaders.WWW_AUTHENTICATE, Collections.singletonList(wwwHeader)); + HttpRequest.Builder builder = mock(HttpRequest.Builder.class, Mockito.RETURNS_SELF); + HttpResponse response = new TestHttpResponse(unAuthorizedResponseHeaders).withCode(HTTP_UNAUTHORIZED); + + // When + CompletableFuture result = ociRegistryInterceptor.afterFailure(builder, response, null); + + // Then + assertThat(result).isCompletedWithValue(true); + verify(builder).setHeader("Authorization", "Bearer mytoken"); + } + + @Test + void afterFailure_whenGetNotAllowedAndPostAlsoFails_thenReturnFalse() { + server.expect().get() + .withPath("/token?service=localhost&scope=repository:myuser/test-chart:pull,push") + .andReturn(HTTP_BAD_METHOD, "") + .once(); + server.expect().post() + .withPath("/token") + .andReturn(HTTP_OK, "{\"message\":\"unauthorized\"}") + .once(); + String service = "localhost"; + String wwwHeader = createWwwHeader(authUrl, service); + Map> unAuthorizedResponseHeaders = Collections.singletonMap(HttpHeaders.WWW_AUTHENTICATE, Collections.singletonList(wwwHeader)); + HttpRequest.Builder builder = mock(HttpRequest.Builder.class, Mockito.RETURNS_SELF); + HttpResponse response = new TestHttpResponse(unAuthorizedResponseHeaders).withCode(HTTP_UNAUTHORIZED); + + // When + CompletableFuture result = ociRegistryInterceptor.afterFailure(builder, response, null); + + // Then + assertThat(result).isCompletedWithValue(false); + verify(builder, times(0)).setHeader(anyString(), anyString()); + } + + @Test + void afterFailure_whenGetNotAllowed_thenShouldAuthenticateWithPostAndFetchAccessToken() throws InterruptedException { + server.expect().get() + .withPath("/token?service=localhost&scope=repository:myuser/test-chart:pull,push") + .andReturn(HTTP_BAD_METHOD, "") + .once(); + server.expect().post() + .withPath("/token") + .andReturn(HTTP_OK, "{\"token\":\"mytoken\"}") + .once(); + String service = "localhost"; + String wwwHeader = createWwwHeader(authUrl, service); + Map> unAuthorizedResponseHeaders = Collections.singletonMap(HttpHeaders.WWW_AUTHENTICATE, Collections.singletonList(wwwHeader)); + HttpRequest.Builder builder = mock(HttpRequest.Builder.class, Mockito.RETURNS_SELF); + HttpResponse response = new TestHttpResponse(unAuthorizedResponseHeaders).withCode(HTTP_UNAUTHORIZED); + + // When + CompletableFuture result = ociRegistryInterceptor.afterFailure(builder, response, null); + + // Then + assertThat(result).isCompletedWithValue(true); + verify(builder).setHeader("Authorization", "Bearer mytoken"); + RecordedRequest request = server.getLastRequest(); + assertThat(request.getBody().readUtf8()) + .isEqualTo("refresh_token=secret&password=secret&grant_type=password&service=localhost&scope=repository%3Amyuser%2Ftest-chart%3Apull%2Cpush&client_id=EclipseJKube&username=myuser"); + } + + private String createWwwHeader(String authUrl, String service) { + return String.format("Bearer realm=\"%s\",service=\"%s\",scope=\"repository:%s/%s:pull\"", authUrl, service, "myuser", "test-chart"); + } +} diff --git a/jkube-kit/resource/helm/src/test/resources/test-oci-manifest.json b/jkube-kit/resource/helm/src/test/resources/test-oci-manifest.json new file mode 100644 index 0000000000..ffb2ba29f8 --- /dev/null +++ b/jkube-kit/resource/helm/src/test/resources/test-oci-manifest.json @@ -0,0 +1,13 @@ +{ + "schemaVersion": 2, + "config": { + "mediaType": "application/vnd.cncf.helm.config.v1+json", + "digest": "sha256:fe8b2f27ce12b302342d4a5da2b2945ab869c7acb9e1b718c5426d91ce38cfc4", + "size": 312 + }, + "layers": [{ + "mediaType": "application/vnd.cncf.helm.chart.content.v1.tar+gzip", + "digest": "sha256:fe8b2f27ce12b302342d4a5da2b2945ab869c7acb9e1b718c5426d91ce38cfc4", + "size": 9272 + }] +} \ No newline at end of file