From 67983d2044b3e193a955cc385dc6d09fecaad5af Mon Sep 17 00:00:00 2001 From: spinnakerbot Date: Mon, 3 Apr 2023 11:07:38 -0400 Subject: [PATCH 01/29] chore(dependencies): Autobump korkVersion (#975) Co-authored-by: root --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 84c034302..2037982da 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -korkVersion=7.170.0 +korkVersion=7.171.0 org.gradle.parallel=true spinnakerGradleVersion=8.26.0 targetJava11=true From 41545f49342230c5b71da68bd11498f238ec9059 Mon Sep 17 00:00:00 2001 From: spinnakerbot Date: Mon, 3 Apr 2023 12:26:45 -0400 Subject: [PATCH 02/29] chore(dependencies): Autobump korkVersion (#976) Co-authored-by: root --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 2037982da..d475944a1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -korkVersion=7.171.0 +korkVersion=7.171.1 org.gradle.parallel=true spinnakerGradleVersion=8.26.0 targetJava11=true From 193aada4d12eff5982e89f9ef75d376f2aa0ad69 Mon Sep 17 00:00:00 2001 From: spinnakerbot Date: Wed, 5 Apr 2023 07:05:05 -0400 Subject: [PATCH 03/29] chore(dependencies): Autobump korkVersion (#977) Co-authored-by: root --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index d475944a1..ce9fcfbe7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -korkVersion=7.171.1 +korkVersion=7.171.2 org.gradle.parallel=true spinnakerGradleVersion=8.26.0 targetJava11=true From 132e7093dce3359a1bef12e03dcca4ced9c2df02 Mon Sep 17 00:00:00 2001 From: Luthan95 <66585454+Luthan95@users.noreply.github.com> Date: Thu, 6 Apr 2023 23:12:38 +0530 Subject: [PATCH 04/29] refactor(retrofit2): Use retrofit2.x client instead of retrofit1.9 for artifact fetch from clouddriver (#953) * refactor(clouddriver): Use retrofit 2.x client to fetch artifact from clouddriver instead of retrofit 1.9 client * refactor(ServiceConfig): remove Primary annotation for OkhttpClient bean * refactor(KustomizationFileReaderSpec): modify test case to resolve test case failure due to retrofit2 changes * refactor(ServiceConfig): modify javadoc meaningfully * feat(retrofit): fix testcases failure due to retrofit2 API changes * feat(retrofit): resolve comments and rabase with master which has fix for test cases not execute * feat(retrofit): make use of okhttpClient from kork service * feat(retrofit): make use of okhttpClient from kork service --- rosco-core/rosco-core.gradle | 2 + .../services/ClouddriverRetrofit2Service.java | 28 ++++++++ .../rosco/services/ServiceConfig.java | 32 +++++++-- rosco-manifests/rosco-manifests.gradle | 2 + .../manifests/ArtifactDownloaderImpl.java | 21 +++--- .../kustomize/KustomizationFileReader.java | 19 +++-- .../KustomizationFileReaderSpec.groovy | 31 +++++--- .../manifests/ArtifactDownloaderImplTest.java | 42 +++++------ .../rosco/manifests/ManifestTestUtils.java | 32 +++++---- .../org.mockito.plugins.MockMaker | 1 + rosco-web/rosco-web.gradle | 3 + .../controllers/V2BakeryControllerTest.java | 34 ++++----- ...yControllerWithClouddriverServiceTest.java | 70 +++++++++++-------- 13 files changed, 207 insertions(+), 110 deletions(-) create mode 100644 rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/services/ClouddriverRetrofit2Service.java create mode 100644 rosco-manifests/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/rosco-core/rosco-core.gradle b/rosco-core/rosco-core.gradle index d36d4b7ee..e916703cb 100644 --- a/rosco-core/rosco-core.gradle +++ b/rosco-core/rosco-core.gradle @@ -13,6 +13,8 @@ dependencies { implementation "io.spinnaker.kork:kork-web" implementation "com.squareup.retrofit:converter-jackson" implementation "com.squareup.retrofit:retrofit" + implementation "com.squareup.retrofit2:converter-jackson" + implementation "com.squareup.retrofit2:retrofit" implementation "io.reactivex:rxjava" implementation "org.apache.commons:commons-exec" implementation "org.codehaus.groovy:groovy" diff --git a/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/services/ClouddriverRetrofit2Service.java b/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/services/ClouddriverRetrofit2Service.java new file mode 100644 index 000000000..fa93dc18f --- /dev/null +++ b/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/services/ClouddriverRetrofit2Service.java @@ -0,0 +1,28 @@ +/* + * Copyright 2023 OpsMx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.rosco.services; + +import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import okhttp3.ResponseBody; +import retrofit2.Call; +import retrofit2.http.Body; +import retrofit2.http.PUT; + +public interface ClouddriverRetrofit2Service { + @PUT("/artifacts/fetch/") + Call fetchArtifact(@Body Artifact artifact); +} diff --git a/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/services/ServiceConfig.java b/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/services/ServiceConfig.java index 503926032..2a29d2ecb 100644 --- a/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/services/ServiceConfig.java +++ b/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/services/ServiceConfig.java @@ -21,6 +21,7 @@ import com.jakewharton.retrofit.Ok3Client; import com.netflix.spinnaker.config.OkHttp3ClientConfiguration; import com.netflix.spinnaker.kork.core.RetrySupport; +import com.netflix.spinnaker.kork.retrofit.ErrorHandlingExecutorCallAdapterFactory; import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerRetrofitErrorHandler; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -28,6 +29,8 @@ import retrofit.RequestInterceptor; import retrofit.RestAdapter; import retrofit.converter.JacksonConverter; +import retrofit2.Retrofit; +import retrofit2.converter.jackson.JacksonConverterFactory; @Configuration public class ServiceConfig { @@ -51,19 +54,38 @@ RetrySupport retrySupport() { @Bean ClouddriverService clouddriverService( Ok3Client ok3Client, RequestInterceptor spinnakerRequestInterceptor) { - ObjectMapper objectMapper = - new ObjectMapper() - .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL) - .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); return new RestAdapter.Builder() .setEndpoint(clouddriverBaseUrl) .setRequestInterceptor(spinnakerRequestInterceptor) .setClient(ok3Client) - .setConverter(new JacksonConverter(objectMapper)) + .setConverter(new JacksonConverter(getObjectMapper())) .setLogLevel(RestAdapter.LogLevel.valueOf(retrofitLogLevel)) .setErrorHandler(SpinnakerRetrofitErrorHandler.getInstance()) .build() .create(ClouddriverService.class); } + + private ObjectMapper getObjectMapper() { + return new ObjectMapper() + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + } + + /*As part of retrofit2 changes, creating clouddriverservice with retrofit2 API changes */ + @Bean + ClouddriverRetrofit2Service clouddriverRetrofit2Service( + OkHttp3ClientConfiguration okHttpClientConfig) { + + /* + * ErrorHandlingExecutorCallAdapterFactory handles exceptions globally in retrofit2, similar to SpinnakerRetrofitErrorHandler with retrofit. + * */ + return new Retrofit.Builder() + .baseUrl(clouddriverBaseUrl) + .client(okHttpClientConfig.createForRetrofit2().build()) + .addCallAdapterFactory(ErrorHandlingExecutorCallAdapterFactory.getInstance()) + .addConverterFactory(JacksonConverterFactory.create(getObjectMapper())) + .build() + .create(ClouddriverRetrofit2Service.class); + } } diff --git a/rosco-manifests/rosco-manifests.gradle b/rosco-manifests/rosco-manifests.gradle index 22ad479ff..aa36dbfeb 100644 --- a/rosco-manifests/rosco-manifests.gradle +++ b/rosco-manifests/rosco-manifests.gradle @@ -14,6 +14,7 @@ dependencies { implementation "org.yaml:snakeyaml" implementation "com.squareup.retrofit:retrofit" + implementation "com.squareup.retrofit2:retrofit" testImplementation "org.assertj:assertj-core" testImplementation "org.junit.jupiter:junit-jupiter-api" testImplementation "org.junit.jupiter:junit-jupiter-params" @@ -21,4 +22,5 @@ dependencies { testImplementation "org.mockito:mockito-core" testImplementation "org.spockframework:spock-core" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" + testImplementation "com.squareup.retrofit2:converter-jackson" } diff --git a/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImpl.java b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImpl.java index 133243be8..fbb8d55db 100644 --- a/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImpl.java +++ b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImpl.java @@ -3,35 +3,40 @@ import com.netflix.spinnaker.kork.artifacts.model.Artifact; import com.netflix.spinnaker.kork.core.RetrySupport; import com.netflix.spinnaker.kork.exceptions.SpinnakerException; +import com.netflix.spinnaker.kork.retrofit.Retrofit2SyncCall; import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerHttpException; -import com.netflix.spinnaker.rosco.services.ClouddriverService; +import com.netflix.spinnaker.rosco.services.ClouddriverRetrofit2Service; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; import lombok.extern.slf4j.Slf4j; +import okhttp3.ResponseBody; import org.apache.commons.io.IOUtils; import org.springframework.stereotype.Component; -import retrofit.client.Response; @Component @Slf4j public final class ArtifactDownloaderImpl implements ArtifactDownloader { - private final ClouddriverService clouddriverService; + private final ClouddriverRetrofit2Service clouddriverService; private final RetrySupport retrySupport = new RetrySupport(); - public ArtifactDownloaderImpl(ClouddriverService clouddriverService) { + public ArtifactDownloaderImpl(ClouddriverRetrofit2Service clouddriverService) { this.clouddriverService = clouddriverService; } public InputStream downloadArtifact(Artifact artifact) throws IOException { - Response response = - retrySupport.retry(() -> clouddriverService.fetchArtifact(artifact), 5, 1000, true); - if (response.getBody() == null) { + ResponseBody response = + retrySupport.retry( + () -> Retrofit2SyncCall.execute(clouddriverService.fetchArtifact(artifact)), + 5, + 1000, + true); + if (response == null) { throw new IOException("Failure to fetch artifact: empty response"); } - return response.getBody().in(); + return response.byteStream(); } public void downloadArtifactToFile(Artifact artifact, Path targetFile) throws IOException { diff --git a/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReader.java b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReader.java index b0a1dbd42..af2acfd9b 100644 --- a/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReader.java +++ b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReader.java @@ -19,8 +19,9 @@ import com.google.common.collect.ImmutableList; import com.netflix.spinnaker.kork.artifacts.model.Artifact; import com.netflix.spinnaker.kork.core.RetrySupport; +import com.netflix.spinnaker.kork.retrofit.Retrofit2SyncCall; import com.netflix.spinnaker.rosco.manifests.kustomize.mapping.Kustomization; -import com.netflix.spinnaker.rosco.services.ClouddriverService; +import com.netflix.spinnaker.rosco.services.ClouddriverRetrofit2Service; import java.io.IOException; import java.io.InputStream; import java.net.URI; @@ -29,21 +30,21 @@ import java.util.*; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; +import okhttp3.ResponseBody; import org.springframework.stereotype.Component; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.Constructor; import org.yaml.snakeyaml.representer.Representer; -import retrofit.client.Response; @Component @Slf4j public class KustomizationFileReader { - private final ClouddriverService clouddriverService; + private final ClouddriverRetrofit2Service clouddriverService; private final RetrySupport retrySupport = new RetrySupport(); private static final List KUSTOMIZATION_FILENAMES = ImmutableList.of("kustomization.yaml", "kustomization.yml", "kustomization"); - public KustomizationFileReader(ClouddriverService clouddriverService) { + public KustomizationFileReader(ClouddriverRetrofit2Service clouddriverService) { this.clouddriverService = clouddriverService; } @@ -95,8 +96,12 @@ private Kustomization convert(Artifact artifact) throws IOException { private InputStream downloadFile(Artifact artifact) throws IOException { log.info("downloading kustomization file {}", artifact.getReference()); - Response response = - retrySupport.retry(() -> clouddriverService.fetchArtifact(artifact), 5, 1000, true); - return response.getBody().in(); + ResponseBody response = + retrySupport.retry( + () -> Retrofit2SyncCall.execute(clouddriverService.fetchArtifact(artifact)), + 5, + 1000, + true); + return response.byteStream(); } } diff --git a/rosco-manifests/src/test/groovy/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReaderSpec.groovy b/rosco-manifests/src/test/groovy/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReaderSpec.groovy index 0292721c0..4d84f92a1 100644 --- a/rosco-manifests/src/test/groovy/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReaderSpec.groovy +++ b/rosco-manifests/src/test/groovy/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReaderSpec.groovy @@ -18,9 +18,11 @@ package com.netflix.spinnaker.rosco.manifests.kustomize import com.netflix.spinnaker.kork.artifacts.model.Artifact import com.netflix.spinnaker.rosco.manifests.kustomize.mapping.Kustomization -import com.netflix.spinnaker.rosco.services.ClouddriverService -import retrofit.client.Response -import retrofit.mime.TypedString +import com.netflix.spinnaker.rosco.services.ClouddriverRetrofit2Service +import okhttp3.MediaType +import okhttp3.ResponseBody +import retrofit2.Call +import retrofit2.Response import spock.lang.Specification class KustomizationFileReaderSpec extends Specification { @@ -34,7 +36,16 @@ class KustomizationFileReaderSpec extends Specification { namePrefix: demo- """ - def clouddriverService = Mock(ClouddriverService) + def mockCall = Mock(Call.class) { + execute() >> { + return Response.success(200, ResponseBody.create(MediaType.parse("text/plain"), kustomizationYaml.stripMargin())); + } + } + def clouddriverService = Mock(ClouddriverRetrofit2Service) { + fetchArtifact(_) >> { + return mockCall + } + } def kustomizationFileReader = new KustomizationFileReader(clouddriverService) def baseArtifact = Artifact.builder() .name("base") @@ -47,18 +58,20 @@ class KustomizationFileReaderSpec extends Specification { Kustomization k = kustomizationFileReader.getKustomization(baseArtifact,"kustomization.yml") then: - 1 * clouddriverService.fetchArtifact(_ as Artifact) >> { Artifact a -> - return new Response("test", 200, "", [], new TypedString(kustomizationYaml.stripMargin())) - } k.getResources().sort() == ["deployment.yml", "service.yml"].sort() k.getSelfReference() == "https://api.github.com/repos/org/repo/contents/base/kustomization.yml" } def "getKustomization throws an exception if it can't find a valid kustomization file"() { given: - def clouddriverService = Mock(ClouddriverService) { + def mockCall = Mock(Call.class) { + execute() >> { + return Response.error(500, ResponseBody.create(null, "")) + } + } + def clouddriverService = Mock(ClouddriverRetrofit2Service) { fetchArtifact(_) >> { - return new Response("test", 500, "", [], null) + return mockCall } } def kustomizationFileReader = new KustomizationFileReader(clouddriverService) diff --git a/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImplTest.java b/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImplTest.java index caf389f0c..a1c1c0030 100644 --- a/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImplTest.java +++ b/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImplTest.java @@ -22,25 +22,27 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import com.google.common.collect.ImmutableList; import com.netflix.spinnaker.kork.artifacts.model.Artifact; import com.netflix.spinnaker.kork.exceptions.SpinnakerException; +import com.netflix.spinnaker.kork.retrofit.exceptions.RetrofitException; import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerHttpException; -import com.netflix.spinnaker.rosco.services.ClouddriverService; +import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerNetworkException; +import com.netflix.spinnaker.rosco.services.ClouddriverRetrofit2Service; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import okhttp3.ResponseBody; import org.junit.jupiter.api.Test; import org.junit.platform.runner.JUnitPlatform; import org.junit.runner.RunWith; -import org.springframework.http.HttpStatus; -import retrofit.RetrofitError; -import retrofit.client.Response; -import retrofit.mime.TypedByteArray; +import retrofit2.Call; +import retrofit2.Response; @RunWith(JUnitPlatform.class) final class ArtifactDownloaderImplTest { - private final ClouddriverService clouddriverService = mock(ClouddriverService.class); + private final ClouddriverRetrofit2Service clouddriverService = + mock(ClouddriverRetrofit2Service.class); + private final Call mockCall = mock(Call.class); private static final Artifact testArtifact = Artifact.builder().name("test-artifact").version("3").build(); @@ -50,8 +52,8 @@ public void downloadsArtifactContent() throws IOException { String testContent = "abcdefg"; try (ArtifactDownloaderImplTest.AutoDeletingFile file = new AutoDeletingFile()) { - when(clouddriverService.fetchArtifact(testArtifact)) - .thenReturn(successfulResponse(testContent)); + when(clouddriverService.fetchArtifact(testArtifact)).thenReturn(mockCall); + when(mockCall.execute()).thenReturn(successfulResponse(testContent)); artifactDownloader.downloadArtifactToFile(testArtifact, file.path); assertThat(file.path).hasContent(testContent); @@ -62,10 +64,12 @@ public void downloadsArtifactContent() throws IOException { public void retries() throws IOException { ArtifactDownloaderImpl artifactDownloader = new ArtifactDownloaderImpl(clouddriverService); String testContent = "abcdefg"; - try (ArtifactDownloaderImplTest.AutoDeletingFile file = new AutoDeletingFile()) { - when(clouddriverService.fetchArtifact(testArtifact)) - .thenThrow(RetrofitError.networkError("", new IOException("timeout"))) + when(clouddriverService.fetchArtifact(testArtifact)).thenReturn(mockCall); + when(mockCall.execute()) + .thenThrow( + new SpinnakerNetworkException( + RetrofitException.networkError(new IOException("timeout")))) .thenReturn(successfulResponse(testContent)); artifactDownloader.downloadArtifactToFile(testArtifact, file.path); @@ -78,8 +82,8 @@ public void exceptionDownloadingArtifactContent() throws IOException { ArtifactDownloaderImpl artifactDownloader = new ArtifactDownloaderImpl(clouddriverService); SpinnakerException spinnakerException = new SpinnakerException("error from clouddriver"); try (ArtifactDownloaderImplTest.AutoDeletingFile file = new AutoDeletingFile()) { - when(clouddriverService.fetchArtifact(testArtifact)).thenThrow(spinnakerException); - + when(clouddriverService.fetchArtifact(testArtifact)).thenReturn(mockCall); + when(mockCall.execute()).thenThrow(spinnakerException); SpinnakerException thrown = assertThrows( SpinnakerException.class, @@ -99,7 +103,8 @@ public void artifactNotFound() throws IOException { ArtifactDownloaderImpl artifactDownloader = new ArtifactDownloaderImpl(clouddriverService); SpinnakerHttpException spinnakerHttpException = makeSpinnakerHttpException(404); try (ArtifactDownloaderImplTest.AutoDeletingFile file = new AutoDeletingFile()) { - when(clouddriverService.fetchArtifact(testArtifact)).thenThrow(spinnakerHttpException); + when(clouddriverService.fetchArtifact(testArtifact)).thenReturn(mockCall); + when(mockCall.execute()).thenThrow(spinnakerHttpException); // Make sure that the exception from artifactDownloader is also a // SpinnakerHttpException with 404 status since that'll make rosco @@ -122,12 +127,7 @@ public void artifactNotFound() throws IOException { } private Response successfulResponse(String content) { - return new Response( - "", - HttpStatus.OK.value(), - "", - ImmutableList.of(), - new TypedByteArray(null, content.getBytes())); + return Response.success(200, ResponseBody.create(null, content.getBytes())); } private static class AutoDeletingFile implements AutoCloseable { diff --git a/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/ManifestTestUtils.java b/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/ManifestTestUtils.java index 7ffee330d..3946cc1f2 100644 --- a/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/ManifestTestUtils.java +++ b/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/ManifestTestUtils.java @@ -16,13 +16,12 @@ package com.netflix.spinnaker.rosco.manifests; -import com.google.gson.Gson; +import com.netflix.spinnaker.kork.retrofit.exceptions.RetrofitException; import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerHttpException; -import java.util.List; -import retrofit.RetrofitError; -import retrofit.client.Response; -import retrofit.converter.GsonConverter; -import retrofit.mime.TypedString; +import okhttp3.MediaType; +import okhttp3.ResponseBody; +import retrofit2.Retrofit; +import retrofit2.converter.jackson.JacksonConverterFactory; public class ManifestTestUtils { @@ -35,17 +34,20 @@ public static SpinnakerHttpException makeSpinnakerHttpException(int status) { // would be sufficient, except in the chained case, where the return value // of this method is the cause of a real SpinnakerHttpException object. // There, getResponseCode needs a real underlying response, at least real - // enough for response.getStatus() to work. So, go ahead and build one. + // enough for retrofit2Response.code() to work. So, go ahead and build one. String url = "https://some-url"; - Response response = - new Response( - url, + retrofit2.Response retrofit2Response = + retrofit2.Response.error( status, - "arbitrary reason", - List.of(), - new TypedString("{ message: \"arbitrary message\" }")); + ResponseBody.create( + MediaType.parse("application/json"), "{ \"message\": \"arbitrary message\" }")); - return new SpinnakerHttpException( - RetrofitError.httpError(url, response, new GsonConverter(new Gson()), String.class)); + Retrofit retrofit = + new Retrofit.Builder() + .baseUrl(url) + .addConverterFactory(JacksonConverterFactory.create()) + .build(); + + return new SpinnakerHttpException(RetrofitException.httpError(retrofit2Response, retrofit)); } } diff --git a/rosco-manifests/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/rosco-manifests/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 000000000..1f0955d45 --- /dev/null +++ b/rosco-manifests/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline diff --git a/rosco-web/rosco-web.gradle b/rosco-web/rosco-web.gradle index 9615f1b76..2a65753c1 100644 --- a/rosco-web/rosco-web.gradle +++ b/rosco-web/rosco-web.gradle @@ -26,6 +26,9 @@ dependencies { testImplementation "org.spockframework:spock-core" testImplementation "org.springframework.boot:spring-boot-starter-test" testImplementation "org.objenesis:objenesis" + testImplementation "com.squareup.retrofit2:retrofit" + testImplementation "com.squareup.retrofit2:converter-jackson" + testImplementation "com.github.tomakehurst:wiremock-jre8" } ospackage { diff --git a/rosco-web/src/test/java/com/netflix/spinnaker/rosco/controllers/V2BakeryControllerTest.java b/rosco-web/src/test/java/com/netflix/spinnaker/rosco/controllers/V2BakeryControllerTest.java index 7527c7870..5873b16aa 100644 --- a/rosco-web/src/test/java/com/netflix/spinnaker/rosco/controllers/V2BakeryControllerTest.java +++ b/rosco-web/src/test/java/com/netflix/spinnaker/rosco/controllers/V2BakeryControllerTest.java @@ -25,15 +25,15 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.ImmutableList; -import com.google.gson.Gson; import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import com.netflix.spinnaker.kork.retrofit.exceptions.RetrofitException; import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerHttpException; import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerServerException; import com.netflix.spinnaker.rosco.Main; import com.netflix.spinnaker.rosco.manifests.helm.HelmBakeManifestRequest; -import com.netflix.spinnaker.rosco.services.ClouddriverService; +import com.netflix.spinnaker.rosco.services.ClouddriverRetrofit2Service; import java.nio.charset.StandardCharsets; -import java.util.List; +import okhttp3.ResponseBody; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInfo; @@ -44,10 +44,8 @@ import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.context.WebApplicationContext; -import retrofit.RetrofitError; -import retrofit.client.Response; -import retrofit.converter.GsonConverter; -import retrofit.mime.TypedString; +import retrofit2.Retrofit; +import retrofit2.converter.jackson.JacksonConverterFactory; @SpringBootTest(classes = Main.class) @TestPropertySource(properties = "spring.application.name = rosco") @@ -57,7 +55,7 @@ class V2BakeryControllerTest { @Autowired private WebApplicationContext webApplicationContext; - @MockBean ClouddriverService clouddriverService; + @MockBean ClouddriverRetrofit2Service clouddriverService; @Autowired ObjectMapper objectMapper; @@ -116,15 +114,19 @@ private static SpinnakerHttpException makeSpinnakerHttpException(int status) { // There, getResponseCode needs a real underlying response, at least real // enough for response.getStatus() to work. So, go ahead and build one. String url = "https://some-url"; - Response response = - new Response( - url, + retrofit2.Response retrofit2Response = + retrofit2.Response.error( status, - "arbitrary reason", - List.of(), - new TypedString("{ message: \"arbitrary message\" }")); + ResponseBody.create( + okhttp3.MediaType.parse("application/json"), + "{ \"message\": \"arbitrary message\" }")); - return new SpinnakerHttpException( - RetrofitError.httpError(url, response, new GsonConverter(new Gson()), String.class)); + Retrofit retrofit = + new Retrofit.Builder() + .baseUrl(url) + .addConverterFactory(JacksonConverterFactory.create()) + .build(); + + return new SpinnakerHttpException(RetrofitException.httpError(retrofit2Response, retrofit)); } } diff --git a/rosco-web/src/test/java/com/netflix/spinnaker/rosco/controllers/V2BakeryControllerWithClouddriverServiceTest.java b/rosco-web/src/test/java/com/netflix/spinnaker/rosco/controllers/V2BakeryControllerWithClouddriverServiceTest.java index 76b9c35bf..ed3c0ff7b 100644 --- a/rosco-web/src/test/java/com/netflix/spinnaker/rosco/controllers/V2BakeryControllerWithClouddriverServiceTest.java +++ b/rosco-web/src/test/java/com/netflix/spinnaker/rosco/controllers/V2BakeryControllerWithClouddriverServiceTest.java @@ -15,10 +15,15 @@ */ package com.netflix.spinnaker.rosco.controllers; +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; +import static com.github.tomakehurst.wiremock.client.WireMock.verify; import static com.netflix.spinnaker.kork.common.Header.USER; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; @@ -26,9 +31,11 @@ import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Iterables; import com.jakewharton.retrofit.Ok3Client; import com.netflix.spinnaker.filters.AuthenticatedRequestFilter; import com.netflix.spinnaker.kork.artifacts.model.Artifact; @@ -37,13 +44,14 @@ import com.netflix.spinnaker.rosco.jobs.JobExecutor; import com.netflix.spinnaker.rosco.jobs.JobRequest; import com.netflix.spinnaker.rosco.manifests.helm.HelmBakeManifestRequest; -import com.netflix.spinnaker.rosco.services.ClouddriverService; +import com.netflix.spinnaker.rosco.services.ClouddriverRetrofit2Service; import java.nio.charset.StandardCharsets; -import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInfo; -import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; @@ -52,10 +60,6 @@ import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.context.WebApplicationContext; -import retrofit.client.Header; -import retrofit.client.Request; -import retrofit.client.Response; -import retrofit.mime.TypedByteArray; @SpringBootTest(classes = Main.class) @TestPropertySource(properties = "spring.application.name = rosco") @@ -63,9 +67,11 @@ class V2BakeryControllerWithClouddriverServiceTest { private MockMvc webAppMockMvc; + private static WireMockServer wireMockServer; + @Autowired private WebApplicationContext webApplicationContext; - @Autowired ClouddriverService clouddriverService; + @Autowired ClouddriverRetrofit2Service clouddriverService; /** So it's possible to verify e.g. request headers sent to clouddriver */ @MockBean Ok3Client ok3Client; @@ -84,6 +90,23 @@ class V2BakeryControllerWithClouddriverServiceTest { private HelmBakeManifestRequest bakeManifestRequest; + @BeforeAll + static void setupClass() { + wireMockServer = new WireMockServer(new WireMockConfiguration().port(7002)); + wireMockServer.start(); + WireMock.configureFor("localhost", 7002); + } + + @AfterEach + void after() { + wireMockServer.resetAll(); + } + + @AfterAll + static void clean() { + wireMockServer.shutdown(); + } + @BeforeEach private void init(TestInfo testInfo) { System.out.println("--------------- Test " + testInfo.getDisplayName()); @@ -122,7 +145,9 @@ void testRequestHeadersToClouddriver() throws Exception { // Simulate a successful response from clouddriver. The actual response // isn't important since we're verifying headers in the request to // clouddriver. - when(ok3Client.execute(any(Request.class))).thenReturn(successfulResponse("")); + stubFor( + WireMock.put(urlMatching("/artifacts/fetch/")) + .willReturn(aResponse().withStatus(HttpStatus.OK.value()).withBody("success"))); String userValue = "some user"; webAppMockMvc @@ -136,22 +161,9 @@ void testRequestHeadersToClouddriver() throws Exception { .andDo(print()) .andExpect(status().isOk()); - // Make sure the request to clouddriver has the same headers as the request - // to rosco - ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(Request.class); - verify(ok3Client).execute(requestCaptor.capture()); - List
headers = requestCaptor.getValue().getHeaders(); - Header header = Iterables.getOnlyElement(headers); - assertEquals(USER.getHeader(), header.getName()); - assertEquals(userValue, header.getValue()); - } - - private Response successfulResponse(String content) { - return new Response( - "", - HttpStatus.OK.value(), - "", - ImmutableList.of(), - new TypedByteArray(null, content.getBytes())); + // verifing request to clouddriver contains header passing to rosco + verify( + putRequestedFor(urlEqualTo("/artifacts/fetch/")) + .withHeader(USER.getHeader(), equalTo(userValue))); } } From d62709087c9af4bfebe7b13a5b937a032cdaa9f1 Mon Sep 17 00:00:00 2001 From: David Byron <82477955+dbyron-sf@users.noreply.github.com> Date: Fri, 7 Apr 2023 05:56:07 -0700 Subject: [PATCH 05/29] refactor(web/test): V2BakeryControllerWithClouddriverServiceTest cleanup (#979) * refactor(web/test): remove unused variables from V2BakeryControllerWithClouddriverServiceTest * refactor(test/web): use com.github.tomakehurst:wiremock-jre8-standalone instead of com.github.tomakehurst:wiremock-jre8 to reduce dependencies, but also to pick up a newer version snippet of $ ./gradlew rosco-web:dependencies --configuration testCompileClasspath before: \--- com.github.tomakehurst:wiremock-jre8 -> 2.26.3 +--- org.eclipse.jetty:jetty-server:9.4.20.v20190813 -> 9.4.46.v20220331 | +--- javax.servlet:javax.servlet-api:3.1.0 -> 4.0.1 | +--- org.eclipse.jetty:jetty-http:9.4.46.v20220331 | | +--- org.eclipse.jetty:jetty-util:9.4.46.v20220331 | | \--- org.eclipse.jetty:jetty-io:9.4.46.v20220331 | | \--- org.eclipse.jetty:jetty-util:9.4.46.v20220331 | \--- org.eclipse.jetty:jetty-io:9.4.46.v20220331 (*) +--- org.eclipse.jetty:jetty-servlet:9.4.20.v20190813 -> 9.4.46.v20220331 | +--- org.eclipse.jetty:jetty-security:9.4.46.v20220331 | | \--- org.eclipse.jetty:jetty-server:9.4.46.v20220331 (*) | \--- org.eclipse.jetty:jetty-util-ajax:9.4.46.v20220331 | \--- org.eclipse.jetty:jetty-util:9.4.46.v20220331 +--- org.eclipse.jetty:jetty-servlets:9.4.20.v20190813 -> 9.4.46.v20220331 | +--- org.eclipse.jetty:jetty-continuation:9.4.46.v20220331 | +--- org.eclipse.jetty:jetty-http:9.4.46.v20220331 (*) | +--- org.eclipse.jetty:jetty-util:9.4.46.v20220331 | \--- org.eclipse.jetty:jetty-io:9.4.46.v20220331 (*) +--- org.eclipse.jetty:jetty-webapp:9.4.20.v20190813 -> 9.4.46.v20220331 | +--- org.eclipse.jetty:jetty-xml:9.4.46.v20220331 | | \--- org.eclipse.jetty:jetty-util:9.4.46.v20220331 | \--- org.eclipse.jetty:jetty-servlet:9.4.46.v20220331 (*) +--- com.google.guava:guava:27.0.1-jre -> 30.0-jre (*) +--- com.fasterxml.jackson.core:jackson-core:2.10.0 -> 2.12.7 (*) +--- com.fasterxml.jackson.core:jackson-annotations:2.10.0 -> 2.12.7 (*) +--- com.fasterxml.jackson.core:jackson-databind:2.10.0 -> 2.12.7.1 (*) +--- org.apache.httpcomponents:httpclient:4.5.6 -> 4.5.13 | +--- org.apache.httpcomponents:httpcore:4.4.13 -> 4.4.15 | \--- commons-codec:commons-codec:1.11 -> 1.15 +--- org.xmlunit:xmlunit-core:2.6.2 -> 2.8.4 +--- org.xmlunit:xmlunit-legacy:2.6.2 -> 2.8.4 | \--- org.xmlunit:xmlunit-core:2.8.4 +--- org.xmlunit:xmlunit-placeholders:2.6.2 -> 2.8.4 | \--- org.xmlunit:xmlunit-core:2.8.4 +--- com.jayway.jsonpath:json-path:2.4.0 -> 2.5.0 (*) +--- org.ow2.asm:asm:7.0 -> 8.0.1 +--- org.slf4j:slf4j-api:1.7.12 -> 1.7.36 +--- net.sf.jopt-simple:jopt-simple:5.0.3 +--- org.apache.commons:commons-lang3:3.7 -> 3.12.0 +--- com.github.jknack:handlebars:4.1.2 | \--- org.slf4j:slf4j-api:1.7.25 -> 1.7.36 +--- com.github.jknack:handlebars-helpers:4.1.2 | +--- com.github.jknack:handlebars:4.1.2 (*) | \--- org.apache.commons:commons-lang3:3.8.1 -> 3.12.0 +--- com.flipkart.zjsonpatch:zjsonpatch:0.4.4 +--- commons-fileupload:commons-fileupload:1.4 | \--- commons-io:commons-io:2.2 -> 2.11.0 +--- org.eclipse.jetty.http2:http2-server:9.4.20.v20190813 -> 9.4.46.v20220331 | +--- org.eclipse.jetty.http2:http2-common:9.4.46.v20220331 | | \--- org.eclipse.jetty.http2:http2-hpack:9.4.46.v20220331 | | +--- org.eclipse.jetty:jetty-util:9.4.46.v20220331 | | +--- org.eclipse.jetty:jetty-http:9.4.46.v20220331 (*) | | \--- org.eclipse.jetty:jetty-io:9.4.46.v20220331 (*) | \--- org.eclipse.jetty:jetty-server:9.4.46.v20220331 (*) +--- org.eclipse.jetty:jetty-alpn-server:9.4.20.v20190813 -> 9.4.46.v20220331 | \--- org.eclipse.jetty:jetty-server:9.4.46.v20220331 (*) +--- org.eclipse.jetty:jetty-alpn-conscrypt-server:9.4.20.v20190813 -> 9.4.46.v20220331 | +--- org.conscrypt:conscrypt-openjdk-uber:2.5.2 | +--- org.eclipse.jetty:jetty-alpn-server:9.4.46.v20220331 (*) | \--- org.eclipse.jetty:jetty-io:9.4.46.v20220331 (*) +--- org.eclipse.jetty:jetty-alpn-conscrypt-client:9.4.20.v20190813 -> 9.4.46.v20220331 | +--- org.conscrypt:conscrypt-openjdk-uber:2.5.2 | \--- org.eclipse.jetty:jetty-alpn-client:9.4.46.v20220331 | \--- org.eclipse.jetty:jetty-io:9.4.46.v20220331 (*) \--- net.javacrumbs.json-unit:json-unit-core:2.12.0 +--- org.opentest4j:opentest4j:1.1.1 -> 1.2.0 \--- org.hamcrest:hamcrest-core:2.1 -> 2.2 (*) after: \--- com.github.tomakehurst:wiremock-jre8-standalone -> 2.31.0 * fix(web/test): mock the BakePoller in to avoid multiple long stack traces in test output similar to 2023-04-06 17:14:20.946 ERROR 56717 --- [RxIoScheduler-5] c.n.spinnaker.rosco.executor.BakePoller : Zombie Killer Polling Error: redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool at redis.clients.jedis.util.Pool.getResource(Pool.java:84) ~[jedis-3.6.3.jar:na] at redis.clients.jedis.JedisPool.getResource(JedisPool.java:370) ~[jedis-3.6.3.jar:na] at redis.clients.jedis.JedisPool.getResource(JedisPool.java:15) ~[jedis-3.6.3.jar:na] at com.netflix.spinnaker.kork.jedis.JedisClientDelegate.withKeyScan(JedisClientDelegate.java:180) ~[kork-jedis-7.171.2.jar:7.171.2] at com.netflix.spinnaker.rosco.persistence.RedisBackedBakeStore.scanIncompleteBakesKeys(RedisBackedBakeStore.groovy:584) ~[rosco-core.jar:na] at com.netflix.spinnaker.rosco.persistence.RedisBackedBakeStore.getAllIncompleteBakeIds(RedisBackedBakeStore.groovy:538) ~[rosco-core.jar:na] at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na] at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:na] at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na] at java.base/java.lang.reflect.Method.invoke(Method.java:566) ~[na:na] at org.codehaus.groovy.reflection.CachedMethod.invoke(CachedMethod.java:101) ~[groovy-2.5.15.jar:2.5.15] at groovy.lang.MetaMethod.doMethodInvoke(MetaMethod.java:323) ~[groovy-2.5.15.jar:2.5.15] at org.codehaus.groovy.runtime.metaclass.MethodMetaProperty$GetBeanMethodMetaProperty.getProperty(MethodMetaProperty.java:76) ~[groovy-2.5.15.jar:2.5.15] at org.codehaus.groovy.runtime.callsite.GetEffectivePogoPropertySite.callGetProperty(GetEffectivePogoPropertySite.java:48) ~[groovy-2.5.15.jar:2.5.15] at com.netflix.spinnaker.rosco.executor.BakePoller$_onApplicationEvent_closure2.doCall(BakePoller.groovy:106) ~[rosco-core.jar:na] at com.netflix.spinnaker.rosco.executor.BakePoller$_onApplicationEvent_closure2.doCall(BakePoller.groovy) ~[rosco-core.jar:na] at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na] at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:na] at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na] at java.base/java.lang.reflect.Method.invoke(Method.java:566) ~[na:na] at org.codehaus.groovy.reflection.CachedMethod.invoke(CachedMethod.java:101) ~[groovy-2.5.15.jar:2.5.15] at groovy.lang.MetaMethod.doMethodInvoke(MetaMethod.java:323) ~[groovy-2.5.15.jar:2.5.15] at org.codehaus.groovy.runtime.metaclass.ClosureMetaClass.invokeMethod(ClosureMetaClass.java:263) ~[groovy-2.5.15.jar:2.5.15] at groovy.lang.MetaClassImpl.invokeMethod(MetaClassImpl.java:1041) ~[groovy-2.5.15.jar:2.5.15] at groovy.lang.Closure.call(Closure.java:405) ~[groovy-2.5.15.jar:2.5.15] at org.codehaus.groovy.runtime.ConvertedClosure.invokeCustom(ConvertedClosure.java:50) ~[groovy-2.5.15.jar:2.5.15] at org.codehaus.groovy.runtime.ConversionHandler.invoke(ConversionHandler.java:122) ~[groovy-2.5.15.jar:2.5.15] at com.sun.proxy.$Proxy198.call(Unknown Source) ~[na:na] at rx.internal.schedulers.SchedulePeriodicHelper$1.call(SchedulePeriodicHelper.java:72) ~[rxjava-1.3.8.jar:1.3.8] at rx.internal.schedulers.CachedThreadScheduler$EventLoopWorker$1.call(CachedThreadScheduler.java:230) ~[rxjava-1.3.8.jar:1.3.8] at rx.internal.schedulers.ScheduledAction.run(ScheduledAction.java:55) ~[rxjava-1.3.8.jar:1.3.8] at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:515) ~[na:na] at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264) ~[na:na] at java.base/java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask.run(ScheduledThreadPoolExecutor.java:304) ~[na:na] at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128) ~[na:na] at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628) ~[na:na] at java.base/java.lang.Thread.run(Thread.java:834) ~[na:na] Caused by: redis.clients.jedis.exceptions.JedisConnectionException: Failed to create socket. at redis.clients.jedis.DefaultJedisSocketFactory.createSocket(DefaultJedisSocketFactory.java:110) ~[jedis-3.6.3.jar:na] at redis.clients.jedis.Connection.connect(Connection.java:226) ~[jedis-3.6.3.jar:na] at redis.clients.jedis.BinaryClient.connect(BinaryClient.java:135) ~[jedis-3.6.3.jar:na] at redis.clients.jedis.BinaryJedis.connect(BinaryJedis.java:309) ~[jedis-3.6.3.jar:na] at redis.clients.jedis.BinaryJedis.initializeFromClientConfig(BinaryJedis.java:87) ~[jedis-3.6.3.jar:na] at redis.clients.jedis.BinaryJedis.(BinaryJedis.java:292) ~[jedis-3.6.3.jar:na] at redis.clients.jedis.Jedis.(Jedis.java:167) ~[jedis-3.6.3.jar:na] at redis.clients.jedis.JedisFactory.makeObject(JedisFactory.java:177) ~[jedis-3.6.3.jar:na] at org.apache.commons.pool2.impl.GenericObjectPool.create(GenericObjectPool.java:918) ~[commons-pool2-2.9.0.jar:2.9.0] at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:431) ~[commons-pool2-2.9.0.jar:2.9.0] at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:356) ~[commons-pool2-2.9.0.jar:2.9.0] at redis.clients.jedis.util.Pool.getResource(Pool.java:75) ~[jedis-3.6.3.jar:na] ... 36 common frames omitted Caused by: java.net.ConnectException: Connection refused (Connection refused) at java.base/java.net.PlainSocketImpl.socketConnect(Native Method) ~[na:na] at java.base/java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:399) ~[na:na] at java.base/java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:242) ~[na:na] at java.base/java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:224) ~[na:na] at java.base/java.net.SocksSocketImpl.connect(SocksSocketImpl.java:392) ~[na:na] at java.base/java.net.Socket.connect(Socket.java:609) ~[na:na] at redis.clients.jedis.DefaultJedisSocketFactory.createSocket(DefaultJedisSocketFactory.java:80) ~[jedis-3.6.3.jar:na] ... 47 common frames omitted * refactor(web/test): use dynamic port in V2BakeryControllerWithClouddriverServiceTest to avoid possible conflicts with fixed ports --- rosco-web/rosco-web.gradle | 2 +- ...yControllerWithClouddriverServiceTest.java | 49 +++++++------------ 2 files changed, 19 insertions(+), 32 deletions(-) diff --git a/rosco-web/rosco-web.gradle b/rosco-web/rosco-web.gradle index 2a65753c1..467a52cd0 100644 --- a/rosco-web/rosco-web.gradle +++ b/rosco-web/rosco-web.gradle @@ -28,7 +28,7 @@ dependencies { testImplementation "org.objenesis:objenesis" testImplementation "com.squareup.retrofit2:retrofit" testImplementation "com.squareup.retrofit2:converter-jackson" - testImplementation "com.github.tomakehurst:wiremock-jre8" + testImplementation "com.github.tomakehurst:wiremock-jre8-standalone" } ospackage { diff --git a/rosco-web/src/test/java/com/netflix/spinnaker/rosco/controllers/V2BakeryControllerWithClouddriverServiceTest.java b/rosco-web/src/test/java/com/netflix/spinnaker/rosco/controllers/V2BakeryControllerWithClouddriverServiceTest.java index ed3c0ff7b..d7113d05c 100644 --- a/rosco-web/src/test/java/com/netflix/spinnaker/rosco/controllers/V2BakeryControllerWithClouddriverServiceTest.java +++ b/rosco-web/src/test/java/com/netflix/spinnaker/rosco/controllers/V2BakeryControllerWithClouddriverServiceTest.java @@ -18,10 +18,9 @@ import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor; -import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching; -import static com.github.tomakehurst.wiremock.client.WireMock.verify; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; import static com.netflix.spinnaker.kork.common.Header.USER; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; @@ -31,32 +30,30 @@ import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; import com.fasterxml.jackson.databind.ObjectMapper; -import com.github.tomakehurst.wiremock.WireMockServer; import com.github.tomakehurst.wiremock.client.WireMock; -import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; -import com.jakewharton.retrofit.Ok3Client; import com.netflix.spinnaker.filters.AuthenticatedRequestFilter; import com.netflix.spinnaker.kork.artifacts.model.Artifact; import com.netflix.spinnaker.rosco.Main; import com.netflix.spinnaker.rosco.api.BakeStatus; +import com.netflix.spinnaker.rosco.executor.BakePoller; import com.netflix.spinnaker.rosco.jobs.JobExecutor; import com.netflix.spinnaker.rosco.jobs.JobRequest; import com.netflix.spinnaker.rosco.manifests.helm.HelmBakeManifestRequest; -import com.netflix.spinnaker.rosco.services.ClouddriverRetrofit2Service; import java.nio.charset.StandardCharsets; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.extension.RegisterExtension; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.context.WebApplicationContext; @@ -67,14 +64,14 @@ class V2BakeryControllerWithClouddriverServiceTest { private MockMvc webAppMockMvc; - private static WireMockServer wireMockServer; + /** See https://wiremock.org/docs/junit-jupiter/#advanced-usage---programmatic */ + @RegisterExtension + static WireMockExtension wm = + WireMockExtension.newInstance().options(wireMockConfig().dynamicPort()).build(); @Autowired private WebApplicationContext webApplicationContext; - @Autowired ClouddriverRetrofit2Service clouddriverService; - - /** So it's possible to verify e.g. request headers sent to clouddriver */ - @MockBean Ok3Client ok3Client; + @MockBean BakePoller bakePoller; @Autowired ObjectMapper objectMapper; @@ -90,21 +87,11 @@ class V2BakeryControllerWithClouddriverServiceTest { private HelmBakeManifestRequest bakeManifestRequest; - @BeforeAll - static void setupClass() { - wireMockServer = new WireMockServer(new WireMockConfiguration().port(7002)); - wireMockServer.start(); - WireMock.configureFor("localhost", 7002); - } - - @AfterEach - void after() { - wireMockServer.resetAll(); - } - - @AfterAll - static void clean() { - wireMockServer.shutdown(); + @DynamicPropertySource + static void registerClouddriverBaseUrl(DynamicPropertyRegistry registry) { + // Configure wiremock's random port into rosco + System.out.println("wiremock server url: " + wm.baseUrl()); + registry.add("services.clouddriver.base-url", wm::baseUrl); } @BeforeEach @@ -145,7 +132,7 @@ void testRequestHeadersToClouddriver() throws Exception { // Simulate a successful response from clouddriver. The actual response // isn't important since we're verifying headers in the request to // clouddriver. - stubFor( + wm.stubFor( WireMock.put(urlMatching("/artifacts/fetch/")) .willReturn(aResponse().withStatus(HttpStatus.OK.value()).withBody("success"))); @@ -162,7 +149,7 @@ void testRequestHeadersToClouddriver() throws Exception { .andExpect(status().isOk()); // verifing request to clouddriver contains header passing to rosco - verify( + wm.verify( putRequestedFor(urlEqualTo("/artifacts/fetch/")) .withHeader(USER.getHeader(), equalTo(userValue))); } From 04b335959666a78354b3c0e45c4d26f687bbe787 Mon Sep 17 00:00:00 2001 From: spinnakerbot Date: Mon, 17 Apr 2023 14:58:07 -0400 Subject: [PATCH 06/29] chore(dependencies): Autobump korkVersion (#981) Co-authored-by: root --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index ce9fcfbe7..8444b6a7d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -korkVersion=7.171.2 +korkVersion=7.172.0 org.gradle.parallel=true spinnakerGradleVersion=8.26.0 targetJava11=true From 975d86f6f2be0f8e9ae127e3d817872fd053d564 Mon Sep 17 00:00:00 2001 From: Luthan95 <66585454+Luthan95@users.noreply.github.com> Date: Wed, 26 Apr 2023 22:44:49 +0530 Subject: [PATCH 07/29] refactor(retrofit2): use retrofit2 client to clouddriver API calls instead of retrofit1.9 (#980) * refactor(retrofit2): use retrofit2 client to clouddriver API calls instead of retrofit1.9 make use of retrofit2.x client instead of retrofit1.9 and achieve similar response * refactor(retrofit2): remove retrofit1.9 client ClouddriverService class and rename retrofit2 API class as ClouddriverService * refactor(retrofit2): remove retrofit1.9 direct dependencies --- rosco-core/rosco-core.gradle | 3 -- .../rosco/providers/aws/AWSBakeHandler.groovy | 4 +- .../services/ClouddriverRetrofit2Service.java | 28 ------------ .../rosco/services/ClouddriverService.java | 31 ++++++++++--- .../rosco/services/ServiceConfig.java | 45 +++---------------- .../providers/aws/AWSBakeHandlerSpec.groovy | 43 ++++++++++++------ rosco-manifests/rosco-manifests.gradle | 2 +- .../manifests/ArtifactDownloaderImpl.java | 6 +-- .../kustomize/KustomizationFileReader.java | 6 +-- .../KustomizationFileReaderSpec.groovy | 6 +-- .../manifests/ArtifactDownloaderImplTest.java | 5 +-- rosco-web/rosco-web.gradle | 2 +- .../controllers/V2BakeryControllerTest.java | 4 +- 13 files changed, 79 insertions(+), 106 deletions(-) delete mode 100644 rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/services/ClouddriverRetrofit2Service.java diff --git a/rosco-core/rosco-core.gradle b/rosco-core/rosco-core.gradle index e916703cb..f73a397a0 100644 --- a/rosco-core/rosco-core.gradle +++ b/rosco-core/rosco-core.gradle @@ -11,8 +11,6 @@ dependencies { implementation "io.spinnaker.kork:kork-retrofit" implementation "io.spinnaker.kork:kork-swagger" implementation "io.spinnaker.kork:kork-web" - implementation "com.squareup.retrofit:converter-jackson" - implementation "com.squareup.retrofit:retrofit" implementation "com.squareup.retrofit2:converter-jackson" implementation "com.squareup.retrofit2:retrofit" implementation "io.reactivex:rxjava" @@ -20,7 +18,6 @@ dependencies { implementation "org.codehaus.groovy:groovy" implementation "org.springframework.boot:spring-boot-starter-web" implementation "redis.clients:jedis" - implementation "com.jakewharton.retrofit:retrofit1-okhttp3-client" testImplementation "org.spockframework:spock-core" testImplementation "org.objenesis:objenesis" } diff --git a/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/providers/aws/AWSBakeHandler.groovy b/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/providers/aws/AWSBakeHandler.groovy index ff255d8f8..02b7e9ae1 100644 --- a/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/providers/aws/AWSBakeHandler.groovy +++ b/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/providers/aws/AWSBakeHandler.groovy @@ -18,6 +18,7 @@ package com.netflix.spinnaker.rosco.providers.aws import com.netflix.spinnaker.kork.artifacts.model.Artifact import com.netflix.spinnaker.kork.core.RetrySupport +import com.netflix.spinnaker.kork.retrofit.Retrofit2SyncCall import com.netflix.spinnaker.rosco.api.Bake import com.netflix.spinnaker.rosco.api.BakeOptions import com.netflix.spinnaker.rosco.api.BakeRequest @@ -26,6 +27,7 @@ import com.netflix.spinnaker.rosco.providers.CloudProviderBakeHandler import com.netflix.spinnaker.rosco.providers.aws.config.RoscoAWSConfiguration import com.netflix.spinnaker.rosco.providers.util.ImageNameFactory import com.netflix.spinnaker.rosco.services.ClouddriverService + import com.netflix.spinnaker.security.AuthenticatedRequest import groovy.util.logging.Slf4j import org.springframework.beans.factory.annotation.Autowired @@ -240,7 +242,7 @@ public class AWSBakeHandler extends CloudProviderBakeHandler { def images = AuthenticatedRequest.allowAnonymous( { retrySupport.retry({ - clouddriverService.findAmazonImageByName(name, account, region) + Retrofit2SyncCall.execute(clouddriverService.findAmazonImageByName(name, account, region)) }, 3, Duration.ofSeconds(3), false) } ) diff --git a/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/services/ClouddriverRetrofit2Service.java b/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/services/ClouddriverRetrofit2Service.java deleted file mode 100644 index fa93dc18f..000000000 --- a/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/services/ClouddriverRetrofit2Service.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2023 OpsMx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.netflix.spinnaker.rosco.services; - -import com.netflix.spinnaker.kork.artifacts.model.Artifact; -import okhttp3.ResponseBody; -import retrofit2.Call; -import retrofit2.http.Body; -import retrofit2.http.PUT; - -public interface ClouddriverRetrofit2Service { - @PUT("/artifacts/fetch/") - Call fetchArtifact(@Body Artifact artifact); -} diff --git a/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/services/ClouddriverService.java b/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/services/ClouddriverService.java index 1a286590d..e1ccd6a76 100644 --- a/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/services/ClouddriverService.java +++ b/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/services/ClouddriverService.java @@ -1,19 +1,36 @@ +/* + * Copyright 2023 OpsMx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.netflix.spinnaker.rosco.services; import com.netflix.spinnaker.kork.artifacts.model.Artifact; import com.netflix.spinnaker.rosco.providers.aws.config.RoscoAWSConfiguration.AWSNamedImage; import java.util.List; -import retrofit.client.Response; -import retrofit.http.Body; -import retrofit.http.GET; -import retrofit.http.PUT; -import retrofit.http.Query; +import okhttp3.ResponseBody; +import retrofit2.Call; +import retrofit2.http.Body; +import retrofit2.http.GET; +import retrofit2.http.PUT; +import retrofit2.http.Query; public interface ClouddriverService { @PUT("/artifacts/fetch/") - Response fetchArtifact(@Body Artifact artifact); + Call fetchArtifact(@Body Artifact artifact); @GET("/aws/images/find") - List findAmazonImageByName( + Call> findAmazonImageByName( @Query("q") String name, @Query("account") String account, @Query("region") String region); } diff --git a/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/services/ServiceConfig.java b/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/services/ServiceConfig.java index 2a29d2ecb..185173abe 100644 --- a/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/services/ServiceConfig.java +++ b/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/services/ServiceConfig.java @@ -18,17 +18,12 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; -import com.jakewharton.retrofit.Ok3Client; import com.netflix.spinnaker.config.OkHttp3ClientConfiguration; import com.netflix.spinnaker.kork.core.RetrySupport; import com.netflix.spinnaker.kork.retrofit.ErrorHandlingExecutorCallAdapterFactory; -import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerRetrofitErrorHandler; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import retrofit.RequestInterceptor; -import retrofit.RestAdapter; -import retrofit.converter.JacksonConverter; import retrofit2.Retrofit; import retrofit2.converter.jackson.JacksonConverterFactory; @@ -37,14 +32,6 @@ public class ServiceConfig { @Value("${services.clouddriver.base-url:http://localhost:7002}") String clouddriverBaseUrl; - @Value("${retrofit.log-level:BASIC}") - String retrofitLogLevel; - - @Bean - Ok3Client okClient(OkHttp3ClientConfiguration okHttpClientConfig) { - return new Ok3Client(okHttpClientConfig.create().build()); - } - @Bean RetrySupport retrySupport() { return new RetrySupport(); @@ -52,30 +39,12 @@ RetrySupport retrySupport() { // This should be service-agnostic if more integrations than clouddriver are used @Bean - ClouddriverService clouddriverService( - Ok3Client ok3Client, RequestInterceptor spinnakerRequestInterceptor) { - - return new RestAdapter.Builder() - .setEndpoint(clouddriverBaseUrl) - .setRequestInterceptor(spinnakerRequestInterceptor) - .setClient(ok3Client) - .setConverter(new JacksonConverter(getObjectMapper())) - .setLogLevel(RestAdapter.LogLevel.valueOf(retrofitLogLevel)) - .setErrorHandler(SpinnakerRetrofitErrorHandler.getInstance()) - .build() - .create(ClouddriverService.class); - } - - private ObjectMapper getObjectMapper() { - return new ObjectMapper() - .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL) - .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - } + ClouddriverService clouddriverService(OkHttp3ClientConfiguration okHttpClientConfig) { - /*As part of retrofit2 changes, creating clouddriverservice with retrofit2 API changes */ - @Bean - ClouddriverRetrofit2Service clouddriverRetrofit2Service( - OkHttp3ClientConfiguration okHttpClientConfig) { + ObjectMapper objectMapper = + new ObjectMapper() + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); /* * ErrorHandlingExecutorCallAdapterFactory handles exceptions globally in retrofit2, similar to SpinnakerRetrofitErrorHandler with retrofit. @@ -84,8 +53,8 @@ ClouddriverRetrofit2Service clouddriverRetrofit2Service( .baseUrl(clouddriverBaseUrl) .client(okHttpClientConfig.createForRetrofit2().build()) .addCallAdapterFactory(ErrorHandlingExecutorCallAdapterFactory.getInstance()) - .addConverterFactory(JacksonConverterFactory.create(getObjectMapper())) + .addConverterFactory(JacksonConverterFactory.create(objectMapper)) .build() - .create(ClouddriverRetrofit2Service.class); + .create(ClouddriverService.class); } } diff --git a/rosco-core/src/test/groovy/com/netflix/spinnaker/rosco/providers/aws/AWSBakeHandlerSpec.groovy b/rosco-core/src/test/groovy/com/netflix/spinnaker/rosco/providers/aws/AWSBakeHandlerSpec.groovy index c22feae09..ee6dd8f88 100644 --- a/rosco-core/src/test/groovy/com/netflix/spinnaker/rosco/providers/aws/AWSBakeHandlerSpec.groovy +++ b/rosco-core/src/test/groovy/com/netflix/spinnaker/rosco/providers/aws/AWSBakeHandlerSpec.groovy @@ -27,6 +27,9 @@ import com.netflix.spinnaker.rosco.providers.util.PackageNameConverter import com.netflix.spinnaker.rosco.providers.util.PackerCommandFactory import com.netflix.spinnaker.rosco.providers.util.TestDefaults import com.netflix.spinnaker.rosco.services.ClouddriverService + +import retrofit2.Call +import retrofit2.Response import spock.lang.Shared import spock.lang.Specification import spock.lang.Subject @@ -489,7 +492,25 @@ class AWSBakeHandlerSpec extends Specification implements TestDefaults { void 'looks up base ami id from name'() { def packerCommandFactoryMock = Mock(PackerCommandFactory) def imageNameFactoryMock = Mock(ImageNameFactory) - def clouddriverService = Mock(ClouddriverService) + def mockCall = Mock(Call.class) { + execute() >> { + return Response.success(200, [ + new RoscoAWSConfiguration.AWSNamedImage( + imageName: SOURCE_BIONIC_HVM_IMAGE_NAME, + attributes: new RoscoAWSConfiguration.AWSImageAttributes(virtualizationType: BakeRequest.VmType.hvm), + amis: [ + (REGION): [ SOURCE_BIONIC_HVM_IMAGE_ID ] + ] + ) + ]) + } + } + + def clouddriverService = Mock(ClouddriverService) { + findAmazonImageByName(_, _, _) >> { + return mockCall + } + } def bakeRequest = new BakeRequest(user: "someuser@gmail.com", package_name: PACKAGES_NAME, base_os: "bionic", @@ -511,16 +532,6 @@ class AWSBakeHandlerSpec extends Specification implements TestDefaults { def vmSettings = awsBakeHandler.findVirtualizationSettings(REGION, bakeRequest) then: - 1 * clouddriverService.findAmazonImageByName(_, _, _) >> [ - new RoscoAWSConfiguration.AWSNamedImage( - imageName: SOURCE_BIONIC_HVM_IMAGE_NAME, - attributes: new RoscoAWSConfiguration.AWSImageAttributes(virtualizationType: BakeRequest.VmType.hvm), - amis: [ - (REGION): [ SOURCE_BIONIC_HVM_IMAGE_ID ] - ] - ) - ] - vmSettings.sourceAmi == SOURCE_BIONIC_HVM_IMAGE_ID } @@ -568,9 +579,15 @@ class AWSBakeHandlerSpec extends Specification implements TestDefaults { void 'produces packer command with all required parameters for ubuntu, using ami lookup by name'() { setup: - def clouddriverService = Stub(ClouddriverService) { + def mockCall = Mock(Call.class) { + execute() >> { + return Response.success(200, searchByNameResults) + } + } + + def clouddriverService = Mock(ClouddriverService) { findAmazonImageByName(_, _, _) >> { - return searchByNameResults + return mockCall } } def packerCommandFactoryMock = Mock(PackerCommandFactory) diff --git a/rosco-manifests/rosco-manifests.gradle b/rosco-manifests/rosco-manifests.gradle index aa36dbfeb..e14a3405f 100644 --- a/rosco-manifests/rosco-manifests.gradle +++ b/rosco-manifests/rosco-manifests.gradle @@ -13,7 +13,6 @@ dependencies { implementation "org.apache.commons:commons-compress:1.21" implementation "org.yaml:snakeyaml" - implementation "com.squareup.retrofit:retrofit" implementation "com.squareup.retrofit2:retrofit" testImplementation "org.assertj:assertj-core" testImplementation "org.junit.jupiter:junit-jupiter-api" @@ -23,4 +22,5 @@ dependencies { testImplementation "org.spockframework:spock-core" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" testImplementation "com.squareup.retrofit2:converter-jackson" + testCompileOnly "com.squareup.retrofit:retrofit" } diff --git a/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImpl.java b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImpl.java index fbb8d55db..cfd5ebd5d 100644 --- a/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImpl.java +++ b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImpl.java @@ -5,7 +5,7 @@ import com.netflix.spinnaker.kork.exceptions.SpinnakerException; import com.netflix.spinnaker.kork.retrofit.Retrofit2SyncCall; import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerHttpException; -import com.netflix.spinnaker.rosco.services.ClouddriverRetrofit2Service; +import com.netflix.spinnaker.rosco.services.ClouddriverService; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -19,10 +19,10 @@ @Component @Slf4j public final class ArtifactDownloaderImpl implements ArtifactDownloader { - private final ClouddriverRetrofit2Service clouddriverService; + private final ClouddriverService clouddriverService; private final RetrySupport retrySupport = new RetrySupport(); - public ArtifactDownloaderImpl(ClouddriverRetrofit2Service clouddriverService) { + public ArtifactDownloaderImpl(ClouddriverService clouddriverService) { this.clouddriverService = clouddriverService; } diff --git a/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReader.java b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReader.java index af2acfd9b..f4d89302f 100644 --- a/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReader.java +++ b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReader.java @@ -21,7 +21,7 @@ import com.netflix.spinnaker.kork.core.RetrySupport; import com.netflix.spinnaker.kork.retrofit.Retrofit2SyncCall; import com.netflix.spinnaker.rosco.manifests.kustomize.mapping.Kustomization; -import com.netflix.spinnaker.rosco.services.ClouddriverRetrofit2Service; +import com.netflix.spinnaker.rosco.services.ClouddriverService; import java.io.IOException; import java.io.InputStream; import java.net.URI; @@ -39,12 +39,12 @@ @Component @Slf4j public class KustomizationFileReader { - private final ClouddriverRetrofit2Service clouddriverService; + private final ClouddriverService clouddriverService; private final RetrySupport retrySupport = new RetrySupport(); private static final List KUSTOMIZATION_FILENAMES = ImmutableList.of("kustomization.yaml", "kustomization.yml", "kustomization"); - public KustomizationFileReader(ClouddriverRetrofit2Service clouddriverService) { + public KustomizationFileReader(ClouddriverService clouddriverService) { this.clouddriverService = clouddriverService; } diff --git a/rosco-manifests/src/test/groovy/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReaderSpec.groovy b/rosco-manifests/src/test/groovy/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReaderSpec.groovy index 4d84f92a1..8513467c2 100644 --- a/rosco-manifests/src/test/groovy/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReaderSpec.groovy +++ b/rosco-manifests/src/test/groovy/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReaderSpec.groovy @@ -18,7 +18,7 @@ package com.netflix.spinnaker.rosco.manifests.kustomize import com.netflix.spinnaker.kork.artifacts.model.Artifact import com.netflix.spinnaker.rosco.manifests.kustomize.mapping.Kustomization -import com.netflix.spinnaker.rosco.services.ClouddriverRetrofit2Service +import com.netflix.spinnaker.rosco.services.ClouddriverService import okhttp3.MediaType import okhttp3.ResponseBody import retrofit2.Call @@ -41,7 +41,7 @@ class KustomizationFileReaderSpec extends Specification { return Response.success(200, ResponseBody.create(MediaType.parse("text/plain"), kustomizationYaml.stripMargin())); } } - def clouddriverService = Mock(ClouddriverRetrofit2Service) { + def clouddriverService = Mock(ClouddriverService) { fetchArtifact(_) >> { return mockCall } @@ -69,7 +69,7 @@ class KustomizationFileReaderSpec extends Specification { return Response.error(500, ResponseBody.create(null, "")) } } - def clouddriverService = Mock(ClouddriverRetrofit2Service) { + def clouddriverService = Mock(ClouddriverService) { fetchArtifact(_) >> { return mockCall } diff --git a/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImplTest.java b/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImplTest.java index a1c1c0030..e54c3d577 100644 --- a/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImplTest.java +++ b/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImplTest.java @@ -27,7 +27,7 @@ import com.netflix.spinnaker.kork.retrofit.exceptions.RetrofitException; import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerHttpException; import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerNetworkException; -import com.netflix.spinnaker.rosco.services.ClouddriverRetrofit2Service; +import com.netflix.spinnaker.rosco.services.ClouddriverService; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -40,8 +40,7 @@ @RunWith(JUnitPlatform.class) final class ArtifactDownloaderImplTest { - private final ClouddriverRetrofit2Service clouddriverService = - mock(ClouddriverRetrofit2Service.class); + private final ClouddriverService clouddriverService = mock(ClouddriverService.class); private final Call mockCall = mock(Call.class); private static final Artifact testArtifact = Artifact.builder().name("test-artifact").version("3").build(); diff --git a/rosco-web/rosco-web.gradle b/rosco-web/rosco-web.gradle index 467a52cd0..c5517c8c3 100644 --- a/rosco-web/rosco-web.gradle +++ b/rosco-web/rosco-web.gradle @@ -21,7 +21,6 @@ dependencies { runtimeOnly "io.spinnaker.kork:kork-runtime" - testImplementation "com.jakewharton.retrofit:retrofit1-okhttp3-client" testImplementation "io.spinnaker.kork:kork-retrofit" testImplementation "org.spockframework:spock-core" testImplementation "org.springframework.boot:spring-boot-starter-test" @@ -29,6 +28,7 @@ dependencies { testImplementation "com.squareup.retrofit2:retrofit" testImplementation "com.squareup.retrofit2:converter-jackson" testImplementation "com.github.tomakehurst:wiremock-jre8-standalone" + testCompileOnly "com.squareup.retrofit:retrofit" } ospackage { diff --git a/rosco-web/src/test/java/com/netflix/spinnaker/rosco/controllers/V2BakeryControllerTest.java b/rosco-web/src/test/java/com/netflix/spinnaker/rosco/controllers/V2BakeryControllerTest.java index 5873b16aa..4441fbc52 100644 --- a/rosco-web/src/test/java/com/netflix/spinnaker/rosco/controllers/V2BakeryControllerTest.java +++ b/rosco-web/src/test/java/com/netflix/spinnaker/rosco/controllers/V2BakeryControllerTest.java @@ -31,7 +31,7 @@ import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerServerException; import com.netflix.spinnaker.rosco.Main; import com.netflix.spinnaker.rosco.manifests.helm.HelmBakeManifestRequest; -import com.netflix.spinnaker.rosco.services.ClouddriverRetrofit2Service; +import com.netflix.spinnaker.rosco.services.ClouddriverService; import java.nio.charset.StandardCharsets; import okhttp3.ResponseBody; import org.junit.jupiter.api.BeforeEach; @@ -55,7 +55,7 @@ class V2BakeryControllerTest { @Autowired private WebApplicationContext webApplicationContext; - @MockBean ClouddriverRetrofit2Service clouddriverService; + @MockBean ClouddriverService clouddriverService; @Autowired ObjectMapper objectMapper; From 119dcac776a4e1a3ae233b4171839ddbcf69ddf5 Mon Sep 17 00:00:00 2001 From: spinnakerbot Date: Tue, 2 May 2023 01:06:54 -0400 Subject: [PATCH 08/29] chore(dependencies): Autobump korkVersion (#982) Co-authored-by: root --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 8444b6a7d..b22184427 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -korkVersion=7.172.0 +korkVersion=7.173.0 org.gradle.parallel=true spinnakerGradleVersion=8.26.0 targetJava11=true From e6db28a41155caf38355aac8147446e930f88153 Mon Sep 17 00:00:00 2001 From: Richard Timpson Date: Tue, 2 May 2023 15:32:58 -0600 Subject: [PATCH 09/29] refactor(manifests): use new methods in manifest exception handling (#983) * refactor(manifests): use new methods in manifest exception handling Uses the newInstance method on the SpinnakerException to clean up some exception handling in ArtifactDownloaderImpl. * refactor(manifest): formatter Ran formatter, deleted unnecessary import. --------- Co-authored-by: Richard Timpson --- .../spinnaker/rosco/manifests/ArtifactDownloaderImpl.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImpl.java b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImpl.java index cfd5ebd5d..63fe3a35e 100644 --- a/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImpl.java +++ b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImpl.java @@ -4,7 +4,6 @@ import com.netflix.spinnaker.kork.core.RetrySupport; import com.netflix.spinnaker.kork.exceptions.SpinnakerException; import com.netflix.spinnaker.kork.retrofit.Retrofit2SyncCall; -import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerHttpException; import com.netflix.spinnaker.rosco.services.ClouddriverService; import java.io.IOException; import java.io.InputStream; @@ -50,10 +49,8 @@ public void downloadArtifactToFile(Artifact artifact, Path targetFile) throws IO artifact, e.getMessage()), e); } - } catch (SpinnakerHttpException e) { - throw new SpinnakerHttpException(downloadFailureMessage(artifact, e), e); } catch (SpinnakerException e) { - throw new SpinnakerException(downloadFailureMessage(artifact, e), e); + throw e.newInstance(downloadFailureMessage(artifact, e)); } } From 4932b7187a78beb31e4a4548133b5b4bebfbe921 Mon Sep 17 00:00:00 2001 From: spinnakerbot Date: Thu, 18 May 2023 13:04:57 -0400 Subject: [PATCH 10/29] chore(dependencies): Autobump spinnakerGradleVersion (#985) Co-authored-by: root --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index b22184427..d3ead947e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ korkVersion=7.173.0 org.gradle.parallel=true -spinnakerGradleVersion=8.26.0 +spinnakerGradleVersion=8.27.0 targetJava11=true # To enable a composite reference to a project, set the From 561733954b6b74f0c0a764d2ae051ea75a2fd37f Mon Sep 17 00:00:00 2001 From: spinnakerbot Date: Thu, 25 May 2023 17:38:08 -0400 Subject: [PATCH 11/29] chore(dependencies): Autobump korkVersion (#987) Co-authored-by: root --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index d3ead947e..348dc7934 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -korkVersion=7.173.0 +korkVersion=7.174.0 org.gradle.parallel=true spinnakerGradleVersion=8.27.0 targetJava11=true From 3f09841ec64c96d81ef07c40fae70ebdcf7439fc Mon Sep 17 00:00:00 2001 From: spinnakerbot Date: Fri, 2 Jun 2023 16:02:20 -0400 Subject: [PATCH 12/29] chore(dependencies): Autobump korkVersion (#988) Co-authored-by: root --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 348dc7934..d5eed2c58 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -korkVersion=7.174.0 +korkVersion=7.175.0 org.gradle.parallel=true spinnakerGradleVersion=8.27.0 targetJava11=true From 2033e3d60b3b59fdbb53317c55cda22e91328c93 Mon Sep 17 00:00:00 2001 From: spinnakerbot Date: Mon, 12 Jun 2023 19:38:57 -0400 Subject: [PATCH 13/29] chore(dependencies): Autobump korkVersion (#990) Co-authored-by: root --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index d5eed2c58..0dc8e3314 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -korkVersion=7.175.0 +korkVersion=7.176.0 org.gradle.parallel=true spinnakerGradleVersion=8.27.0 targetJava11=true From 739575db35db017c778d632fad54295d34ea7373 Mon Sep 17 00:00:00 2001 From: spinnakerbot Date: Tue, 13 Jun 2023 01:17:09 -0400 Subject: [PATCH 14/29] chore(dependencies): Autobump spinnakerGradleVersion (#991) Co-authored-by: root --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 0dc8e3314..6ef51d37a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ korkVersion=7.176.0 org.gradle.parallel=true -spinnakerGradleVersion=8.27.0 +spinnakerGradleVersion=8.28.0 targetJava11=true # To enable a composite reference to a project, set the From ff352db606a0eb9db31f2341c64f52d7807a0b7a Mon Sep 17 00:00:00 2001 From: Sandesh <30489233+j-sandy@users.noreply.github.com> Date: Wed, 14 Jun 2023 00:08:30 +0530 Subject: [PATCH 15/29] chore(build): upgrade gradle to 7.6.1 (#989) --- gradle/wrapper/gradle-wrapper.jar | Bin 59203 -> 61574 bytes gradle/wrapper/gradle-wrapper.properties | 3 +- gradlew | 269 ++++++++++++++--------- gradlew.bat | 15 +- 4 files changed, 175 insertions(+), 112 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c023ec8b20f512888fe07c5bd3ff77bb8f..943f0cbfa754578e88a3dae77fce6e3dea56edbf 100644 GIT binary patch delta 39316 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 delta 36846 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 CcabXq diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 28ff446a2..508322917 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0c8..65dcd68d6 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,101 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -106,80 +140,105 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index ac1b06f93..6689b85be 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal From 6340c4205a24db23c04237aa980d43826cbf1672 Mon Sep 17 00:00:00 2001 From: spinnakerbot Date: Tue, 13 Jun 2023 15:19:54 -0400 Subject: [PATCH 16/29] chore(dependencies): Autobump korkVersion (#992) Co-authored-by: root --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 6ef51d37a..29af84b8f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -korkVersion=7.176.0 +korkVersion=7.177.0 org.gradle.parallel=true spinnakerGradleVersion=8.28.0 targetJava11=true From 2be28e14d224785db4f58ccef18d5e8ad39ff735 Mon Sep 17 00:00:00 2001 From: spinnakerbot Date: Tue, 13 Jun 2023 18:49:20 -0400 Subject: [PATCH 17/29] chore(dependencies): Autobump korkVersion (#993) Co-authored-by: root --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 29af84b8f..f4aed44b4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -korkVersion=7.177.0 +korkVersion=7.178.0 org.gradle.parallel=true spinnakerGradleVersion=8.28.0 targetJava11=true From d49a43507428ebd67bd0942f1050c755e45ab6bc Mon Sep 17 00:00:00 2001 From: spinnakerbot Date: Wed, 14 Jun 2023 12:17:44 -0400 Subject: [PATCH 18/29] chore(dependencies): Autobump spinnakerGradleVersion (#994) Co-authored-by: root --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index f4aed44b4..ee7c9a627 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ korkVersion=7.178.0 org.gradle.parallel=true -spinnakerGradleVersion=8.28.0 +spinnakerGradleVersion=8.29.0 targetJava11=true # To enable a composite reference to a project, set the From ab55ac7d00d6679d0cce88c7c3279811ebedad97 Mon Sep 17 00:00:00 2001 From: spinnakerbot Date: Fri, 16 Jun 2023 05:46:19 -0400 Subject: [PATCH 19/29] chore(dependencies): Autobump spinnakerGradleVersion (#995) Co-authored-by: root --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index ee7c9a627..6e55d65fc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ korkVersion=7.178.0 org.gradle.parallel=true -spinnakerGradleVersion=8.29.0 +spinnakerGradleVersion=8.30.0 targetJava11=true # To enable a composite reference to a project, set the From d0bb474d47b8e73a6c6cdc04349b32861223cb1a Mon Sep 17 00:00:00 2001 From: Sandesh <30489233+j-sandy@users.noreply.github.com> Date: Mon, 19 Jun 2023 08:28:24 +0530 Subject: [PATCH 20/29] chore(preview): remove preview feature of version ordering (VERSION_ORDERING_V2) for gradle dependencies (#996) With gradle 6.x, used [feature preview API](https://docs.gradle.org/7.6.1/userguide/feature_lifecycle.html#feature_preview) to take advantage of [improvised dependency version ordering](https://docs.gradle.org/6.5/release-notes.html#improved-dependency-version-ordering). This feature is made public for gradle 7.x. So, removing it from settings.gradle as we moved to gradle 7.6.1 --- settings.gradle | 2 -- 1 file changed, 2 deletions(-) diff --git a/settings.gradle b/settings.gradle index 95c7a5d2f..53e004d6a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -6,8 +6,6 @@ } } -enableFeaturePreview("VERSION_ORDERING_V2") - rootProject.name = 'rosco' include 'rosco-bom', 'rosco-core', 'rosco-web', 'rosco-manifests' From 3e1eddde71662c15588bed198b177cf6c9ddf091 Mon Sep 17 00:00:00 2001 From: spinnakerbot Date: Mon, 26 Jun 2023 13:13:20 -0400 Subject: [PATCH 21/29] chore(dependencies): Autobump korkVersion (#997) Co-authored-by: root --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 6e55d65fc..a000468d9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -korkVersion=7.178.0 +korkVersion=7.179.0 org.gradle.parallel=true spinnakerGradleVersion=8.30.0 targetJava11=true From f81d187db2c6bd178011f931ec3e59811013528f Mon Sep 17 00:00:00 2001 From: spinnakerbot Date: Mon, 3 Jul 2023 15:17:36 -0400 Subject: [PATCH 22/29] chore(dependencies): Autobump korkVersion (#999) * chore(dependencies): Autobump korkVersion * fix(manifests/tests): keep up with the removal of RetrofitException.networkError from https://github.com/spinnaker/kork/pull/1068 --------- Co-authored-by: root Co-authored-by: David Byron --- gradle.properties | 2 +- .../rosco/manifests/ArtifactDownloaderImplTest.java | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/gradle.properties b/gradle.properties index a000468d9..b2ddf3d75 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -korkVersion=7.179.0 +korkVersion=7.180.0 org.gradle.parallel=true spinnakerGradleVersion=8.30.0 targetJava11=true diff --git a/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImplTest.java b/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImplTest.java index e54c3d577..f811849e9 100644 --- a/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImplTest.java +++ b/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImplTest.java @@ -24,7 +24,6 @@ import com.netflix.spinnaker.kork.artifacts.model.Artifact; import com.netflix.spinnaker.kork.exceptions.SpinnakerException; -import com.netflix.spinnaker.kork.retrofit.exceptions.RetrofitException; import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerHttpException; import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerNetworkException; import com.netflix.spinnaker.rosco.services.ClouddriverService; @@ -66,9 +65,7 @@ public void retries() throws IOException { try (ArtifactDownloaderImplTest.AutoDeletingFile file = new AutoDeletingFile()) { when(clouddriverService.fetchArtifact(testArtifact)).thenReturn(mockCall); when(mockCall.execute()) - .thenThrow( - new SpinnakerNetworkException( - RetrofitException.networkError(new IOException("timeout")))) + .thenThrow(new SpinnakerNetworkException(new IOException("timeout"))) .thenReturn(successfulResponse(testContent)); artifactDownloader.downloadArtifactToFile(testArtifact, file.path); From 528c5efa474654edc88233b700db268628facf14 Mon Sep 17 00:00:00 2001 From: spinnakerbot Date: Wed, 5 Jul 2023 20:32:10 -0400 Subject: [PATCH 23/29] chore(dependencies): Autobump korkVersion (#1000) Co-authored-by: root --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index b2ddf3d75..16d972a92 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -korkVersion=7.180.0 +korkVersion=7.181.0 org.gradle.parallel=true spinnakerGradleVersion=8.30.0 targetJava11=true From 977040ea6dc0f8d537719dc01d2c44df6a139617 Mon Sep 17 00:00:00 2001 From: xibz Date: Thu, 6 Jul 2023 12:53:43 -0500 Subject: [PATCH 24/29] feat(artifacts): Add ArtifactStore to rosco (#998) This commit adds artifact storage to rosco. When rosco sends a request out to any service, any embedded/base64 artifacts will be automatically stored. Signed-off-by: benjamin-j-powell Co-authored-by: Benevolent Benjamin Powell Co-authored-by: Nicolas Favre-Felix --- .../rosco/config/RoscoConfiguration.groovy | 4 +-- .../com/netflix/spinnaker/rosco/Main.groovy | 26 ++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/config/RoscoConfiguration.groovy b/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/config/RoscoConfiguration.groovy index 9589ae6aa..8637a67c6 100644 --- a/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/config/RoscoConfiguration.groovy +++ b/rosco-core/src/main/groovy/com/netflix/spinnaker/rosco/config/RoscoConfiguration.groovy @@ -17,6 +17,7 @@ package com.netflix.spinnaker.rosco.config import com.netflix.spinnaker.config.PluginsAutoConfiguration +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactStoreConfiguration; import com.netflix.spinnaker.kork.jedis.JedisClientDelegate import com.netflix.spinnaker.kork.jedis.RedisClientDelegate import com.netflix.spinnaker.rosco.executor.BakePoller @@ -39,7 +40,7 @@ import redis.clients.jedis.JedisPool @Configuration @CompileStatic @Slf4j -@Import(PluginsAutoConfiguration.class) +@Import([ PluginsAutoConfiguration.class, ArtifactStoreConfiguration.class ]) class RoscoConfiguration { @Bean @@ -88,5 +89,4 @@ class RoscoConfiguration { PackerCommandFactory localJobFriendlyPackerCommandFactory() { return new LocalJobFriendlyPackerCommandFactory() } - } diff --git a/rosco-web/src/main/groovy/com/netflix/spinnaker/rosco/Main.groovy b/rosco-web/src/main/groovy/com/netflix/spinnaker/rosco/Main.groovy index 1f6fa34a8..cfef45dc4 100644 --- a/rosco-web/src/main/groovy/com/netflix/spinnaker/rosco/Main.groovy +++ b/rosco-web/src/main/groovy/com/netflix/spinnaker/rosco/Main.groovy @@ -16,6 +16,11 @@ package com.netflix.spinnaker.rosco +import com.fasterxml.jackson.databind.ObjectMapper +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactStore +import com.netflix.spinnaker.kork.artifacts.artifactstore.ArtifactStoreConfiguration +import com.netflix.spinnaker.kork.artifacts.artifactstore.EmbeddedArtifactSerializer +import com.netflix.spinnaker.kork.artifacts.model.Artifact import com.netflix.spinnaker.rosco.config.RoscoPackerConfigurationProperties import com.netflix.spinnaker.rosco.jobs.config.LocalJobConfig import com.netflix.spinnaker.rosco.manifests.config.RoscoHelmConfigurationProperties @@ -29,8 +34,11 @@ import com.netflix.spinnaker.rosco.providers.huaweicloud.config.RoscoHuaweiCloud import com.netflix.spinnaker.rosco.providers.oracle.config.RoscoOracleConfiguration import com.netflix.spinnaker.rosco.providers.tencentcloud.config.RoscoTencentCloudConfiguration import com.netflix.spinnaker.rosco.services.ServiceConfig +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.boot.autoconfigure.EnableAutoConfiguration import org.springframework.boot.autoconfigure.batch.BatchAutoConfiguration +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression import org.springframework.boot.autoconfigure.groovy.template.GroovyTemplateAutoConfiguration import org.springframework.boot.builder.SpringApplicationBuilder import org.springframework.boot.web.servlet.support.SpringBootServletInitializer @@ -38,6 +46,7 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.ComponentScan import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Import +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder import org.springframework.scheduling.annotation.EnableScheduling import org.springframework.web.filter.ShallowEtagHeaderFilter import com.netflix.spinnaker.kork.boot.DefaultPropertiesBuilder @@ -69,7 +78,8 @@ import javax.servlet.Filter RoscoPackerConfigurationProperties, RoscoHelmConfigurationProperties, RoscoKustomizeConfigurationProperties, - LocalJobConfig + LocalJobConfig, + ArtifactStoreConfiguration ]) @EnableAutoConfiguration(exclude = [BatchAutoConfiguration, GroovyTemplateAutoConfiguration]) @EnableScheduling @@ -90,4 +100,18 @@ class Main extends SpringBootServletInitializer { Filter eTagFilter() { new ShallowEtagHeaderFilter() } + + @Bean + @ConditionalOnExpression('${artifact-store.enabled:false}') + EmbeddedArtifactSerializer artifactSerializer(ArtifactStore store, @Qualifier("artifactObjectMapper") ObjectMapper objectMapper) { + return new EmbeddedArtifactSerializer(objectMapper, store); + } + + @Bean + @ConditionalOnBean(EmbeddedArtifactSerializer.class) + ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder, EmbeddedArtifactSerializer serializer) { + return builder.createXmlMapper(false) + .serializerByType(Artifact.class, serializer) + .build(); + } } From 08ca6f3b79f6322c0a8bf07493c0be6e8f5b73d0 Mon Sep 17 00:00:00 2001 From: Sandesh <30489233+j-sandy@users.noreply.github.com> Date: Mon, 10 Jul 2023 22:21:05 +0530 Subject: [PATCH 25/29] refactor(tests): convert junit4 based testcases to junit5 and clean up in rosco (#1001) Before refactor, total test cases executed were 275. After refactor, total test cases executed are 275. --- rosco-manifests/rosco-manifests.gradle | 1 - .../rosco/manifests/ArtifactDownloaderImplTest.java | 3 --- .../rosco/manifests/BakeManifestEnvironmentTest.java | 3 --- .../CloudFoundryBakeManifestServiceTest.java | 3 --- .../rosco/manifests/helm/HelmTemplateUtilsTest.java | 9 +++------ 5 files changed, 3 insertions(+), 16 deletions(-) diff --git a/rosco-manifests/rosco-manifests.gradle b/rosco-manifests/rosco-manifests.gradle index e14a3405f..d135d0997 100644 --- a/rosco-manifests/rosco-manifests.gradle +++ b/rosco-manifests/rosco-manifests.gradle @@ -17,7 +17,6 @@ dependencies { testImplementation "org.assertj:assertj-core" testImplementation "org.junit.jupiter:junit-jupiter-api" testImplementation "org.junit.jupiter:junit-jupiter-params" - testImplementation "org.junit.platform:junit-platform-runner" testImplementation "org.mockito:mockito-core" testImplementation "org.spockframework:spock-core" testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine" diff --git a/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImplTest.java b/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImplTest.java index f811849e9..041ad76b4 100644 --- a/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImplTest.java +++ b/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/ArtifactDownloaderImplTest.java @@ -32,12 +32,9 @@ import java.nio.file.Path; import okhttp3.ResponseBody; import org.junit.jupiter.api.Test; -import org.junit.platform.runner.JUnitPlatform; -import org.junit.runner.RunWith; import retrofit2.Call; import retrofit2.Response; -@RunWith(JUnitPlatform.class) final class ArtifactDownloaderImplTest { private final ClouddriverService clouddriverService = mock(ClouddriverService.class); private final Call mockCall = mock(Call.class); diff --git a/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/BakeManifestEnvironmentTest.java b/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/BakeManifestEnvironmentTest.java index 205039c00..69766d435 100644 --- a/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/BakeManifestEnvironmentTest.java +++ b/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/BakeManifestEnvironmentTest.java @@ -23,10 +23,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import org.junit.jupiter.api.Test; -import org.junit.platform.runner.JUnitPlatform; -import org.junit.runner.RunWith; -@RunWith(JUnitPlatform.class) final class BakeManifestEnvironmentTest { @Test void rejectsInvalidPaths() throws IOException { diff --git a/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/cloudfoundry/CloudFoundryBakeManifestServiceTest.java b/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/cloudfoundry/CloudFoundryBakeManifestServiceTest.java index 5c9c89b43..0be8d2bee 100644 --- a/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/cloudfoundry/CloudFoundryBakeManifestServiceTest.java +++ b/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/cloudfoundry/CloudFoundryBakeManifestServiceTest.java @@ -31,10 +31,7 @@ import java.io.InputStream; import java.util.Base64; import org.junit.jupiter.api.Test; -import org.junit.platform.runner.JUnitPlatform; -import org.junit.runner.RunWith; -@RunWith(JUnitPlatform.class) public class CloudFoundryBakeManifestServiceTest { private ArtifactDownloader artifactDownloader = mock(ArtifactDownloader.class); diff --git a/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/helm/HelmTemplateUtilsTest.java b/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/helm/HelmTemplateUtilsTest.java index f88de02fd..1a6eb41e9 100644 --- a/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/helm/HelmTemplateUtilsTest.java +++ b/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/helm/HelmTemplateUtilsTest.java @@ -18,10 +18,10 @@ import static com.netflix.spinnaker.rosco.manifests.ManifestTestUtils.makeSpinnakerHttpException; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.any; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; @@ -60,11 +60,8 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; -import org.junit.platform.runner.JUnitPlatform; -import org.junit.runner.RunWith; import org.springframework.http.HttpStatus; -@RunWith(JUnitPlatform.class) final class HelmTemplateUtilsTest { private ArtifactDownloader artifactDownloader; From efb107c9bf498390f17aa2dc4f91f8b212daf025 Mon Sep 17 00:00:00 2001 From: spinnakerbot Date: Wed, 12 Jul 2023 12:48:23 -0400 Subject: [PATCH 26/29] chore(dependencies): Autobump korkVersion (#1002) Co-authored-by: root --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 16d972a92..e669e4b2d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -korkVersion=7.181.0 +korkVersion=7.182.0 org.gradle.parallel=true spinnakerGradleVersion=8.30.0 targetJava11=true From b036df6093092e14e00285b8d02a3682d7bdb5c0 Mon Sep 17 00:00:00 2001 From: spinnakerbot Date: Wed, 12 Jul 2023 18:15:23 -0400 Subject: [PATCH 27/29] chore(dependencies): Autobump korkVersion (#1003) Co-authored-by: root --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index e669e4b2d..2e6895312 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -korkVersion=7.182.0 +korkVersion=7.182.1 org.gradle.parallel=true spinnakerGradleVersion=8.30.0 targetJava11=true From e5ea7789a3ed986599d816c10b36a1f50bd7948a Mon Sep 17 00:00:00 2001 From: thameezbo Date: Fri, 14 Jul 2023 18:44:15 +0200 Subject: [PATCH 28/29] feat(manifests/helmfile): add helmfile templating engine (#986) * feat(manifests/helmfile): install helmfile binary * feat(manifests/helmfile): add code feat(manifests/helmfile): add tests * copyright Signed-off-by: Salvatore Mazzarino * test changes Signed-off-by: Salvatore Mazzarino * add javadoc Signed-off-by: Salvatore Mazzarino * make the code dry Signed-off-by: Salvatore Mazzarino * reduce value paths Signed-off-by: Salvatore Mazzarino * avoid using wildcards Signed-off-by: Salvatore Mazzarino * reduce redundant code Signed-off-by: Salvatore Mazzarino * increase reuse Signed-off-by: Salvatore Mazzarino * run spotless Signed-off-by: Salvatore Mazzarino * run another round of spotless Signed-off-by: Salvatore Mazzarino --------- Signed-off-by: Salvatore Mazzarino Co-authored-by: Salvatore Mazzarino Co-authored-by: Jason Co-authored-by: Salvatore Mazzarino --- Dockerfile.slim | 7 + Dockerfile.ubuntu | 7 + .../rosco/manifests/BakeManifestRequest.java | 1 + .../manifests/HelmBakeTemplateUtils.java | 106 +++ .../RoscoHelmfileConfigurationProperties.java | 26 + .../manifests/helm/HelmTemplateUtils.java | 92 +-- .../helmfile/HelmfileBakeManifestRequest.java | 50 ++ .../helmfile/HelmfileBakeManifestService.java | 65 ++ .../helmfile/HelmfileTemplateUtils.java | 125 ++++ .../helmfile/HelmfileTemplateUtilsTest.java | 671 ++++++++++++++++++ .../com/netflix/spinnaker/rosco/Main.groovy | 2 + 11 files changed, 1081 insertions(+), 71 deletions(-) create mode 100644 rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/HelmBakeTemplateUtils.java create mode 100644 rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/config/RoscoHelmfileConfigurationProperties.java create mode 100644 rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/helmfile/HelmfileBakeManifestRequest.java create mode 100644 rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/helmfile/HelmfileBakeManifestService.java create mode 100644 rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/helmfile/HelmfileTemplateUtils.java create mode 100644 rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/helmfile/HelmfileTemplateUtilsTest.java diff --git a/Dockerfile.slim b/Dockerfile.slim index 4c5ba1b15..759d909b0 100644 --- a/Dockerfile.slim +++ b/Dockerfile.slim @@ -4,6 +4,7 @@ LABEL maintainer="sig-platform@spinnaker.io" ENV KUSTOMIZE_VERSION=3.8.6 ENV KUSTOMIZE4_VERSION=4.5.5 ENV PACKER_VERSION=1.8.1 +ENV HELMFILE_VERSION=0.153.1 ARG TARGETARCH @@ -42,6 +43,12 @@ RUN mkdir kustomize && \ mv ./kustomize/kustomize /usr/local/bin/kustomize4 && \ rm -rf ./kustomize +RUN mkdir helmfile && \ + curl -s -L https://github.com/helmfile/helmfile/releases/download/v${HELMFILE_VERSION}/helmfile_${HELMFILE_VERSION}_linux_${TARGETARCH}.tar.gz|\ + tar xvz -C helmfile/ && \ + mv ./helmfile/helmfile /usr/local/bin/helmfile && \ + rm -rf ./helmfile + RUN addgroup -S -g 10111 spinnaker RUN adduser -S -G spinnaker -u 10111 spinnaker COPY rosco-web/build/install/rosco /opt/rosco diff --git a/Dockerfile.ubuntu b/Dockerfile.ubuntu index 0bbd3f963..080147d92 100644 --- a/Dockerfile.ubuntu +++ b/Dockerfile.ubuntu @@ -4,6 +4,7 @@ LABEL maintainer="sig-platform@spinnaker.io" ENV KUSTOMIZE_VERSION=3.8.6 ENV KUSTOMIZE4_VERSION=4.5.5 ENV PACKER_VERSION=1.8.1 +ENV HELMFILE_VERSION=0.153.1 ARG TARGETARCH @@ -41,6 +42,12 @@ RUN mkdir kustomize && \ mv ./kustomize/kustomize /usr/local/bin/kustomize4 && \ rm -rf ./kustomize +RUN mkdir helmfile && \ + curl -s -L https://github.com/helmfile/helmfile/releases/download/v${HELMFILE_VERSION}/helmfile_${HELMFILE_VERSION}_linux_${TARGETARCH}.tar.gz|\ + tar xvz -C helmfile/ && \ + mv ./helmfile/helmfile /usr/local/bin/helmfile && \ + rm -rf ./helmfile + RUN adduser --system --uid 10111 --group spinnaker COPY rosco-web/build/install/rosco /opt/rosco COPY rosco-web/config /opt/rosco diff --git a/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/BakeManifestRequest.java b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/BakeManifestRequest.java index be5c8b67e..6a1eb36c8 100644 --- a/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/BakeManifestRequest.java +++ b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/BakeManifestRequest.java @@ -17,6 +17,7 @@ public enum TemplateRenderer { HELM3, KUSTOMIZE, KUSTOMIZE4, + HELMFILE, CF; @JsonCreator diff --git a/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/HelmBakeTemplateUtils.java b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/HelmBakeTemplateUtils.java new file mode 100644 index 000000000..5e1f99075 --- /dev/null +++ b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/HelmBakeTemplateUtils.java @@ -0,0 +1,106 @@ +/* + * Copyright 2023 Grab Holdings, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.rosco.manifests; + +import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import com.netflix.spinnaker.kork.exceptions.SpinnakerException; +import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerHttpException; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public abstract class HelmBakeTemplateUtils { + private static final String MANIFEST_SEPARATOR = "---\n"; + private static final Pattern REGEX_TESTS_MANIFESTS = + Pattern.compile("# Source: .*/templates/tests/.*"); + + private final ArtifactDownloader artifactDownloader; + + protected HelmBakeTemplateUtils(ArtifactDownloader artifactDownloader) { + this.artifactDownloader = artifactDownloader; + } + + public ArtifactDownloader getArtifactDownloader() { + return artifactDownloader; + } + + public abstract String fetchFailureMessage(String description, Exception e); + + public String removeTestsDirectoryTemplates(String inputString) { + return Arrays.stream(inputString.split(MANIFEST_SEPARATOR)) + .filter(manifest -> !REGEX_TESTS_MANIFESTS.matcher(manifest).find()) + .collect(Collectors.joining(MANIFEST_SEPARATOR)); + } + + protected Path downloadArtifactToTmpFile(BakeManifestEnvironment env, Artifact artifact) + throws IOException { + String fileName = UUID.randomUUID().toString(); + Path targetPath = env.resolvePath(fileName); + artifactDownloader.downloadArtifactToFile(artifact, targetPath); + return targetPath; + } + + public abstract String getHelmExecutableForRequest(T request); + + protected List getValuePaths(List artifacts, BakeManifestEnvironment env) { + List valuePaths = new ArrayList<>(); + + try { + // not a stream to keep exception handling cleaner + for (Artifact valueArtifact : artifacts.subList(1, artifacts.size())) { + valuePaths.add(downloadArtifactToTmpFile(env, valueArtifact)); + } + } catch (SpinnakerHttpException e) { + throw new SpinnakerHttpException(fetchFailureMessage("values file", e), e); + } catch (IOException | SpinnakerException e) { + throw new IllegalStateException(fetchFailureMessage("values file", e), e); + } + + return valuePaths; + } + + protected Path getHelmTypePathFromArtifact( + BakeManifestEnvironment env, List inputArtifacts, String filePath) + throws IOException { + Path helmTypeFilePath; + + Artifact helmTypeTemplateArtifact = inputArtifacts.get(0); + String artifactType = Optional.ofNullable(helmTypeTemplateArtifact.getType()).orElse(""); + + if ("git/repo".equals(artifactType)) { + env.downloadArtifactTarballAndExtract(getArtifactDownloader(), helmTypeTemplateArtifact); + + helmTypeFilePath = env.resolvePath(Optional.ofNullable(filePath).orElse("")); + } else { + try { + helmTypeFilePath = downloadArtifactToTmpFile(env, helmTypeTemplateArtifact); + } catch (SpinnakerHttpException e) { + throw new SpinnakerHttpException(fetchFailureMessage("template", e), e); + } catch (IOException | SpinnakerException e) { + throw new IllegalStateException(fetchFailureMessage("template", e), e); + } + } + + return helmTypeFilePath; + } +} diff --git a/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/config/RoscoHelmfileConfigurationProperties.java b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/config/RoscoHelmfileConfigurationProperties.java new file mode 100644 index 000000000..c9bd9fa93 --- /dev/null +++ b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/config/RoscoHelmfileConfigurationProperties.java @@ -0,0 +1,26 @@ +/* + * Copyright 2023 Grab Holdings, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.rosco.manifests.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("helmfile") +@Data +public class RoscoHelmfileConfigurationProperties { + private String executablePath = "helmfile"; +} diff --git a/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/helm/HelmTemplateUtils.java b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/helm/HelmTemplateUtils.java index cd92fd3e8..f0d74e943 100644 --- a/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/helm/HelmTemplateUtils.java +++ b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/helm/HelmTemplateUtils.java @@ -1,88 +1,63 @@ package com.netflix.spinnaker.rosco.manifests.helm; import com.netflix.spinnaker.kork.artifacts.model.Artifact; -import com.netflix.spinnaker.kork.exceptions.SpinnakerException; -import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerHttpException; import com.netflix.spinnaker.rosco.jobs.BakeRecipe; import com.netflix.spinnaker.rosco.manifests.ArtifactDownloader; import com.netflix.spinnaker.rosco.manifests.BakeManifestEnvironment; import com.netflix.spinnaker.rosco.manifests.BakeManifestRequest; +import com.netflix.spinnaker.rosco.manifests.HelmBakeTemplateUtils; import com.netflix.spinnaker.rosco.manifests.config.RoscoHelmConfigurationProperties; import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import java.util.regex.Pattern; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @Component @Slf4j -public class HelmTemplateUtils { - private static final String MANIFEST_SEPARATOR = "---\n"; - private static final Pattern REGEX_TESTS_MANIFESTS = - Pattern.compile("# Source: .*/templates/tests/.*"); - - private final ArtifactDownloader artifactDownloader; +public class HelmTemplateUtils extends HelmBakeTemplateUtils { private final RoscoHelmConfigurationProperties helmConfigurationProperties; public HelmTemplateUtils( ArtifactDownloader artifactDownloader, RoscoHelmConfigurationProperties helmConfigurationProperties) { - this.artifactDownloader = artifactDownloader; + super(artifactDownloader); this.helmConfigurationProperties = helmConfigurationProperties; } public BakeRecipe buildBakeRecipe(BakeManifestEnvironment env, HelmBakeManifestRequest request) throws IOException { - BakeRecipe result = new BakeRecipe(); - result.setName(request.getOutputName()); - Path templatePath; - List valuePaths = new ArrayList<>(); + List inputArtifacts = request.getInputArtifacts(); if (inputArtifacts == null || inputArtifacts.isEmpty()) { throw new IllegalArgumentException("At least one input artifact must be provided to bake"); } - Artifact helmTemplateArtifact = inputArtifacts.get(0); - String artifactType = Optional.ofNullable(helmTemplateArtifact.getType()).orElse(""); - if ("git/repo".equals(artifactType)) { - env.downloadArtifactTarballAndExtract(artifactDownloader, helmTemplateArtifact); - - log.info("helmChartFilePath: '{}'", request.getHelmChartFilePath()); - - // If there's no helm chart path specified, assume it lives in the root of - // the git/repo artifact. - templatePath = - env.resolvePath(Optional.ofNullable(request.getHelmChartFilePath()).orElse("")); - } else { - try { - templatePath = downloadArtifactToTmpFile(env, helmTemplateArtifact); - } catch (SpinnakerHttpException e) { - throw new SpinnakerHttpException(fetchFailureMessage("template", e), e); - } catch (IOException | SpinnakerException e) { - throw new IllegalStateException(fetchFailureMessage("template", e), e); - } - } + templatePath = getHelmTypePathFromArtifact(env, inputArtifacts, request.getHelmChartFilePath()); log.info("path to Chart.yaml: {}", templatePath); + return buildCommand(request, getValuePaths(inputArtifacts, env), templatePath); + } - try { - // not a stream to keep exception handling cleaner - for (Artifact valueArtifact : inputArtifacts.subList(1, inputArtifacts.size())) { - valuePaths.add(downloadArtifactToTmpFile(env, valueArtifact)); - } - } catch (SpinnakerHttpException e) { - throw new SpinnakerHttpException(fetchFailureMessage("values file", e), e); - } catch (IOException | SpinnakerException e) { - throw new IllegalStateException(fetchFailureMessage("values file", e), e); + public String fetchFailureMessage(String description, Exception e) { + return "Failed to fetch helm " + description + ": " + e.getMessage(); + } + + public String getHelmExecutableForRequest(HelmBakeManifestRequest request) { + if (BakeManifestRequest.TemplateRenderer.HELM2.equals(request.getTemplateRenderer())) { + return helmConfigurationProperties.getV2ExecutablePath(); } + return helmConfigurationProperties.getV3ExecutablePath(); + } + + public BakeRecipe buildCommand( + HelmBakeManifestRequest request, List valuePaths, Path templatePath) { + BakeRecipe result = new BakeRecipe(); + result.setName(request.getOutputName()); List command = new ArrayList<>(); String executable = getHelmExecutableForRequest(request); @@ -133,29 +108,4 @@ public BakeRecipe buildBakeRecipe(BakeManifestEnvironment env, HelmBakeManifestR return result; } - - private String fetchFailureMessage(String description, Exception e) { - return "Failed to fetch helm " + description + ": " + e.getMessage(); - } - - public String removeTestsDirectoryTemplates(String inputString) { - return Arrays.stream(inputString.split(MANIFEST_SEPARATOR)) - .filter(manifest -> !REGEX_TESTS_MANIFESTS.matcher(manifest).find()) - .collect(Collectors.joining(MANIFEST_SEPARATOR)); - } - - private Path downloadArtifactToTmpFile(BakeManifestEnvironment env, Artifact artifact) - throws IOException { - String fileName = UUID.randomUUID().toString(); - Path targetPath = env.resolvePath(fileName); - artifactDownloader.downloadArtifactToFile(artifact, targetPath); - return targetPath; - } - - private String getHelmExecutableForRequest(HelmBakeManifestRequest request) { - if (BakeManifestRequest.TemplateRenderer.HELM2.equals(request.getTemplateRenderer())) { - return helmConfigurationProperties.getV2ExecutablePath(); - } - return helmConfigurationProperties.getV3ExecutablePath(); - } } diff --git a/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/helmfile/HelmfileBakeManifestRequest.java b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/helmfile/HelmfileBakeManifestRequest.java new file mode 100644 index 000000000..2367059d2 --- /dev/null +++ b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/helmfile/HelmfileBakeManifestRequest.java @@ -0,0 +1,50 @@ +/* + * Copyright 2023 Grab Holdings, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.rosco.manifests.helmfile; + +import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import com.netflix.spinnaker.rosco.manifests.BakeManifestRequest; +import java.util.List; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class HelmfileBakeManifestRequest extends BakeManifestRequest { + private String helmfileFilePath; + + /** + * The environment name used to customize the content of the helmfile manifest. The environment + * name defaults to default. + */ + private String environment; + + /** The namespace to be released into. */ + private String namespace; + + /** + * The 0th element is (or contains) the helmfile template. The rest (possibly none) are values + * files. + */ + List inputArtifacts; + + /** + * Include custom resource definition manifests in the templated output. Helmfile uses Helm v3 + * only which provides the option to include CRDs as part of the rendered output. + */ + boolean includeCRDs; +} diff --git a/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/helmfile/HelmfileBakeManifestService.java b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/helmfile/HelmfileBakeManifestService.java new file mode 100644 index 000000000..a3061927a --- /dev/null +++ b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/helmfile/HelmfileBakeManifestService.java @@ -0,0 +1,65 @@ +/* + * Copyright 2023 Grab Holdings, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.rosco.manifests.helmfile; + +import static com.netflix.spinnaker.rosco.manifests.BakeManifestRequest.TemplateRenderer; + +import com.google.common.collect.ImmutableSet; +import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import com.netflix.spinnaker.rosco.jobs.BakeRecipe; +import com.netflix.spinnaker.rosco.jobs.JobExecutor; +import com.netflix.spinnaker.rosco.manifests.BakeManifestEnvironment; +import com.netflix.spinnaker.rosco.manifests.BakeManifestService; +import java.io.IOException; +import java.util.Base64; +import org.springframework.stereotype.Component; + +@Component +public class HelmfileBakeManifestService extends BakeManifestService { + private final HelmfileTemplateUtils helmfileTemplateUtils; + private static final ImmutableSet supportedTemplates = + ImmutableSet.of(TemplateRenderer.HELMFILE.toString()); + + public HelmfileBakeManifestService( + HelmfileTemplateUtils helmTemplateUtils, JobExecutor jobExecutor) { + super(jobExecutor); + this.helmfileTemplateUtils = helmTemplateUtils; + } + + @Override + public Class requestType() { + return HelmfileBakeManifestRequest.class; + } + + @Override + public boolean handles(String type) { + return supportedTemplates.contains(type); + } + + public Artifact bake(HelmfileBakeManifestRequest helmfileBakeManifestRequest) throws IOException { + try (BakeManifestEnvironment env = BakeManifestEnvironment.create()) { + BakeRecipe recipe = helmfileTemplateUtils.buildBakeRecipe(env, helmfileBakeManifestRequest); + + String bakeResult = helmfileTemplateUtils.removeTestsDirectoryTemplates(doBake(recipe)); + return Artifact.builder() + .type("embedded/base64") + .name(helmfileBakeManifestRequest.getOutputArtifactName()) + .reference(Base64.getEncoder().encodeToString(bakeResult.getBytes())) + .build(); + } + } +} diff --git a/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/helmfile/HelmfileTemplateUtils.java b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/helmfile/HelmfileTemplateUtils.java new file mode 100644 index 000000000..3abb66c95 --- /dev/null +++ b/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/helmfile/HelmfileTemplateUtils.java @@ -0,0 +1,125 @@ +/* + * Copyright 2023 Grab Holdings, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.rosco.manifests.helmfile; + +import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import com.netflix.spinnaker.rosco.jobs.BakeRecipe; +import com.netflix.spinnaker.rosco.manifests.ArtifactDownloader; +import com.netflix.spinnaker.rosco.manifests.BakeManifestEnvironment; +import com.netflix.spinnaker.rosco.manifests.HelmBakeTemplateUtils; +import com.netflix.spinnaker.rosco.manifests.config.RoscoHelmConfigurationProperties; +import com.netflix.spinnaker.rosco.manifests.config.RoscoHelmfileConfigurationProperties; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class HelmfileTemplateUtils extends HelmBakeTemplateUtils { + private final RoscoHelmfileConfigurationProperties helmfileConfigurationProperties; + private final RoscoHelmConfigurationProperties helmConfigurationProperties = + new RoscoHelmConfigurationProperties(); + + public HelmfileTemplateUtils( + ArtifactDownloader artifactDownloader, + RoscoHelmfileConfigurationProperties helmfileConfigurationProperties) { + super(artifactDownloader); + this.helmfileConfigurationProperties = helmfileConfigurationProperties; + } + + public BakeRecipe buildBakeRecipe( + BakeManifestEnvironment env, HelmfileBakeManifestRequest request) throws IOException { + Path helmfileFilePath; + + List inputArtifacts = request.getInputArtifacts(); + if (inputArtifacts == null || inputArtifacts.isEmpty()) { + throw new IllegalArgumentException("At least one input artifact must be provided to bake"); + } + + log.info("helmfileFilePath: '{}'", request.getHelmfileFilePath()); + helmfileFilePath = + getHelmTypePathFromArtifact(env, inputArtifacts, request.getHelmfileFilePath()); + + log.info("path to helmfile: {}", helmfileFilePath); + return buildCommand(request, getValuePaths(inputArtifacts, env), helmfileFilePath); + } + + public String fetchFailureMessage(String description, Exception e) { + return "Failed to fetch helmfile " + description + ": " + e.getMessage(); + } + + public String getHelmExecutableForRequest(HelmfileBakeManifestRequest request) { + return helmConfigurationProperties.getV3ExecutablePath(); + } + + public BakeRecipe buildCommand( + HelmfileBakeManifestRequest request, List valuePaths, Path helmfileFilePath) { + BakeRecipe result = new BakeRecipe(); + result.setName(request.getOutputName()); + + List command = new ArrayList<>(); + String executable = helmfileConfigurationProperties.getExecutablePath(); + + command.add(executable); + command.add("template"); + command.add("--file"); + command.add(helmfileFilePath.toString()); + + command.add("--helm-binary"); + command.add(getHelmExecutableForRequest(null)); + + String environment = request.getEnvironment(); + if (environment != null && !environment.isEmpty()) { + command.add("--environment"); + command.add(environment); + } + + String namespace = request.getNamespace(); + if (namespace != null && !namespace.isEmpty()) { + command.add("--namespace"); + command.add(namespace); + } + + if (request.isIncludeCRDs()) { + command.add("--include-crds"); + } + + Map overrides = request.getOverrides(); + if (!overrides.isEmpty()) { + List overrideList = new ArrayList<>(); + for (Map.Entry entry : overrides.entrySet()) { + overrideList.add(entry.getKey() + "=" + entry.getValue().toString()); + } + command.add("--set"); + command.add(String.join(",", overrideList)); + } + + if (!valuePaths.isEmpty()) { + command.add("--values"); + command.add(valuePaths.stream().map(Path::toString).collect(Collectors.joining(","))); + } + + result.setCommand(command); + + return result; + } +} diff --git a/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/helmfile/HelmfileTemplateUtilsTest.java b/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/helmfile/HelmfileTemplateUtilsTest.java new file mode 100644 index 000000000..a6119e6be --- /dev/null +++ b/rosco-manifests/src/test/java/com/netflix/spinnaker/rosco/manifests/helmfile/HelmfileTemplateUtilsTest.java @@ -0,0 +1,671 @@ +/* + * Copyright 2023 Grab Holdings, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.rosco.manifests.helmfile; + +import static com.netflix.spinnaker.rosco.manifests.ManifestTestUtils.makeSpinnakerHttpException; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import com.netflix.spinnaker.kork.exceptions.SpinnakerException; +import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerHttpException; +import com.netflix.spinnaker.rosco.jobs.BakeRecipe; +import com.netflix.spinnaker.rosco.manifests.ArtifactDownloader; +import com.netflix.spinnaker.rosco.manifests.BakeManifestEnvironment; +import com.netflix.spinnaker.rosco.manifests.BakeManifestRequest; +import com.netflix.spinnaker.rosco.manifests.config.RoscoHelmConfigurationProperties; +import com.netflix.spinnaker.rosco.manifests.config.RoscoHelmfileConfigurationProperties; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.FileVisitOption; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream; +import org.apache.commons.compress.utils.IOUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.http.HttpStatus; + +final class HelmfileTemplateUtilsTest { + + private ArtifactDownloader artifactDownloader; + + private HelmfileTemplateUtils helmfileTemplateUtils; + + private HelmfileBakeManifestRequest bakeManifestRequest; + + @BeforeEach + private void init(TestInfo testInfo) { + System.out.println("--------------- Test " + testInfo.getDisplayName()); + + artifactDownloader = mock(ArtifactDownloader.class); + RoscoHelmfileConfigurationProperties helmfileConfigurationProperties = + new RoscoHelmfileConfigurationProperties(); + helmfileTemplateUtils = + new HelmfileTemplateUtils(artifactDownloader, helmfileConfigurationProperties); + Artifact chartArtifact = Artifact.builder().name("test-artifact").version("3").build(); + + bakeManifestRequest = new HelmfileBakeManifestRequest(); + bakeManifestRequest.setInputArtifacts(ImmutableList.of(chartArtifact)); + } + + @Test + public void nullReferenceTest() throws IOException { + bakeManifestRequest.setOverrides(ImmutableMap.of()); + + try (BakeManifestEnvironment env = BakeManifestEnvironment.create()) { + BakeRecipe recipe = helmfileTemplateUtils.buildBakeRecipe(env, bakeManifestRequest); + } + } + + @Test + public void exceptionDownloading() throws IOException { + // When artifactDownloader throws an exception, make sure we wrap it and get + // a chance to include our own message, so the exception that goes up the + // chain includes something about helmfile. + SpinnakerException spinnakerException = new SpinnakerException("error from ArtifactDownloader"); + doThrow(spinnakerException) + .when(artifactDownloader) + .downloadArtifactToFile(any(Artifact.class), any(Path.class)); + + try (BakeManifestEnvironment env = BakeManifestEnvironment.create()) { + IllegalStateException thrown = + assertThrows( + IllegalStateException.class, + () -> helmfileTemplateUtils.buildBakeRecipe(env, bakeManifestRequest)); + + assertThat(thrown.getMessage()).contains("Failed to fetch helmfile template"); + assertThat(thrown.getCause()).isEqualTo(spinnakerException); + } + } + + @Test + public void httpExceptionDownloading() throws IOException { + // When artifactDownloader throws a SpinnakerHttpException, make sure we + // wrap it and get a chance to include our own message, so the exception + // that goes up the chain includes something about helm charts. It's + // important that HelmTemplateUtils also throws a SpinnakerHttpException so + // it's eventually handled properly...meaning the status code in the http + // response and the logging correspond to what happened. For example, if + // there's a 404 from clouddriver, rosco also responds with 404, and doesn't + // log an error. + + SpinnakerHttpException spinnakerHttpException = + makeSpinnakerHttpException(HttpStatus.NOT_FOUND.value()); + doThrow(spinnakerHttpException) + .when(artifactDownloader) + .downloadArtifactToFile(any(Artifact.class), any(Path.class)); + + try (BakeManifestEnvironment env = BakeManifestEnvironment.create()) { + SpinnakerHttpException thrown = + assertThrows( + SpinnakerHttpException.class, + () -> helmfileTemplateUtils.buildBakeRecipe(env, bakeManifestRequest)); + + assertThat(thrown.getMessage()).contains("Failed to fetch helmfile template"); + assertThat(thrown.getResponseCode()).isEqualTo(HttpStatus.NOT_FOUND.value()); + assertThat(thrown.getCause()).isEqualTo(spinnakerHttpException); + } + } + + @Test + public void removeTestsDirectoryTemplatesWithTests() throws IOException { + String inputManifests = + "---\n" + + "# Source: mysql/templates/pvc.yaml\n" + + "\n" + + "kind: PersistentVolumeClaim\n" + + "apiVersion: v1\n" + + "metadata:\n" + + " name: release-name-mysql\n" + + " namespace: default\n" + + "spec:\n" + + " accessModes:\n" + + " - \"ReadWriteOnce\"\n" + + " resources:\n" + + " requests:\n" + + " storage: \"8Gi\"\n" + + "---\n" + + "# Source: mysql/templates/tests/test-configmap.yaml\n" + + "apiVersion: v1\n" + + "kind: ConfigMap\n" + + "metadata:\n" + + " name: release-name-mysql-test\n" + + " namespace: default\n" + + "data:\n" + + " run.sh: |-\n"; + + ArtifactDownloader artifactDownloader = mock(ArtifactDownloader.class); + RoscoHelmfileConfigurationProperties helmfileConfigurationProperties = + new RoscoHelmfileConfigurationProperties(); + HelmfileTemplateUtils helmfileTemplateUtils = + new HelmfileTemplateUtils(artifactDownloader, helmfileConfigurationProperties); + + String output = helmfileTemplateUtils.removeTestsDirectoryTemplates(inputManifests); + + String expected = + "---\n" + + "# Source: mysql/templates/pvc.yaml\n" + + "\n" + + "kind: PersistentVolumeClaim\n" + + "apiVersion: v1\n" + + "metadata:\n" + + " name: release-name-mysql\n" + + " namespace: default\n" + + "spec:\n" + + " accessModes:\n" + + " - \"ReadWriteOnce\"\n" + + " resources:\n" + + " requests:\n" + + " storage: \"8Gi\"\n"; + + assertEquals(expected.trim(), output.trim()); + } + + @Test + public void removeTestsDirectoryTemplatesWithoutTests() throws IOException { + String inputManifests = + "---\n" + + "# Source: mysql/templates/pvc.yaml\n" + + "\n" + + "kind: PersistentVolumeClaim\n" + + "apiVersion: v1\n" + + "metadata:\n" + + " name: release-name-mysql\n" + + " namespace: default\n" + + "spec:\n" + + " accessModes:\n" + + " - \"ReadWriteOnce\"\n" + + " resources:\n" + + " requests:\n" + + " storage: \"8Gi\"\n" + + "---\n" + + "# Source: mysql/templates/configmap.yaml\n" + + "apiVersion: v1\n" + + "kind: ConfigMap\n" + + "metadata:\n" + + " name: release-name-mysql-test\n" + + " namespace: default\n" + + "data:\n" + + " run.sh: |-\n"; + + ArtifactDownloader artifactDownloader = mock(ArtifactDownloader.class); + RoscoHelmfileConfigurationProperties helmfileConfigurationProperties = + new RoscoHelmfileConfigurationProperties(); + HelmfileTemplateUtils helmfileTemplateUtils = + new HelmfileTemplateUtils(artifactDownloader, helmfileConfigurationProperties); + + String output = helmfileTemplateUtils.removeTestsDirectoryTemplates(inputManifests); + + assertEquals(inputManifests.trim(), output.trim()); + } + + @ParameterizedTest + @MethodSource("helmfileRendererArgs") + public void buildBakeRecipeSelectsHelm3ExecutableWhenNoneSet( + String command, BakeManifestRequest.TemplateRenderer templateRenderer) throws IOException { + ArtifactDownloader artifactDownloader = mock(ArtifactDownloader.class); + RoscoHelmfileConfigurationProperties helmfileConfigurationProperties = + new RoscoHelmfileConfigurationProperties(); + HelmfileTemplateUtils helmfileTemplateUtils = + new HelmfileTemplateUtils(artifactDownloader, helmfileConfigurationProperties); + RoscoHelmConfigurationProperties helmConfigurationProperties = + new RoscoHelmConfigurationProperties(); + + HelmfileBakeManifestRequest request = new HelmfileBakeManifestRequest(); + Artifact artifact = Artifact.builder().build(); + request.setInputArtifacts(Collections.singletonList(artifact)); + request.setOverrides(Collections.emptyMap()); + + request.setTemplateRenderer(templateRenderer); + try (BakeManifestEnvironment env = BakeManifestEnvironment.create()) { + BakeRecipe recipe = helmfileTemplateUtils.buildBakeRecipe(env, request); + + assertEquals(helmConfigurationProperties.getV3ExecutablePath(), recipe.getCommand().get(5)); + } + } + + private static Stream helmfileRendererArgs() { + // The command here (e.g. helmfile) must match the defaults in + // RoscoHelmfileConfigurationProperties + return Stream.of(Arguments.of("helmfile", BakeManifestRequest.TemplateRenderer.HELMFILE)); + } + + @Test + public void buildBakeRecipeWithGitRepoArtifact(@TempDir Path tempDir) throws IOException { + // git/repo artifacts appear as a tarball, so create one that contains a + // helmfile file + // and helm chart. + addTestHelmfile(tempDir); + + ArtifactDownloader artifactDownloader = mock(ArtifactDownloader.class); + RoscoHelmfileConfigurationProperties helmfileConfigurationProperties = + new RoscoHelmfileConfigurationProperties(); + HelmfileTemplateUtils helmfileTemplateUtils = + new HelmfileTemplateUtils(artifactDownloader, helmfileConfigurationProperties); + + HelmfileBakeManifestRequest request = new HelmfileBakeManifestRequest(); + + Artifact artifact = + Artifact.builder().type("git/repo").reference("https://github.com/some/repo.git").build(); + + // Set up the mock artifactDownloader to supply the tarball that represents + // the git/repo artifact + when(artifactDownloader.downloadArtifact(artifact)).thenReturn(makeTarball(tempDir)); + + request.setInputArtifacts(Collections.singletonList(artifact)); + request.setOverrides(Collections.emptyMap()); + + try (BakeManifestEnvironment env = BakeManifestEnvironment.create()) { + BakeRecipe recipe = helmfileTemplateUtils.buildBakeRecipe(env, request); + + // Make sure we're really testing the git/repo logic + verify(artifactDownloader).downloadArtifact(artifact); + + // Make sure the BakeManifestEnvironment has the files in our git/repo artifact. + assertTrue(env.resolvePath("helmfile.yaml").toFile().exists()); + assertTrue(env.resolvePath("Chart.yaml").toFile().exists()); + assertTrue(env.resolvePath("values.yaml").toFile().exists()); + assertTrue(env.resolvePath("templates/foo.yaml").toFile().exists()); + } + } + + @Test + public void buildBakeRecipeWithGitRepoArtifactUsingHelmfileFilePath(@TempDir Path tempDir) + throws IOException { + // Create a tarball with a helmfile in a sub directory + String subDirName = "subdir"; + Path subDir = tempDir.resolve(subDirName); + addTestHelmfile(subDir); + + ArtifactDownloader artifactDownloader = mock(ArtifactDownloader.class); + RoscoHelmfileConfigurationProperties helmfileConfigurationProperties = + new RoscoHelmfileConfigurationProperties(); + HelmfileTemplateUtils helmfileTemplateUtils = + new HelmfileTemplateUtils(artifactDownloader, helmfileConfigurationProperties); + + HelmfileBakeManifestRequest request = new HelmfileBakeManifestRequest(); + + // Note that supplying a location for a git/repo artifact doesn't change the + // path in the resulting tarball. It's here because it's likely that it's + // used together with helmChartFilePath. Removing it wouldn't change the + // test. + Artifact artifact = + Artifact.builder() + .type("git/repo") + .reference("https://github.com/some/repo.git") + .location(subDirName) + .build(); + + // Set up the mock artifactDownloader to supply the tarball that represents + // the git/repo artifact + when(artifactDownloader.downloadArtifact(artifact)).thenReturn(makeTarball(tempDir)); + + request.setInputArtifacts(Collections.singletonList(artifact)); + request.setOverrides(Collections.emptyMap()); + + // This is the key part of this test. + request.setHelmfileFilePath(subDirName); + + try (BakeManifestEnvironment env = BakeManifestEnvironment.create()) { + BakeRecipe recipe = helmfileTemplateUtils.buildBakeRecipe(env, request); + + // Make sure we're really testing the git/repo logic + verify(artifactDownloader).downloadArtifact(artifact); + + // Make sure the BakeManifestEnvironment has the files in our git/repo + // artifact in the expected location. + assertTrue(env.resolvePath(Path.of(subDirName, "helmfile.yaml")).toFile().exists()); + assertTrue(env.resolvePath(Path.of(subDirName, "Chart.yaml")).toFile().exists()); + assertTrue(env.resolvePath(Path.of(subDirName, "values.yaml")).toFile().exists()); + assertTrue(env.resolvePath(Path.of(subDirName, "templates/foo.yaml")).toFile().exists()); + + // And that the helm template command includes the path to the subdirectory + // + // The expected elements in the + // command list are: + // + // 0 - the helm executable + // 1 - template + // 2 - --file flag + // 3 - the path to helmfile.yaml + assertEquals(env.resolvePath(subDirName).toString(), recipe.getCommand().get(3)); + } + } + + /** + * Add a helmfile and helm chart for testing + * + * @param path the location of the helmfile file (e.g. helmfile.yaml) + */ + void addTestHelmfile(Path path) throws IOException { + addFile( + path, + "helmfile.yaml", + "releases:\n" + + " - name: test\n" + + " namespace: namespace\n" + + " chart: Chart.yaml\n" + + " values:\n" + + " - values.yaml\n"); + + addFile( + path, + "Chart.yaml", + "apiVersion: v1\n" + + "name: example\n" + + "description: chart for testing\n" + + "version: 0.1\n" + + "engine: gotpl\n"); + + addFile(path, "values.yaml", "foo: bar\n"); + + addFile( + path, + "templates/foo.yaml", + "labels:\n" + "helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}\n"); + } + + /** + * Create a new file in the temp directory + * + * @param path the path of the file to create (relative to the temp directory's root) + * @param content the content of the file, or null for an empty file + */ + void addFile(Path tempDir, String path, String content) throws IOException { + Path pathToCreate = tempDir.resolve(path); + pathToCreate.toFile().getParentFile().mkdirs(); + Files.write(pathToCreate, content.getBytes()); + } + + /** + * Make a gzipped tarball of all files in a path + * + * @param rootPath the root path of the tarball + * @return an InputStream containing the gzipped tarball + */ + InputStream makeTarball(Path rootPath) throws IOException { + ArrayList filePathsToAdd = + Files.walk(rootPath, FileVisitOption.FOLLOW_LINKS) + .filter(path -> !path.equals(rootPath)) + .collect(Collectors.toCollection(ArrayList::new)); + + // See + // https://commons.apache.org/proper/commons-compress/examples.html#Common_Archival_Logic + // for background + try (ByteArrayOutputStream os = new ByteArrayOutputStream(); + GzipCompressorOutputStream gzo = new GzipCompressorOutputStream(os); + TarArchiveOutputStream tarArchive = new TarArchiveOutputStream(gzo)) { + for (Path path : filePathsToAdd) { + TarArchiveEntry tarEntry = + new TarArchiveEntry(path.toFile(), rootPath.relativize(path).toString()); + tarArchive.setBigNumberMode(tarArchive.BIGNUMBER_POSIX); + tarArchive.setLongFileMode(tarArchive.LONGFILE_POSIX); + tarArchive.putArchiveEntry(tarEntry); + if (path.toFile().isFile()) { + IOUtils.copy(Files.newInputStream(path), tarArchive); + } + tarArchive.closeArchiveEntry(); + } + + tarArchive.finish(); + gzo.finish(); + + return new ByteArrayInputStream(os.toByteArray()); + } + } + + @Test + public void buildBakeRecipeIncludesEnvironmentWhenSet() throws IOException { + ArtifactDownloader artifactDownloader = mock(ArtifactDownloader.class); + RoscoHelmfileConfigurationProperties helmfileConfigurationProperties = + new RoscoHelmfileConfigurationProperties(); + HelmfileTemplateUtils helmfileTemplateUtils = + new HelmfileTemplateUtils(artifactDownloader, helmfileConfigurationProperties); + + HelmfileBakeManifestRequest request = new HelmfileBakeManifestRequest(); + Artifact artifact = Artifact.builder().build(); + String envName = "testEnvironment"; + request.setEnvironment(envName); + request.setInputArtifacts(Collections.singletonList(artifact)); + request.setOverrides(Collections.emptyMap()); + + try (BakeManifestEnvironment env = BakeManifestEnvironment.create()) { + BakeRecipe recipe = helmfileTemplateUtils.buildBakeRecipe(env, request); + assertTrue(recipe.getCommand().contains("--environment")); + assertTrue(recipe.getCommand().contains(envName)); + // Assert that the flag position goes after 'helmfile template' subcommand + assertTrue(recipe.getCommand().indexOf("--environment") > 1); + } + } + + @Test + public void buildBakeRecipeDoesNotIncludeEnvironmentWhenNotSet() throws IOException { + ArtifactDownloader artifactDownloader = mock(ArtifactDownloader.class); + RoscoHelmfileConfigurationProperties helmfileConfigurationProperties = + new RoscoHelmfileConfigurationProperties(); + HelmfileTemplateUtils helmfileTemplateUtils = + new HelmfileTemplateUtils(artifactDownloader, helmfileConfigurationProperties); + + HelmfileBakeManifestRequest request = new HelmfileBakeManifestRequest(); + Artifact artifact = Artifact.builder().build(); + request.setInputArtifacts(Collections.singletonList(artifact)); + request.setOverrides(Collections.emptyMap()); + + try (BakeManifestEnvironment env = BakeManifestEnvironment.create()) { + BakeRecipe recipe = helmfileTemplateUtils.buildBakeRecipe(env, request); + assertFalse(recipe.getCommand().contains("--environment")); + } + } + + @Test + public void buildBakeRecipeIncludesNamespaceWhenSet() throws IOException { + ArtifactDownloader artifactDownloader = mock(ArtifactDownloader.class); + RoscoHelmfileConfigurationProperties helmfileConfigurationProperties = + new RoscoHelmfileConfigurationProperties(); + HelmfileTemplateUtils helmfileTemplateUtils = + new HelmfileTemplateUtils(artifactDownloader, helmfileConfigurationProperties); + + HelmfileBakeManifestRequest request = new HelmfileBakeManifestRequest(); + Artifact artifact = Artifact.builder().build(); + String namespaceName = "testNamespace"; + request.setNamespace(namespaceName); + request.setInputArtifacts(Collections.singletonList(artifact)); + request.setOverrides(Collections.emptyMap()); + + try (BakeManifestEnvironment env = BakeManifestEnvironment.create()) { + BakeRecipe recipe = helmfileTemplateUtils.buildBakeRecipe(env, request); + assertTrue(recipe.getCommand().contains("--namespace")); + assertTrue(recipe.getCommand().contains(namespaceName)); + // Assert that the flag position goes after 'helmfile template' subcommand + assertTrue(recipe.getCommand().indexOf("--namespace") > 1); + } + } + + @Test + public void buildBakeRecipeDoesNotIncludeNamespaceWhenNotSet() throws IOException { + ArtifactDownloader artifactDownloader = mock(ArtifactDownloader.class); + RoscoHelmfileConfigurationProperties helmfileConfigurationProperties = + new RoscoHelmfileConfigurationProperties(); + HelmfileTemplateUtils helmfileTemplateUtils = + new HelmfileTemplateUtils(artifactDownloader, helmfileConfigurationProperties); + + HelmfileBakeManifestRequest request = new HelmfileBakeManifestRequest(); + Artifact artifact = Artifact.builder().build(); + request.setInputArtifacts(Collections.singletonList(artifact)); + request.setOverrides(Collections.emptyMap()); + + try (BakeManifestEnvironment env = BakeManifestEnvironment.create()) { + BakeRecipe recipe = helmfileTemplateUtils.buildBakeRecipe(env, request); + assertFalse(recipe.getCommand().contains("--namespace")); + } + } + + @Test + public void buildBakeRecipeIncludingCRDsWithHelm3() throws IOException { + ArtifactDownloader artifactDownloader = mock(ArtifactDownloader.class); + RoscoHelmfileConfigurationProperties helmfileConfigurationProperties = + new RoscoHelmfileConfigurationProperties(); + HelmfileTemplateUtils helmfileTemplateUtils = + new HelmfileTemplateUtils(artifactDownloader, helmfileConfigurationProperties); + + HelmfileBakeManifestRequest request = new HelmfileBakeManifestRequest(); + Artifact artifact = Artifact.builder().build(); + request.setIncludeCRDs(true); + request.setInputArtifacts(Collections.singletonList(artifact)); + request.setOverrides(Collections.emptyMap()); + + try (BakeManifestEnvironment env = BakeManifestEnvironment.create()) { + BakeRecipe recipe = helmfileTemplateUtils.buildBakeRecipe(env, request); + assertTrue(recipe.getCommand().contains("--include-crds")); + // Assert that the flag position goes after 'helmfile template' subcommand + assertTrue(recipe.getCommand().indexOf("--include-crds") > 1); + } + } + + @Test + public void buildBakeRecipeNotIncludingCRDsWithHelm3() throws IOException { + ArtifactDownloader artifactDownloader = mock(ArtifactDownloader.class); + RoscoHelmfileConfigurationProperties helmfileConfigurationProperties = + new RoscoHelmfileConfigurationProperties(); + HelmfileTemplateUtils helmfileTemplateUtils = + new HelmfileTemplateUtils(artifactDownloader, helmfileConfigurationProperties); + + HelmfileBakeManifestRequest request = new HelmfileBakeManifestRequest(); + Artifact artifact = Artifact.builder().build(); + request.setInputArtifacts(Collections.singletonList(artifact)); + request.setOverrides(Collections.emptyMap()); + + try (BakeManifestEnvironment env = BakeManifestEnvironment.create()) { + BakeRecipe recipe = helmfileTemplateUtils.buildBakeRecipe(env, request); + assertFalse(recipe.getCommand().contains("--include-crds")); + } + } + + @Test + public void buildBakeRecipeIncludesOverridesWhenSet() throws IOException { + ArtifactDownloader artifactDownloader = mock(ArtifactDownloader.class); + RoscoHelmfileConfigurationProperties helmfileConfigurationProperties = + new RoscoHelmfileConfigurationProperties(); + HelmfileTemplateUtils helmfileTemplateUtils = + new HelmfileTemplateUtils(artifactDownloader, helmfileConfigurationProperties); + + HelmfileBakeManifestRequest request = new HelmfileBakeManifestRequest(); + Artifact artifact = Artifact.builder().build(); + String overrideKey = "testOverrideKey"; + String overrideValue = "testOverrideValue"; + request.setInputArtifacts(Collections.singletonList(artifact)); + request.setOverrides(Collections.singletonMap(overrideKey, overrideValue)); + + try (BakeManifestEnvironment env = BakeManifestEnvironment.create()) { + BakeRecipe recipe = helmfileTemplateUtils.buildBakeRecipe(env, request); + assertTrue(recipe.getCommand().contains("--set")); + assertTrue(recipe.getCommand().contains(overrideKey + "=" + overrideValue)); + // Assert that the flag position goes after 'helmfile template' subcommand + assertTrue(recipe.getCommand().indexOf("--set") > 1); + } + } + + @Test + public void buildBakeRecipeDoesNotIncludeOverridesWhenNotSet() throws IOException { + ArtifactDownloader artifactDownloader = mock(ArtifactDownloader.class); + RoscoHelmfileConfigurationProperties helmfileConfigurationProperties = + new RoscoHelmfileConfigurationProperties(); + HelmfileTemplateUtils helmfileTemplateUtils = + new HelmfileTemplateUtils(artifactDownloader, helmfileConfigurationProperties); + + HelmfileBakeManifestRequest request = new HelmfileBakeManifestRequest(); + Artifact artifact = Artifact.builder().build(); + request.setInputArtifacts(Collections.singletonList(artifact)); + request.setOverrides(Collections.emptyMap()); + + try (BakeManifestEnvironment env = BakeManifestEnvironment.create()) { + BakeRecipe recipe = helmfileTemplateUtils.buildBakeRecipe(env, request); + assertFalse(recipe.getCommand().contains("--set")); + } + } + + @Test + public void buildBakeRecipeIncludesValuesWhenSet() throws IOException { + ArtifactDownloader artifactDownloader = mock(ArtifactDownloader.class); + RoscoHelmfileConfigurationProperties helmfileConfigurationProperties = + new RoscoHelmfileConfigurationProperties(); + HelmfileTemplateUtils helmfileTemplateUtils = + new HelmfileTemplateUtils(artifactDownloader, helmfileConfigurationProperties); + + HelmfileBakeManifestRequest request = new HelmfileBakeManifestRequest(); + + List artifacts = new ArrayList<>(); + Artifact artifact = Artifact.builder().build(); + Artifact testValue = Artifact.builder().build(); + artifacts.add(artifact); + artifacts.add(testValue); + + request.setInputArtifacts(artifacts); + request.setOverrides(Collections.emptyMap()); + + try (BakeManifestEnvironment env = BakeManifestEnvironment.create()) { + BakeRecipe recipe = helmfileTemplateUtils.buildBakeRecipe(env, request); + assertTrue(recipe.getCommand().contains("--values")); + // Assert that the flag position goes after 'helmfile template' subcommand + assertTrue(recipe.getCommand().indexOf("--values") > 1); + } + } + + @Test + public void buildBakeRecipeDoesNotIncludeValuesWhenNotSet() throws IOException { + ArtifactDownloader artifactDownloader = mock(ArtifactDownloader.class); + RoscoHelmfileConfigurationProperties helmfileConfigurationProperties = + new RoscoHelmfileConfigurationProperties(); + HelmfileTemplateUtils helmfileTemplateUtils = + new HelmfileTemplateUtils(artifactDownloader, helmfileConfigurationProperties); + + HelmfileBakeManifestRequest request = new HelmfileBakeManifestRequest(); + Artifact artifact = Artifact.builder().build(); + request.setInputArtifacts(Collections.singletonList(artifact)); + request.setOverrides(Collections.emptyMap()); + + try (BakeManifestEnvironment env = BakeManifestEnvironment.create()) { + BakeRecipe recipe = helmfileTemplateUtils.buildBakeRecipe(env, request); + assertFalse(recipe.getCommand().contains("--values")); + } + } +} diff --git a/rosco-web/src/main/groovy/com/netflix/spinnaker/rosco/Main.groovy b/rosco-web/src/main/groovy/com/netflix/spinnaker/rosco/Main.groovy index cfef45dc4..d98596f69 100644 --- a/rosco-web/src/main/groovy/com/netflix/spinnaker/rosco/Main.groovy +++ b/rosco-web/src/main/groovy/com/netflix/spinnaker/rosco/Main.groovy @@ -24,6 +24,7 @@ import com.netflix.spinnaker.kork.artifacts.model.Artifact import com.netflix.spinnaker.rosco.config.RoscoPackerConfigurationProperties import com.netflix.spinnaker.rosco.jobs.config.LocalJobConfig import com.netflix.spinnaker.rosco.manifests.config.RoscoHelmConfigurationProperties +import com.netflix.spinnaker.rosco.manifests.config.RoscoHelmfileConfigurationProperties import com.netflix.spinnaker.rosco.manifests.config.RoscoKustomizeConfigurationProperties import com.netflix.spinnaker.rosco.providers.alicloud.config.RoscoAliCloudConfiguration import com.netflix.spinnaker.rosco.providers.aws.config.RoscoAWSConfiguration @@ -77,6 +78,7 @@ import javax.servlet.Filter RoscoTencentCloudConfiguration, RoscoPackerConfigurationProperties, RoscoHelmConfigurationProperties, + RoscoHelmfileConfigurationProperties, RoscoKustomizeConfigurationProperties, LocalJobConfig, ArtifactStoreConfiguration From 1e86a308014127c8f13aeb021f7c66aa7463d7e6 Mon Sep 17 00:00:00 2001 From: spinnakerbot Date: Tue, 18 Jul 2023 17:37:47 -0400 Subject: [PATCH 29/29] chore(dependencies): Autobump spinnakerGradleVersion (#1004) Co-authored-by: root --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 2e6895312..5413edd78 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ korkVersion=7.182.1 org.gradle.parallel=true -spinnakerGradleVersion=8.30.0 +spinnakerGradleVersion=8.31.0 targetJava11=true # To enable a composite reference to a project, set the