diff --git a/orca-bakery/src/main/java/com/netflix/spinnaker/orca/bakery/api/manifests/helmfile/HelmfileBakeManifestRequest.java b/orca-bakery/src/main/java/com/netflix/spinnaker/orca/bakery/api/manifests/helmfile/HelmfileBakeManifestRequest.java new file mode 100644 index 0000000000..59818e92fe --- /dev/null +++ b/orca-bakery/src/main/java/com/netflix/spinnaker/orca/bakery/api/manifests/helmfile/HelmfileBakeManifestRequest.java @@ -0,0 +1,69 @@ +/* + * Copyright 2023 DoubleCloud, 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.orca.bakery.api.manifests.helmfile; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import com.netflix.spinnaker.orca.bakery.api.manifests.BakeManifestRequest; +import com.netflix.spinnaker.orca.bakery.tasks.manifests.BakeManifestContext; +import java.util.List; +import java.util.Map; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class HelmfileBakeManifestRequest extends BakeManifestRequest { + + @JsonProperty("environment") + private String environment; + + @JsonProperty("namespace") + private String namespace; + + @JsonProperty("overrides") + private Map overrides; + + @JsonProperty("inputArtifacts") + private List inputArtifacts; + + private List values; + + @JsonProperty("includeCRDs") + private Boolean includeCRDs; + + @JsonProperty("helmfileFilePath") + private String helmfileFilePath; + + public HelmfileBakeManifestRequest( + BakeManifestContext bakeManifestContext, + List inputArtifacts, + String outputArtifactName, + Map overrides) { + super( + bakeManifestContext.getTemplateRenderer(), + outputArtifactName, + bakeManifestContext.getOutputName()); + this.setEnvironment(bakeManifestContext.getEnvironment()); + this.setNamespace(bakeManifestContext.getNamespace()); + this.setOverrides(overrides); + this.setInputArtifacts(inputArtifacts); + this.setIncludeCRDs(bakeManifestContext.getIncludeCRDs()); + this.setHelmfileFilePath(bakeManifestContext.getHelmfileFilePath()); + } +} diff --git a/orca-bakery/src/main/java/com/netflix/spinnaker/orca/bakery/tasks/manifests/BakeManifestContext.java b/orca-bakery/src/main/java/com/netflix/spinnaker/orca/bakery/tasks/manifests/BakeManifestContext.java index 942fab4611..bb1734c68d 100644 --- a/orca-bakery/src/main/java/com/netflix/spinnaker/orca/bakery/tasks/manifests/BakeManifestContext.java +++ b/orca-bakery/src/main/java/com/netflix/spinnaker/orca/bakery/tasks/manifests/BakeManifestContext.java @@ -34,11 +34,15 @@ public class BakeManifestContext { private final String templateRenderer; private final String outputName; private final String namespace; + private final String environment; private final Boolean rawOverrides; private final Boolean includeCRDs; @Nullable private final String kustomizeFilePath; @Nullable private final String helmChartFilePath; - // There does not seem to be a way to auto-generate a constructor using our current version of + @Nullable private final String helmfileFilePath; + + // There does not seem to be a way to auto-generate a constructor using our + // current version of // Lombok (1.16.20) that // Jackson can use to deserialize. public BakeManifestContext( @@ -50,9 +54,11 @@ public BakeManifestContext( @JsonProperty("templateRenderer") String templateRenderer, @JsonProperty("outputName") String outputName, @JsonProperty("namespace") String namespace, + @Nullable @JsonProperty("environment") String environment, @Nullable @JsonProperty("inputArtifact") CreateBakeManifestTask.InputArtifact inputArtifact, @Nullable @JsonProperty("kustomizeFilePath") String kustomizeFilePath, @Nullable @JsonProperty("helmChartFilePath") String helmChartFilePath, + @Nullable @JsonProperty("helmfileFilePath") String helmfileFilePath, @JsonProperty("rawOverrides") Boolean rawOverrides, @JsonProperty("includeCRDs") Boolean includeCRDs) { this.inputArtifacts = Optional.ofNullable(inputArtifacts).orElse(new ArrayList<>()); @@ -66,8 +72,10 @@ public BakeManifestContext( this.templateRenderer = templateRenderer; this.outputName = outputName; this.namespace = namespace; + this.environment = environment; this.kustomizeFilePath = kustomizeFilePath; this.helmChartFilePath = helmChartFilePath; + this.helmfileFilePath = helmfileFilePath; this.rawOverrides = rawOverrides; this.includeCRDs = includeCRDs; } diff --git a/orca-bakery/src/main/java/com/netflix/spinnaker/orca/bakery/tasks/manifests/CreateBakeManifestTask.java b/orca-bakery/src/main/java/com/netflix/spinnaker/orca/bakery/tasks/manifests/CreateBakeManifestTask.java index 460bf0fa9e..d386ec4743 100644 --- a/orca-bakery/src/main/java/com/netflix/spinnaker/orca/bakery/tasks/manifests/CreateBakeManifestTask.java +++ b/orca-bakery/src/main/java/com/netflix/spinnaker/orca/bakery/tasks/manifests/CreateBakeManifestTask.java @@ -26,6 +26,7 @@ import com.netflix.spinnaker.orca.bakery.api.BakeryService; import com.netflix.spinnaker.orca.bakery.api.manifests.BakeManifestRequest; import com.netflix.spinnaker.orca.bakery.api.manifests.helm.HelmBakeManifestRequest; +import com.netflix.spinnaker.orca.bakery.api.manifests.helmfile.HelmfileBakeManifestRequest; import com.netflix.spinnaker.orca.bakery.api.manifests.kustomize.KustomizeBakeManifestRequest; import com.netflix.spinnaker.orca.pipeline.util.ArtifactUtils; import com.netflix.spinnaker.orca.pipeline.util.ContextParameterProcessor; @@ -130,6 +131,11 @@ public TaskResult execute(@Nonnull StageExecution stage) { new HelmBakeManifestRequest( context, resolvedInputArtifacts, outputArtifactName, overrides); break; + case "HELMFILE": + request = + new HelmfileBakeManifestRequest( + context, resolvedInputArtifacts, outputArtifactName, overrides); + break; case "KUSTOMIZE": case "KUSTOMIZE4": Artifact inputArtifact = resolvedInputArtifacts.get(0); diff --git a/orca-bakery/src/test/java/com/netflix/spinnaker/orca/bakery/tasks/manifests/CreateBakeManifestTaskTest.java b/orca-bakery/src/test/java/com/netflix/spinnaker/orca/bakery/tasks/manifests/CreateBakeManifestTaskTest.java new file mode 100644 index 0000000000..9d681add58 --- /dev/null +++ b/orca-bakery/src/test/java/com/netflix/spinnaker/orca/bakery/tasks/manifests/CreateBakeManifestTaskTest.java @@ -0,0 +1,293 @@ +/* + * Copyright 2023 DoubleCloud, 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.orca.bakery.tasks.manifests; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.netflix.spinnaker.kork.artifacts.model.Artifact; +import com.netflix.spinnaker.orca.bakery.api.BakeryService; +import com.netflix.spinnaker.orca.pipeline.model.StageExecutionImpl; +import com.netflix.spinnaker.orca.pipeline.util.ArtifactUtils; +import com.netflix.spinnaker.orca.pipeline.util.ContextParameterProcessor; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +public class CreateBakeManifestTaskTest { + + private ObjectMapper mapper = + new ObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + + private final BakeryService bakery = mock(BakeryService.class); + private final ArtifactUtils artifactUtils = mock(ArtifactUtils.class); + private final ContextParameterProcessor contextParameterProcessor = + mock(ContextParameterProcessor.class); + + private CreateBakeManifestTask createBakeManifestTask = + new CreateBakeManifestTask(artifactUtils, contextParameterProcessor, Optional.of(bakery)); + + @Test + public void shouldMapStageToContext() throws JsonProcessingException { + String stageJson = + "{\n" + + " \"expectedArtifacts\": [\n" + + " {\n" + + " \"defaultArtifact\": {\n" + + " \"customKind\": true,\n" + + " \"id\": \"bd95dd08-58a3-4012-9db5-4c4cde176e0a\"\n" + + " },\n" + + " \"displayName\": \"rare-gecko-67\",\n" + + " \"id\": \"ea011068-f42e-4df0-8cf0-2fad1a6fc47e\",\n" + + " \"matchArtifact\": {\n" + + " \"artifactAccount\": \"embedded-artifact\",\n" + + " \"id\": \"86c1ef35-0b8a-4892-a60a-82759d8aa6ad\",\n" + + " \"name\": \"hi\",\n" + + " \"type\": \"embedded/base64\"\n" + + " },\n" + + " \"useDefaultArtifact\": false,\n" + + " \"usePriorArtifact\": false\n" + + " }\n" + + " ],\n" + + " \"inputArtifacts\": [\n" + + " {\n" + + " \"account\": \"no-auth-http-account\",\n" + + " \"artifact\": {\n" + + " \"artifactAccount\": \"no-auth-http-account\",\n" + + " \"id\": \"c4d18108-2b3b-40b1-ba82-d22ce17e708f\",\n" + + " \"reference\": \"helmfile.yml\",\n" + + " \"type\": \"http/file\"\n" + + " },\n" + + " \"id\": null\n" + + " }\n" + + " ],\n" + + " \"isNew\": true,\n" + + " \"name\": \"BakeManifest\",\n" + + " \"outputName\": \"resolvedartifact\",\n" + + " \"helmfileFilePath\": \"helmfile.yml\",\n" + + " \"type\": \"createBakeManifest\",\n" + + " \"environment\": \"prod\",\n" + + " \"includeCRDs\": \"true\",\n" + + " \"templateRenderer\": \"helmfile\",\n" + + " \"namespace\": \"test\"\n" + + "}"; + + StageExecutionImpl stage = new StageExecutionImpl(); + stage.setContext(mapper.readValue(stageJson, Map.class)); + BakeManifestContext context = stage.mapTo(BakeManifestContext.class); + + assertThat(context.getInputArtifacts().size()).isEqualTo(1); + assertThat(context.getExpectedArtifacts().size()).isEqualTo(1); + assertThat(context.getOutputName()).isEqualTo("resolvedartifact"); + assertThat(context.getHelmfileFilePath()).isEqualTo("helmfile.yml"); + assertThat(context.getEnvironment()).isEqualTo("prod"); + assertThat(context.getIncludeCRDs()).isEqualTo(true); + assertThat(context.getTemplateRenderer()).isEqualTo("helmfile"); + } + + @Test + public void shouldThrowExceptionForEmptyInputArtifacts() throws JsonProcessingException { + String stageJson = + "{\n" + + " \"isNew\": true,\n" + + " \"name\": \"Bake Helmfile Manifest\",\n" + + " \"outputName\": \"hi\",\n" + + " \"type\": \"createBakeManifest\"\n" + + "}"; + + StageExecutionImpl stage = new StageExecutionImpl(); + stage.setContext(mapper.readValue(stageJson, Map.class)); + BakeManifestContext context = stage.mapTo(BakeManifestContext.class); + Exception exception = + assertThrows( + IllegalArgumentException.class, + () -> createBakeManifestTask.execute(stage), + "Expected it to throw an error but it didn't"); + assertThat(exception.getMessage()) + .isEqualTo("At least one input artifact to bake must be supplied"); + } + + @Test + public void shouldThrowExceptionForEmptyProducedArtifact() throws JsonProcessingException { + String stageJson = + "{\n" + + " \"inputArtifacts\": [\n" + + " {\n" + + " \"account\": \"no-auth-http-account\",\n" + + " \"artifact\": {\n" + + " \"artifactAccount\": \"no-auth-http-account\",\n" + + " \"id\": \"c4d18108-2b3b-40b1-ba82-d22ce17e708f\",\n" + + " \"reference\": \"helmfile.yml\",\n" + + " \"type\": \"http/file\"\n" + + " },\n" + + " \"id\": null\n" + + " }\n" + + " ],\n" + + " \"isNew\": true,\n" + + " \"name\": \"Bake Helmfile Manifest\",\n" + + " \"outputName\": \"hi\",\n" + + " \"type\": \"createBakeManifest\"\n" + + "}"; + + StageExecutionImpl stage = new StageExecutionImpl(); + stage.setContext(mapper.readValue(stageJson, Map.class)); + BakeManifestContext context = stage.mapTo(BakeManifestContext.class); + when(artifactUtils.getBoundArtifactForStage(any(), any(), any())) + .thenReturn(Artifact.builder().build()) + .thenReturn(Artifact.builder().build()); + Exception exception = + assertThrows( + IllegalArgumentException.class, + () -> createBakeManifestTask.execute(stage), + "Expected it to throw an error but it didn't"); + assertThat(exception.getMessage()) + .isEqualTo( + "The Bake (Manifest) stage produces one embedded base64 artifact. Please ensure that your Bake (Manifest) stage config's `Produces Artifacts` section (`expectedArtifacts` field) contains exactly one artifact."); + } + + @Test + public void shouldThrowErrorIfTemplateRendererDoesNotExist() throws JsonProcessingException { + String stageJson = + "{\n" + + " \"expectedArtifacts\": [\n" + + " {\n" + + " \"defaultArtifact\": {\n" + + " \"customKind\": true,\n" + + " \"id\": \"bd95dd08-58a3-4012-9db5-4c4cde176e0a\"\n" + + " },\n" + + " \"displayName\": \"rare-gecko-67\",\n" + + " \"id\": \"ea011068-f42e-4df0-8cf0-2fad1a6fc47e\",\n" + + " \"matchArtifact\": {\n" + + " \"artifactAccount\": \"embedded-artifact\",\n" + + " \"id\": \"86c1ef35-0b8a-4892-a60a-82759d8aa6ad\",\n" + + " \"name\": \"hi\",\n" + + " \"type\": \"embedded/base64\"\n" + + " },\n" + + " \"useDefaultArtifact\": false,\n" + + " \"usePriorArtifact\": false\n" + + " }\n" + + " ],\n" + + " \"inputArtifacts\": [\n" + + " {\n" + + " \"account\": \"no-auth-http-account\",\n" + + " \"artifact\": {\n" + + " \"artifactAccount\": \"no-auth-http-account\",\n" + + " \"id\": \"c4d18108-2b3b-40b1-ba82-d22ce17e708f\",\n" + + " \"reference\": \"helmfile.yml\",\n" + + " \"type\": \"http/file\"\n" + + " },\n" + + " \"id\": null\n" + + " },\n" + + " {\n" + + " \"account\": \"no-auth-http-account\",\n" + + " \"artifact\": {\n" + + " \"artifactAccount\": \"no-auth-http-account\",\n" + + " \"id\": \"8f546da4-d198-48c1-9806-2835f59df2b3\",\n" + + " \"reference\": \"values.yml\",\n" + + " \"type\": \"http/file\"\n" + + " },\n" + + " \"id\": null\n" + + " }\n" + + " ],\n" + + " \"isNew\": true,\n" + + " \"name\": \"BakeManifest\",\n" + + " \"outputName\": \"hi\",\n" + + " \"type\": \"bakeHelmfileManifest\",\n" + + " \"templateRenderer\": \"IDONOTEXIST\"\n" + + "}"; + + StageExecutionImpl stage = new StageExecutionImpl(); + stage.setContext(mapper.readValue(stageJson, Map.class)); + when(artifactUtils.getBoundArtifactForStage(any(), any(), any())) + .thenReturn(Artifact.builder().build()) + .thenReturn(Artifact.builder().build()); + Exception exception = + assertThrows( + IllegalArgumentException.class, + () -> createBakeManifestTask.execute(stage), + "Expected it to throw an error but it didn't"); + assertThat(exception.getMessage()).isEqualTo("Invalid template renderer IDONOTEXIST"); + } + + @Test + public void shouldNotThrowErrorIfTemplateRendererDoesExist() throws JsonProcessingException { + String stageJson = + "{\n" + + " \"expectedArtifacts\": [\n" + + " {\n" + + " \"defaultArtifact\": {\n" + + " \"customKind\": true,\n" + + " \"id\": \"bd95dd08-58a3-4012-9db5-4c4cde176e0a\"\n" + + " },\n" + + " \"displayName\": \"rare-gecko-67\",\n" + + " \"id\": \"ea011068-f42e-4df0-8cf0-2fad1a6fc47e\",\n" + + " \"matchArtifact\": {\n" + + " \"artifactAccount\": \"embedded-artifact\",\n" + + " \"id\": \"86c1ef35-0b8a-4892-a60a-82759d8aa6ad\",\n" + + " \"name\": \"hi\",\n" + + " \"type\": \"embedded/base64\"\n" + + " },\n" + + " \"useDefaultArtifact\": false,\n" + + " \"usePriorArtifact\": false\n" + + " }\n" + + " ],\n" + + " \"inputArtifacts\": [\n" + + " {\n" + + " \"account\": \"no-auth-http-account\",\n" + + " \"artifact\": {\n" + + " \"artifactAccount\": \"no-auth-http-account\",\n" + + " \"id\": \"c4d18108-2b3b-40b1-ba82-d22ce17e708f\",\n" + + " \"reference\": \"helmfile.yml\",\n" + + " \"type\": \"http/file\"\n" + + " },\n" + + " \"id\": null\n" + + " },\n" + + " {\n" + + " \"account\": \"no-auth-http-account\",\n" + + " \"artifact\": {\n" + + " \"artifactAccount\": \"no-auth-http-account\",\n" + + " \"id\": \"8f546da4-d198-48c1-9806-2835f59df2b3\",\n" + + " \"reference\": \"values.yml\",\n" + + " \"type\": \"http/file\"\n" + + " },\n" + + " \"id\": null\n" + + " }\n" + + " ],\n" + + " \"isNew\": true,\n" + + " \"name\": \"BakeManifest\",\n" + + " \"outputName\": \"hi\",\n" + + " \"type\": \"bakeHelmfileManifest\",\n" + + " \"templateRenderer\": \"helmfile\"\n" + + "}"; + + StageExecutionImpl stage = new StageExecutionImpl(); + stage.setContext(mapper.readValue(stageJson, Map.class)); + when(artifactUtils.getBoundArtifactForStage(any(), any(), any())) + .thenReturn(Artifact.builder().build()) + .thenReturn(Artifact.builder().build()); + + assertDoesNotThrow( + () -> createBakeManifestTask.execute(stage), "No errors were expected to be thrown"); + } +}