diff --git a/orca-clouddriver/src/main/java/com/netflix/spinnaker/orca/clouddriver/pipeline/manifest/DeployCloudrunManifestStage.java b/orca-clouddriver/src/main/java/com/netflix/spinnaker/orca/clouddriver/pipeline/manifest/DeployCloudrunManifestStage.java new file mode 100644 index 0000000000..6c79baba08 --- /dev/null +++ b/orca-clouddriver/src/main/java/com/netflix/spinnaker/orca/clouddriver/pipeline/manifest/DeployCloudrunManifestStage.java @@ -0,0 +1,168 @@ +/* + * Copyright 2022 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.orca.clouddriver.pipeline.manifest; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static java.util.Collections.emptyMap; + +import com.google.common.collect.ImmutableList; +import com.netflix.spinnaker.kork.expressions.ExpressionEvaluationSummary; +import com.netflix.spinnaker.orca.api.pipeline.graph.StageGraphBuilder; +import com.netflix.spinnaker.orca.api.pipeline.graph.TaskNode; +import com.netflix.spinnaker.orca.api.pipeline.models.StageExecution; +import com.netflix.spinnaker.orca.clouddriver.OortService; +import com.netflix.spinnaker.orca.clouddriver.model.ManifestCoordinates; +import com.netflix.spinnaker.orca.clouddriver.tasks.MonitorKatoTask; +import com.netflix.spinnaker.orca.clouddriver.tasks.artifacts.CleanupArtifactsTask; +import com.netflix.spinnaker.orca.clouddriver.tasks.manifest.*; +import com.netflix.spinnaker.orca.clouddriver.tasks.manifest.DeployManifestContext.TrafficManagement; +import com.netflix.spinnaker.orca.pipeline.ExpressionAwareStageDefinitionBuilder; +import com.netflix.spinnaker.orca.pipeline.tasks.artifacts.BindProducedArtifactsTask; +import com.netflix.spinnaker.orca.pipeline.util.ContextParameterProcessor; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class DeployCloudrunManifestStage extends ExpressionAwareStageDefinitionBuilder { + + public static final String PIPELINE_CONFIG_TYPE = "deployCloudrunManifest"; + + private final OortService oortService; + + @Autowired + public DeployCloudrunManifestStage(OortService oortService) { + this.oortService = oortService; + } + + @Override + public void taskGraph(@Nonnull StageExecution stage, @Nonnull TaskNode.Builder builder) { + builder + .withTask(ResolveDeploySourceManifestTask.TASK_NAME, ResolveDeploySourceManifestTask.class) + .withTask(DeployCloudrunManifestTask.TASK_NAME, DeployCloudrunManifestTask.class) + .withTask("monitorDeploy", MonitorKatoTask.class) + .withTask(PromoteManifestKatoOutputsTask.TASK_NAME, PromoteManifestKatoOutputsTask.class) + .withTask(WaitForManifestStableTask.TASK_NAME, WaitForManifestStableTask.class) + .withTask(CleanupArtifactsTask.TASK_NAME, CleanupArtifactsTask.class) + .withTask("monitorCleanup", MonitorKatoTask.class) + .withTask(PromoteManifestKatoOutputsTask.TASK_NAME, PromoteManifestKatoOutputsTask.class) + .withTask(BindProducedArtifactsTask.TASK_NAME, BindProducedArtifactsTask.class); + } + + public void afterStages(@Nonnull StageExecution stage, @Nonnull StageGraphBuilder graph) { + TrafficManagement trafficManagement = + stage.mapTo(DeployManifestContext.class).getTrafficManagement(); + if (trafficManagement.isEnabled()) { + switch (trafficManagement.getOptions().getStrategy()) { + case RED_BLACK: + disableOldManifests(stage.getContext(), graph); + break; + case HIGHLANDER: + disableOldManifests(stage.getContext(), graph); + deleteOldManifests(stage.getContext(), graph); + break; + case NONE: + // do nothing + } + } + if (stage.getContext().getOrDefault("noOutput", "false").toString().equals("true")) { + stage.setOutputs(emptyMap()); + } + } + + private void disableOldManifests(Map parentContext, StageGraphBuilder graph) { + addStagesForOldManifests(parentContext, graph, DisableManifestStage.PIPELINE_CONFIG_TYPE); + } + + private void deleteOldManifests(Map parentContext, StageGraphBuilder graph) { + addStagesForOldManifests(parentContext, graph, DeleteManifestStage.PIPELINE_CONFIG_TYPE); + } + + private void addStagesForOldManifests( + Map parentContext, StageGraphBuilder graph, String stageType) { + List> deployedManifests = getNewManifests(parentContext); + String account = (String) parentContext.get("account"); + Map manifestMoniker = (Map) parentContext.get("moniker"); + String application = (String) manifestMoniker.get("app"); + + deployedManifests.forEach( + manifest -> { + Map manifestMetadata = (Map) manifest.get("metadata"); + String manifestName = + String.format("replicaSet %s", (String) manifestMetadata.get("name")); + String namespace = (String) manifestMetadata.get("namespace"); + Map annotations = (Map) manifestMetadata.get("annotations"); + String clusterName = (String) annotations.get("moniker.spinnaker.io/cluster"); + String cloudProvider = "kubernetes"; + + ImmutableList previousManifestNames = + getOldManifestNames(application, account, clusterName, namespace, manifestName); + previousManifestNames.forEach( + name -> { + graph.append( + (stage) -> { + stage.setType(stageType); + Map context = stage.getContext(); + context.put("account", account); + context.put("app", application); + context.put("cloudProvider", cloudProvider); + context.put("manifestName", name); + context.put("location", namespace); + }); + }); + }); + } + + private List> getNewManifests(Map parentContext) { + List> manifests = (List>) parentContext.get("outputs.manifests"); + return manifests.stream() + .filter(manifest -> manifest.get("kind").equals("ReplicaSet")) + .collect(Collectors.toList()); + } + + private ImmutableList getOldManifestNames( + String application, + String account, + String clusterName, + String namespace, + String newManifestName) { + return oortService + .getClusterManifests(account, namespace, "replicaSet", application, clusterName) + .stream() + .filter(m -> !m.getFullResourceName().equals(newManifestName)) + .map(ManifestCoordinates::getFullResourceName) + .collect(toImmutableList()); + } + + @Override + public boolean processExpressions( + @Nonnull StageExecution stage, + @Nonnull ContextParameterProcessor contextParameterProcessor, + @Nonnull ExpressionEvaluationSummary summary) { + DeployManifestContext context = stage.mapTo(DeployManifestContext.class); + if (context.isSkipExpressionEvaluation()) { + processDefaultEntries( + stage, contextParameterProcessor, summary, Collections.singletonList("manifests")); + return false; + } + return true; + } +} diff --git a/orca-clouddriver/src/main/java/com/netflix/spinnaker/orca/clouddriver/pipeline/providers/cloudrun/UpsertCloudrunLoadBalancersStage.java b/orca-clouddriver/src/main/java/com/netflix/spinnaker/orca/clouddriver/pipeline/providers/cloudrun/UpsertCloudrunLoadBalancersStage.java new file mode 100644 index 0000000000..3bb8651684 --- /dev/null +++ b/orca-clouddriver/src/main/java/com/netflix/spinnaker/orca/clouddriver/pipeline/providers/cloudrun/UpsertCloudrunLoadBalancersStage.java @@ -0,0 +1,37 @@ +/* + * Copyright 2022 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.orca.clouddriver.pipeline.providers.cloudrun; + +import com.netflix.spinnaker.orca.api.pipeline.graph.StageDefinitionBuilder; +import com.netflix.spinnaker.orca.api.pipeline.graph.TaskNode; +import com.netflix.spinnaker.orca.api.pipeline.models.StageExecution; +import com.netflix.spinnaker.orca.clouddriver.tasks.MonitorKatoTask; +import com.netflix.spinnaker.orca.clouddriver.tasks.loadbalancer.UpsertLoadBalancerResultObjectExtrapolationTask; +import com.netflix.spinnaker.orca.clouddriver.tasks.providers.cloudrun.UpsertCloudrunLoadBalancersTask; +import javax.annotation.Nonnull; +import org.springframework.stereotype.Component; + +@Component +public class UpsertCloudrunLoadBalancersStage implements StageDefinitionBuilder { + @Override + public void taskGraph(@Nonnull StageExecution stage, @Nonnull TaskNode.Builder builder) { + builder + .withTask("upsertLoadBalancers", UpsertCloudrunLoadBalancersTask.class) + .withTask("monitorUpsert", MonitorKatoTask.class) + .withTask("extrapolateUpsertResult", UpsertLoadBalancerResultObjectExtrapolationTask.class); + } +} diff --git a/orca-clouddriver/src/main/java/com/netflix/spinnaker/orca/clouddriver/tasks/manifest/DeployCloudrunManifestTask.java b/orca-clouddriver/src/main/java/com/netflix/spinnaker/orca/clouddriver/tasks/manifest/DeployCloudrunManifestTask.java new file mode 100644 index 0000000000..25fb3424f6 --- /dev/null +++ b/orca-clouddriver/src/main/java/com/netflix/spinnaker/orca/clouddriver/tasks/manifest/DeployCloudrunManifestTask.java @@ -0,0 +1,89 @@ +/* + * Copyright 2022 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.orca.clouddriver.tasks.manifest; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import com.netflix.spinnaker.orca.api.pipeline.Task; +import com.netflix.spinnaker.orca.api.pipeline.TaskResult; +import com.netflix.spinnaker.orca.api.pipeline.models.ExecutionStatus; +import com.netflix.spinnaker.orca.api.pipeline.models.StageExecution; +import com.netflix.spinnaker.orca.clouddriver.KatoService; +import com.netflix.spinnaker.orca.clouddriver.model.TaskId; +import com.netflix.spinnaker.orca.clouddriver.utils.CloudProviderAware; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import javax.annotation.Nonnull; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +@NonnullByDefault +public final class DeployCloudrunManifestTask implements CloudProviderAware, Task { + + public static final String TASK_NAME = "deployCloudrunManifest"; + + private final KatoService katoService; + + @Autowired + public DeployCloudrunManifestTask(KatoService katoService) { + this.katoService = katoService; + } + + @Nonnull + @Override + public TaskResult execute(@Nonnull StageExecution stage) { + ImmutableMap operation = getOperation(stage); + TaskId taskId = executeOperation(stage, operation); + ImmutableMap outputs = getOutputs(stage, taskId); + return TaskResult.builder(ExecutionStatus.SUCCEEDED).context(outputs).build(); + } + + private ImmutableMap getOperation(StageExecution stage) { + DeployManifestContext context = stage.mapTo(DeployManifestContext.class); + + Map task = new HashMap<>(stage.getContext()); + + task.put("source", "text"); + if (context.getTrafficManagement().isEnabled()) { + task.put("services", context.getTrafficManagement().getOptions().getServices()); + task.put("enableTraffic", context.getTrafficManagement().getOptions().isEnableTraffic()); + task.put("strategy", context.getTrafficManagement().getOptions().getStrategy().name()); + } else { + // For backwards compatibility, traffic is always enabled to new server groups when the new + // traffic management + // features are not enabled. + task.put("enableTraffic", true); + } + + return ImmutableMap.of(TASK_NAME, task); + } + + private TaskId executeOperation(StageExecution stage, ImmutableMap operation) { + return katoService.requestOperations(getCloudProvider(stage), ImmutableList.of(operation)); + } + + private ImmutableMap getOutputs(StageExecution stage, TaskId taskId) { + return new ImmutableMap.Builder() + .put("kato.result.expected", true) + .put("kato.last.task.id", taskId) + .put("deploy.account.name", Objects.requireNonNull(getCredentials(stage))) + .build(); + } +} diff --git a/orca-clouddriver/src/main/java/com/netflix/spinnaker/orca/clouddriver/tasks/providers/cloudrun/UpsertCloudrunLoadBalancersTask.java b/orca-clouddriver/src/main/java/com/netflix/spinnaker/orca/clouddriver/tasks/providers/cloudrun/UpsertCloudrunLoadBalancersTask.java new file mode 100644 index 0000000000..bda05c395e --- /dev/null +++ b/orca-clouddriver/src/main/java/com/netflix/spinnaker/orca/clouddriver/tasks/providers/cloudrun/UpsertCloudrunLoadBalancersTask.java @@ -0,0 +1,131 @@ +/* + * Copyright 2022 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.orca.clouddriver.tasks.providers.cloudrun; + +import com.google.common.collect.ImmutableMap; +import com.netflix.spinnaker.orca.api.pipeline.RetryableTask; +import com.netflix.spinnaker.orca.api.pipeline.TaskResult; +import com.netflix.spinnaker.orca.api.pipeline.models.ExecutionStatus; +import com.netflix.spinnaker.orca.api.pipeline.models.StageExecution; +import com.netflix.spinnaker.orca.clouddriver.KatoService; +import com.netflix.spinnaker.orca.clouddriver.model.TaskId; +import com.netflix.spinnaker.orca.clouddriver.pipeline.servergroup.support.Location; +import com.netflix.spinnaker.orca.clouddriver.pipeline.servergroup.support.TargetServerGroup; +import com.netflix.spinnaker.orca.clouddriver.pipeline.servergroup.support.TargetServerGroupResolver; +import com.netflix.spinnaker.orca.clouddriver.utils.CloudProviderAware; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class UpsertCloudrunLoadBalancersTask implements CloudProviderAware, RetryableTask { + + public static final String CLOUD_OPERATION_TYPE = "upsertLoadBalancer"; + public static final String CLOUD_PROVIDER = "cloudrun"; + public static final String[] attrList = {"cluster", "target", "credentials", "region"}; + + @Override + public long getBackoffPeriod() { + return 2000; + } + + @Override + public long getTimeout() { + return 300000; + } + + private KatoService kato; + + private TargetServerGroupResolver resolver; + + @Autowired + public UpsertCloudrunLoadBalancersTask(KatoService kato, TargetServerGroupResolver resolver) { + this.kato = kato; + this.resolver = resolver; + } + + @Override + public TaskResult execute(StageExecution stage) { + + List operations = new ArrayList(); + boolean katoResultExpected = false; + Map context = new HashMap(stage.getContext()); + List loadBalancerList = (List) context.get("loadBalancers"); + for (Object loadBalancer : loadBalancerList) { + katoResultExpected = true; + Map lbcontext = (Map) loadBalancer; + if (lbcontext.get("splitDescription") != null + && ((Map) lbcontext.get("splitDescription")).get("allocationDescriptions") != null) { + List allocationDescList = + (List) ((Map) lbcontext.get("splitDescription")).get("allocationDescriptions"); + for (Object allocationDesc : allocationDescList) { + Map description = (Map) allocationDesc; + String revisionName = resolveTargetServerGroupName(lbcontext, description); + description.put("serverGroupName", revisionName); + description.put("revisionName", revisionName); + } + } + Map operation = new HashMap(); + operation.put(CLOUD_OPERATION_TYPE, lbcontext); + operations.add(operation); + } + TaskId taskId = kato.requestOperations(CLOUD_PROVIDER, operations); + ImmutableMap outputs = getOutputs(operations, taskId, katoResultExpected); + return TaskResult.builder(ExecutionStatus.SUCCEEDED).context(outputs).build(); + } + + public String resolveTargetServerGroupName(Map loadBalancer, Map allocationDescription) { + + for (String attr : attrList) { + if (loadBalancer.get(attr) == null && allocationDescription.get(attr) == null) { + throw new IllegalArgumentException( + "Could not resolve target server group, " + attr + " not specified."); + } + } + + TargetServerGroup.Params params = new TargetServerGroup.Params(); + params.setCloudProvider(CLOUD_PROVIDER); + params.setCredentials((String) loadBalancer.get("credentials")); + params.setCluster((String) allocationDescription.get("cluster")); + params.setTarget( + TargetServerGroup.Params.Target.valueOf((String) allocationDescription.get("target"))); + Location location = new Location(Location.Type.REGION, (String) loadBalancer.get("region")); + List locations = new ArrayList<>(); + locations.add(location); + params.setLocations(locations); + List serverGroups = resolver.resolveByParams(params); + return (serverGroups != null && !serverGroups.isEmpty()) ? serverGroups.get(0).getName() : ""; + } + + private ImmutableMap getOutputs( + List> operations, TaskId taskId, boolean katoResultExpected) { + + ImmutableMap.Builder returnMap = new ImmutableMap.Builder<>(); + returnMap.put("notification.type", CLOUD_OPERATION_TYPE.toLowerCase()); + returnMap.put("kato.result.expected", katoResultExpected); + returnMap.put("kato.last.task.id", taskId); + Map targetMap = new HashMap<>(); + for (Map op : operations) { + targetMap = (Map) op.get(CLOUD_OPERATION_TYPE); + } + returnMap.put("targets", targetMap); + return returnMap.build(); + } +} diff --git a/orca-clouddriver/src/test/java/com/netflix/spinnaker/orca/clouddriver/tasks/providers/cloudrun/UpsertCloudrunLoadBalancersTaskTest.java b/orca-clouddriver/src/test/java/com/netflix/spinnaker/orca/clouddriver/tasks/providers/cloudrun/UpsertCloudrunLoadBalancersTaskTest.java new file mode 100644 index 0000000000..647f224530 --- /dev/null +++ b/orca-clouddriver/src/test/java/com/netflix/spinnaker/orca/clouddriver/tasks/providers/cloudrun/UpsertCloudrunLoadBalancersTaskTest.java @@ -0,0 +1,111 @@ +/* + * Copyright 2022 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 aw 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.clouddriver.tasks.providers.cloudrun; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.netflix.spinnaker.orca.api.pipeline.TaskResult; +import com.netflix.spinnaker.orca.api.pipeline.models.StageExecution; +import com.netflix.spinnaker.orca.clouddriver.KatoService; +import com.netflix.spinnaker.orca.clouddriver.model.TaskId; +import com.netflix.spinnaker.orca.clouddriver.pipeline.servergroup.support.TargetServerGroupResolver; +import com.netflix.spinnaker.orca.pipeline.model.StageExecutionImpl; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +/** Unit tests for UpsertCloudrunLoadBalancersTaskTest */ +@ExtendWith(MockitoExtension.class) +public class UpsertCloudrunLoadBalancersTaskTest { + + private UpsertCloudrunLoadBalancersTask task; + + private TaskId taskId; + + private KatoService kato; + + private TargetServerGroupResolver resolver; + + @BeforeEach + void setUp() { + kato = mock(KatoService.class); + resolver = mock(TargetServerGroupResolver.class); + task = new UpsertCloudrunLoadBalancersTask(kato, resolver); + taskId = new TaskId(UUID.randomUUID().toString()); + } + + @Test + void upsertLoadBalancersWithNoLoadBalancers() { + + StageExecution stage = new StageExecutionImpl(); + List operations = new ArrayList(); + when(kato.requestOperations("cloudrun", operations)).thenReturn(taskId); + stage.setType(""); + Map contextMap = new HashMap<>(); + stage.setContext(contextMap); + contextMap.put("cloudProvider", "cloudrun"); + contextMap.put("loadBalancers", new ArrayList<>()); + TaskResult result = task.execute(stage); + assertThat((result.getStatus()).name()).isEqualTo("SUCCEEDED"); + assertThat((result.getContext()).get("kato.result.expected")).isEqualTo(false); + } + + @Test + void upsertLoadBalancersWithTwoLoadBalancers() { + + StageExecution stage = new StageExecutionImpl(); + List operations = new ArrayList(); + stage.setType(""); + Map contextMap = new HashMap<>(); + stage.setContext(contextMap); + List loadBalancersList = new ArrayList(); + contextMap.put("cloudProvider", "cloudrun"); + contextMap.put("loadBalancers", loadBalancersList); + Map loadBalancersMap = new HashMap<>(); + loadBalancersMap.put("credentials", "my-cloudrun-account"); + loadBalancersMap.put("region", "us-central1"); + List allocationDescriptionsList = new ArrayList(); + Map allocationDescriptionsMap = new HashMap<>(); + Map allocationDescriptionsMap1 = new HashMap<>(); + allocationDescriptionsMap1.put("cluster", "testeditlb005-s1-d1"); + allocationDescriptionsMap1.put("percent", 98); + allocationDescriptionsMap1.put("target", "current_asg_dynamic"); + allocationDescriptionsList.add(allocationDescriptionsMap1); + Map allocationDescriptionsMap2 = new HashMap<>(); + allocationDescriptionsMap2.put("cluster", "testeditlb005-s1-d1"); + allocationDescriptionsMap2.put("percent", 2); + allocationDescriptionsMap2.put("target", "ancestor_asg_dynamic"); + allocationDescriptionsList.add(allocationDescriptionsMap2); + allocationDescriptionsMap.put("allocationDescriptions", allocationDescriptionsList); + loadBalancersMap.put("splitDescription", allocationDescriptionsMap); + loadBalancersList.add(loadBalancersMap); + Map operation = new HashMap(); + operation.put("upsertLoadBalancer", loadBalancersMap); + operations.add(operation); + when(kato.requestOperations("cloudrun", operations)).thenReturn(taskId); + TaskResult result = task.execute(stage); + assertThat((result.getStatus()).name()).isEqualTo("SUCCEEDED"); + assertThat((result.getContext()).get("kato.result.expected")).isEqualTo(true); + } +}